Skip to content

Commit a36f0c0

Browse files
committed
Create Job to build project specific variants
1 parent 351d5df commit a36f0c0

2 files changed

Lines changed: 260 additions & 0 deletions

File tree

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
# frozen_string_literal: true
2+
3+
#-- copyright
4+
# OpenProject is an open source project management software.
5+
# Copyright (C) the OpenProject GmbH
6+
#
7+
# This program is free software; you can redistribute it and/or
8+
# modify it under the terms of the GNU General Public License version 3.
9+
#
10+
# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
11+
# Copyright (C) 2006-2013 Jean-Philippe Lang
12+
# Copyright (C) 2010-2013 the ChiliProject Team
13+
#
14+
# This program is free software; you can redistribute it and/or
15+
# modify it under the terms of the GNU General Public License
16+
# as published by the Free Software Foundation; either version 2
17+
# of the License, or (at your option) any later version.
18+
#
19+
# This program is distributed in the hope that it will be useful,
20+
# but WITHOUT ANY WARRANTY; without even the implied warranty of
21+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
22+
# GNU General Public License for more details.
23+
#
24+
# You should have received a copy of the GNU General Public License
25+
# along with this program; if not, write to the Free Software
26+
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
27+
#
28+
# See COPYRIGHT and LICENSE files for more details.
29+
#++
30+
31+
module WorkPackageTypes
32+
# Moves every project's per-project custom field deactivations into the form configuration, by
33+
# building a variant per project that narrows anything and resolving the project to it.
34+
#
35+
# Safe to re-run: once a project resolves to its variant, that variant already excludes what the
36+
# project disabled, so BuildVariantFromProjectService hands the variant straight back and nothing
37+
# further happens.
38+
#
39+
# A project that fails is logged and skipped rather than aborting the run, so one broken project
40+
# cannot hold back every project after it.
41+
class BuildProjectVariantsJob < ApplicationJob
42+
include GoodJob::ActiveJobExtensions::Concurrency
43+
44+
good_job_control_concurrency_with(total_limit: 1)
45+
46+
def perform
47+
unless OpenProject::FeatureDecisions.type_variants_active?
48+
raise "expected the type_variants feature to be active"
49+
end
50+
51+
@built = 0
52+
@unchanged = 0
53+
@failed = 0
54+
55+
User.system.run_given do |user|
56+
ProjectType.includes(:project, :type, :variant).find_each do |project_type|
57+
build_variant_for(project_type, user)
58+
end
59+
end
60+
61+
log_summary
62+
end
63+
64+
private
65+
66+
def build_variant_for(project_type, user)
67+
project = project_type.project
68+
type = project_type.effective_type
69+
70+
result = BuildVariantFromProjectService.new(user:, type:).call(project:)
71+
return log_failure(project, type, result) if result.failure?
72+
73+
# The service returns the type it was given when the project narrows nothing, which is the
74+
# signal that no variant is needed here.
75+
return @unchanged += 1 if result.result == type
76+
77+
resolve(project, type, result.result, user)
78+
end
79+
80+
def resolve(project, type, variant, user)
81+
result = Projects::Types::SwitchVariantService
82+
.new(user:, model: project)
83+
.call(source: type, target: variant)
84+
85+
return log_failure(project, type, result) if result.failure?
86+
87+
@built += 1
88+
end
89+
90+
def log_failure(project, type, result)
91+
@failed += 1
92+
93+
Rails.logger.error do
94+
"[#{self.class.name}] Skipped #{type.composite_name} in project #{project.identifier}: " \
95+
"#{result.errors.full_messages.join(', ')}"
96+
end
97+
end
98+
99+
def log_summary
100+
Rails.logger.info do
101+
"[#{self.class.name}] Built #{@built} variant(s), left #{@unchanged} project/type pair(s) " \
102+
"unchanged, skipped #{@failed} after failures."
103+
end
104+
end
105+
end
106+
end
Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
# frozen_string_literal: true
2+
3+
#-- copyright
4+
# OpenProject is an open source project management software.
5+
# Copyright (C) the OpenProject GmbH
6+
#
7+
# This program is free software; you can redistribute it and/or
8+
# modify it under the terms of the GNU General Public License version 3.
9+
#
10+
# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
11+
# Copyright (C) 2006-2013 Jean-Philippe Lang
12+
# Copyright (C) 2010-2013 the ChiliProject Team
13+
#
14+
# This program is free software; you can redistribute it and/or
15+
# modify it under the terms of the GNU General Public License
16+
# as published by the Free Software Foundation; either version 2
17+
# of the License, or (at your option) any later version.
18+
#
19+
# This program is distributed in the hope that it will be useful,
20+
# but WITHOUT ANY WARRANTY; without even the implied warranty of
21+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
22+
# GNU General Public License for more details.
23+
#
24+
# You should have received a copy of the GNU General Public License
25+
# along with this program; if not, write to the Free Software
26+
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
27+
#
28+
# See COPYRIGHT and LICENSE files for more details.
29+
#++
30+
31+
require "spec_helper"
32+
33+
RSpec.describe WorkPackageTypes::BuildProjectVariantsJob, with_flag: { type_variants: true } do
34+
let(:kept_field) { create(:work_package_custom_field, is_for_all: false) }
35+
let(:dropped_field) { create(:work_package_custom_field, is_for_all: false) }
36+
37+
let!(:type) do
38+
create(:type, name: "Bug", custom_fields: [kept_field, dropped_field]).tap do |type|
39+
type.attribute_groups = [["custom group", %w[assignee] + [kept_field, dropped_field].map(&:attribute_name)]]
40+
type.save!
41+
end
42+
end
43+
44+
let!(:narrowing_project) do
45+
create(:project, name: "Website Relaunch", types: [type], work_package_custom_fields: [kept_field])
46+
end
47+
48+
let!(:complete_project) do
49+
create(:project, name: "Intranet", types: [type], work_package_custom_fields: [kept_field, dropped_field])
50+
end
51+
52+
def resolved_type(project)
53+
project.reload.project_types.sole.effective_type
54+
end
55+
56+
subject(:run_job) { described_class.perform_now }
57+
58+
before { RequestStore.clear! }
59+
60+
it "builds a variant only for the project that narrows the form configuration" do
61+
expect { run_job }.to change(Type, :count).by(1)
62+
63+
expect(resolved_type(narrowing_project).own_name).to eq("Bug - Website Relaunch")
64+
expect(resolved_type(complete_project)).to eq(type)
65+
end
66+
67+
it "resolves the narrowing project to its variant" do
68+
run_job
69+
70+
variant = resolved_type(narrowing_project)
71+
72+
expect(variant).to be_variant
73+
expect(variant.parent).to eq(type)
74+
expect(variant.custom_fields).to contain_exactly(kept_field)
75+
end
76+
77+
it "keeps the project on the shared root type" do
78+
run_job
79+
80+
expect(narrowing_project.reload.types).to contain_exactly(type)
81+
end
82+
83+
it "leaves the root type untouched" do
84+
run_job
85+
86+
expect(type.reload.custom_fields).to contain_exactly(kept_field, dropped_field)
87+
expect(type.configuration_links).to be_empty
88+
end
89+
90+
it "does not retype the work packages" do
91+
work_package = create(:work_package, project: narrowing_project, type:)
92+
93+
expect { run_job }.not_to change { work_package.reload.type_id }
94+
end
95+
96+
it "is idempotent" do
97+
run_job
98+
99+
expect { described_class.perform_now }.not_to change(Type, :count)
100+
expect(resolved_type(narrowing_project).own_name).to eq("Bug - Website Relaunch")
101+
end
102+
103+
context "when a project already resolves to a variant that narrows further" do
104+
let(:third_field) { create(:work_package_custom_field, is_for_all: false) }
105+
106+
let!(:type) do
107+
create(:type, name: "Bug", custom_fields: [kept_field, dropped_field, third_field]).tap do |type|
108+
type.attribute_groups = [
109+
["custom group", %w[assignee] + [kept_field, dropped_field, third_field].map(&:attribute_name)]
110+
]
111+
type.save!
112+
end
113+
end
114+
115+
let!(:variant) { create(:type, name: "Regression", parent: type) }
116+
117+
let!(:narrowing_project) do
118+
create(:project, name: "Website Relaunch", types: [variant], work_package_custom_fields: [kept_field])
119+
end
120+
121+
it "builds the new variant from the resolved variant rather than the root" do
122+
run_job
123+
124+
built = resolved_type(narrowing_project)
125+
126+
expect(built.own_name).to eq("Regression - Website Relaunch")
127+
expect(built.source_for(Type::ConfigurationLink::FORM_CONFIGURATION)).to eq(variant)
128+
expect(built.custom_fields).to contain_exactly(kept_field)
129+
end
130+
end
131+
132+
context "when the type_variants feature is inactive", with_flag: { type_variants: false } do
133+
it "refuses to run, as exclusions would have no effect" do
134+
expect { run_job }.to raise_error(/type_variants/)
135+
expect(resolved_type(narrowing_project)).to eq(type)
136+
end
137+
end
138+
139+
context "when one project fails" do
140+
before do
141+
allow(Projects::Types::SwitchVariantService)
142+
.to receive(:new)
143+
.and_return(instance_double(Projects::Types::SwitchVariantService,
144+
call: ServiceResult.failure(errors: ActiveModel::Errors.new(Project.new))))
145+
end
146+
147+
it "logs the failure and keeps going" do
148+
allow(Rails.logger).to receive(:error)
149+
150+
expect { run_job }.not_to raise_error
151+
expect(Rails.logger).to have_received(:error)
152+
end
153+
end
154+
end

0 commit comments

Comments
 (0)