mirror of
https://github.com/opf/openproject.git
synced 2026-06-14 03:30:14 +00:00
706 lines
21 KiB
Ruby
706 lines
21 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 WorkPackages
|
|
class BaseContract < ::ModelContract
|
|
include ::Attachments::ValidateReplacements
|
|
include AssignableValuesContract
|
|
include WorkPackages::SetAttributesService::ProgressValuesCalculations
|
|
|
|
attribute :subject
|
|
attribute :description
|
|
attribute :status_id,
|
|
permission: %i[edit_work_packages change_work_package_status],
|
|
writable: ->(*) {
|
|
# If we did not change into the status,
|
|
# mark unwritable if status and version is closed
|
|
model.status_id_change || !closed_version_and_status?
|
|
}
|
|
attribute :type_id
|
|
attribute :priority_id
|
|
attribute :category_id
|
|
attribute :version_id,
|
|
permission: :assign_versions do
|
|
validate_version_is_assignable
|
|
end
|
|
|
|
validate :validate_no_reopen_on_closed_version
|
|
|
|
attribute :project_id
|
|
|
|
attribute :done_ratio,
|
|
writable: ->(*) {
|
|
WorkPackage.work_based_mode?
|
|
} do
|
|
next if invalid_work_or_remaining_work_values? # avoid too many error messages at the same time
|
|
|
|
validate_percent_complete_matches_work_and_remaining_work
|
|
validate_percent_complete_is_empty_when_work_is_zero
|
|
validate_percent_complete_is_set_when_work_and_remaining_work_are_set
|
|
end
|
|
attribute :derived_done_ratio,
|
|
writable: false
|
|
|
|
attribute :estimated_hours do
|
|
validate_work_is_set_when_remaining_work_and_percent_complete_are_set
|
|
end
|
|
attribute :derived_estimated_hours,
|
|
writable: false
|
|
|
|
attribute :remaining_hours do
|
|
validate_remaining_work_is_lower_than_work
|
|
validate_remaining_work_is_zero_or_empty_when_percent_complete_is_100p
|
|
validate_remaining_work_is_set_when_work_and_percent_complete_are_set
|
|
end
|
|
attribute :derived_remaining_hours,
|
|
writable: false
|
|
|
|
attribute :parent_id,
|
|
permission: :manage_subtasks
|
|
|
|
attribute :project_phase_definition_id,
|
|
permission: :view_project_phases do
|
|
validate_phase_active_in_project
|
|
end
|
|
attribute_alias :project_phase_definition_id, :project_phase_id
|
|
|
|
attribute :assigned_to_id do
|
|
next unless model.project
|
|
|
|
validate_people_visible :assigned_to,
|
|
"assigned_to_id",
|
|
assignable_assignees
|
|
end
|
|
|
|
attribute :responsible_id do
|
|
next unless model.project
|
|
|
|
validate_people_visible :responsible,
|
|
"responsible_id",
|
|
assignable_responsibles
|
|
end
|
|
|
|
attribute :schedule_manually
|
|
attribute :ignore_non_working_days,
|
|
writable: ->(*) {
|
|
leaf_or_manually_scheduled?
|
|
}
|
|
|
|
attribute :start_date,
|
|
writable: ->(*) {
|
|
leaf_or_manually_scheduled?
|
|
} do
|
|
validate_after_soonest_start(:start_date)
|
|
end
|
|
|
|
attribute :due_date,
|
|
writable: ->(*) {
|
|
leaf_or_manually_scheduled?
|
|
} do
|
|
validate_after_soonest_start(:due_date)
|
|
end
|
|
|
|
attribute :duration
|
|
|
|
attribute :budget
|
|
|
|
validates :subject,
|
|
presence: true,
|
|
unless: -> { model.type&.replacement_pattern_defined_for?(:subject) }
|
|
validates :subject, length: { maximum: 255 }
|
|
|
|
validates :due_date,
|
|
date: { after_or_equal_to: :start_date,
|
|
message: :greater_than_or_equal_to_start_date,
|
|
allow_blank: true },
|
|
unless: Proc.new { |wp| wp.start_date.blank? }
|
|
|
|
validate :validate_enabled_type
|
|
validate :validate_type_exists
|
|
|
|
validate :validate_milestone_constraint
|
|
validate :validate_parent_not_milestone
|
|
|
|
validate :validate_parent_exists
|
|
validate :validate_parent_in_same_project
|
|
validate :validate_parent_not_self
|
|
validate :validate_parent_not_subtask
|
|
|
|
validate :validate_status_exists
|
|
validate :validate_status_transition
|
|
|
|
validate :validate_active_priority
|
|
validate :validate_priority_exists
|
|
|
|
validate :validate_category
|
|
|
|
validate :validate_assigned_to_exists
|
|
|
|
validates :duration,
|
|
# only_integer: true, cannot be used as that will not compare with the value
|
|
# before the type cast. So even a float value will pass the validation as it is silently
|
|
# floored.
|
|
numericality: { greater_than: 0 },
|
|
allow_nil: true
|
|
|
|
validate :validate_duration_integer
|
|
validate :validate_duration_matches_dates
|
|
validate :validate_duration_constraint_for_milestone
|
|
|
|
validate :validate_duration_and_dates_are_not_derivable
|
|
|
|
def assignable_statuses(include_default: false)
|
|
# Do not allow skipping statuses without intermediately saving the work package.
|
|
# We therefore take the original status of the work_package, while preserving all
|
|
# other changes to it (e.g. type, assignee, etc.)
|
|
status = if model.persisted? && model.status_id_changed?
|
|
Status.find_by(id: model.status_id_was)
|
|
else
|
|
model.status
|
|
end
|
|
|
|
statuses = new_statuses_allowed_from(status)
|
|
|
|
statuses = statuses.or(Status.where_default) if include_default
|
|
|
|
statuses.order_by_position
|
|
end
|
|
|
|
def assignable_types
|
|
scope = if model.project.nil?
|
|
Type
|
|
else
|
|
model.project.types.includes(:color)
|
|
end
|
|
|
|
scope.includes(:color)
|
|
end
|
|
|
|
def assignable_categories
|
|
model.project.categories if model.project.respond_to?(:categories)
|
|
end
|
|
|
|
def assignable_priorities
|
|
IssuePriority.active
|
|
end
|
|
|
|
def assignable_project_phases
|
|
if model.project
|
|
model
|
|
.project
|
|
.phases
|
|
.active
|
|
.order_by_position
|
|
else
|
|
Project::Phase.none
|
|
end
|
|
end
|
|
|
|
def assignable_versions(only_open: true)
|
|
model.try(:assignable_versions, only_open:) if model.project
|
|
end
|
|
|
|
def assignable_budgets
|
|
model.project&.budgets
|
|
end
|
|
|
|
def assignable_assignees
|
|
if model.persisted?
|
|
Principal.possible_assignee(model)
|
|
elsif model.project
|
|
Principal.possible_assignee(model.project)
|
|
else
|
|
Principal.none
|
|
end
|
|
end
|
|
alias_method :assignable_responsibles, :assignable_assignees
|
|
|
|
def valid?(context = :saving_custom_fields) = super
|
|
|
|
def writable_attributes
|
|
attributes = super
|
|
|
|
unless auto_generated_attributes_writable?
|
|
attributes -= auto_generated_attribute_names
|
|
end
|
|
|
|
attributes
|
|
end
|
|
|
|
private
|
|
|
|
def validate_after_soonest_start(date_attribute)
|
|
return if model.schedule_manually?
|
|
return if model.children.any?
|
|
|
|
if before_soonest_start?(date_attribute)
|
|
message = I18n.t("activerecord.errors.models.work_package.attributes.start_date.violates_relationships",
|
|
soonest_start: model.soonest_start)
|
|
|
|
errors.add date_attribute, message, error_symbol: :violates_relationships
|
|
end
|
|
end
|
|
|
|
def validate_enabled_type
|
|
# Checks that the issue can not be added/moved to a disabled type
|
|
if type_context_changed? && model.project.types.exclude?(model.type)
|
|
errors.add :type_id, :inclusion
|
|
end
|
|
end
|
|
|
|
def validate_assigned_to_exists
|
|
errors.add :assigned_to, :does_not_exist if model.assigned_to.is_a?(Users::InexistentUser)
|
|
end
|
|
|
|
def validate_type_exists
|
|
errors.add :type, :does_not_exist if type_inexistent?
|
|
end
|
|
|
|
def validate_milestone_constraint
|
|
if model.is_milestone? && model.due_date && model.start_date && model.start_date != model.due_date
|
|
errors.add :due_date, :not_start_date
|
|
end
|
|
end
|
|
|
|
def validate_parent_not_milestone
|
|
if model.parent&.is_milestone?
|
|
errors.add :parent, :cannot_be_milestone
|
|
end
|
|
end
|
|
|
|
def validate_parent_exists
|
|
if model.parent.is_a?(WorkPackage::InexistentWorkPackage) ||
|
|
(model.parent_id && model.parent.nil?)
|
|
errors.add :parent, :does_not_exist
|
|
end
|
|
end
|
|
|
|
def validate_parent_not_self
|
|
if model.parent == model
|
|
errors.delete(:parent_id) # remove the error added by closure_tree's cycle detection
|
|
errors.add :parent, :cannot_be_self_assigned
|
|
end
|
|
end
|
|
|
|
def validate_parent_in_same_project
|
|
if parent_in_different_project?
|
|
errors.add :parent, :cannot_be_in_another_project
|
|
end
|
|
end
|
|
|
|
# have to validate ourself as the parent relation is created after saving
|
|
def validate_parent_not_subtask
|
|
if model.parent_id_changed? &&
|
|
model.parent_id &&
|
|
errors.exclude?(:parent) &&
|
|
current_parent_unrelatable?
|
|
# closure_tree adds an error on :parent_id because of the cycle
|
|
# detection, and active_record sees the error when saving the children
|
|
# association and adds an error on :children as well. We need to remove
|
|
# them.
|
|
errors.delete(:parent_id) # remove the error added by closure_tree
|
|
errors.delete(:children) # remove the error added by active_record
|
|
# add our own error
|
|
errors.add :parent, :cant_link_a_work_package_with_a_descendant
|
|
end
|
|
end
|
|
|
|
def current_parent_unrelatable?
|
|
WorkPackage.relatable(model, Relation::TYPE_PARENT).where(id: model.parent_id).empty?
|
|
end
|
|
|
|
def validate_status_exists
|
|
errors.add :status, :does_not_exist if model.status && !status_exists?
|
|
end
|
|
|
|
def validate_status_transition
|
|
if status_changed? && status_exists? && !(model.type_id_changed? || status_transition_exists?)
|
|
errors.add :status_id, :status_transition_invalid
|
|
end
|
|
end
|
|
|
|
def validate_active_priority
|
|
if model.priority && !model.priority.active? && model.priority_id_changed?
|
|
errors.add :priority_id, :only_active_priorities_allowed
|
|
end
|
|
end
|
|
|
|
def validate_priority_exists
|
|
errors.add :priority, :does_not_exist if model.priority.is_a?(Priority::InexistentPriority)
|
|
end
|
|
|
|
def validate_category
|
|
if inexistent_category?
|
|
errors.add :category, :does_not_exist
|
|
elsif category_not_of_project?
|
|
errors.add :category, :only_same_project_categories_allowed
|
|
end
|
|
end
|
|
|
|
def validate_version_is_assignable
|
|
if model.version_id && model.assignable_versions.map(&:id).exclude?(model.version_id)
|
|
errors.add :version_id, :inclusion
|
|
end
|
|
end
|
|
|
|
def validate_remaining_work_is_lower_than_work
|
|
if remaining_work_exceeds_work?
|
|
if model.changed.include?("estimated_hours")
|
|
errors.add(:estimated_hours, :cant_be_inferior_to_remaining_work)
|
|
end
|
|
|
|
if model.changed.include?("remaining_hours")
|
|
errors.add(:remaining_hours, :cant_exceed_work)
|
|
end
|
|
end
|
|
end
|
|
|
|
def validate_work_is_set_when_remaining_work_and_percent_complete_are_set
|
|
if remaining_work_set_and_valid? && percent_complete_set_and_valid? && work_empty? && percent_complete != 100
|
|
errors.add(:estimated_hours, :must_be_set_when_remaining_work_and_percent_complete_are_set)
|
|
end
|
|
end
|
|
|
|
def validate_remaining_work_is_zero_or_empty_when_percent_complete_is_100p
|
|
return unless percent_complete == 100
|
|
|
|
if work_set_and_valid? && remaining_work != 0
|
|
errors.add(:remaining_hours, :must_be_set_to_zero_hours_when_work_is_set_and_percent_complete_is_100p)
|
|
elsif work_empty? && remaining_work_set?
|
|
errors.add(:remaining_hours, :must_be_empty_when_work_is_empty_and_percent_complete_is_100p)
|
|
end
|
|
end
|
|
|
|
def validate_remaining_work_is_set_when_work_and_percent_complete_are_set
|
|
return if percent_complete == 100
|
|
|
|
if work_set_and_valid? && percent_complete_set_and_valid? && remaining_work_empty?
|
|
errors.add(:remaining_hours, :must_be_set_when_work_and_percent_complete_are_set)
|
|
end
|
|
end
|
|
|
|
def validate_percent_complete_is_set_when_work_and_remaining_work_are_set
|
|
if work_set? && remaining_work_set? && work != 0 && percent_complete_empty?
|
|
errors.add(:done_ratio, :must_be_set_when_work_and_remaining_work_are_set)
|
|
end
|
|
end
|
|
|
|
def validate_percent_complete_matches_work_and_remaining_work
|
|
if correctable_percent_complete_value?(work:, remaining_work:, percent_complete:)
|
|
errors.add(:done_ratio, :does_not_match_work_and_remaining_work)
|
|
end
|
|
end
|
|
|
|
def validate_percent_complete_is_empty_when_work_is_zero
|
|
return if WorkPackage.status_based_mode?
|
|
|
|
if work == 0 && percent_complete_set?
|
|
errors.add(:done_ratio, :cannot_be_set_when_work_is_zero)
|
|
end
|
|
end
|
|
|
|
def work
|
|
model.estimated_hours
|
|
end
|
|
|
|
def work_set?
|
|
work.present?
|
|
end
|
|
|
|
def work_set_and_valid?
|
|
work_set? && work >= 0 && !model.errors.has_key?(:estimated_hours)
|
|
end
|
|
|
|
def work_empty?
|
|
work.nil?
|
|
end
|
|
|
|
def remaining_work
|
|
model.remaining_hours
|
|
end
|
|
|
|
def remaining_work_set?
|
|
remaining_work.present?
|
|
end
|
|
|
|
def remaining_work_set_and_valid?
|
|
remaining_work_set? && remaining_work >= 0 && !model.errors.has_key?(:remaining_hours)
|
|
end
|
|
|
|
def remaining_work_empty?
|
|
remaining_work.nil?
|
|
end
|
|
|
|
def invalid_work_or_remaining_work_values?
|
|
(work_set? && work.negative?) ||
|
|
(remaining_work_set? && remaining_work.negative?) ||
|
|
(model.errors.has_key?(:estimated_hours) || model.errors.has_key?(:remaining_hours)) ||
|
|
remaining_work_exceeds_work?
|
|
end
|
|
|
|
def remaining_work_exceeds_work?
|
|
# if % complete is 100%, then remaining work should be 0h or empty, so no
|
|
# need to display an error for remaining work exceeding work
|
|
return false if percent_complete == 100
|
|
|
|
work_set_and_valid? && remaining_work_set_and_valid? && remaining_work > work
|
|
end
|
|
|
|
def percent_complete
|
|
model.done_ratio
|
|
end
|
|
|
|
def percent_complete_set?
|
|
percent_complete.present?
|
|
end
|
|
|
|
def percent_complete_set_and_valid?
|
|
percent_complete_set? && percent_complete.between?(0, 100)
|
|
end
|
|
|
|
def percent_complete_empty?
|
|
percent_complete.nil?
|
|
end
|
|
|
|
def validate_no_reopen_on_closed_version
|
|
if model.version_id && model.reopened? && model.version.closed?
|
|
errors.add :base, I18n.t(:error_can_not_reopen_work_package_on_closed_version)
|
|
end
|
|
end
|
|
|
|
def validate_people_visible(attribute, id_attribute, list)
|
|
id = model[id_attribute]
|
|
|
|
return if id.nil? || id == 0 || model.changed.exclude?(id_attribute)
|
|
|
|
unless principal_visible?(id, list)
|
|
errors.add attribute,
|
|
I18n.t("api_v3.errors.validation.invalid_user_assigned_to_work_package",
|
|
property: I18n.t("attributes.#{attribute}"))
|
|
end
|
|
end
|
|
|
|
def validate_duration_integer
|
|
unless valid_duration?(model.duration_before_type_cast, model.duration)
|
|
errors.delete(:duration) # delete :greater_than error, because it's not relevant anymore
|
|
errors.add :duration, :not_an_integer
|
|
end
|
|
end
|
|
|
|
def valid_duration?(value, duration)
|
|
# the values don't match (e.g because a float was passed)
|
|
return false if !value.is_a?(String) && value != duration
|
|
|
|
if value.is_a?(String)
|
|
return true if value == "" && duration.nil?
|
|
|
|
# A string is passed, put the transformed value does not match
|
|
return false if value.to_i.to_s != value.strip
|
|
end
|
|
|
|
# duration is valid
|
|
true
|
|
end
|
|
|
|
def validate_duration_matches_dates
|
|
return unless calculated_duration && model.duration && model.duration > 0
|
|
|
|
if calculated_duration > model.duration
|
|
errors.add :duration, :smaller_than_dates
|
|
elsif calculated_duration < model.duration
|
|
errors.add :duration, :larger_than_dates
|
|
end
|
|
end
|
|
|
|
def validate_duration_constraint_for_milestone
|
|
if model.is_milestone? && model.duration != 1
|
|
errors.add :duration, :not_available_for_milestones
|
|
end
|
|
end
|
|
|
|
def validate_duration_and_dates_are_not_derivable
|
|
return if dates_derivation_impossible?
|
|
|
|
%i[start_date due_date duration].each do |field|
|
|
if not_set_but_others_are_present?(field)
|
|
errors.add field, :cannot_be_null
|
|
end
|
|
end
|
|
end
|
|
|
|
def validate_phase_active_in_project
|
|
if model.project.present? &&
|
|
model.project_phase_definition_id.present? &&
|
|
model.project_phase_definition_changed? &&
|
|
!project_definition_assignable?
|
|
errors.add :project_phase_id, :inclusion
|
|
end
|
|
end
|
|
|
|
def dates_derivation_impossible?
|
|
model.errors[:duration].any?
|
|
end
|
|
|
|
def not_set_but_others_are_present?(field)
|
|
other_fields = %i[start_date due_date duration].without(field)
|
|
model[field].nil? && model.values_at(*other_fields).all?(&:present?)
|
|
end
|
|
|
|
def readonly_attributes_unchanged
|
|
super.tap do
|
|
if already_in_readonly_status? && unauthenticated_changed.any?
|
|
# Better documentation on why a property is readonly.
|
|
errors.add :base, :readonly_status
|
|
end
|
|
end
|
|
end
|
|
|
|
def reduce_by_writable_permissions(attributes)
|
|
# If we're in a readonly status only allow other status transitions.
|
|
if already_in_readonly_status?
|
|
super & %w(status status_id)
|
|
else
|
|
super
|
|
end
|
|
end
|
|
|
|
def principal_visible?(id, list)
|
|
list.exists?(id:)
|
|
end
|
|
|
|
def before_soonest_start?(date_attribute)
|
|
model[date_attribute] &&
|
|
model.soonest_start &&
|
|
model[date_attribute] < model.soonest_start
|
|
end
|
|
|
|
def parent_in_different_project?
|
|
model.parent &&
|
|
model.parent.project != model.project &&
|
|
!Setting.cross_project_work_package_relations? &&
|
|
!model.parent.is_a?(WorkPackage::InexistentWorkPackage)
|
|
end
|
|
|
|
def inexistent_category?
|
|
model.category_id.present? && !model.category
|
|
end
|
|
|
|
def category_not_of_project?
|
|
model.category && (model.project.nil? || model.project.categories.exclude?(model.category))
|
|
end
|
|
|
|
def status_changed?
|
|
model.status_id_was != 0 && model.status_id_changed?
|
|
end
|
|
|
|
def status_exists?
|
|
model.status_id && model.status && !model.status.is_a?(Status::InexistentStatus)
|
|
end
|
|
|
|
def status_transition_exists?
|
|
assignable_statuses.exists?(model.status_id)
|
|
end
|
|
|
|
def type_context_changed?
|
|
model.project && !type_inexistent? && (model.type_id_changed? || model.project_id_changed?)
|
|
end
|
|
|
|
def type_inexistent?
|
|
model.type.is_a?(Type::InexistentType)
|
|
end
|
|
|
|
def project_definition_assignable?
|
|
assignable_project_phases.exists?(definition_id: model.project_phase_definition_id)
|
|
end
|
|
|
|
# Returns a scope of status the user is able to apply
|
|
def new_statuses_allowed_from(status)
|
|
return Status.none if status.nil?
|
|
|
|
current_status = Status.where(id: status.id)
|
|
|
|
return current_status if closed_version_and_status?(status)
|
|
|
|
statuses = new_statuses_by_workflow(status)
|
|
.or(current_status)
|
|
|
|
statuses = statuses.where(is_closed: false) if model.blocked?
|
|
|
|
statuses
|
|
end
|
|
|
|
def closed_version_and_status?(status = model.status)
|
|
model.version&.closed? && status.is_closed?
|
|
end
|
|
|
|
def new_statuses_by_workflow(status)
|
|
workflows = Workflow
|
|
.from_status(status.id,
|
|
model.type_id,
|
|
user_roles.map(&:id),
|
|
user_is_author?,
|
|
user_was_or_is_assignee?)
|
|
|
|
Status.where(id: workflows.select(:new_status_id))
|
|
end
|
|
|
|
def user_was_or_is_assignee?
|
|
model.assigned_to_id_changed? ? model.assigned_to_id_was == user.id : model.assigned_to_id == user.id
|
|
end
|
|
|
|
def user_is_author?
|
|
model.author == user
|
|
end
|
|
|
|
def user_roles
|
|
user.roles_for_work_package(model)
|
|
end
|
|
|
|
# We're in a readonly status and did not move into that status right now.
|
|
def already_in_readonly_status?
|
|
model.readonly_status? && !model.status_id_change
|
|
end
|
|
|
|
def calculated_duration
|
|
@calculated_duration ||= WorkPackages::Shared::Days.for(model).duration(model.start_date, model.due_date)
|
|
end
|
|
|
|
def leaf_or_manually_scheduled?
|
|
model.leaf? || model.schedule_manually?
|
|
end
|
|
|
|
def auto_generated_attributes_writable? = false
|
|
|
|
def auto_generated_attribute_names
|
|
(model.type && model.type.enabled_patterns&.keys&.map(&:to_s)) || []
|
|
end
|
|
end
|
|
end
|