Make "Project copy" copy all work packages as-is

When doing a project copy, copy as-is and accept that the state can be
faulty.

Before, it was disabling parts of the validations while keeping some
other parts, which made the experience confusing: the
SetAttributesService would be ok, but then saving the work package would
fail, and later the work package would be saved again without validation
in the `UpdateAncestorsService`.

Actually it was working previously only due to side-effects of
`UpdateAncestorsService` which does save and also due to changes
introduced in 0700299 which refactored the part that detect `save`
returned `false`, leading to believe that `save` always returned `true`.

Now the validation are done solely by the `SetAttributesService`, and
when saving the work package validations are ignored.
This commit is contained in:
Christophe Bliard
2026-01-05 17:55:11 +01:00
parent b41671c128
commit 8b8f8f0c35
4 changed files with 66 additions and 32 deletions
@@ -42,6 +42,12 @@ module WorkPackages
delegate :to_s,
to: :model
def valid?(_context = nil)
# For project copying, we want to preserve the exact state
# even if copied work packages would normally be invalid
true
end
def validate_model? = false
private
+14 -13
View File
@@ -55,15 +55,14 @@ class WorkPackages::CreateService < BaseServices::BaseCallable
def create(attributes, work_package)
result = set_attributes(attributes, work_package)
result.success =
if result.success
work_package.attachments = work_package.attachments_replacements if work_package.attachments_replacements
work_package.save
set_templated_subject(work_package)
end
if result.success?
# Set attributes service passed, meaning the contract is fullfilled.
# Avoid running validations again as we might be in a project copy scenario.
work_package.attachments = work_package.attachments_replacements if work_package.attachments_replacements
work_package.save(validate: false)
update_subject_if_automatically_generated(work_package)
# update ancestors before rescheduling, as the parent might switch to automatic mode
multi_update_ancestors(result.all_results).each do |ancestor_result|
result.merge!(ancestor_result)
@@ -77,11 +76,13 @@ class WorkPackages::CreateService < BaseServices::BaseCallable
result
end
def set_templated_subject(work_package)
return true unless work_package.type&.replacement_pattern_defined_for?(:subject)
work_package.subject = work_package.type.enabled_patterns[:subject].resolve(work_package)
work_package.save
def update_subject_if_automatically_generated(work_package)
if work_package.type&.replacement_pattern_defined_for?(:subject)
Journal::NotificationConfiguration.with(false) do
work_package.subject = work_package.type.enabled_patterns[:subject].resolve(work_package)
work_package.save(validate: false)
end
end
end
def set_attributes(attributes, work_package)
@@ -84,6 +84,7 @@ class WorkPackages::UpdateAncestorsService < BaseServices::BaseCallable
def save_updated_work_packages(updated_work_packages)
updated_initiators, updated_ancestors = updated_work_packages.partition { initiator?(it) }
# TODO: Can we do better and not save if not changed?
# Send notifications for initiator updates
success = updated_initiators.all? { |wp| wp.save(validate: false) }
# Do not send notifications for parent updates
+45 -19
View File
@@ -38,12 +38,25 @@ RSpec.describe CopyProjectJob, type: :model, with_good_job_batches: [CopyProject
before { allow(mail_double).to receive(:deliver_later) }
describe "copy project succeeds with errors" do
let(:source_project) { create(:project, types: [type]) }
describe "with a source project having invalid work packages" do
shared_let(:type) { create(:type_bug) }
shared_let(:source_project) { create(:project, types: [type]) }
let!(:work_package) { create(:work_package, :skip_validations, done_ratio: 101, project: source_project, type:) }
shared_let(:work_package_with_model_errors) do
# done ratio must be between 0 and 100, this is a model validation
create(:work_package, :skip_validations,
project: source_project, type:,
subject: "wp with invalid done_ratio",
done_ratio: 101)
end
shared_let(:work_package_with_contract_errors) do
# remaining work must be less than or equal to work, this is a WorkPackages::BaseContract validation
create(:work_package, :skip_validations,
project: source_project, type:,
subject: "wp with invalid work and remaining work",
remaining_hours: 10, estimated_hours: 2)
end
let(:type) { create(:type_bug) }
let(:job_args) do
{
target_project_params: params,
@@ -52,13 +65,8 @@ RSpec.describe CopyProjectJob, type: :model, with_good_job_batches: [CopyProject
end
let(:params) { { name: "Copy", identifier: "copy", type_ids: [type.id] } }
let(:expected_error_message) do
"#{WorkPackage.model_name.human} '#{work_package.type.name} ##{work_package.id}: " \
"#{work_package.subject}': % Complete " \
"#{I18n.t('activerecord.errors.models.work_package.attributes.done_ratio.inclusion')}"
end
it "copies the project", :aggregate_failures do
it "copies the project and invalid work packages without reporting any errors", :aggregate_failures do
copy_job = nil
batch = GoodJob::Batch.enqueue(user: admin, source_project:) do
copy_job = described_class.perform_later(**job_args)
@@ -67,9 +75,12 @@ RSpec.describe CopyProjectJob, type: :model, with_good_job_batches: [CopyProject
batch.reload
copied_project = Project.find_by(identifier: params[:identifier])
expect(copied_project).to eq(batch.properties[:target_project])
expect(batch.properties[:errors].first).to eq(expected_error_message)
# all work packages were copied, even if they are invalid
expect(copied_project.work_packages.count).to eq(source_project.work_packages.count)
# no errors reported
expect(batch.properties[:errors]).to be_empty
# expect to create a status
expect(copy_job.job_status).to be_present
@@ -80,15 +91,30 @@ RSpec.describe CopyProjectJob, type: :model, with_good_job_batches: [CopyProject
expect(copy_job.job_status[:payload]["_links"]["project"]).to eq(expected_link)
end
it "ensures that error messages are correctly localized" do
batch = GoodJob::Batch.enqueue(user: user_de, source_project:) do
described_class.perform_later(**job_args)
context "when the source project has automatically generated subjects" do
before do
type.update(patterns: { subject: { blueprint: "wp {{id}} in project {{project_id}}", enabled: true } })
end
GoodJob.perform_inline
batch.reload
msg = /Arbeitspaket 'Bug #\d+: WorkPackage No. \d+': % abgeschlossen muss zwischen 0 und 100 liegen\./
expect(batch.properties[:errors].first).to match(msg)
it "copies the project without reporting any errors and generates subjects different from source project" do
batch = GoodJob::Batch.enqueue(user: admin, source_project:) do
described_class.perform_later(**job_args)
end
GoodJob.perform_inline
batch.reload
copied_project = Project.find_by(identifier: params[:identifier])
# all work packages were copied, even if they are invalid
expect(copied_project.work_packages.count).to eq(source_project.work_packages.count)
# no errors reported
expect(batch.properties[:errors]).to be_empty
# generated subjects are different from source project
copied_project.work_packages.each do |work_package|
expect(work_package.subject).to eq("wp #{work_package.id} in project #{copied_project.id}")
end
end
end
end