Skip to content

Commit d12a9e5

Browse files
authored
Merge pull request #24658 from opf/fnd-209-build-variant-from-project
[FND-209] Build service & job to generate project specific variants for form configs
2 parents 0c8e81b + 0fba536 commit d12a9e5

4 files changed

Lines changed: 615 additions & 0 deletions

File tree

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
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+
# Turns a project's per-project custom field deactivations into a variant, so the narrowing a
33+
# project used to express by disabling single fields becomes part of the form configuration
34+
# instead.
35+
#
36+
# The variant inherits every aspect from the given type and excludes only the custom fields the
37+
# project has not enabled. A project that narrows nothing needs no variant, so the given type is
38+
# returned unchanged — callers can assign the result to the project either way.
39+
class BuildVariantFromProjectService < ::BaseServices::BaseCallable
40+
def initialize(user:, type:)
41+
super()
42+
@user = user
43+
@source = type
44+
end
45+
46+
protected
47+
48+
def perform(*)
49+
project = params[:project]
50+
51+
elements = elements_to_exclude(project)
52+
return ServiceResult.success(result: source) if elements.empty?
53+
54+
build_variant(project, elements)
55+
end
56+
57+
private
58+
59+
attr_reader :source, :user
60+
61+
def build_variant(project, elements)
62+
result = nil
63+
64+
Type.transaction do
65+
result = create_variant(project)
66+
raise ActiveRecord::Rollback if result.failure?
67+
68+
variant = result.result
69+
link_aspects_to_source(variant)
70+
71+
exclusion = exclude_elements(variant, elements)
72+
if exclusion.failure?
73+
result = exclusion
74+
raise ActiveRecord::Rollback
75+
end
76+
end
77+
78+
result
79+
end
80+
81+
def create_variant(project)
82+
# TODO: When FND-204 is fully implemented and we can create project specific variants, let's ensure that
83+
# we create a project specific variant here.
84+
CreateService
85+
.new(user:)
86+
.call(name: variant_name(project), parent_id: source.root_id)
87+
end
88+
89+
# A variant is always a child of a root, so a variant built from another variant cannot nest
90+
# under it. The configuration links are what carry the relationship: pointing them at the
91+
# source variant makes its own exclusions accumulate with the ones added below.
92+
def link_aspects_to_source(variant)
93+
return unless source.variant?
94+
95+
Type::ConfigurationLink::ASPECTS.each { |aspect| variant.link!(aspect, source:) }
96+
end
97+
98+
def exclude_elements(variant, elements)
99+
ExcludedElements::AddService
100+
.new(user:, type: variant)
101+
.call(aspect: Type::ConfigurationLink::FORM_CONFIGURATION, elements:)
102+
end
103+
104+
# `source.custom_fields` is the type-level set the form configuration puts on a work package,
105+
# already resolved through the source's own links and exclusions. Whatever of it the project
106+
# has not enabled is exactly what disabling single fields used to hide.
107+
def elements_to_exclude(project)
108+
active_ids = project.all_work_package_custom_fields.pluck(:id)
109+
110+
source.custom_fields
111+
.reject { active_ids.include?(it.id) }
112+
.map(&:attribute_name)
113+
end
114+
115+
# Name of a variant must stay unique per root type
116+
def variant_name(project)
117+
base = "#{source.own_name} - #{project.name}"
118+
taken = Type.where(parent_id: source.root_id).pluck(:name).map(&:downcase)
119+
120+
return base unless taken.include?(base.downcase)
121+
122+
counter = 2
123+
counter += 1 while taken.include?("#{base} (#{counter})".downcase)
124+
"#{base} (#{counter})"
125+
end
126+
end
127+
end
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
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+
ApplicationRecord.transaction do
71+
result = BuildVariantFromProjectService.new(user:, type:).call(project:)
72+
rollback(project, type, result) if result.failure?
73+
74+
# The service returns the type it was given when the project narrows nothing, which is the
75+
# signal that no variant is needed here.
76+
next @unchanged += 1 if result.result == type
77+
78+
resolve(project, type, result.result, user)
79+
end
80+
end
81+
82+
def resolve(project, type, variant, user)
83+
result = Projects::Types::SwitchVariantService
84+
.new(user:, model: project, contract_class: EmptyContract)
85+
.call(source: type, target: variant)
86+
87+
rollback(project, type, result) if result.failure?
88+
89+
@built += 1
90+
end
91+
92+
def rollback(project, type, result)
93+
log_failure(project, type, result)
94+
95+
raise ActiveRecord::Rollback
96+
end
97+
98+
def log_failure(project, type, result)
99+
@failed += 1
100+
101+
Rails.logger.error do
102+
"[#{self.class.name}] Skipped #{type.composite_name} in project #{project.identifier}: " \
103+
"#{result.errors.full_messages.join(', ')}"
104+
end
105+
end
106+
107+
def log_summary
108+
Rails.logger.info do
109+
"[#{self.class.name}] Built #{@built} variant(s), left #{@unchanged} project/type pair(s) " \
110+
"unchanged, skipped #{@failed} after failures."
111+
end
112+
end
113+
end
114+
end

0 commit comments

Comments
 (0)