mirror of
https://github.com/opf/openproject.git
synced 2026-06-13 19:20:00 +00:00
fd9a5bea77
Extract semantic project identifier regex into a composed constant
169 lines
7.4 KiB
Ruby
169 lines
7.4 KiB
Ruby
# frozen_string_literal: true
|
|
|
|
#-- copyright
|
|
# OpenProject is an open source project management software.
|
|
# Copyright (C) the OpenProject GmbH
|
|
#
|
|
# This program is free software; you can redistribute it and/or
|
|
# modify it under the terms of the GNU General Public License version 3.
|
|
#
|
|
# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
|
|
# Copyright (C) 2006-2013 Jean-Philippe Lang
|
|
# Copyright (C) 2010-2013 the ChiliProject Team
|
|
#
|
|
# This program is free software; you can redistribute it and/or
|
|
# modify it under the terms of the GNU General Public License
|
|
# as published by the Free Software Foundation; either version 2
|
|
# of the License, or (at your option) any later version.
|
|
#
|
|
# This program is distributed in the hope that it will be useful,
|
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
# GNU General Public License for more details.
|
|
#
|
|
# You should have received a copy of the GNU General Public License
|
|
# along with this program; if not, write to the Free Software
|
|
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
|
|
#
|
|
# See COPYRIGHT and LICENSE files for more details.
|
|
#++
|
|
|
|
module Projects::Identifier
|
|
extend ActiveSupport::Concern
|
|
|
|
CLASSIC_IDENTIFIER_MAX_LENGTH = 100
|
|
SEMANTIC_IDENTIFIER_MAX_LENGTH = 10
|
|
# Classic identifier format: lowercase letters, digits, hyphens, underscores — but not all-numeric.
|
|
CLASSIC_IDENTIFIER_FORMAT = /\A(?!\d+\z)[a-z0-9\-_]+\z/
|
|
|
|
# Unanchored shape of a semantic project identifier ("PROJ", "MY_PROJECT_1").
|
|
# Composed into `WorkPackage::SemanticIdentifier::SEMANTIC_ID_PATTERN`.
|
|
SEMANTIC_FORMAT = /[A-Z][A-Z0-9_]*/
|
|
|
|
RESERVED_IDENTIFIERS = %w[new menu queries filters identifier_update_dialog identifier_suggestion].freeze
|
|
|
|
included do
|
|
extend FriendlyId
|
|
|
|
normalizes :identifier, with: OpenProject::RemoveInvisibleCharacters
|
|
|
|
# Generators
|
|
# There are two supported formats:
|
|
# 1. slug identifiers (e.g. "project_one"), generated by acts_as_url
|
|
# * work package ID = global ID (e.g. "#123")
|
|
# 2. semantic identifiers (e.g. "PROJ1"), generated by the :generate_semantic_identifier hook
|
|
# * work package ID = {project identifier + dash + project-local sequence number ID} (e.g. "PROJ1-123")
|
|
acts_as_url :name,
|
|
url_attribute: :identifier,
|
|
sync_url: false, # Don't update identifier when name changes
|
|
only_when_blank: true, # Only generate when identifier not set
|
|
limit: CLASSIC_IDENTIFIER_MAX_LENGTH,
|
|
blacklist: RESERVED_IDENTIFIERS,
|
|
adapter: OpenProject::ActsAsUrl::Adapter::OpActiveRecord, # use a custom adapter able to handle edge cases
|
|
skip_if: -> { Setting::WorkPackageIdentifier.semantic? }
|
|
|
|
# Generate semantic identifier (when in the semantic mode)
|
|
before_validation :generate_semantic_identifier,
|
|
on: :create,
|
|
if: -> { Setting::WorkPackageIdentifier.semantic? && identifier.blank? }
|
|
|
|
validates :identifier,
|
|
presence: true,
|
|
uniqueness: { case_sensitive: false },
|
|
if: ->(p) { p.persisted? || p.identifier.present? }
|
|
|
|
validates :identifier, "projects/identifier" => true, if: :identifier_changed?
|
|
|
|
friendly_id :identifier, use: %i[finders history], slug_column: :identifier
|
|
|
|
# FriendlyId::Slugged adds after_validation :unset_slug_if_invalid, which reverts the
|
|
# slug column to its previous value when validation fails. With slug_column: :identifier,
|
|
# this would reset a manually-set identifier back to nil on new records. Since the
|
|
# identifier is managed by acts_as_url and user input (not FriendlyId's slug generator),
|
|
# we disable this behaviour entirely.
|
|
# Must be inside `included` to override FriendlyId::Slugged in the MRO.
|
|
def unset_slug_if_invalid; end
|
|
end
|
|
|
|
# Domain-named scopes for the FriendlyId::Slug relation returned by Project.identifier_slugs.
|
|
# Lets callers compose against verbs like .historically_reserved / .for_identifier / .upcased_values
|
|
# instead of raw SQL fragments — keeping FriendlyId::Slug column knowledge in one place.
|
|
module IdentifierSlugScopes
|
|
# Slugs that are no longer used as any active project's identifier, but remain reserved
|
|
# because FriendlyId still owns them — so they cannot be reused by another project.
|
|
def historically_reserved
|
|
where("LOWER(slug) NOT IN (SELECT LOWER(identifier) FROM projects)")
|
|
end
|
|
|
|
# Slugs whose lowercase form equals the lowercased input.
|
|
def for_identifier(value)
|
|
where("LOWER(slug) = ?", value.downcase)
|
|
end
|
|
|
|
# Excludes the given project's own slug history. No-op when project is nil.
|
|
def excluding_project(project)
|
|
project ? where.not(sluggable_id: project) : self
|
|
end
|
|
|
|
def upcased_values = pluck(Arel.sql("UPPER(slug)"))
|
|
def downcased_values = pluck(Arel.sql("LOWER(slug)"))
|
|
# Verbatim values, no case folding. Named `raw_values` to avoid colliding
|
|
# with `ActiveRecord::Relation#values` (an internal Rails method).
|
|
def raw_values = pluck(:slug)
|
|
end
|
|
|
|
class_methods do
|
|
def classic_identifier_format?(str)
|
|
str.match?(CLASSIC_IDENTIFIER_FORMAT)
|
|
end
|
|
|
|
# FriendlyId's :history module records a row on every save, so this relation contains
|
|
# both currently-used identifiers and historically-reserved ones. Compose with
|
|
# `.historically_reserved` to filter to the latter. The name aligns with FriendlyId's
|
|
# `project.slugs` association for vocabulary consistency.
|
|
def identifier_slugs
|
|
FriendlyId::Slug.where(sluggable_type: name).extending(IdentifierSlugScopes)
|
|
end
|
|
|
|
def suggest_identifier(name, mode: Setting[:work_packages_identifier])
|
|
if mode == Setting::WorkPackageIdentifier::SEMANTIC
|
|
exclude = ProjectIdentifiers::IdentifierAutofix::ProblematicIdentifiers.reserved_identifiers
|
|
ProjectIdentifiers::IdentifierAutofix::ProjectIdentifierSuggestionGenerator
|
|
.suggest_identifier(name, exclude:)
|
|
else
|
|
ProjectIdentifiers::ClassicIdentifierSuggestionGenerator.new.suggest_identifier(name)
|
|
end
|
|
end
|
|
end
|
|
|
|
def suggest_identifier(mode: Setting[:work_packages_identifier])
|
|
self.class.suggest_identifier(name, mode:)
|
|
end
|
|
|
|
# Override the `validation_context` getter to include the `default_validation_context` when the
|
|
# context is `:saving_custom_fields`. This is required, because the `acts_as_url` plugin from
|
|
# `stringex` defines a callback on the `:create` context for initialising the `identifier` field.
|
|
# Providing a custom context while creating the project, will not execute the callbacks on the
|
|
# `:create` or `:update` contexts, meaning the identifier will not get initialised.
|
|
# In order to initialise the identifier, the `default_validation_context` (`:create`, or `:update`)
|
|
# should be included when validating via the `:saving_custom_fields`. This way every create
|
|
# or update callback will also be executed alongside the `:saving_custom_fields` callbacks.
|
|
# This problem does not affect the contextless callbacks, they are always executed.
|
|
def validation_context
|
|
case Array(super)
|
|
in [*, :saving_custom_fields, *] => context
|
|
context | [default_validation_context]
|
|
else
|
|
super
|
|
end
|
|
end
|
|
|
|
private
|
|
|
|
def generate_semantic_identifier
|
|
return if name.blank?
|
|
|
|
self.identifier = self.class.suggest_identifier(name)
|
|
end
|
|
end
|