comparison app/models/issue.rb @ 1338:25603efa57b5

Merge from live branch
author Chris Cannam
date Thu, 20 Jun 2013 13:14:14 +0100
parents 0a574315af3e
children 4f746d8966dd fb9a13467253
comparison
equal deleted inserted replaced
1209:1b1138f6f55e 1338:25603efa57b5
1 # Redmine - project management software 1 # Redmine - project management software
2 # Copyright (C) 2006-2011 Jean-Philippe Lang 2 # Copyright (C) 2006-2012 Jean-Philippe Lang
3 # 3 #
4 # This program is free software; you can redistribute it and/or 4 # This program is free software; you can redistribute it and/or
5 # modify it under the terms of the GNU General Public License 5 # modify it under the terms of the GNU General Public License
6 # as published by the Free Software Foundation; either version 2 6 # as published by the Free Software Foundation; either version 2
7 # of the License, or (at your option) any later version. 7 # of the License, or (at your option) any later version.
15 # along with this program; if not, write to the Free Software 15 # along with this program; if not, write to the Free Software
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. 16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17 17
18 class Issue < ActiveRecord::Base 18 class Issue < ActiveRecord::Base
19 include Redmine::SafeAttributes 19 include Redmine::SafeAttributes
20 include Redmine::Utils::DateCalculation
20 21
21 belongs_to :project 22 belongs_to :project
22 belongs_to :tracker 23 belongs_to :tracker
23 belongs_to :status, :class_name => 'IssueStatus', :foreign_key => 'status_id' 24 belongs_to :status, :class_name => 'IssueStatus', :foreign_key => 'status_id'
24 belongs_to :author, :class_name => 'User', :foreign_key => 'author_id' 25 belongs_to :author, :class_name => 'User', :foreign_key => 'author_id'
26 belongs_to :fixed_version, :class_name => 'Version', :foreign_key => 'fixed_version_id' 27 belongs_to :fixed_version, :class_name => 'Version', :foreign_key => 'fixed_version_id'
27 belongs_to :priority, :class_name => 'IssuePriority', :foreign_key => 'priority_id' 28 belongs_to :priority, :class_name => 'IssuePriority', :foreign_key => 'priority_id'
28 belongs_to :category, :class_name => 'IssueCategory', :foreign_key => 'category_id' 29 belongs_to :category, :class_name => 'IssueCategory', :foreign_key => 'category_id'
29 30
30 has_many :journals, :as => :journalized, :dependent => :destroy 31 has_many :journals, :as => :journalized, :dependent => :destroy
32 has_many :visible_journals,
33 :class_name => 'Journal',
34 :as => :journalized,
35 :conditions => Proc.new {
36 ["(#{Journal.table_name}.private_notes = ? OR (#{Project.allowed_to_condition(User.current, :view_private_notes)}))", false]
37 },
38 :readonly => true
39
31 has_many :time_entries, :dependent => :delete_all 40 has_many :time_entries, :dependent => :delete_all
32 has_and_belongs_to_many :changesets, :order => "#{Changeset.table_name}.committed_on ASC, #{Changeset.table_name}.id ASC" 41 has_and_belongs_to_many :changesets, :order => "#{Changeset.table_name}.committed_on ASC, #{Changeset.table_name}.id ASC"
33 42
34 has_many :relations_from, :class_name => 'IssueRelation', :foreign_key => 'issue_from_id', :dependent => :delete_all 43 has_many :relations_from, :class_name => 'IssueRelation', :foreign_key => 'issue_from_id', :dependent => :delete_all
35 has_many :relations_to, :class_name => 'IssueRelation', :foreign_key => 'issue_to_id', :dependent => :delete_all 44 has_many :relations_to, :class_name => 'IssueRelation', :foreign_key => 'issue_to_id', :dependent => :delete_all
37 acts_as_nested_set :scope => 'root_id', :dependent => :destroy 46 acts_as_nested_set :scope => 'root_id', :dependent => :destroy
38 acts_as_attachable :after_add => :attachment_added, :after_remove => :attachment_removed 47 acts_as_attachable :after_add => :attachment_added, :after_remove => :attachment_removed
39 acts_as_customizable 48 acts_as_customizable
40 acts_as_watchable 49 acts_as_watchable
41 acts_as_searchable :columns => ['subject', "#{table_name}.description", "#{Journal.table_name}.notes"], 50 acts_as_searchable :columns => ['subject', "#{table_name}.description", "#{Journal.table_name}.notes"],
42 :include => [:project, :journals], 51 :include => [:project, :visible_journals],
43 # sort by id so that limited eager loading doesn't break with postgresql 52 # sort by id so that limited eager loading doesn't break with postgresql
44 :order_column => "#{table_name}.id" 53 :order_column => "#{table_name}.id"
45 acts_as_event :title => Proc.new {|o| "#{o.tracker.name} ##{o.id} (#{o.status}): #{o.subject}"}, 54 acts_as_event :title => Proc.new {|o| "#{o.tracker.name} ##{o.id} (#{o.status}): #{o.subject}"},
46 :url => Proc.new {|o| {:controller => 'issues', :action => 'show', :id => o.id}}, 55 :url => Proc.new {|o| {:controller => 'issues', :action => 'show', :id => o.id}},
47 :type => Proc.new {|o| 'issue' + (o.closed? ? ' closed' : '') } 56 :type => Proc.new {|o| 'issue' + (o.closed? ? ' closed' : '') }
50 :author_key => :author_id 59 :author_key => :author_id
51 60
52 DONE_RATIO_OPTIONS = %w(issue_field issue_status) 61 DONE_RATIO_OPTIONS = %w(issue_field issue_status)
53 62
54 attr_reader :current_journal 63 attr_reader :current_journal
64 delegate :notes, :notes=, :private_notes, :private_notes=, :to => :current_journal, :allow_nil => true
55 65
56 validates_presence_of :subject, :priority, :project, :tracker, :author, :status 66 validates_presence_of :subject, :priority, :project, :tracker, :author, :status
57 67
58 validates_length_of :subject, :maximum => 255 68 validates_length_of :subject, :maximum => 255
59 validates_inclusion_of :done_ratio, :in => 0..100 69 validates_inclusion_of :done_ratio, :in => 0..100
60 validates_numericality_of :estimated_hours, :allow_nil => true 70 validates_numericality_of :estimated_hours, :allow_nil => true
61 validate :validate_issue 71 validate :validate_issue, :validate_required_fields
62 72
63 named_scope :visible, lambda {|*args| { :include => :project, 73 scope :visible,
64 :conditions => Issue.visible_condition(args.shift || User.current, *args) } } 74 lambda {|*args| { :include => :project,
65 75 :conditions => Issue.visible_condition(args.shift || User.current, *args) } }
66 named_scope :open, :conditions => ["#{IssueStatus.table_name}.is_closed = ?", false], :include => :status 76
67 77 scope :open, lambda {|*args|
68 named_scope :recently_updated, :order => "#{Issue.table_name}.updated_on DESC" 78 is_closed = args.size > 0 ? !args.first : false
69 named_scope :with_limit, lambda { |limit| { :limit => limit} } 79 {:conditions => ["#{IssueStatus.table_name}.is_closed = ?", is_closed], :include => :status}
70 named_scope :on_active_project, :include => [:status, :project, :tracker],
71 :conditions => ["#{Project.table_name}.status=#{Project::STATUS_ACTIVE}"]
72
73 named_scope :without_version, lambda {
74 {
75 :conditions => { :fixed_version_id => nil}
76 }
77 } 80 }
78 81
79 named_scope :with_query, lambda {|query| 82 scope :recently_updated, :order => "#{Issue.table_name}.updated_on DESC"
80 { 83 scope :on_active_project, :include => [:status, :project, :tracker],
81 :conditions => Query.merge_conditions(query.statement) 84 :conditions => ["#{Project.table_name}.status=#{Project::STATUS_ACTIVE}"]
82 }
83 }
84 85
85 before_create :default_assign 86 before_create :default_assign
86 before_save :close_duplicates, :update_done_ratio_from_issue_status 87 before_save :close_duplicates, :update_done_ratio_from_issue_status, :force_updated_on_change
88 after_save {|issue| issue.send :after_project_change if !issue.id_changed? && issue.project_id_changed?}
87 after_save :reschedule_following_issues, :update_nested_set_attributes, :update_parent_attributes, :create_journal 89 after_save :reschedule_following_issues, :update_nested_set_attributes, :update_parent_attributes, :create_journal
90 # Should be after_create but would be called before previous after_save callbacks
91 after_save :after_create_from_copy
88 after_destroy :update_parent_attributes 92 after_destroy :update_parent_attributes
89 93
90 # Returns a SQL conditions string used to find all issues visible by the specified user 94 # Returns a SQL conditions string used to find all issues visible by the specified user
91 def self.visible_condition(user, options={}) 95 def self.visible_condition(user, options={})
92 Project.allowed_to_condition(user, :view_issues, options) do |role, user| 96 Project.allowed_to_condition(user, :view_issues, options) do |role, user|
93 case role.issues_visibility 97 if user.logged?
94 when 'all' 98 case role.issues_visibility
95 nil 99 when 'all'
96 when 'default' 100 nil
97 user_ids = [user.id] + user.groups.map(&:id) 101 when 'default'
98 "(#{table_name}.is_private = #{connection.quoted_false} OR #{table_name}.author_id = #{user.id} OR #{table_name}.assigned_to_id IN (#{user_ids.join(',')}))" 102 user_ids = [user.id] + user.groups.map(&:id)
99 when 'own' 103 "(#{table_name}.is_private = #{connection.quoted_false} OR #{table_name}.author_id = #{user.id} OR #{table_name}.assigned_to_id IN (#{user_ids.join(',')}))"
100 user_ids = [user.id] + user.groups.map(&:id) 104 when 'own'
101 "(#{table_name}.author_id = #{user.id} OR #{table_name}.assigned_to_id IN (#{user_ids.join(',')}))" 105 user_ids = [user.id] + user.groups.map(&:id)
106 "(#{table_name}.author_id = #{user.id} OR #{table_name}.assigned_to_id IN (#{user_ids.join(',')}))"
107 else
108 '1=0'
109 end
102 else 110 else
103 '1=0' 111 "(#{table_name}.is_private = #{connection.quoted_false})"
104 end 112 end
105 end 113 end
106 end 114 end
107 115
108 # Returns true if usr or current user is allowed to view the issue 116 # Returns true if usr or current user is allowed to view the issue
109 def visible?(usr=nil) 117 def visible?(usr=nil)
110 (usr || User.current).allowed_to?(:view_issues, self.project) do |role, user| 118 (usr || User.current).allowed_to?(:view_issues, self.project) do |role, user|
111 case role.issues_visibility 119 if user.logged?
112 when 'all' 120 case role.issues_visibility
113 true 121 when 'all'
114 when 'default' 122 true
115 !self.is_private? || self.author == user || user.is_or_belongs_to?(assigned_to) 123 when 'default'
116 when 'own' 124 !self.is_private? || (self.author == user || user.is_or_belongs_to?(assigned_to))
117 self.author == user || user.is_or_belongs_to?(assigned_to) 125 when 'own'
126 self.author == user || user.is_or_belongs_to?(assigned_to)
127 else
128 false
129 end
118 else 130 else
119 false 131 !self.is_private?
120 end 132 end
121 end 133 end
122 end 134 end
123 135
124 def after_initialize 136 def initialize(attributes=nil, *args)
137 super
125 if new_record? 138 if new_record?
126 # set default values for new records only 139 # set default values for new records only
127 self.status ||= IssueStatus.default 140 self.status ||= IssueStatus.default
128 self.priority ||= IssuePriority.default 141 self.priority ||= IssuePriority.default
129 end 142 self.watcher_user_ids = []
143 end
144 end
145
146 # AR#Persistence#destroy would raise and RecordNotFound exception
147 # if the issue was already deleted or updated (non matching lock_version).
148 # This is a problem when bulk deleting issues or deleting a project
149 # (because an issue may already be deleted if its parent was deleted
150 # first).
151 # The issue is reloaded by the nested_set before being deleted so
152 # the lock_version condition should not be an issue but we handle it.
153 def destroy
154 super
155 rescue ActiveRecord::RecordNotFound
156 # Stale or already deleted
157 begin
158 reload
159 rescue ActiveRecord::RecordNotFound
160 # The issue was actually already deleted
161 @destroyed = true
162 return freeze
163 end
164 # The issue was stale, retry to destroy
165 super
166 end
167
168 def reload(*args)
169 @workflow_rule_by_attribute = nil
170 @assignable_versions = nil
171 super
130 end 172 end
131 173
132 # Overrides Redmine::Acts::Customizable::InstanceMethods#available_custom_fields 174 # Overrides Redmine::Acts::Customizable::InstanceMethods#available_custom_fields
133 def available_custom_fields 175 def available_custom_fields
134 (project && tracker) ? (project.all_issue_custom_fields & tracker.custom_fields.all) : [] 176 (project && tracker) ? (project.all_issue_custom_fields & tracker.custom_fields.all) : []
135 end 177 end
136 178
137 def copy_from(arg) 179 # Copies attributes from another issue, arg can be an id or an Issue
180 def copy_from(arg, options={})
138 issue = arg.is_a?(Issue) ? arg : Issue.visible.find(arg) 181 issue = arg.is_a?(Issue) ? arg : Issue.visible.find(arg)
139 self.attributes = issue.attributes.dup.except("id", "root_id", "parent_id", "lft", "rgt", "created_on", "updated_on") 182 self.attributes = issue.attributes.dup.except("id", "root_id", "parent_id", "lft", "rgt", "created_on", "updated_on")
140 self.custom_field_values = issue.custom_field_values.inject({}) {|h,v| h[v.custom_field_id] = v.value; h} 183 self.custom_field_values = issue.custom_field_values.inject({}) {|h,v| h[v.custom_field_id] = v.value; h}
141 self.status = issue.status 184 self.status = issue.status
185 self.author = User.current
186 unless options[:attachments] == false
187 self.attachments = issue.attachments.map do |attachement|
188 attachement.copy(:container => self)
189 end
190 end
191 @copied_from = issue
192 @copy_options = options
142 self 193 self
194 end
195
196 # Returns an unsaved copy of the issue
197 def copy(attributes=nil, copy_options={})
198 copy = self.class.new.copy_from(self, copy_options)
199 copy.attributes = attributes if attributes
200 copy
201 end
202
203 # Returns true if the issue is a copy
204 def copy?
205 @copied_from.present?
143 end 206 end
144 207
145 # Moves/copies an issue to a new project and tracker 208 # Moves/copies an issue to a new project and tracker
146 # Returns the moved/copied issue on success, false on failure 209 # Returns the moved/copied issue on success, false on failure
147 def move_to_project(*args) 210 def move_to_project(new_project, new_tracker=nil, options={})
148 ret = Issue.transaction do 211 ActiveSupport::Deprecation.warn "Issue#move_to_project is deprecated, use #project= instead."
149 move_to_project_without_transaction(*args) || raise(ActiveRecord::Rollback) 212
150 end || false 213 if options[:copy]
151 end 214 issue = self.copy
152 215 else
153 def move_to_project_without_transaction(new_project, new_tracker = nil, options = {}) 216 issue = self
154 options ||= {} 217 end
155 issue = options[:copy] ? self.class.new.copy_from(self) : self 218
156 219 issue.init_journal(User.current, options[:notes])
157 if new_project && issue.project_id != new_project.id 220
158 # delete issue relations 221 # Preserve previous behaviour
159 unless Setting.cross_project_issue_relations? 222 # #move_to_project doesn't change tracker automatically
160 issue.relations_from.clear 223 issue.send :project=, new_project, true
161 issue.relations_to.clear
162 end
163 # issue is moved to another project
164 # reassign to the category with same name if any
165 new_category = issue.category.nil? ? nil : new_project.issue_categories.find_by_name(issue.category.name)
166 issue.category = new_category
167 # Keep the fixed_version if it's still valid in the new_project
168 unless new_project.shared_versions.include?(issue.fixed_version)
169 issue.fixed_version = nil
170 end
171 issue.project = new_project
172 if issue.parent && issue.parent.project_id != issue.project_id
173 issue.parent_issue_id = nil
174 end
175 end
176 if new_tracker 224 if new_tracker
177 issue.tracker = new_tracker 225 issue.tracker = new_tracker
178 issue.reset_custom_values!
179 end
180 if options[:copy]
181 issue.author = User.current
182 issue.custom_field_values = self.custom_field_values.inject({}) {|h,v| h[v.custom_field_id] = v.value; h}
183 issue.status = if options[:attributes] && options[:attributes][:status_id]
184 IssueStatus.find_by_id(options[:attributes][:status_id])
185 else
186 self.status
187 end
188 end 226 end
189 # Allow bulk setting of attributes on the issue 227 # Allow bulk setting of attributes on the issue
190 if options[:attributes] 228 if options[:attributes]
191 issue.attributes = options[:attributes] 229 issue.attributes = options[:attributes]
192 end 230 end
193 if issue.save 231
194 if options[:copy] 232 issue.save ? issue : false
195 if current_journal && current_journal.notes.present?
196 issue.init_journal(current_journal.user, current_journal.notes)
197 issue.current_journal.notify = false
198 issue.save
199 end
200 else
201 # Manually update project_id on related time entries
202 TimeEntry.update_all("project_id = #{new_project.id}", {:issue_id => id})
203
204 issue.children.each do |child|
205 unless child.move_to_project_without_transaction(new_project)
206 # Move failed and transaction was rollback'd
207 return false
208 end
209 end
210 end
211 else
212 return false
213 end
214 issue
215 end 233 end
216 234
217 def status_id=(sid) 235 def status_id=(sid)
218 self.status = nil 236 self.status = nil
219 write_attribute(:status_id, sid) 237 result = write_attribute(:status_id, sid)
238 @workflow_rule_by_attribute = nil
239 result
220 end 240 end
221 241
222 def priority_id=(pid) 242 def priority_id=(pid)
223 self.priority = nil 243 self.priority = nil
224 write_attribute(:priority_id, pid) 244 write_attribute(:priority_id, pid)
245 end
246
247 def category_id=(cid)
248 self.category = nil
249 write_attribute(:category_id, cid)
250 end
251
252 def fixed_version_id=(vid)
253 self.fixed_version = nil
254 write_attribute(:fixed_version_id, vid)
225 end 255 end
226 256
227 def tracker_id=(tid) 257 def tracker_id=(tid)
228 self.tracker = nil 258 self.tracker = nil
229 result = write_attribute(:tracker_id, tid) 259 result = write_attribute(:tracker_id, tid)
230 @custom_field_values = nil 260 @custom_field_values = nil
261 @workflow_rule_by_attribute = nil
231 result 262 result
263 end
264
265 def project_id=(project_id)
266 if project_id.to_s != self.project_id.to_s
267 self.project = (project_id.present? ? Project.find_by_id(project_id) : nil)
268 end
269 end
270
271 def project=(project, keep_tracker=false)
272 project_was = self.project
273 write_attribute(:project_id, project ? project.id : nil)
274 association_instance_set('project', project)
275 if project_was && project && project_was != project
276 @assignable_versions = nil
277
278 unless keep_tracker || project.trackers.include?(tracker)
279 self.tracker = project.trackers.first
280 end
281 # Reassign to the category with same name if any
282 if category
283 self.category = project.issue_categories.find_by_name(category.name)
284 end
285 # Keep the fixed_version if it's still valid in the new_project
286 if fixed_version && fixed_version.project != project && !project.shared_versions.include?(fixed_version)
287 self.fixed_version = nil
288 end
289 # Clear the parent task if it's no longer valid
290 unless valid_parent_project?
291 self.parent_issue_id = nil
292 end
293 @custom_field_values = nil
294 end
232 end 295 end
233 296
234 def description=(arg) 297 def description=(arg)
235 if arg.is_a?(String) 298 if arg.is_a?(String)
236 arg = arg.gsub(/(\r\n|\n|\r)/, "\r\n") 299 arg = arg.gsub(/(\r\n|\n|\r)/, "\r\n")
237 end 300 end
238 write_attribute(:description, arg) 301 write_attribute(:description, arg)
239 end 302 end
240 303
241 # Overrides attributes= so that tracker_id gets assigned first 304 # Overrides assign_attributes so that project and tracker get assigned first
242 def attributes_with_tracker_first=(new_attributes, *args) 305 def assign_attributes_with_project_and_tracker_first(new_attributes, *args)
243 return if new_attributes.nil? 306 return if new_attributes.nil?
244 new_tracker_id = new_attributes['tracker_id'] || new_attributes[:tracker_id] 307 attrs = new_attributes.dup
245 if new_tracker_id 308 attrs.stringify_keys!
246 self.tracker_id = new_tracker_id 309
247 end 310 %w(project project_id tracker tracker_id).each do |attr|
248 send :attributes_without_tracker_first=, new_attributes, *args 311 if attrs.has_key?(attr)
312 send "#{attr}=", attrs.delete(attr)
313 end
314 end
315 send :assign_attributes_without_project_and_tracker_first, attrs, *args
249 end 316 end
250 # Do not redefine alias chain on reload (see #4838) 317 # Do not redefine alias chain on reload (see #4838)
251 alias_method_chain(:attributes=, :tracker_first) unless method_defined?(:attributes_without_tracker_first=) 318 alias_method_chain(:assign_attributes, :project_and_tracker_first) unless method_defined?(:assign_attributes_without_project_and_tracker_first)
252 319
253 def estimated_hours=(h) 320 def estimated_hours=(h)
254 write_attribute :estimated_hours, (h.is_a?(String) ? h.to_hours : h) 321 write_attribute :estimated_hours, (h.is_a?(String) ? h.to_hours : h)
255 end 322 end
256 323
324 safe_attributes 'project_id',
325 :if => lambda {|issue, user|
326 if issue.new_record?
327 issue.copy?
328 elsif user.allowed_to?(:move_issues, issue.project)
329 projects = Issue.allowed_target_projects_on_move(user)
330 projects.include?(issue.project) && projects.size > 1
331 end
332 }
333
257 safe_attributes 'tracker_id', 334 safe_attributes 'tracker_id',
258 'status_id', 335 'status_id',
259 'parent_issue_id',
260 'category_id', 336 'category_id',
261 'assigned_to_id', 337 'assigned_to_id',
262 'priority_id', 338 'priority_id',
263 'fixed_version_id', 339 'fixed_version_id',
264 'subject', 340 'subject',
268 'done_ratio', 344 'done_ratio',
269 'estimated_hours', 345 'estimated_hours',
270 'custom_field_values', 346 'custom_field_values',
271 'custom_fields', 347 'custom_fields',
272 'lock_version', 348 'lock_version',
349 'notes',
273 :if => lambda {|issue, user| issue.new_record? || user.allowed_to?(:edit_issues, issue.project) } 350 :if => lambda {|issue, user| issue.new_record? || user.allowed_to?(:edit_issues, issue.project) }
274 351
275 safe_attributes 'status_id', 352 safe_attributes 'status_id',
276 'assigned_to_id', 353 'assigned_to_id',
277 'fixed_version_id', 354 'fixed_version_id',
278 'done_ratio', 355 'done_ratio',
356 'lock_version',
357 'notes',
279 :if => lambda {|issue, user| issue.new_statuses_allowed_to(user).any? } 358 :if => lambda {|issue, user| issue.new_statuses_allowed_to(user).any? }
359
360 safe_attributes 'notes',
361 :if => lambda {|issue, user| user.allowed_to?(:add_issue_notes, issue.project)}
362
363 safe_attributes 'private_notes',
364 :if => lambda {|issue, user| !issue.new_record? && user.allowed_to?(:set_notes_private, issue.project)}
365
366 safe_attributes 'watcher_user_ids',
367 :if => lambda {|issue, user| issue.new_record? && user.allowed_to?(:add_issue_watchers, issue.project)}
280 368
281 safe_attributes 'is_private', 369 safe_attributes 'is_private',
282 :if => lambda {|issue, user| 370 :if => lambda {|issue, user|
283 user.allowed_to?(:set_issues_private, issue.project) || 371 user.allowed_to?(:set_issues_private, issue.project) ||
284 (issue.author == user && user.allowed_to?(:set_own_issues_private, issue.project)) 372 (issue.author == user && user.allowed_to?(:set_own_issues_private, issue.project))
285 } 373 }
286 374
375 safe_attributes 'parent_issue_id',
376 :if => lambda {|issue, user| (issue.new_record? || user.allowed_to?(:edit_issues, issue.project)) &&
377 user.allowed_to?(:manage_subtasks, issue.project)}
378
379 def safe_attribute_names(user=nil)
380 names = super
381 names -= disabled_core_fields
382 names -= read_only_attribute_names(user)
383 names
384 end
385
287 # Safely sets attributes 386 # Safely sets attributes
288 # Should be called from controllers instead of #attributes= 387 # Should be called from controllers instead of #attributes=
289 # attr_accessible is too rough because we still want things like 388 # attr_accessible is too rough because we still want things like
290 # Issue.new(:project => foo) to work 389 # Issue.new(:project => foo) to work
291 # TODO: move workflow/permission checks from controllers to here
292 def safe_attributes=(attrs, user=User.current) 390 def safe_attributes=(attrs, user=User.current)
293 return unless attrs.is_a?(Hash) 391 return unless attrs.is_a?(Hash)
294 392
295 # User can change issue attributes only if he has :edit permission or if a workflow transition is allowed 393 attrs = attrs.dup
394
395 # Project and Tracker must be set before since new_statuses_allowed_to depends on it.
396 if (p = attrs.delete('project_id')) && safe_attribute?('project_id')
397 if allowed_target_projects(user).collect(&:id).include?(p.to_i)
398 self.project_id = p
399 end
400 end
401
402 if (t = attrs.delete('tracker_id')) && safe_attribute?('tracker_id')
403 self.tracker_id = t
404 end
405
406 if (s = attrs.delete('status_id')) && safe_attribute?('status_id')
407 if new_statuses_allowed_to(user).collect(&:id).include?(s.to_i)
408 self.status_id = s
409 end
410 end
411
296 attrs = delete_unsafe_attributes(attrs, user) 412 attrs = delete_unsafe_attributes(attrs, user)
297 return if attrs.empty? 413 return if attrs.empty?
298 414
299 # Tracker must be set before since new_statuses_allowed_to depends on it.
300 if t = attrs.delete('tracker_id')
301 self.tracker_id = t
302 end
303
304 if attrs['status_id']
305 unless new_statuses_allowed_to(user).collect(&:id).include?(attrs['status_id'].to_i)
306 attrs.delete('status_id')
307 end
308 end
309
310 unless leaf? 415 unless leaf?
311 attrs.reject! {|k,v| %w(priority_id done_ratio start_date due_date estimated_hours).include?(k)} 416 attrs.reject! {|k,v| %w(priority_id done_ratio start_date due_date estimated_hours).include?(k)}
312 end 417 end
313 418
314 if attrs.has_key?('parent_issue_id') 419 if attrs['parent_issue_id'].present?
315 if !user.allowed_to?(:manage_subtasks, project) 420 s = attrs['parent_issue_id'].to_s
316 attrs.delete('parent_issue_id') 421 unless (m = s.match(%r{\A#?(\d+)\z})) && (m[1] == parent_id.to_s || Issue.visible(user).exists?(m[1]))
317 elsif !attrs['parent_issue_id'].blank? 422 @invalid_parent_issue_id = attrs.delete('parent_issue_id')
318 attrs.delete('parent_issue_id') unless Issue.visible(user).exists?(attrs['parent_issue_id'].to_i) 423 end
319 end 424 end
320 end 425
321 426 if attrs['custom_field_values'].present?
322 self.attributes = attrs 427 attrs['custom_field_values'] = attrs['custom_field_values'].reject {|k, v| read_only_attribute_names(user).include? k.to_s}
323 end 428 end
429
430 if attrs['custom_fields'].present?
431 attrs['custom_fields'] = attrs['custom_fields'].reject {|c| read_only_attribute_names(user).include? c['id'].to_s}
432 end
433
434 # mass-assignment security bypass
435 assign_attributes attrs, :without_protection => true
436 end
437
438 def disabled_core_fields
439 tracker ? tracker.disabled_core_fields : []
440 end
441
442 # Returns the custom_field_values that can be edited by the given user
443 def editable_custom_field_values(user=nil)
444 custom_field_values.reject do |value|
445 read_only_attribute_names(user).include?(value.custom_field_id.to_s)
446 end
447 end
448
449 # Returns the names of attributes that are read-only for user or the current user
450 # For users with multiple roles, the read-only fields are the intersection of
451 # read-only fields of each role
452 # The result is an array of strings where sustom fields are represented with their ids
453 #
454 # Examples:
455 # issue.read_only_attribute_names # => ['due_date', '2']
456 # issue.read_only_attribute_names(user) # => []
457 def read_only_attribute_names(user=nil)
458 workflow_rule_by_attribute(user).reject {|attr, rule| rule != 'readonly'}.keys
459 end
460
461 # Returns the names of required attributes for user or the current user
462 # For users with multiple roles, the required fields are the intersection of
463 # required fields of each role
464 # The result is an array of strings where sustom fields are represented with their ids
465 #
466 # Examples:
467 # issue.required_attribute_names # => ['due_date', '2']
468 # issue.required_attribute_names(user) # => []
469 def required_attribute_names(user=nil)
470 workflow_rule_by_attribute(user).reject {|attr, rule| rule != 'required'}.keys
471 end
472
473 # Returns true if the attribute is required for user
474 def required_attribute?(name, user=nil)
475 required_attribute_names(user).include?(name.to_s)
476 end
477
478 # Returns a hash of the workflow rule by attribute for the given user
479 #
480 # Examples:
481 # issue.workflow_rule_by_attribute # => {'due_date' => 'required', 'start_date' => 'readonly'}
482 def workflow_rule_by_attribute(user=nil)
483 return @workflow_rule_by_attribute if @workflow_rule_by_attribute && user.nil?
484
485 user_real = user || User.current
486 roles = user_real.admin ? Role.all : user_real.roles_for_project(project)
487 return {} if roles.empty?
488
489 result = {}
490 workflow_permissions = WorkflowPermission.where(:tracker_id => tracker_id, :old_status_id => status_id, :role_id => roles.map(&:id)).all
491 if workflow_permissions.any?
492 workflow_rules = workflow_permissions.inject({}) do |h, wp|
493 h[wp.field_name] ||= []
494 h[wp.field_name] << wp.rule
495 h
496 end
497 workflow_rules.each do |attr, rules|
498 next if rules.size < roles.size
499 uniq_rules = rules.uniq
500 if uniq_rules.size == 1
501 result[attr] = uniq_rules.first
502 else
503 result[attr] = 'required'
504 end
505 end
506 end
507 @workflow_rule_by_attribute = result if user.nil?
508 result
509 end
510 private :workflow_rule_by_attribute
324 511
325 def done_ratio 512 def done_ratio
326 if Issue.use_status_for_done_ratio? && status && status.default_done_ratio 513 if Issue.use_status_for_done_ratio? && status && status.default_done_ratio
327 status.default_done_ratio 514 status.default_done_ratio
328 else 515 else
337 def self.use_field_for_done_ratio? 524 def self.use_field_for_done_ratio?
338 Setting.issue_done_ratio == 'issue_field' 525 Setting.issue_done_ratio == 'issue_field'
339 end 526 end
340 527
341 def validate_issue 528 def validate_issue
342 if self.due_date.nil? && @attributes['due_date'] && !@attributes['due_date'].empty? 529 if due_date.nil? && @attributes['due_date'].present?
343 errors.add :due_date, :not_a_date 530 errors.add :due_date, :not_a_date
344 end 531 end
345 532
346 if self.due_date and self.start_date and self.due_date < self.start_date 533 if start_date.nil? && @attributes['start_date'].present?
534 errors.add :start_date, :not_a_date
535 end
536
537 if due_date && start_date && due_date < start_date
347 errors.add :due_date, :greater_than_start_date 538 errors.add :due_date, :greater_than_start_date
348 end 539 end
349 540
350 if start_date && soonest_start && start_date < soonest_start 541 if start_date && soonest_start && start_date < soonest_start
351 errors.add :start_date, :invalid 542 errors.add :start_date, :invalid
365 errors.add :tracker_id, :inclusion 556 errors.add :tracker_id, :inclusion
366 end 557 end
367 end 558 end
368 559
369 # Checks parent issue assignment 560 # Checks parent issue assignment
370 if @parent_issue 561 if @invalid_parent_issue_id.present?
371 if @parent_issue.project_id != project_id 562 errors.add :parent_issue_id, :invalid
372 errors.add :parent_issue_id, :not_same_project 563 elsif @parent_issue
564 if !valid_parent_project?(@parent_issue)
565 errors.add :parent_issue_id, :invalid
373 elsif !new_record? 566 elsif !new_record?
374 # moving an existing issue 567 # moving an existing issue
375 if @parent_issue.root_id != root_id 568 if @parent_issue.root_id != root_id
376 # we can always move to another tree 569 # we can always move to another tree
377 elsif move_possible?(@parent_issue) 570 elsif move_possible?(@parent_issue)
378 # move accepted inside tree 571 # move accepted inside tree
379 else 572 else
380 errors.add :parent_issue_id, :not_a_valid_parent 573 errors.add :parent_issue_id, :invalid
574 end
575 end
576 end
577 end
578
579 # Validates the issue against additional workflow requirements
580 def validate_required_fields
581 user = new_record? ? author : current_journal.try(:user)
582
583 required_attribute_names(user).each do |attribute|
584 if attribute =~ /^\d+$/
585 attribute = attribute.to_i
586 v = custom_field_values.detect {|v| v.custom_field_id == attribute }
587 if v && v.value.blank?
588 errors.add :base, v.custom_field.name + ' ' + l('activerecord.errors.messages.blank')
589 end
590 else
591 if respond_to?(attribute) && send(attribute).blank?
592 errors.add attribute, :blank
381 end 593 end
382 end 594 end
383 end 595 end
384 end 596 end
385 597
391 end 603 end
392 end 604 end
393 605
394 def init_journal(user, notes = "") 606 def init_journal(user, notes = "")
395 @current_journal ||= Journal.new(:journalized => self, :user => user, :notes => notes) 607 @current_journal ||= Journal.new(:journalized => self, :user => user, :notes => notes)
396 @issue_before_change = self.clone 608 if new_record?
397 @issue_before_change.status = self.status 609 @current_journal.notify = false
398 @custom_values_before_change = {} 610 else
399 self.custom_values.each {|c| @custom_values_before_change.store c.custom_field_id, c.value } 611 @attributes_before_change = attributes.dup
400 # Make sure updated_on is updated when adding a note. 612 @custom_values_before_change = {}
401 updated_on_will_change! 613 self.custom_field_values.each {|c| @custom_values_before_change.store c.custom_field_id, c.value }
614 end
402 @current_journal 615 @current_journal
616 end
617
618 # Returns the id of the last journal or nil
619 def last_journal_id
620 if new_record?
621 nil
622 else
623 journals.maximum(:id)
624 end
625 end
626
627 # Returns a scope for journals that have an id greater than journal_id
628 def journals_after(journal_id)
629 scope = journals.reorder("#{Journal.table_name}.id ASC")
630 if journal_id.present?
631 scope = scope.where("#{Journal.table_name}.id > ?", journal_id.to_i)
632 end
633 scope
403 end 634 end
404 635
405 # Return true if the issue is closed, otherwise false 636 # Return true if the issue is closed, otherwise false
406 def closed? 637 def closed?
407 self.status.is_closed? 638 self.status.is_closed?
456 users.uniq.sort 687 users.uniq.sort
457 end 688 end
458 689
459 # Versions that the issue can be assigned to 690 # Versions that the issue can be assigned to
460 def assignable_versions 691 def assignable_versions
461 @assignable_versions ||= (project.shared_versions.open + [Version.find_by_id(fixed_version_id_was)]).compact.uniq.sort 692 return @assignable_versions if @assignable_versions
693
694 versions = project.shared_versions.open.all
695 if fixed_version
696 if fixed_version_id_changed?
697 # nothing to do
698 elsif project_id_changed?
699 if project.shared_versions.include?(fixed_version)
700 versions << fixed_version
701 end
702 else
703 versions << fixed_version
704 end
705 end
706 @assignable_versions = versions.uniq.sort
462 end 707 end
463 708
464 # Returns true if this issue is blocked by another issue that is still open 709 # Returns true if this issue is blocked by another issue that is still open
465 def blocked? 710 def blocked?
466 !relations_to.detect {|ir| ir.relation_type == 'blocks' && !ir.issue_from.closed?}.nil? 711 !relations_to.detect {|ir| ir.relation_type == 'blocks' && !ir.issue_from.closed?}.nil?
467 end 712 end
468 713
469 # Returns an array of status that user is able to apply 714 # Returns an array of statuses that user is able to apply
470 def new_statuses_allowed_to(user, include_default=false) 715 def new_statuses_allowed_to(user=User.current, include_default=false)
471 statuses = status.find_new_statuses_allowed_to( 716 if new_record? && @copied_from
472 user.roles_for_project(project), 717 [IssueStatus.default, @copied_from.status].compact.uniq.sort
473 tracker, 718 else
474 author == user, 719 initial_status = nil
475 assigned_to_id_changed? ? assigned_to_id_was == user.id : assigned_to_id == user.id 720 if new_record?
476 ) 721 initial_status = IssueStatus.default
477 statuses << status unless statuses.empty? 722 elsif status_id_was
478 statuses << IssueStatus.default if include_default 723 initial_status = IssueStatus.find_by_id(status_id_was)
479 statuses = statuses.uniq.sort 724 end
480 blocked? ? statuses.reject {|s| s.is_closed?} : statuses 725 initial_status ||= status
481 end 726
482 727 statuses = initial_status.find_new_statuses_allowed_to(
483 # Returns the mail adresses of users that should be notified 728 user.admin ? Role.all : user.roles_for_project(project),
484 def recipients 729 tracker,
485 notified = project.notified_users 730 author == user,
731 assigned_to_id_changed? ? assigned_to_id_was == user.id : assigned_to_id == user.id
732 )
733 statuses << initial_status unless statuses.empty?
734 statuses << IssueStatus.default if include_default
735 statuses = statuses.compact.uniq.sort
736 blocked? ? statuses.reject {|s| s.is_closed?} : statuses
737 end
738 end
739
740 def assigned_to_was
741 if assigned_to_id_changed? && assigned_to_id_was.present?
742 @assigned_to_was ||= User.find_by_id(assigned_to_id_was)
743 end
744 end
745
746 # Returns the users that should be notified
747 def notified_users
748 notified = []
486 # Author and assignee are always notified unless they have been 749 # Author and assignee are always notified unless they have been
487 # locked or don't want to be notified 750 # locked or don't want to be notified
488 notified << author if author && author.active? && author.notify_about?(self) 751 notified << author if author
489 if assigned_to 752 if assigned_to
490 if assigned_to.is_a?(Group) 753 notified += (assigned_to.is_a?(Group) ? assigned_to.users : [assigned_to])
491 notified += assigned_to.users.select {|u| u.active? && u.notify_about?(self)} 754 end
492 else 755 if assigned_to_was
493 notified << assigned_to if assigned_to.active? && assigned_to.notify_about?(self) 756 notified += (assigned_to_was.is_a?(Group) ? assigned_to_was.users : [assigned_to_was])
494 end 757 end
495 end 758 notified = notified.select {|u| u.active? && u.notify_about?(self)}
759
760 notified += project.notified_users
496 notified.uniq! 761 notified.uniq!
497 # Remove users that can not view the issue 762 # Remove users that can not view the issue
498 notified.reject! {|user| !visible?(user)} 763 notified.reject! {|user| !visible?(user)}
499 notified.collect(&:mail) 764 notified
765 end
766
767 # Returns the email addresses that should be notified
768 def recipients
769 notified_users.collect(&:mail)
770 end
771
772 # Returns the number of hours spent on this issue
773 def spent_hours
774 @spent_hours ||= time_entries.sum(:hours) || 0
500 end 775 end
501 776
502 # Returns the total number of hours spent on this issue and its descendants 777 # Returns the total number of hours spent on this issue and its descendants
503 # 778 #
504 # Example: 779 # Example:
505 # spent_hours => 0.0 780 # spent_hours => 0.0
506 # spent_hours => 50.2 781 # spent_hours => 50.2
507 def spent_hours 782 def total_spent_hours
508 @spent_hours ||= self_and_descendants.sum("#{TimeEntry.table_name}.hours", :include => :time_entries).to_f || 0.0 783 @total_spent_hours ||= self_and_descendants.sum("#{TimeEntry.table_name}.hours",
784 :joins => "LEFT JOIN #{TimeEntry.table_name} ON #{TimeEntry.table_name}.issue_id = #{Issue.table_name}.id").to_f || 0.0
509 end 785 end
510 786
511 def relations 787 def relations
512 @relations ||= (relations_from + relations_to).sort 788 @relations ||= IssueRelations.new(self, (relations_from + relations_to).sort)
513 end 789 end
514 790
515 # Preloads relations for a collection of issues 791 # Preloads relations for a collection of issues
516 def self.load_relations(issues) 792 def self.load_relations(issues)
517 if issues.any? 793 if issues.any?
518 relations = IssueRelation.all(:conditions => ["issue_from_id IN (:ids) OR issue_to_id IN (:ids)", {:ids => issues.map(&:id)}]) 794 relations = IssueRelation.all(:conditions => ["issue_from_id IN (:ids) OR issue_to_id IN (:ids)", {:ids => issues.map(&:id)}])
519 issues.each do |issue| 795 issues.each do |issue|
520 issue.instance_variable_set "@relations", relations.select {|r| r.issue_from_id == issue.id || r.issue_to_id == issue.id} 796 issue.instance_variable_set "@relations", relations.select {|r| r.issue_from_id == issue.id || r.issue_to_id == issue.id}
797 end
798 end
799 end
800
801 # Preloads visible spent time for a collection of issues
802 def self.load_visible_spent_hours(issues, user=User.current)
803 if issues.any?
804 hours_by_issue_id = TimeEntry.visible(user).sum(:hours, :group => :issue_id)
805 issues.each do |issue|
806 issue.instance_variable_set "@spent_hours", (hours_by_issue_id[issue.id] || 0)
807 end
808 end
809 end
810
811 # Preloads visible relations for a collection of issues
812 def self.load_visible_relations(issues, user=User.current)
813 if issues.any?
814 issue_ids = issues.map(&:id)
815 # Relations with issue_from in given issues and visible issue_to
816 relations_from = IssueRelation.includes(:issue_to => [:status, :project]).where(visible_condition(user)).where(:issue_from_id => issue_ids).all
817 # Relations with issue_to in given issues and visible issue_from
818 relations_to = IssueRelation.includes(:issue_from => [:status, :project]).where(visible_condition(user)).where(:issue_to_id => issue_ids).all
819
820 issues.each do |issue|
821 relations =
822 relations_from.select {|relation| relation.issue_from_id == issue.id} +
823 relations_to.select {|relation| relation.issue_to_id == issue.id}
824
825 issue.instance_variable_set "@relations", IssueRelations.new(issue, relations.sort)
521 end 826 end
522 end 827 end
523 end 828 end
524 829
525 # Finds an issue relation given its id. 830 # Finds an issue relation given its id.
557 # duration => 6 862 # duration => 6
558 def duration 863 def duration
559 (start_date && due_date) ? due_date - start_date : 0 864 (start_date && due_date) ? due_date - start_date : 0
560 end 865 end
561 866
562 def soonest_start 867 # Returns the duration in working days
868 def working_duration
869 (start_date && due_date) ? working_days(start_date, due_date) : 0
870 end
871
872 def soonest_start(reload=false)
873 @soonest_start = nil if reload
563 @soonest_start ||= ( 874 @soonest_start ||= (
564 relations_to.collect{|relation| relation.successor_soonest_start} + 875 relations_to(reload).collect{|relation| relation.successor_soonest_start} +
565 ancestors.collect(&:soonest_start) 876 ancestors.collect(&:soonest_start)
566 ).compact.max 877 ).compact.max
567 end 878 end
568 879
569 def reschedule_after(date) 880 # Sets start_date on the given date or the next working day
881 # and changes due_date to keep the same working duration.
882 def reschedule_on(date)
883 wd = working_duration
884 date = next_working_date(date)
885 self.start_date = date
886 self.due_date = add_working_days(date, wd)
887 end
888
889 # Reschedules the issue on the given date or the next working day and saves the record.
890 # If the issue is a parent task, this is done by rescheduling its subtasks.
891 def reschedule_on!(date)
570 return if date.nil? 892 return if date.nil?
571 if leaf? 893 if leaf?
572 if start_date.nil? || start_date < date 894 if start_date.nil? || start_date != date
573 self.start_date, self.due_date = date, date + duration 895 if start_date && start_date > date
574 save 896 # Issue can not be moved earlier than its soonest start date
897 date = [soonest_start(true), date].compact.max
898 end
899 reschedule_on(date)
900 begin
901 save
902 rescue ActiveRecord::StaleObjectError
903 reload
904 reschedule_on(date)
905 save
906 end
575 end 907 end
576 else 908 else
577 leaves.each do |leaf| 909 leaves.each do |leaf|
578 leaf.reschedule_after(date) 910 if leaf.start_date
911 # Only move subtask if it starts at the same date as the parent
912 # or if it starts before the given date
913 if start_date == leaf.start_date || date > leaf.start_date
914 leaf.reschedule_on!(date)
915 end
916 else
917 leaf.reschedule_on!(date)
918 end
579 end 919 end
580 end 920 end
581 end 921 end
582 922
583 def <=>(issue) 923 def <=>(issue)
594 "#{tracker} ##{id}: #{subject}" 934 "#{tracker} ##{id}: #{subject}"
595 end 935 end
596 936
597 # Returns a string of css classes that apply to the issue 937 # Returns a string of css classes that apply to the issue
598 def css_classes 938 def css_classes
599 s = "issue status-#{status.position} " 939 s = "issue status-#{status_id} #{priority.try(:css_classes)}"
600 s << "priority-#{priority.position}"
601 s << ' closed' if closed? 940 s << ' closed' if closed?
602 s << ' overdue' if overdue? 941 s << ' overdue' if overdue?
603 s << ' child' if child? 942 s << ' child' if child?
604 s << ' parent' unless leaf? 943 s << ' parent' unless leaf?
605 s << ' private' if is_private? 944 s << ' private' if is_private?
606 s << ' created-by-me' if User.current.logged? && author_id == User.current.id 945 s << ' created-by-me' if User.current.logged? && author_id == User.current.id
607 s << ' assigned-to-me' if User.current.logged? && assigned_to_id == User.current.id 946 s << ' assigned-to-me' if User.current.logged? && assigned_to_id == User.current.id
608 s 947 s
609 end 948 end
610 949
611 # Saves an issue, time_entry, attachments, and a journal from the parameters 950 # Saves an issue and a time_entry from the parameters
612 # Returns false if save fails
613 def save_issue_with_child_records(params, existing_time_entry=nil) 951 def save_issue_with_child_records(params, existing_time_entry=nil)
614 Issue.transaction do 952 Issue.transaction do
615 if params[:time_entry] && (params[:time_entry][:hours].present? || params[:time_entry][:comments].present?) && User.current.allowed_to?(:log_time, project) 953 if params[:time_entry] && (params[:time_entry][:hours].present? || params[:time_entry][:comments].present?) && User.current.allowed_to?(:log_time, project)
616 @time_entry = existing_time_entry || TimeEntry.new 954 @time_entry = existing_time_entry || TimeEntry.new
617 @time_entry.project = project 955 @time_entry.project = project
620 @time_entry.spent_on = User.current.today 958 @time_entry.spent_on = User.current.today
621 @time_entry.attributes = params[:time_entry] 959 @time_entry.attributes = params[:time_entry]
622 self.time_entries << @time_entry 960 self.time_entries << @time_entry
623 end 961 end
624 962
625 if valid? 963 # TODO: Rename hook
626 attachments = Attachment.attach_files(self, params[:attachments]) 964 Redmine::Hook.call_hook(:controller_issues_edit_before_save, { :params => params, :issue => self, :time_entry => @time_entry, :journal => @current_journal})
965 if save
627 # TODO: Rename hook 966 # TODO: Rename hook
628 Redmine::Hook.call_hook(:controller_issues_edit_before_save, { :params => params, :issue => self, :time_entry => @time_entry, :journal => @current_journal}) 967 Redmine::Hook.call_hook(:controller_issues_edit_after_save, { :params => params, :issue => self, :time_entry => @time_entry, :journal => @current_journal})
629 begin 968 else
630 if save 969 raise ActiveRecord::Rollback
631 # TODO: Rename hook
632 Redmine::Hook.call_hook(:controller_issues_edit_after_save, { :params => params, :issue => self, :time_entry => @time_entry, :journal => @current_journal})
633 else
634 raise ActiveRecord::Rollback
635 end
636 rescue ActiveRecord::StaleObjectError
637 attachments[:files].each(&:destroy)
638 errors.add :base, l(:notice_locking_conflict)
639 raise ActiveRecord::Rollback
640 end
641 end 970 end
642 end 971 end
643 end 972 end
644 973
645 # Unassigns issues from +version+ if it's no longer shared with issue's project 974 # Unassigns issues from +version+ if it's no longer shared with issue's project
655 # Update issues of the moved projects and issues assigned to a version of a moved project 984 # Update issues of the moved projects and issues assigned to a version of a moved project
656 Issue.update_versions(["#{Version.table_name}.project_id IN (?) OR #{Issue.table_name}.project_id IN (?)", moved_project_ids, moved_project_ids]) 985 Issue.update_versions(["#{Version.table_name}.project_id IN (?) OR #{Issue.table_name}.project_id IN (?)", moved_project_ids, moved_project_ids])
657 end 986 end
658 987
659 def parent_issue_id=(arg) 988 def parent_issue_id=(arg)
660 parent_issue_id = arg.blank? ? nil : arg.to_i 989 s = arg.to_s.strip.presence
661 if parent_issue_id && @parent_issue = Issue.find_by_id(parent_issue_id) 990 if s && (m = s.match(%r{\A#?(\d+)\z})) && (@parent_issue = Issue.find_by_id(m[1]))
662 @parent_issue.id 991 @parent_issue.id
663 else 992 else
664 @parent_issue = nil 993 @parent_issue = nil
665 nil 994 @invalid_parent_issue_id = arg
666 end 995 end
667 end 996 end
668 997
669 def parent_issue_id 998 def parent_issue_id
670 if instance_variable_defined? :@parent_issue 999 if @invalid_parent_issue_id
1000 @invalid_parent_issue_id
1001 elsif instance_variable_defined? :@parent_issue
671 @parent_issue.nil? ? nil : @parent_issue.id 1002 @parent_issue.nil? ? nil : @parent_issue.id
672 else 1003 else
673 parent_id 1004 parent_id
1005 end
1006 end
1007
1008 # Returns true if issue's project is a valid
1009 # parent issue project
1010 def valid_parent_project?(issue=parent)
1011 return true if issue.nil? || issue.project_id == project_id
1012
1013 case Setting.cross_project_subtasks
1014 when 'system'
1015 true
1016 when 'tree'
1017 issue.project.root == project.root
1018 when 'hierarchy'
1019 issue.project.is_or_is_ancestor_of?(project) || issue.project.is_descendant_of?(project)
1020 when 'descendants'
1021 issue.project.is_or_is_ancestor_of?(project)
1022 else
1023 false
674 end 1024 end
675 end 1025 end
676 1026
677 # Extracted from the ReportsController. 1027 # Extracted from the ReportsController.
678 def self.by_tracker(project) 1028 def self.by_tracker(project)
725 and #{Issue.table_name}.project_id <> #{project.id} 1075 and #{Issue.table_name}.project_id <> #{project.id}
726 group by s.id, s.is_closed, #{Issue.table_name}.project_id") if project.descendants.active.any? 1076 group by s.id, s.is_closed, #{Issue.table_name}.project_id") if project.descendants.active.any?
727 end 1077 end
728 # End ReportsController extraction 1078 # End ReportsController extraction
729 1079
730 # Returns an array of projects that current user can move issues to 1080 # Returns an array of projects that user can assign the issue to
731 def self.allowed_target_projects_on_move 1081 def allowed_target_projects(user=User.current)
732 projects = [] 1082 if new_record?
733 if User.current.admin? 1083 Project.all(:conditions => Project.allowed_to_condition(user, :add_issues))
734 # admin is allowed to move issues to any active (visible) project 1084 else
735 projects = Project.visible.all 1085 self.class.allowed_target_projects_on_move(user)
736 elsif User.current.logged? 1086 end
737 if Role.non_member.allowed_to?(:move_issues) 1087 end
738 projects = Project.visible.all 1088
739 else 1089 # Returns an array of projects that user can move issues to
740 User.current.memberships.each {|m| projects << m.project if m.roles.detect {|r| r.allowed_to?(:move_issues)}} 1090 def self.allowed_target_projects_on_move(user=User.current)
741 end 1091 Project.all(:conditions => Project.allowed_to_condition(user, :move_issues))
742 end
743 projects
744 end 1092 end
745 1093
746 private 1094 private
1095
1096 def after_project_change
1097 # Update project_id on related time entries
1098 TimeEntry.update_all(["project_id = ?", project_id], {:issue_id => id})
1099
1100 # Delete issue relations
1101 unless Setting.cross_project_issue_relations?
1102 relations_from.clear
1103 relations_to.clear
1104 end
1105
1106 # Move subtasks that were in the same project
1107 children.each do |child|
1108 next unless child.project_id == project_id_was
1109 # Change project and keep project
1110 child.send :project=, project, true
1111 unless child.save
1112 raise ActiveRecord::Rollback
1113 end
1114 end
1115 end
1116
1117 # Callback for after the creation of an issue by copy
1118 # * adds a "copied to" relation with the copied issue
1119 # * copies subtasks from the copied issue
1120 def after_create_from_copy
1121 return unless copy? && !@after_create_from_copy_handled
1122
1123 if (@copied_from.project_id == project_id || Setting.cross_project_issue_relations?) && @copy_options[:link] != false
1124 relation = IssueRelation.new(:issue_from => @copied_from, :issue_to => self, :relation_type => IssueRelation::TYPE_COPIED_TO)
1125 unless relation.save
1126 logger.error "Could not create relation while copying ##{@copied_from.id} to ##{id} due to validation errors: #{relation.errors.full_messages.join(', ')}" if logger
1127 end
1128 end
1129
1130 unless @copied_from.leaf? || @copy_options[:subtasks] == false
1131 @copied_from.children.each do |child|
1132 unless child.visible?
1133 # Do not copy subtasks that are not visible to avoid potential disclosure of private data
1134 logger.error "Subtask ##{child.id} was not copied during ##{@copied_from.id} copy because it is not visible to the current user" if logger
1135 next
1136 end
1137 copy = Issue.new.copy_from(child, @copy_options)
1138 copy.author = author
1139 copy.project = project
1140 copy.parent_issue_id = id
1141 # Children subtasks are copied recursively
1142 unless copy.save
1143 logger.error "Could not copy subtask ##{child.id} while copying ##{@copied_from.id} to ##{id} due to validation errors: #{copy.errors.full_messages.join(', ')}" if logger
1144 end
1145 end
1146 end
1147 @after_create_from_copy_handled = true
1148 end
747 1149
748 def update_nested_set_attributes 1150 def update_nested_set_attributes
749 if root_id.nil? 1151 if root_id.nil?
750 # issue was just created 1152 # issue was just created
751 self.root_id = (@parent_issue.nil? ? id : @parent_issue.root_id) 1153 self.root_id = (@parent_issue.nil? ? id : @parent_issue.root_id)
797 end 1199 end
798 1200
799 def recalculate_attributes_for(issue_id) 1201 def recalculate_attributes_for(issue_id)
800 if issue_id && p = Issue.find_by_id(issue_id) 1202 if issue_id && p = Issue.find_by_id(issue_id)
801 # priority = highest priority of children 1203 # priority = highest priority of children
802 if priority_position = p.children.maximum("#{IssuePriority.table_name}.position", :include => :priority) 1204 if priority_position = p.children.maximum("#{IssuePriority.table_name}.position", :joins => :priority)
803 p.priority = IssuePriority.find_by_position(priority_position) 1205 p.priority = IssuePriority.find_by_position(priority_position)
804 end 1206 end
805 1207
806 # start/due dates = lowest/highest dates of children 1208 # start/due dates = lowest/highest dates of children
807 p.start_date = p.children.minimum(:start_date) 1209 p.start_date = p.children.minimum(:start_date)
816 if leaves_count > 0 1218 if leaves_count > 0
817 average = p.leaves.average(:estimated_hours).to_f 1219 average = p.leaves.average(:estimated_hours).to_f
818 if average == 0 1220 if average == 0
819 average = 1 1221 average = 1
820 end 1222 end
821 done = p.leaves.sum("COALESCE(estimated_hours, #{average}) * (CASE WHEN is_closed = #{connection.quoted_true} THEN 100 ELSE COALESCE(done_ratio, 0) END)", :include => :status).to_f 1223 done = p.leaves.sum("COALESCE(estimated_hours, #{average}) * (CASE WHEN is_closed = #{connection.quoted_true} THEN 100 ELSE COALESCE(done_ratio, 0) END)", :joins => :status).to_f
822 progress = done / (average * leaves_count) 1224 progress = done / (average * leaves_count)
823 p.done_ratio = progress.round 1225 p.done_ratio = progress.round
824 end 1226 end
825 end 1227 end
826 1228
827 # estimate = sum of leaves estimates 1229 # estimate = sum of leaves estimates
828 p.estimated_hours = p.leaves.sum(:estimated_hours).to_f 1230 p.estimated_hours = p.leaves.sum(:estimated_hours).to_f
829 p.estimated_hours = nil if p.estimated_hours == 0.0 1231 p.estimated_hours = nil if p.estimated_hours == 0.0
830 1232
831 # ancestors will be recursively updated 1233 # ancestors will be recursively updated
832 p.save(false) 1234 p.save(:validate => false)
833 end 1235 end
834 end 1236 end
835 1237
836 # Update issues so their versions are not pointing to a 1238 # Update issues so their versions are not pointing to a
837 # fixed_version that is not shared with the issue's project 1239 # fixed_version that is not shared with the issue's project
838 def self.update_versions(conditions=nil) 1240 def self.update_versions(conditions=nil)
839 # Only need to update issues with a fixed_version from 1241 # Only need to update issues with a fixed_version from
840 # a different project and that is not systemwide shared 1242 # a different project and that is not systemwide shared
841 Issue.all(:conditions => merge_conditions("#{Issue.table_name}.fixed_version_id IS NOT NULL" + 1243 Issue.scoped(:conditions => conditions).all(
842 " AND #{Issue.table_name}.project_id <> #{Version.table_name}.project_id" + 1244 :conditions => "#{Issue.table_name}.fixed_version_id IS NOT NULL" +
843 " AND #{Version.table_name}.sharing <> 'system'", 1245 " AND #{Issue.table_name}.project_id <> #{Version.table_name}.project_id" +
844 conditions), 1246 " AND #{Version.table_name}.sharing <> 'system'",
845 :include => [:project, :fixed_version] 1247 :include => [:project, :fixed_version]
846 ).each do |issue| 1248 ).each do |issue|
847 next if issue.project.nil? || issue.fixed_version.nil? 1249 next if issue.project.nil? || issue.fixed_version.nil?
848 unless issue.project.shared_versions.include?(issue.fixed_version) 1250 unless issue.project.shared_versions.include?(issue.fixed_version)
849 issue.init_journal(User.current) 1251 issue.init_journal(User.current)
850 issue.fixed_version = nil 1252 issue.fixed_version = nil
851 issue.save 1253 issue.save
852 end 1254 end
853 end 1255 end
854 end 1256 end
855 1257
856 # Callback on attachment deletion 1258 # Callback on file attachment
857 def attachment_added(obj) 1259 def attachment_added(obj)
858 if @current_journal && !obj.new_record? 1260 if @current_journal && !obj.new_record?
859 @current_journal.details << JournalDetail.new(:property => 'attachment', :prop_key => obj.id, :value => obj.filename) 1261 @current_journal.details << JournalDetail.new(:property => 'attachment', :prop_key => obj.id, :value => obj.filename)
860 end 1262 end
861 end 1263 end
862 1264
863 # Callback on attachment deletion 1265 # Callback on attachment deletion
864 def attachment_removed(obj) 1266 def attachment_removed(obj)
865 journal = init_journal(User.current) 1267 if @current_journal && !obj.new_record?
866 journal.details << JournalDetail.new(:property => 'attachment', 1268 @current_journal.details << JournalDetail.new(:property => 'attachment', :prop_key => obj.id, :old_value => obj.filename)
867 :prop_key => obj.id, 1269 @current_journal.save
868 :old_value => obj.filename) 1270 end
869 journal.save
870 end 1271 end
871 1272
872 # Default assignment based on category 1273 # Default assignment based on category
873 def default_assign 1274 def default_assign
874 if assigned_to.nil? && category && category.assigned_to 1275 if assigned_to.nil? && category && category.assigned_to
900 duplicate.update_attribute :status, self.status 1301 duplicate.update_attribute :status, self.status
901 end 1302 end
902 end 1303 end
903 end 1304 end
904 1305
1306 # Make sure updated_on is updated when adding a note
1307 def force_updated_on_change
1308 if @current_journal
1309 self.updated_on = current_time_from_proper_timezone
1310 end
1311 end
1312
905 # Saves the changes in a Journal 1313 # Saves the changes in a Journal
906 # Called after_save 1314 # Called after_save
907 def create_journal 1315 def create_journal
908 if @current_journal 1316 if @current_journal
909 # attributes changes 1317 # attributes changes
910 (Issue.column_names - %w(id root_id lft rgt lock_version created_on updated_on)).each {|c| 1318 if @attributes_before_change
911 before = @issue_before_change.send(c) 1319 (Issue.column_names - %w(id root_id lft rgt lock_version created_on updated_on)).each {|c|
912 after = send(c) 1320 before = @attributes_before_change[c]
913 next if before == after || (before.blank? && after.blank?) 1321 after = send(c)
914 @current_journal.details << JournalDetail.new(:property => 'attr', 1322 next if before == after || (before.blank? && after.blank?)
915 :prop_key => c, 1323 @current_journal.details << JournalDetail.new(:property => 'attr',
916 :old_value => @issue_before_change.send(c), 1324 :prop_key => c,
917 :value => send(c)) 1325 :old_value => before,
918 } 1326 :value => after)
919 # custom fields changes 1327 }
920 custom_values.each {|c| 1328 end
921 next if (@custom_values_before_change[c.custom_field_id]==c.value || 1329 if @custom_values_before_change
922 (@custom_values_before_change[c.custom_field_id].blank? && c.value.blank?)) 1330 # custom fields changes
923 @current_journal.details << JournalDetail.new(:property => 'cf', 1331 custom_field_values.each {|c|
924 :prop_key => c.custom_field_id, 1332 before = @custom_values_before_change[c.custom_field_id]
925 :old_value => @custom_values_before_change[c.custom_field_id], 1333 after = c.value
926 :value => c.value) 1334 next if before == after || (before.blank? && after.blank?)
927 } 1335
1336 if before.is_a?(Array) || after.is_a?(Array)
1337 before = [before] unless before.is_a?(Array)
1338 after = [after] unless after.is_a?(Array)
1339
1340 # values removed
1341 (before - after).reject(&:blank?).each do |value|
1342 @current_journal.details << JournalDetail.new(:property => 'cf',
1343 :prop_key => c.custom_field_id,
1344 :old_value => value,
1345 :value => nil)
1346 end
1347 # values added
1348 (after - before).reject(&:blank?).each do |value|
1349 @current_journal.details << JournalDetail.new(:property => 'cf',
1350 :prop_key => c.custom_field_id,
1351 :old_value => nil,
1352 :value => value)
1353 end
1354 else
1355 @current_journal.details << JournalDetail.new(:property => 'cf',
1356 :prop_key => c.custom_field_id,
1357 :old_value => before,
1358 :value => after)
1359 end
1360 }
1361 end
928 @current_journal.save 1362 @current_journal.save
929 # reset current journal 1363 # reset current journal
930 init_journal @current_journal.user, @current_journal.notes 1364 init_journal @current_journal.user, @current_journal.notes
931 end 1365 end
932 end 1366 end