You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

issue_relation.rb 8.3KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265
  1. # frozen_string_literal: true
  2. # Redmine - project management software
  3. # Copyright (C) 2006-2023 Jean-Philippe Lang
  4. #
  5. # This program is free software; you can redistribute it and/or
  6. # modify it under the terms of the GNU General Public License
  7. # as published by the Free Software Foundation; either version 2
  8. # of the License, or (at your option) any later version.
  9. #
  10. # This program is distributed in the hope that it will be useful,
  11. # but WITHOUT ANY WARRANTY; without even the implied warranty of
  12. # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  13. # GNU General Public License for more details.
  14. #
  15. # You should have received a copy of the GNU General Public License
  16. # along with this program; if not, write to the Free Software
  17. # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
  18. class IssueRelation < ApplicationRecord
  19. # Class used to represent the relations of an issue
  20. class Relations < Array
  21. include Redmine::I18n
  22. def initialize(issue, *args)
  23. @issue = issue
  24. super(*args)
  25. end
  26. def to_s(*args)
  27. map {|relation| relation.to_s(@issue)}.join(', ')
  28. end
  29. end
  30. include Redmine::SafeAttributes
  31. include Redmine::Utils::DateCalculation
  32. belongs_to :issue_from, :class_name => 'Issue'
  33. belongs_to :issue_to, :class_name => 'Issue'
  34. TYPE_RELATES = "relates"
  35. TYPE_DUPLICATES = "duplicates"
  36. TYPE_DUPLICATED = "duplicated"
  37. TYPE_BLOCKS = "blocks"
  38. TYPE_BLOCKED = "blocked"
  39. TYPE_PRECEDES = "precedes"
  40. TYPE_FOLLOWS = "follows"
  41. TYPE_COPIED_TO = "copied_to"
  42. TYPE_COPIED_FROM = "copied_from"
  43. TYPES = {
  44. TYPE_RELATES => {:name => :label_relates_to, :sym_name => :label_relates_to,
  45. :order => 1, :sym => TYPE_RELATES},
  46. TYPE_DUPLICATES => {:name => :label_duplicates, :sym_name => :label_duplicated_by,
  47. :order => 2, :sym => TYPE_DUPLICATED},
  48. TYPE_DUPLICATED => {:name => :label_duplicated_by, :sym_name => :label_duplicates,
  49. :order => 3, :sym => TYPE_DUPLICATES, :reverse => TYPE_DUPLICATES},
  50. TYPE_BLOCKS => {:name => :label_blocks, :sym_name => :label_blocked_by,
  51. :order => 4, :sym => TYPE_BLOCKED},
  52. TYPE_BLOCKED => {:name => :label_blocked_by, :sym_name => :label_blocks,
  53. :order => 5, :sym => TYPE_BLOCKS, :reverse => TYPE_BLOCKS},
  54. TYPE_PRECEDES => {:name => :label_precedes, :sym_name => :label_follows,
  55. :order => 6, :sym => TYPE_FOLLOWS},
  56. TYPE_FOLLOWS => {:name => :label_follows, :sym_name => :label_precedes,
  57. :order => 7, :sym => TYPE_PRECEDES, :reverse => TYPE_PRECEDES},
  58. TYPE_COPIED_TO => {:name => :label_copied_to, :sym_name => :label_copied_from,
  59. :order => 8, :sym => TYPE_COPIED_FROM},
  60. TYPE_COPIED_FROM => {:name => :label_copied_from, :sym_name => :label_copied_to,
  61. :order => 9, :sym => TYPE_COPIED_TO, :reverse => TYPE_COPIED_TO}
  62. }.freeze
  63. validates_presence_of :issue_from, :issue_to, :relation_type
  64. validates_inclusion_of :relation_type, :in => TYPES.keys
  65. validates_numericality_of :delay, :allow_nil => true
  66. validates_uniqueness_of :issue_to_id, :scope => :issue_from_id, :case_sensitive => true
  67. validate :validate_issue_relation
  68. before_save :handle_issue_order
  69. after_create :call_issues_relation_added_callback
  70. after_destroy :call_issues_relation_removed_callback
  71. safe_attributes 'relation_type',
  72. 'delay',
  73. 'issue_to_id'
  74. def safe_attributes=(attrs, user=User.current)
  75. if attrs.respond_to?(:to_unsafe_hash)
  76. attrs = attrs.to_unsafe_hash
  77. end
  78. return unless attrs.is_a?(Hash)
  79. attrs = attrs.deep_dup
  80. if issue_id = attrs.delete('issue_to_id')
  81. if issue_id.to_s.strip =~ /\A#?(\d+)\z/
  82. issue_id = $1.to_i
  83. self.issue_to = Issue.visible(user).find_by_id(issue_id)
  84. end
  85. end
  86. super(attrs)
  87. end
  88. def visible?(user=User.current)
  89. (issue_from.nil? || issue_from.visible?(user)) && (issue_to.nil? || issue_to.visible?(user))
  90. end
  91. def deletable?(user=User.current)
  92. visible?(user) &&
  93. ((issue_from.nil? || user.allowed_to?(:manage_issue_relations, issue_from.project)) ||
  94. (issue_to.nil? || user.allowed_to?(:manage_issue_relations, issue_to.project)))
  95. end
  96. def initialize(attributes=nil, *args)
  97. super
  98. if new_record?
  99. if relation_type.blank?
  100. self.relation_type = IssueRelation::TYPE_RELATES
  101. end
  102. end
  103. end
  104. def validate_issue_relation
  105. if issue_from && issue_to
  106. errors.add :issue_to_id, :invalid if issue_from_id == issue_to_id
  107. unless issue_from.project_id == issue_to.project_id ||
  108. Setting.cross_project_issue_relations?
  109. errors.add :issue_to_id, :not_same_project
  110. end
  111. if circular_dependency?
  112. errors.add :base, :circular_dependency
  113. end
  114. if issue_from.is_descendant_of?(issue_to) || issue_from.is_ancestor_of?(issue_to)
  115. errors.add :base, :cant_link_an_issue_with_a_descendant
  116. end
  117. end
  118. end
  119. def other_issue(issue)
  120. (self.issue_from_id == issue.id) ? issue_to : issue_from
  121. end
  122. # Returns the relation type for +issue+
  123. def relation_type_for(issue)
  124. if TYPES[relation_type]
  125. if self.issue_from_id == issue.id
  126. relation_type
  127. else
  128. TYPES[relation_type][:sym]
  129. end
  130. end
  131. end
  132. def label_for(issue)
  133. if TYPES[relation_type]
  134. TYPES[relation_type][(self.issue_from_id == issue.id) ? :name : :sym_name]
  135. else
  136. :unknow
  137. end
  138. end
  139. def to_s(issue=nil)
  140. issue ||= issue_from
  141. issue_text = block_given? ? yield(other_issue(issue)) : "##{other_issue(issue).try(:id)}"
  142. s = []
  143. s << l(label_for(issue))
  144. s << "(#{l('datetime.distance_in_words.x_days', :count => delay)})" if delay && delay != 0
  145. s << issue_text
  146. s.join(' ')
  147. end
  148. def css_classes_for(issue)
  149. "rel-#{relation_type_for(issue)}"
  150. end
  151. def handle_issue_order
  152. reverse_if_needed
  153. if relation_type == TYPE_PRECEDES
  154. self.delay ||= 0
  155. else
  156. self.delay = nil
  157. end
  158. set_issue_to_dates
  159. end
  160. def set_issue_to_dates(journal=nil)
  161. soonest_start = self.successor_soonest_start
  162. if soonest_start && issue_to
  163. issue_to.reschedule_on!(soonest_start, journal)
  164. end
  165. end
  166. def successor_soonest_start
  167. if (self.relation_type == TYPE_PRECEDES) && delay && issue_from &&
  168. (issue_from.start_date || issue_from.due_date)
  169. add_working_days((issue_from.due_date || issue_from.start_date), (1 + delay))
  170. end
  171. end
  172. def <=>(relation)
  173. return nil unless relation.is_a?(IssueRelation)
  174. r = TYPES[self.relation_type][:order] <=> TYPES[relation.relation_type][:order]
  175. r == 0 ? id <=> relation.id : r
  176. end
  177. def init_journals(user)
  178. issue_from.init_journal(user) if issue_from
  179. issue_to.init_journal(user) if issue_to
  180. end
  181. private
  182. # Reverses the relation if needed so that it gets stored in the proper way
  183. # Should not be reversed before validation so that it can be displayed back
  184. # as entered on new relation form.
  185. #
  186. # Orders relates relations by ID, so that uniqueness index in DB is triggered
  187. # on concurrent access.
  188. def reverse_if_needed
  189. if TYPES.has_key?(relation_type) && TYPES[relation_type][:reverse]
  190. issue_tmp = issue_to
  191. self.issue_to = issue_from
  192. self.issue_from = issue_tmp
  193. self.relation_type = TYPES[relation_type][:reverse]
  194. elsif relation_type == TYPE_RELATES && issue_from_id > issue_to_id
  195. self.issue_to, self.issue_from = issue_from, issue_to
  196. end
  197. end
  198. # Returns true if the relation would create a circular dependency
  199. def circular_dependency?
  200. case relation_type
  201. when 'follows'
  202. issue_from.would_reschedule? issue_to
  203. when 'precedes'
  204. issue_to.would_reschedule? issue_from
  205. when 'blocked'
  206. issue_from.blocks? issue_to
  207. when 'blocks'
  208. issue_to.blocks? issue_from
  209. when 'relates'
  210. self.class.where(issue_from_id: issue_to, issue_to_id: issue_from).present?
  211. else
  212. false
  213. end
  214. end
  215. def call_issues_relation_added_callback
  216. call_issues_callback :relation_added
  217. end
  218. def call_issues_relation_removed_callback
  219. call_issues_callback :relation_removed
  220. end
  221. def call_issues_callback(name)
  222. [issue_from, issue_to].each do |issue|
  223. if issue
  224. issue.send name, self
  225. end
  226. end
  227. end
  228. end