Skip to content

Commit 6fa71f2

Browse files
committed
Extract Projects::Identifier concern from Project model
Move all identifier-related logic out of the Project model into a dedicated Projects::Identifier concern. This includes: - Constants (IDENTIFIER_MAX_LENGTH, SEMANTIC_IDENTIFIER_MAX_LENGTH, RESERVED_IDENTIFIERS) - FriendlyId configuration and slug management - acts_as_url configuration for identifier generation - All identifier format validators (legacy and alphanumeric) - Historical identifier reservation check - suggest_identifier class method - validation_context override for :saving_custom_fields The unset_slug_if_invalid override is moved inside the `included` block to correctly override FriendlyId::Slugged in the MRO. Corresponding tests are moved from project_spec.rb to a new projects/identifier_spec.rb. No behavior changes — pure extraction and reorganization.
1 parent 81c43ca commit 6fa71f2

File tree

4 files changed

+300
-231
lines changed

4 files changed

+300
-231
lines changed

app/models/project.rb

Lines changed: 1 addition & 97 deletions
Original file line numberDiff line numberDiff line change
@@ -29,8 +29,6 @@
2929
#++
3030

3131
class Project < ApplicationRecord
32-
extend FriendlyId
33-
3432
include Projects::Activity
3533
include Projects::AncestorsFromRoot
3634
include Projects::CustomFields
@@ -40,16 +38,10 @@ class Project < ApplicationRecord
4038
include Projects::Versions
4139
include Projects::WorkPackageCustomFields
4240
include Projects::CreationWizard
41+
include Projects::Identifier
4342

4443
include ::Scopes::Scoped
4544

46-
# Maximum length for project identifiers
47-
IDENTIFIER_MAX_LENGTH = 100
48-
SEMANTIC_IDENTIFIER_MAX_LENGTH = 10
49-
50-
# reserved identifiers
51-
RESERVED_IDENTIFIERS = %w[new menu queries filters identifier_update_dialog identifier_suggestion].freeze
52-
5345
enum :workspace_type, {
5446
project: "project",
5547
program: "program",
@@ -135,25 +127,6 @@ class Project < ApplicationRecord
135127
# extended in Projects::CustomFields in order to support sections
136128
# and project-level activation of custom fields
137129

138-
# Override the `validation_context` getter to include the `default_validation_context` when the
139-
# context is `:saving_custom_fields`. This is required, because the `acts_as_url` plugin from
140-
# `stringex` defines a callback on the `:create` context for initialising the `identifier` field.
141-
# Providing a custom context while creating the project, will not execute the callbacks on the
142-
# `:create` or `:update` contexts, meaning the identifier will not get initialised.
143-
# In order to initialise the identifier, the `default_validation_context` (`:create`, or `:update`)
144-
# should be included when validating via the `:saving_custom_fields`. This way every create
145-
# or update callback will also be executed alongside the `:saving_custom_fields` callbacks.
146-
# This problem does not affect the contextless callbacks, they are always executed.
147-
148-
def validation_context
149-
case Array(super)
150-
in [*, :saving_custom_fields, *] => context
151-
context | [default_validation_context]
152-
else
153-
super
154-
end
155-
end
156-
157130
acts_as_searchable columns: %W(#{table_name}.name #{table_name}.identifier #{table_name}.description),
158131
date_column: "#{table_name}.created_at",
159132
project_key: "id",
@@ -193,53 +166,8 @@ def validation_context
193166
# neither development nor deployment setups are prepared for this
194167
# validates_presence_of :types
195168

196-
acts_as_url :name,
197-
url_attribute: :identifier,
198-
sync_url: false, # Don't update identifier when name changes
199-
only_when_blank: true, # Only generate when identifier not set
200-
limit: IDENTIFIER_MAX_LENGTH,
201-
blacklist: RESERVED_IDENTIFIERS,
202-
adapter: OpenProject::ActsAsUrl::Adapter::OpActiveRecord # use a custom adapter able to handle edge cases
203-
204-
### Validators for the legacy underscored identifier format (e.g. "project_one")
205-
validates :identifier,
206-
presence: true,
207-
uniqueness: { case_sensitive: true },
208-
length: { maximum: IDENTIFIER_MAX_LENGTH },
209-
exclusion: RESERVED_IDENTIFIERS,
210-
if: ->(p) { p.persisted? || p.identifier.present? }
211-
# Contains only a-z, 0-9, dashes and underscores but cannot consist of numbers only as it would clash with the id.
212-
validates :identifier,
213-
format: { with: /\A(?!^\d+\z)[a-z0-9\-_]+\z/ },
214-
if: ->(p) {
215-
p.identifier_changed? && p.identifier.present? && !Setting::WorkPackageIdentifier.alphanumeric?
216-
}
217-
218-
### Validators for the uppercase identifier format (e.g. "PROJ1")
219-
validates :identifier,
220-
format: { with: /\A[A-Z]/, message: :must_start_with_letter },
221-
if: ->(p) { p.identifier_changed? && p.identifier.present? && Setting::WorkPackageIdentifier.alphanumeric? }
222-
validates :identifier,
223-
format: { with: /\A[A-Z][A-Z0-9_]*\z/, message: :no_special_characters },
224-
length: { maximum: SEMANTIC_IDENTIFIER_MAX_LENGTH },
225-
if: ->(p) { p.identifier_changed? && p.identifier.present? && Setting::WorkPackageIdentifier.alphanumeric? }
226-
227-
# Complements the uniqueness validation above: once an identifier has been used by a
228-
# project, it remains reserved for that project even after the project moves to a new
229-
# identifier. This prevents another project from claiming a "retired" identifier.
230-
validate :identifier_not_historically_reserved, if: ->(p) { p.identifier_changed? }
231-
232169
validates_associated :repository, :wiki
233170

234-
friendly_id :identifier, use: %i[finders history], slug_column: :identifier
235-
236-
# FriendlyId::Slugged adds after_validation :unset_slug_if_invalid, which reverts the
237-
# slug column to its previous value when validation fails. With slug_column: :identifier,
238-
# this would reset a manually-set identifier back to nil on new records. Since the
239-
# identifier is managed by acts_as_url and user input (not FriendlyId's slug generator),
240-
# we disable this behaviour entirely.
241-
def unset_slug_if_invalid; end
242-
243171
scopes :activated_in_storage,
244172
:allowed_to,
245173
:assignable_parents,
@@ -291,14 +219,6 @@ def copy_allowed?
291219
User.current.allowed_in_project?(:copy_projects, self)
292220
end
293221

294-
def self.suggest_identifier(name)
295-
if Setting::WorkPackageIdentifier.alphanumeric?
296-
WorkPackages::IdentifierAutofix::ProjectIdentifierSuggestionGenerator.suggest_identifier(name)
297-
else # This should closely enough emulate Project models' usage of acts_as_url
298-
name.to_url.first(IDENTIFIER_MAX_LENGTH).presence || "project"
299-
end
300-
end
301-
302222
def self.selectable_projects
303223
Project.visible.select { |p| User.current.member_of? p }.sort_by(&:to_s)
304224
end
@@ -388,20 +308,4 @@ def module_disabled(disabled_module)
388308
OpenProject::Events::MODULE_DISABLED, disabled_module:
389309
)
390310
end
391-
392-
private
393-
394-
# Checks friendly_id_slugs for any project that previously used this identifier and
395-
# has since changed it. It allows to switch back to an identifier the project itself
396-
# has used before.
397-
def identifier_not_historically_reserved
398-
return if errors.any? { |error| error.attribute == :identifier && error.type == :taken }
399-
400-
already_existing = FriendlyId::Slug
401-
.where(slug: identifier, sluggable_type: self.class.to_s)
402-
.where.not(sluggable_id: id)
403-
.exists?
404-
405-
errors.add(:identifier, :taken) if already_existing
406-
end
407311
end

app/models/projects/identifier.rb

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
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 Projects::Identifier
32+
extend ActiveSupport::Concern
33+
34+
IDENTIFIER_MAX_LENGTH = 100
35+
SEMANTIC_IDENTIFIER_MAX_LENGTH = 10
36+
RESERVED_IDENTIFIERS = %w[new menu queries filters identifier_update_dialog identifier_suggestion].freeze
37+
38+
included do
39+
extend FriendlyId
40+
41+
acts_as_url :name,
42+
url_attribute: :identifier,
43+
sync_url: false, # Don't update identifier when name changes
44+
only_when_blank: true, # Only generate when identifier not set
45+
limit: IDENTIFIER_MAX_LENGTH,
46+
blacklist: RESERVED_IDENTIFIERS,
47+
adapter: OpenProject::ActsAsUrl::Adapter::OpActiveRecord # use a custom adapter able to handle edge cases
48+
49+
### Validators for the legacy underscored identifier format (e.g. "project_one")
50+
validates :identifier,
51+
presence: true,
52+
uniqueness: { case_sensitive: true },
53+
length: { maximum: IDENTIFIER_MAX_LENGTH },
54+
exclusion: RESERVED_IDENTIFIERS,
55+
if: ->(p) { p.persisted? || p.identifier.present? }
56+
# Contains only a-z, 0-9, dashes and underscores but cannot consist of numbers only as it would clash with the id.
57+
validates :identifier,
58+
format: { with: /\A(?!^\d+\z)[a-z0-9\-_]+\z/ },
59+
if: ->(p) {
60+
p.identifier_changed? && p.identifier.present? && !Setting::WorkPackageIdentifier.alphanumeric?
61+
}
62+
63+
### Validators for the uppercase identifier format (e.g. "PROJ1")
64+
validates :identifier,
65+
format: { with: /\A[A-Z]/, message: :must_start_with_letter },
66+
if: ->(p) { p.identifier_changed? && p.identifier.present? && Setting::WorkPackageIdentifier.alphanumeric? }
67+
validates :identifier,
68+
format: { with: /\A[A-Z][A-Z0-9_]*\z/, message: :no_special_characters },
69+
length: { maximum: SEMANTIC_IDENTIFIER_MAX_LENGTH },
70+
if: ->(p) { p.identifier_changed? && p.identifier.present? && Setting::WorkPackageIdentifier.alphanumeric? }
71+
72+
# Complements the uniqueness validation above: once an identifier has been used by a
73+
# project, it remains reserved for that project even after the project moves to a new
74+
# identifier. This prevents another project from claiming a "retired" identifier.
75+
validate :identifier_not_historically_reserved, if: ->(p) { p.identifier_changed? }
76+
77+
friendly_id :identifier, use: %i[finders history], slug_column: :identifier
78+
79+
# FriendlyId::Slugged adds after_validation :unset_slug_if_invalid, which reverts the
80+
# slug column to its previous value when validation fails. With slug_column: :identifier,
81+
# this would reset a manually-set identifier back to nil on new records. Since the
82+
# identifier is managed by acts_as_url and user input (not FriendlyId's slug generator),
83+
# we disable this behaviour entirely.
84+
# Must be inside `included` to override FriendlyId::Slugged in the MRO.
85+
def unset_slug_if_invalid; end
86+
end
87+
88+
class_methods do
89+
def suggest_identifier(name)
90+
if Setting::WorkPackageIdentifier.alphanumeric?
91+
WorkPackages::IdentifierAutofix::ProjectIdentifierSuggestionGenerator.suggest_identifier(name)
92+
else # This should closely enough emulate Project models' usage of acts_as_url
93+
name.to_url.first(IDENTIFIER_MAX_LENGTH).presence || "project"
94+
end
95+
end
96+
end
97+
98+
# Override the `validation_context` getter to include the `default_validation_context` when the
99+
# context is `:saving_custom_fields`. This is required, because the `acts_as_url` plugin from
100+
# `stringex` defines a callback on the `:create` context for initialising the `identifier` field.
101+
# Providing a custom context while creating the project, will not execute the callbacks on the
102+
# `:create` or `:update` contexts, meaning the identifier will not get initialised.
103+
# In order to initialise the identifier, the `default_validation_context` (`:create`, or `:update`)
104+
# should be included when validating via the `:saving_custom_fields`. This way every create
105+
# or update callback will also be executed alongside the `:saving_custom_fields` callbacks.
106+
# This problem does not affect the contextless callbacks, they are always executed.
107+
def validation_context
108+
case Array(super)
109+
in [*, :saving_custom_fields, *] => context
110+
context | [default_validation_context]
111+
else
112+
super
113+
end
114+
end
115+
116+
private
117+
118+
# Checks friendly_id_slugs for any project that previously used this identifier and
119+
# has since changed it. It allows to switch back to an identifier the project itself
120+
# has used before.
121+
def identifier_not_historically_reserved
122+
return if errors.any? { |error| error.attribute == :identifier && error.type == :taken }
123+
124+
already_existing = FriendlyId::Slug
125+
.where(slug: identifier, sluggable_type: self.class.to_s)
126+
.where.not(sluggable_id: id)
127+
.exists?
128+
129+
errors.add(:identifier, :taken) if already_existing
130+
end
131+
end

0 commit comments

Comments
 (0)