Skip to content

Commit 63afc1c

Browse files
authored
Merge pull request #21325 from opf/bugfix/69448-send-assignee-notification-email-only-once-after-pir-completion
[69448] Send assignee notification email only once after PIR completion
2 parents daacefa + 0db1903 commit 63afc1c

File tree

9 files changed

+152
-42
lines changed

9 files changed

+152
-42
lines changed

app/contracts/work_packages/copy_project_contract.rb

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,12 @@ class CopyProjectContract < CopyContract
4242
delegate :to_s,
4343
to: :model
4444

45+
def valid?(_context = nil)
46+
# For project copying, we want to preserve the exact state
47+
# even if copied work packages would normally be invalid
48+
true
49+
end
50+
4551
def validate_model? = false
4652

4753
private

app/services/journals/create_service.rb

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -361,7 +361,7 @@ def insert_data_sql(predecessor, notes, cause)
361361
# will already have been increased so nothing needs to be done.
362362
# But if any of the associated data is updated or if only a cause or note is added, the journable would
363363
# otherwise not have receive an updated timestamp.
364-
def touch_journable_sql(predecessor, notes, cause)
364+
def touch_journable_sql(predecessor, notes, cause) # rubocop:disable Metrics/AbcSize
365365
if journable.class.aaj_options[:timestamp].to_sym == :updated_at
366366
sql = <<~SQL
367367
UPDATE
@@ -375,8 +375,9 @@ def touch_journable_sql(predecessor, notes, cause)
375375
RETURNING updated_at
376376
SQL
377377

378+
keep_same_updated_at = journable.updated_at_previously_changed? && !journable.previously_new_record?
378379
sanitize(sql,
379-
update_timestamp: journable.updated_at_previously_changed? ? journable.updated_at : nil,
380+
update_timestamp: keep_same_updated_at ? journable.updated_at : nil,
380381
predecessor_timestamp: predecessor&.updated_at,
381382
id: journable.id)
382383
else

app/services/work_packages/create_service.rb

Lines changed: 24 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -55,15 +55,14 @@ def perform
5555
def create(attributes, work_package)
5656
result = set_attributes(attributes, work_package)
5757

58-
result.success =
59-
if result.success
60-
work_package.attachments = work_package.attachments_replacements if work_package.attachments_replacements
61-
work_package.save
58+
if result.success?
59+
# Set attributes service passed, meaning the contract is fullfilled.
60+
# Avoid running validations again as we might be in a project copy scenario.
61+
work_package.attachments = work_package.attachments_replacements if work_package.attachments_replacements
62+
work_package.save(validate: false)
6263

63-
set_templated_subject(work_package)
64-
end
64+
update_subject_if_automatically_generated(work_package)
6565

66-
if result.success?
6766
# update ancestors before rescheduling, as the parent might switch to automatic mode
6867
multi_update_ancestors(result.all_results).each do |ancestor_result|
6968
result.merge!(ancestor_result)
@@ -77,11 +76,13 @@ def create(attributes, work_package)
7776
result
7877
end
7978

80-
def set_templated_subject(work_package)
81-
return true unless work_package.type&.replacement_pattern_defined_for?(:subject)
82-
83-
work_package.subject = work_package.type.enabled_patterns[:subject].resolve(work_package)
84-
work_package.save
79+
def update_subject_if_automatically_generated(work_package)
80+
if work_package.type&.replacement_pattern_defined_for?(:subject)
81+
Journal::NotificationConfiguration.with(false) do
82+
work_package.subject = work_package.type.enabled_patterns[:subject].resolve(work_package)
83+
work_package.save(validate: false)
84+
end
85+
end
8586
end
8687

8788
def set_attributes(attributes, work_package)
@@ -93,16 +94,22 @@ def reschedule_related(work_package)
9394
# This is necessary in bulk duplicate scenarios.
9495
switching_to_automatic_mode = []
9596
switching_to_automatic_mode << work_package if work_package.schedule_automatically?
96-
result = WorkPackages::SetScheduleService.new(user:, work_package:, switching_to_automatic_mode:).call
97+
rescheduling_result = WorkPackages::SetScheduleService.new(user:, work_package:, switching_to_automatic_mode:).call
9798

98-
result.self_and_dependent.each do |r|
99+
persist_reschedule_changes(rescheduling_result)
100+
101+
rescheduling_result
102+
end
103+
104+
def persist_reschedule_changes(rescheduling_result)
105+
rescheduling_result.self_and_dependent
106+
.filter { it.result.changed? }
107+
.each do |r|
99108
unless r.result.save
100-
result.success = false
109+
rescheduling_result.success = false
101110
r.errors = r.result.errors
102111
end
103112
end
104-
105-
result
106113
end
107114

108115
def set_user_as_watcher(work_package)

app/services/work_packages/update_ancestors_service.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,7 @@ def changes?(work_package)
8484
def save_updated_work_packages(updated_work_packages)
8585
updated_initiators, updated_ancestors = updated_work_packages.partition { initiator?(it) }
8686

87+
# TODO: Can we do better and not save if not changed?
8788
# Send notifications for initiator updates
8889
success = updated_initiators.all? { |wp| wp.save(validate: false) }
8990
# Do not send notifications for parent updates

spec/models/projects/project_acts_as_journalized_spec.rb

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -370,10 +370,10 @@
370370
create(:project_phase, project_id: project.id)
371371
end
372372

373-
it "fails when using save_journals" do
373+
it "succeeds when using save_journals" do
374374
expect do
375375
project.save_journals
376-
end.to raise_error(ActiveRecord::StatementInvalid)
376+
end.to change { project.journals.count }.from(1).to(2)
377377
end
378378

379379
it "succeeds when using touch_and_save_journals" do

spec/models/work_package/work_package_acts_as_journalized_spec.rb

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,39 @@
5454

5555
current_user { create(:user) }
5656

57+
# The below test was failing with the following error:
58+
# ERROR: new row for relation "journals" violates check constraint "journals_validity_period_not_empty" (PG::CheckViolation)
59+
# DETAIL: Failing row contains (1178, WorkPackage, 481, 1252, , 2025-12-04 07:58:21.028586+00, 1,
60+
# 2025-12-04 07:58:21.028586+00, Journal::WorkPackageJournal, 833, {}, empty, f).
61+
it "can add multiple comments right after creation" do
62+
work_package
63+
User.execute_as current_user do
64+
# create multiple journals after creation
65+
work_package.add_journal(user: current_user, notes: "First comment")
66+
work_package.save_journals
67+
work_package.add_journal(user: current_user, notes: "Second comment")
68+
work_package.save_journals
69+
70+
##### The fix is incomplete: the part below still fails.
71+
##### There may also be some issues with timestamps inaccuracies: some
72+
##### updated_at not matching the journal's validity periods.
73+
74+
# # create multiple journals after update
75+
# work_package.update(subject: "Updated subject")
76+
# work_package.add_journal(user: current_user, notes: "Third comment")
77+
# work_package.save_journals
78+
# work_package.add_journal(user: current_user, notes: "Fourth comment")
79+
# work_package.save_journals
80+
81+
# # Verify journals were created with aggregation:
82+
# # - Journal 1: creation + first comment (aggregated)
83+
# # - Journal 2: second comment (can't aggregate - both have notes)
84+
# # - Journal 3: update + third comment (aggregated)
85+
# # - Journal 4: fourth comment (can't aggregate - both have notes)
86+
# expect(work_package.journals.count).to eq(4)
87+
end
88+
end
89+
5790
context "for work package creation" do
5891
it { expect(Journal.for_work_package.count).to eq(1) }
5992

spec/services/projects/creation_wizard/create_artifact_work_package_service_spec.rb

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,27 @@
161161
expect(artifact_work_package.description).to include(expected_link)
162162
end
163163

164+
it "sends only one notification for the work package comment" do
165+
clear_enqueued_jobs
166+
result = instance.call
167+
168+
project = result.result
169+
artifact_work_package = WorkPackage.find(project.project_creation_wizard_artifact_work_package_id)
170+
171+
expect(enqueued_jobs).to include(a_hash_including(job: Notifications::WorkflowJob))
172+
workflow_jobs = enqueued_jobs.select { it[:job] == Notifications::WorkflowJob }
173+
174+
# There should be exactly 2 WorkflowJobs: one for the attachment journal,
175+
# one for the work package journal
176+
journals = workflow_jobs.pluck(:args)
177+
.map { |_state_arg, journal_arg, _send_notification| journal_arg["_aj_globalid"] }
178+
.map { |journal_gid| GlobalID::Locator.locate(journal_gid) }
179+
expect(journals.pluck(:journable_type, :journable_id)).to contain_exactly(
180+
[WorkPackage.name, artifact_work_package.id],
181+
[Attachment.name, artifact_work_package.attachments.first.id]
182+
)
183+
end
184+
164185
context "when artifact storage is internal" do
165186
it "attaches directly to the work package" do
166187
project.update(project_creation_wizard_artifact_export_type: "attachment")
@@ -406,7 +427,6 @@
406427
end
407428
end
408429
end
409-
410430
end
411431

412432
context "when contract is invalid" do

spec/services/work_packages/create_service_integration_spec.rb

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,22 @@
169169
.to contain_exactly(user)
170170
end
171171

172+
it "creates only one WorkflowJob and one JournalsCompletedJob for the created work package" do
173+
# avoid setting the parent to avoid creating unrelated jobs
174+
attributes[:parent] = nil
175+
176+
# create objects before clearing jobs to make sure unrelated jobs are cleared
177+
project
178+
clear_enqueued_jobs
179+
expect(service_result).to be_success
180+
181+
got_enqueued_jobs = enqueued_jobs.pluck(:job)
182+
expect(got_enqueued_jobs)
183+
.to contain_exactly(WorkPackages::WorkflowJob,
184+
Notifications::WorkflowJob,
185+
Journals::CompletedJob)
186+
end
187+
172188
describe "setting the attachments" do
173189
let!(:other_users_attachment) do
174190
create(:attachment, container: nil, author: create(:user))

spec/workers/copy_project_job_spec.rb

Lines changed: 46 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -38,12 +38,25 @@
3838

3939
before { allow(mail_double).to receive(:deliver_later) }
4040

41-
describe "copy project succeeds with errors" do
42-
let(:source_project) { create(:project, types: [type]) }
43-
44-
let!(:work_package) { create(:work_package, :skip_validations, done_ratio: 101, project: source_project, type:) }
41+
describe "with a source project having invalid work packages" do
42+
shared_let(:type) { create(:type_bug) }
43+
shared_let(:source_project) { create(:project, types: [type]) }
44+
45+
shared_let(:work_package_with_model_errors) do
46+
# done ratio must be between 0 and 100, this is a model validation
47+
create(:work_package, :skip_validations,
48+
project: source_project, type:,
49+
subject: "wp with invalid done_ratio",
50+
done_ratio: 101)
51+
end
52+
shared_let(:work_package_with_contract_errors) do
53+
# remaining work must be less than or equal to work, this is a WorkPackages::BaseContract validation
54+
create(:work_package, :skip_validations,
55+
project: source_project, type:,
56+
subject: "wp with invalid work and remaining work",
57+
remaining_hours: 10, estimated_hours: 2)
58+
end
4559

46-
let(:type) { create(:type_bug) }
4760
let(:job_args) do
4861
{
4962
target_project_params: params,
@@ -52,13 +65,8 @@
5265
end
5366

5467
let(:params) { { name: "Copy", identifier: "copy", type_ids: [type.id] } }
55-
let(:expected_error_message) do
56-
"#{WorkPackage.model_name.human} '#{work_package.type.name} ##{work_package.id}: " \
57-
"#{work_package.subject}': % Complete " \
58-
"#{I18n.t('activerecord.errors.models.work_package.attributes.done_ratio.inclusion')}"
59-
end
6068

61-
it "copies the project", :aggregate_failures do
69+
it "copies the project and invalid work packages without reporting any errors", :aggregate_failures do
6270
copy_job = nil
6371
batch = GoodJob::Batch.enqueue(user: admin, source_project:) do
6472
copy_job = described_class.perform_later(**job_args)
@@ -67,9 +75,12 @@
6775
batch.reload
6876

6977
copied_project = Project.find_by(identifier: params[:identifier])
70-
7178
expect(copied_project).to eq(batch.properties[:target_project])
72-
expect(batch.properties[:errors].first).to eq(expected_error_message)
79+
80+
# all work packages were copied, even if they are invalid
81+
expect(copied_project.work_packages.count).to eq(source_project.work_packages.count)
82+
# no errors reported
83+
expect(batch.properties[:errors]).to be_empty
7384

7485
# expect to create a status
7586
expect(copy_job.job_status).to be_present
@@ -80,15 +91,30 @@
8091
expect(copy_job.job_status[:payload]["_links"]["project"]).to eq(expected_link)
8192
end
8293

83-
it "ensures that error messages are correctly localized" do
84-
batch = GoodJob::Batch.enqueue(user: user_de, source_project:) do
85-
described_class.perform_later(**job_args)
94+
context "when the source project has automatically generated subjects" do
95+
before do
96+
type.update(patterns: { subject: { blueprint: "wp {{id}} in project {{project_id}}", enabled: true } })
8697
end
87-
GoodJob.perform_inline
88-
batch.reload
8998

90-
msg = /Arbeitspaket 'Bug #\d+: WorkPackage No. \d+': % abgeschlossen muss zwischen 0 und 100 liegen\./
91-
expect(batch.properties[:errors].first).to match(msg)
99+
it "copies the project without reporting any errors and generates subjects different from source project" do
100+
batch = GoodJob::Batch.enqueue(user: admin, source_project:) do
101+
described_class.perform_later(**job_args)
102+
end
103+
GoodJob.perform_inline
104+
batch.reload
105+
106+
copied_project = Project.find_by(identifier: params[:identifier])
107+
108+
# all work packages were copied, even if they are invalid
109+
expect(copied_project.work_packages.count).to eq(source_project.work_packages.count)
110+
# no errors reported
111+
expect(batch.properties[:errors]).to be_empty
112+
113+
# generated subjects are different from source project
114+
copied_project.work_packages.each do |work_package|
115+
expect(work_package.subject).to eq("wp #{work_package.id} in project #{copied_project.id}")
116+
end
117+
end
92118
end
93119
end
94120

0 commit comments

Comments
 (0)