opf/openproject

View on GitHub
app/models/projects/acts_as_customizable_patches.rb

Summary

Maintainability
A
35 mins
Test Coverage
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) 2012-2024 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::ActsAsCustomizablePatches
  extend ActiveSupport::Concern

  attr_accessor :_limit_custom_fields_validation_to_section_id, :_query_available_custom_fields_on_global_level

  # attr_accessor :_limit_custom_fields_validation_to_field_id
  # not needed for now, but might be relevant if we want to have edit dialogs just for one custom field

  included do
    has_many :project_custom_field_project_mappings, class_name: "ProjectCustomFieldProjectMapping", foreign_key: :project_id,
                                                     dependent: :destroy, inverse_of: :project
    has_many :project_custom_fields, through: :project_custom_field_project_mappings, class_name: "ProjectCustomField"

    # we need to reset the query_available_custom_fields_on_global_level already after validation
    # as the update service just calls .valid? and returns if invalid
    # after_save is not touched in this case which causes the flag to stay active
    after_validation :set_query_available_custom_fields_to_project_level

    before_update :set_query_available_custom_fields_to_global_level

    before_create :reject_section_scoped_validation_for_creation
    before_create :build_missing_project_custom_field_project_mappings

    after_create :disable_custom_fields_with_empty_values
    after_save :reset_section_scoped_validation, :set_query_available_custom_fields_to_project_level

    def build_missing_project_custom_field_project_mappings
      # activate custom fields for this project (via mapping table) if values have been provided for custom_fields but no mapping exists
      custom_field_ids = project.custom_values
        .select { |cv| cv.value.present? }
        .pluck(:custom_field_id).uniq
      activated_custom_field_ids = project_custom_field_project_mappings.pluck(:custom_field_id).uniq

      mappings = (custom_field_ids - activated_custom_field_ids).uniq
        .map { |pcf_id| { project_id: id, custom_field_id: pcf_id } }

      project_custom_field_project_mappings.build(mappings)
    end

    def reset_section_scoped_validation
      # reset the section scope after saving
      # in order not to silently carry this setting in this instance
      self._limit_custom_fields_validation_to_section_id = nil
    end

    def set_query_available_custom_fields_to_global_level
      # query the available custom fields on a global level when updating custom field values
      # in order to support implicit activation of custom fields when values are provided during an update
      self._query_available_custom_fields_on_global_level = true
    end

    def set_query_available_custom_fields_to_project_level
      # reset the query_available_custom_fields_on_global_level after saving
      # in order not to silently carry this setting in this instance
      self._query_available_custom_fields_on_global_level = nil
    end

    def reject_section_scoped_validation_for_creation
      if _limit_custom_fields_validation_to_section_id.present?
        raise ArgumentError,
              "Section scoped validation is not supported for project creation, only for project updates"
      end
    end

    def disable_custom_fields_with_empty_values
      # run only on initial creation! (otherwise we would deactivate custom fields with empty values on every update!)
      #
      # ideally, `build_missing_project_custom_field_project_mappings` would not activate custom fields with empty values
      # but:
      # this hook is required as acts_as_customizable build custom values with their default value even if a blank value was provided in the project creation form
      # `build_missing_project_custom_field_project_mappings` will then activate the custom field although the user explicitly provided a blank value
      # in order to not patch `acts_as_customizable` further, we simply identify these custom values and deactivate the custom field
      custom_field_ids = project.custom_values.select { |cv| cv.value.blank? && !cv.required? }.pluck(:custom_field_id)

      project_custom_field_project_mappings.where(custom_field_id: custom_field_ids).destroy_all
    end

    def with_all_available_custom_fields
      # query the available custom fields on a global level when updating custom field values
      # in order to support implicit activation of custom fields when values are provided during an update
      self._query_available_custom_fields_on_global_level = true
      result = yield
      self._query_available_custom_fields_on_global_level = nil

      result
    end

    def available_custom_fields(global: false)
      # overrides acts_as_customizable
      # in contrast to acts_as_customizable, custom_fields are enabled per project
      # thus we need to check the project_custom_field_project_mappings
      custom_fields = ProjectCustomField
        .includes(:project_custom_field_section)
        .order("custom_field_sections.position", :position_in_custom_field_section)

      # Do not hide the invisble fields when accessing via the _query_available_custom_fields_on_global_level
      # flag. Due to the internal working of the acts_as_customizable plugin, when a project admin updates
      # the custom fields, it will clear out all the hidden fields that are not visible for them.
      # This happens because the `#ensure_custom_values_complete` will gather all the `custom_field_values`
      # and assigns them to the custom_fields association. If the `custom_field_values` do not contain the
      # hidden fields, they will be cleared from the association. The `custom_field_values` will contain the
      # hidden fields, only if they are returned from this method. Hence we should not hide them,
      # when accessed with the _query_available_custom_fields_on_global_level flag on.
      unless _query_available_custom_fields_on_global_level
        custom_fields = custom_fields.visible
      end

      # available_custom_fields is called from within the acts_as_customizable module
      # we don't want to adjust these calls, but need a way to query the available custom fields on a global level in some cases
      # thus we pass in this parameter as an instance flag implicitly here,
      # which is not nice but helps us to touch acts_as_customizable as little as possible
      #
      # additionally we provide the `global` parameter to allow querying the available custom fields on a global level
      # when we have explicit control over the call of `available_custom_fields`
      unless global || new_record? || _query_available_custom_fields_on_global_level
        custom_fields = custom_fields
          .where(id: project_custom_field_project_mappings.select(:custom_field_id))
          .or(ProjectCustomField.required)
      end

      custom_fields
    end

    def validate_custom_values
      # validate custom values only of a specified section
      # instead of validating ALL custom values like done in acts_as_customizable
      custom_field_section_ids = CustomField
        .where(id: custom_field_values.pluck(:custom_field_id))
        .where(custom_field_section_id: _limit_custom_fields_validation_to_section_id)
        .pluck(:id)

      super(custom_field_section_ids)
    end

    # we need to query the available custom fields on a global level when updating custom field values
    # in order to support implicit activation of custom fields when values are provided during an update
    def custom_field_values=(values)
      with_all_available_custom_fields { super }
    end

    # We need to query the available custom fields on a global level when
    # trying to set a custom field which is not enabled via the API e.g. custom_field_123="foo"
    # This implies implicit activation of the disabled custom fields via the API. As a side effect,
    # we will have empty CustomValue objects created for each custom field, regardless of its
    # enabled/disabled state in the project.
    def for_custom_field_accessor(method_symbol)
      with_all_available_custom_fields { super }
    end
  end
end