Files
Yauheni Suhakou 0de0713a53 Big rename
Renames `scored_list` to `weighted_item_list`
2025-10-30 15:36:55 +01:00

195 lines
7.2 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.
#++
# Methods for custom fields with a format of "calculated value".
# Should be included in the CustomField model.
module CustomField::CalculatedValue
extend ActiveSupport::Concern
# Mathematical operators that are allowed in the formula.
MATH_OPERATORS_FOR_FORMULA = %w[+ - * / % ( )].freeze
# Field formats that can be used within a formula.
FIELD_FORMATS_FOR_FORMULA = %w[int float calculated_value weighted_item_list].freeze
def self.calculator_instance
Dentaku::Calculator.new(case_sensitive: true)
end
class_methods do
def with_formula_referencing(id)
where("(formula -> 'referenced_custom_fields') @> ?", id)
end
# Select custom fields of type calculated_value that are listed in
# changed_cf_ids, or are referencing custom fields in changed_cf_ids either
# directly or through other calculated fields
def affected_calculated_fields(changed_cf_ids)
return [] if changed_cf_ids.empty?
# exclude ids that are not in the scope
changed_cf_ids = where(id: changed_cf_ids).pluck(:id)
return [] if changed_cf_ids.empty?
to_check = field_format_calculated_value
# include calculated value fields themselves
all_affected, to_check = to_check.partition { it.id.in?(changed_cf_ids) }
loop do
affected, to_check = to_check.partition { it.formula_referenced_custom_field_ids.intersect?(changed_cf_ids) }
break if affected.empty?
all_affected += affected
changed_cf_ids = affected.map(&:id)
end
all_affected
end
end
included do
validate :validate_formula, if: :field_format_calculated_value?
def validate_formula
if formula_string.blank?
errors.add(:formula, :blank)
elsif !formula_contains_only_allowed_characters?
errors.add(:formula, :invalid_characters)
elsif !valid_formula_syntax?
errors.add(:formula, :invalid)
else
validate_referenced_custom_fields
end
end
def formula=(value)
if value.is_a?(String)
super({ formula: value, referenced_custom_fields: cf_ids_used_in_formula(value) })
else
super
end
end
# Returns the formula as a string. Will return an empty string if the formula is not set.
def formula_string
formula ? formula.fetch("formula", "") : ""
end
def formula_referenced_custom_field_ids
formula ? formula.fetch("referenced_custom_fields", []) : []
end
def usable_custom_field_references_for_formula
visible_cfs = ProjectCustomField
.where(field_format: FIELD_FORMATS_FOR_FORMULA)
.where.not(id:)
.visible
cache = {}
visible_cfs.reject do |custom_field|
custom_field.formula_references_id?(id, cache)
end
end
def validate_referenced_custom_fields
# We can only validate used custom fields from a high-level perspective, since at this point in validation,
# we do not have a project context to check against. So we cannot check if the custom fields are actually
# enabled for a project and visible to a non-admin user.
formula_cfs = formula_referenced_custom_field_ids
allowed_cfs = usable_custom_field_references_for_formula.pluck(:id)
surplus_cfs = formula_cfs - allowed_cfs
if surplus_cfs.any?
custom_field_names = CustomField.where(id: surplus_cfs).pluck(:name)
errors.add(:formula, :not_allowed_custom_fields_referenced, custom_fields: custom_field_names.join(", "))
end
end
def formula_references_id?(original_id, cache = {})
cache.fetch(id) do
cache[id] = if field_format_calculated_value?
referenced_custom_fields = formula_referenced_custom_field_ids
if referenced_custom_fields.include?(original_id) || referenced_custom_fields.include?(id)
true
else
ProjectCustomField.where(id: referenced_custom_fields).any? do |referenced_field|
referenced_field.formula_references_id?(original_id, cache)
end
end
else
false
end
end
end
# Returns `formula_string` with all custom field references detokenized so that they are parseable by Dentaku.
# For example, for `2 + {{cf_12}} + {{cf_4}}` it returns `2 + cf_12 + cf_4`.
def formula_str_without_patterns
formula_string.gsub(/\{\{(cf_\d+)}}/, '\1')
end
private
def valid_formula_syntax?
# Attempt to parse the formula. If no error is returned, the formula is syntactically valid.
CustomField::CalculatedValue.calculator_instance.ast(formula_str_without_patterns)
true
rescue Dentaku::ParseError, Dentaku::TokenizerError
false
end
def formula_contains_only_allowed_characters?
# List of allowed characters in a formula. This only performs a very basic validation.
# The allowed characters are:
# Our mathematical operators, whitespace, digits and decimal points
# Additionally, the formula may contain references to custom fields in the form of `{{cf_123}}`
# where 123 is the ID of the custom field.
# Once this basic validation passes, the formula will be parsed and validated by Dentaku, which builds an AST
# and ensures that the formula is really valid. A welcome side effect of the basic validation done here is that
# it prevents built-in functions from being used in the formula, which we do not want to allow.
allowed_chars = MATH_OPERATORS_FOR_FORMULA + [" "]
allowed_tokens = /\A(\{\{cf_\d+}}|[\d.]+)\z/
formula_string.split(Regexp.union(allowed_chars)).all? do |token|
token.empty? || token.match?(allowed_tokens)
end
end
# Returns a list of custom field IDs used in the formula.
# For a formula like `2 + {{cf_12}} + {{cf_4}}` it returns `[12, 4]`.
def cf_ids_used_in_formula(formula_str)
formula_str.scan(/(?<=\{\{cf_)\d+(?=}})/).map(&:to_i).uniq
end
end
end