##// END OF EJS Templates
Moved journal details generation to Journal model....
Jean-Philippe Lang -
r13321:7673910a2d9f
parent child
Show More
@@ -1,1688 +1,1630
1 # Redmine - project management software
1 # Redmine - project management software
2 # Copyright (C) 2006-2014 Jean-Philippe Lang
2 # Copyright (C) 2006-2014 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.
8 #
8 #
9 # This program is distributed in the hope that it will be useful,
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 # GNU General Public License for more details.
12 # GNU General Public License for more details.
13 #
13 #
14 # You should have received a copy of the GNU General Public License
14 # You should have received a copy of the GNU General Public License
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 include Redmine::Utils::DateCalculation
21 include Redmine::I18n
21 include Redmine::I18n
22
22
23 belongs_to :project
23 belongs_to :project
24 belongs_to :tracker
24 belongs_to :tracker
25 belongs_to :status, :class_name => 'IssueStatus'
25 belongs_to :status, :class_name => 'IssueStatus'
26 belongs_to :author, :class_name => 'User'
26 belongs_to :author, :class_name => 'User'
27 belongs_to :assigned_to, :class_name => 'Principal'
27 belongs_to :assigned_to, :class_name => 'Principal'
28 belongs_to :fixed_version, :class_name => 'Version'
28 belongs_to :fixed_version, :class_name => 'Version'
29 belongs_to :priority, :class_name => 'IssuePriority'
29 belongs_to :priority, :class_name => 'IssuePriority'
30 belongs_to :category, :class_name => 'IssueCategory'
30 belongs_to :category, :class_name => 'IssueCategory'
31
31
32 has_many :journals, :as => :journalized, :dependent => :destroy
32 has_many :journals, :as => :journalized, :dependent => :destroy
33 has_many :visible_journals,
33 has_many :visible_journals,
34 lambda {where(["(#{Journal.table_name}.private_notes = ? OR (#{Project.allowed_to_condition(User.current, :view_private_notes)}))", false])},
34 lambda {where(["(#{Journal.table_name}.private_notes = ? OR (#{Project.allowed_to_condition(User.current, :view_private_notes)}))", false])},
35 :class_name => 'Journal',
35 :class_name => 'Journal',
36 :as => :journalized
36 :as => :journalized
37
37
38 has_many :time_entries, :dependent => :destroy
38 has_many :time_entries, :dependent => :destroy
39 has_and_belongs_to_many :changesets, lambda {order("#{Changeset.table_name}.committed_on ASC, #{Changeset.table_name}.id ASC")}
39 has_and_belongs_to_many :changesets, lambda {order("#{Changeset.table_name}.committed_on ASC, #{Changeset.table_name}.id ASC")}
40
40
41 has_many :relations_from, :class_name => 'IssueRelation', :foreign_key => 'issue_from_id', :dependent => :delete_all
41 has_many :relations_from, :class_name => 'IssueRelation', :foreign_key => 'issue_from_id', :dependent => :delete_all
42 has_many :relations_to, :class_name => 'IssueRelation', :foreign_key => 'issue_to_id', :dependent => :delete_all
42 has_many :relations_to, :class_name => 'IssueRelation', :foreign_key => 'issue_to_id', :dependent => :delete_all
43
43
44 acts_as_nested_set :scope => 'root_id', :dependent => :destroy
44 acts_as_nested_set :scope => 'root_id', :dependent => :destroy
45 acts_as_attachable :after_add => :attachment_added, :after_remove => :attachment_removed
45 acts_as_attachable :after_add => :attachment_added, :after_remove => :attachment_removed
46 acts_as_customizable
46 acts_as_customizable
47 acts_as_watchable
47 acts_as_watchable
48 acts_as_searchable :columns => ['subject', "#{table_name}.description", "#{Journal.table_name}.notes"],
48 acts_as_searchable :columns => ['subject', "#{table_name}.description", "#{Journal.table_name}.notes"],
49 # sort by id so that limited eager loading doesn't break with postgresql
49 # sort by id so that limited eager loading doesn't break with postgresql
50 :order_column => "#{table_name}.id",
50 :order_column => "#{table_name}.id",
51 :scope => lambda { joins(:project).
51 :scope => lambda { joins(:project).
52 joins("LEFT OUTER JOIN #{Journal.table_name} ON #{Journal.table_name}.journalized_type='Issue'" +
52 joins("LEFT OUTER JOIN #{Journal.table_name} ON #{Journal.table_name}.journalized_type='Issue'" +
53 " AND #{Journal.table_name}.journalized_id = #{Issue.table_name}.id" +
53 " AND #{Journal.table_name}.journalized_id = #{Issue.table_name}.id" +
54 " AND (#{Journal.table_name}.private_notes = #{connection.quoted_false}" +
54 " AND (#{Journal.table_name}.private_notes = #{connection.quoted_false}" +
55 " OR (#{Project.allowed_to_condition(User.current, :view_private_notes)}))") }
55 " OR (#{Project.allowed_to_condition(User.current, :view_private_notes)}))") }
56
56
57 acts_as_event :title => Proc.new {|o| "#{o.tracker.name} ##{o.id} (#{o.status}): #{o.subject}"},
57 acts_as_event :title => Proc.new {|o| "#{o.tracker.name} ##{o.id} (#{o.status}): #{o.subject}"},
58 :url => Proc.new {|o| {:controller => 'issues', :action => 'show', :id => o.id}},
58 :url => Proc.new {|o| {:controller => 'issues', :action => 'show', :id => o.id}},
59 :type => Proc.new {|o| 'issue' + (o.closed? ? ' closed' : '') }
59 :type => Proc.new {|o| 'issue' + (o.closed? ? ' closed' : '') }
60
60
61 acts_as_activity_provider :scope => preload(:project, :author, :tracker),
61 acts_as_activity_provider :scope => preload(:project, :author, :tracker),
62 :author_key => :author_id
62 :author_key => :author_id
63
63
64 DONE_RATIO_OPTIONS = %w(issue_field issue_status)
64 DONE_RATIO_OPTIONS = %w(issue_field issue_status)
65
65
66 attr_reader :current_journal
66 attr_reader :current_journal
67 delegate :notes, :notes=, :private_notes, :private_notes=, :to => :current_journal, :allow_nil => true
67 delegate :notes, :notes=, :private_notes, :private_notes=, :to => :current_journal, :allow_nil => true
68
68
69 validates_presence_of :subject, :priority, :project, :tracker, :author, :status
69 validates_presence_of :subject, :priority, :project, :tracker, :author, :status
70
70
71 validates_length_of :subject, :maximum => 255
71 validates_length_of :subject, :maximum => 255
72 validates_inclusion_of :done_ratio, :in => 0..100
72 validates_inclusion_of :done_ratio, :in => 0..100
73 validates :estimated_hours, :numericality => {:greater_than_or_equal_to => 0, :allow_nil => true, :message => :invalid}
73 validates :estimated_hours, :numericality => {:greater_than_or_equal_to => 0, :allow_nil => true, :message => :invalid}
74 validates :start_date, :date => true
74 validates :start_date, :date => true
75 validates :due_date, :date => true
75 validates :due_date, :date => true
76 validate :validate_issue, :validate_required_fields
76 validate :validate_issue, :validate_required_fields
77 attr_protected :id
77 attr_protected :id
78
78
79 scope :visible, lambda {|*args|
79 scope :visible, lambda {|*args|
80 includes(:project).
80 includes(:project).
81 references(:project).
81 references(:project).
82 where(Issue.visible_condition(args.shift || User.current, *args))
82 where(Issue.visible_condition(args.shift || User.current, *args))
83 }
83 }
84
84
85 scope :open, lambda {|*args|
85 scope :open, lambda {|*args|
86 is_closed = args.size > 0 ? !args.first : false
86 is_closed = args.size > 0 ? !args.first : false
87 joins(:status).
87 joins(:status).
88 where("#{IssueStatus.table_name}.is_closed = ?", is_closed)
88 where("#{IssueStatus.table_name}.is_closed = ?", is_closed)
89 }
89 }
90
90
91 scope :recently_updated, lambda { order("#{Issue.table_name}.updated_on DESC") }
91 scope :recently_updated, lambda { order("#{Issue.table_name}.updated_on DESC") }
92 scope :on_active_project, lambda {
92 scope :on_active_project, lambda {
93 joins(:project).
93 joins(:project).
94 where("#{Project.table_name}.status = ?", Project::STATUS_ACTIVE)
94 where("#{Project.table_name}.status = ?", Project::STATUS_ACTIVE)
95 }
95 }
96 scope :fixed_version, lambda {|versions|
96 scope :fixed_version, lambda {|versions|
97 ids = [versions].flatten.compact.map {|v| v.is_a?(Version) ? v.id : v}
97 ids = [versions].flatten.compact.map {|v| v.is_a?(Version) ? v.id : v}
98 ids.any? ? where(:fixed_version_id => ids) : where('1=0')
98 ids.any? ? where(:fixed_version_id => ids) : where('1=0')
99 }
99 }
100
100
101 before_create :default_assign
101 before_create :default_assign
102 before_save :close_duplicates, :update_done_ratio_from_issue_status,
102 before_save :close_duplicates, :update_done_ratio_from_issue_status,
103 :force_updated_on_change, :update_closed_on, :set_assigned_to_was
103 :force_updated_on_change, :update_closed_on, :set_assigned_to_was
104 after_save {|issue| issue.send :after_project_change if !issue.id_changed? && issue.project_id_changed?}
104 after_save {|issue| issue.send :after_project_change if !issue.id_changed? && issue.project_id_changed?}
105 after_save :reschedule_following_issues, :update_nested_set_attributes,
105 after_save :reschedule_following_issues, :update_nested_set_attributes,
106 :update_parent_attributes, :create_journal
106 :update_parent_attributes, :create_journal
107 # Should be after_create but would be called before previous after_save callbacks
107 # Should be after_create but would be called before previous after_save callbacks
108 after_save :after_create_from_copy
108 after_save :after_create_from_copy
109 after_destroy :update_parent_attributes
109 after_destroy :update_parent_attributes
110 after_create :send_notification
110 after_create :send_notification
111 # Keep it at the end of after_save callbacks
111 # Keep it at the end of after_save callbacks
112 after_save :clear_assigned_to_was
112 after_save :clear_assigned_to_was
113
113
114 # Returns a SQL conditions string used to find all issues visible by the specified user
114 # Returns a SQL conditions string used to find all issues visible by the specified user
115 def self.visible_condition(user, options={})
115 def self.visible_condition(user, options={})
116 Project.allowed_to_condition(user, :view_issues, options) do |role, user|
116 Project.allowed_to_condition(user, :view_issues, options) do |role, user|
117 if user.id && user.logged?
117 if user.id && user.logged?
118 case role.issues_visibility
118 case role.issues_visibility
119 when 'all'
119 when 'all'
120 nil
120 nil
121 when 'default'
121 when 'default'
122 user_ids = [user.id] + user.groups.map(&:id).compact
122 user_ids = [user.id] + user.groups.map(&:id).compact
123 "(#{table_name}.is_private = #{connection.quoted_false} OR #{table_name}.author_id = #{user.id} OR #{table_name}.assigned_to_id IN (#{user_ids.join(',')}))"
123 "(#{table_name}.is_private = #{connection.quoted_false} OR #{table_name}.author_id = #{user.id} OR #{table_name}.assigned_to_id IN (#{user_ids.join(',')}))"
124 when 'own'
124 when 'own'
125 user_ids = [user.id] + user.groups.map(&:id).compact
125 user_ids = [user.id] + user.groups.map(&:id).compact
126 "(#{table_name}.author_id = #{user.id} OR #{table_name}.assigned_to_id IN (#{user_ids.join(',')}))"
126 "(#{table_name}.author_id = #{user.id} OR #{table_name}.assigned_to_id IN (#{user_ids.join(',')}))"
127 else
127 else
128 '1=0'
128 '1=0'
129 end
129 end
130 else
130 else
131 "(#{table_name}.is_private = #{connection.quoted_false})"
131 "(#{table_name}.is_private = #{connection.quoted_false})"
132 end
132 end
133 end
133 end
134 end
134 end
135
135
136 # Returns true if usr or current user is allowed to view the issue
136 # Returns true if usr or current user is allowed to view the issue
137 def visible?(usr=nil)
137 def visible?(usr=nil)
138 (usr || User.current).allowed_to?(:view_issues, self.project) do |role, user|
138 (usr || User.current).allowed_to?(:view_issues, self.project) do |role, user|
139 if user.logged?
139 if user.logged?
140 case role.issues_visibility
140 case role.issues_visibility
141 when 'all'
141 when 'all'
142 true
142 true
143 when 'default'
143 when 'default'
144 !self.is_private? || (self.author == user || user.is_or_belongs_to?(assigned_to))
144 !self.is_private? || (self.author == user || user.is_or_belongs_to?(assigned_to))
145 when 'own'
145 when 'own'
146 self.author == user || user.is_or_belongs_to?(assigned_to)
146 self.author == user || user.is_or_belongs_to?(assigned_to)
147 else
147 else
148 false
148 false
149 end
149 end
150 else
150 else
151 !self.is_private?
151 !self.is_private?
152 end
152 end
153 end
153 end
154 end
154 end
155
155
156 # Returns true if user or current user is allowed to edit or add a note to the issue
156 # Returns true if user or current user is allowed to edit or add a note to the issue
157 def editable?(user=User.current)
157 def editable?(user=User.current)
158 user.allowed_to?(:edit_issues, project) || user.allowed_to?(:add_issue_notes, project)
158 user.allowed_to?(:edit_issues, project) || user.allowed_to?(:add_issue_notes, project)
159 end
159 end
160
160
161 def initialize(attributes=nil, *args)
161 def initialize(attributes=nil, *args)
162 super
162 super
163 if new_record?
163 if new_record?
164 # set default values for new records only
164 # set default values for new records only
165 self.priority ||= IssuePriority.default
165 self.priority ||= IssuePriority.default
166 self.watcher_user_ids = []
166 self.watcher_user_ids = []
167 end
167 end
168 end
168 end
169
169
170 def create_or_update
170 def create_or_update
171 super
171 super
172 ensure
172 ensure
173 @status_was = nil
173 @status_was = nil
174 end
174 end
175 private :create_or_update
175 private :create_or_update
176
176
177 # AR#Persistence#destroy would raise and RecordNotFound exception
177 # AR#Persistence#destroy would raise and RecordNotFound exception
178 # if the issue was already deleted or updated (non matching lock_version).
178 # if the issue was already deleted or updated (non matching lock_version).
179 # This is a problem when bulk deleting issues or deleting a project
179 # This is a problem when bulk deleting issues or deleting a project
180 # (because an issue may already be deleted if its parent was deleted
180 # (because an issue may already be deleted if its parent was deleted
181 # first).
181 # first).
182 # The issue is reloaded by the nested_set before being deleted so
182 # The issue is reloaded by the nested_set before being deleted so
183 # the lock_version condition should not be an issue but we handle it.
183 # the lock_version condition should not be an issue but we handle it.
184 def destroy
184 def destroy
185 super
185 super
186 rescue ActiveRecord::RecordNotFound
186 rescue ActiveRecord::RecordNotFound
187 # Stale or already deleted
187 # Stale or already deleted
188 begin
188 begin
189 reload
189 reload
190 rescue ActiveRecord::RecordNotFound
190 rescue ActiveRecord::RecordNotFound
191 # The issue was actually already deleted
191 # The issue was actually already deleted
192 @destroyed = true
192 @destroyed = true
193 return freeze
193 return freeze
194 end
194 end
195 # The issue was stale, retry to destroy
195 # The issue was stale, retry to destroy
196 super
196 super
197 end
197 end
198
198
199 alias :base_reload :reload
199 alias :base_reload :reload
200 def reload(*args)
200 def reload(*args)
201 @workflow_rule_by_attribute = nil
201 @workflow_rule_by_attribute = nil
202 @assignable_versions = nil
202 @assignable_versions = nil
203 @relations = nil
203 @relations = nil
204 @spent_hours = nil
204 @spent_hours = nil
205 base_reload(*args)
205 base_reload(*args)
206 end
206 end
207
207
208 # Overrides Redmine::Acts::Customizable::InstanceMethods#available_custom_fields
208 # Overrides Redmine::Acts::Customizable::InstanceMethods#available_custom_fields
209 def available_custom_fields
209 def available_custom_fields
210 (project && tracker) ? (project.all_issue_custom_fields & tracker.custom_fields) : []
210 (project && tracker) ? (project.all_issue_custom_fields & tracker.custom_fields) : []
211 end
211 end
212
212
213 def visible_custom_field_values(user=nil)
213 def visible_custom_field_values(user=nil)
214 user_real = user || User.current
214 user_real = user || User.current
215 custom_field_values.select do |value|
215 custom_field_values.select do |value|
216 value.custom_field.visible_by?(project, user_real)
216 value.custom_field.visible_by?(project, user_real)
217 end
217 end
218 end
218 end
219
219
220 # Copies attributes from another issue, arg can be an id or an Issue
220 # Copies attributes from another issue, arg can be an id or an Issue
221 def copy_from(arg, options={})
221 def copy_from(arg, options={})
222 issue = arg.is_a?(Issue) ? arg : Issue.visible.find(arg)
222 issue = arg.is_a?(Issue) ? arg : Issue.visible.find(arg)
223 self.attributes = issue.attributes.dup.except("id", "root_id", "parent_id", "lft", "rgt", "created_on", "updated_on")
223 self.attributes = issue.attributes.dup.except("id", "root_id", "parent_id", "lft", "rgt", "created_on", "updated_on")
224 self.custom_field_values = issue.custom_field_values.inject({}) {|h,v| h[v.custom_field_id] = v.value; h}
224 self.custom_field_values = issue.custom_field_values.inject({}) {|h,v| h[v.custom_field_id] = v.value; h}
225 self.status = issue.status
225 self.status = issue.status
226 self.author = User.current
226 self.author = User.current
227 unless options[:attachments] == false
227 unless options[:attachments] == false
228 self.attachments = issue.attachments.map do |attachement|
228 self.attachments = issue.attachments.map do |attachement|
229 attachement.copy(:container => self)
229 attachement.copy(:container => self)
230 end
230 end
231 end
231 end
232 @copied_from = issue
232 @copied_from = issue
233 @copy_options = options
233 @copy_options = options
234 self
234 self
235 end
235 end
236
236
237 # Returns an unsaved copy of the issue
237 # Returns an unsaved copy of the issue
238 def copy(attributes=nil, copy_options={})
238 def copy(attributes=nil, copy_options={})
239 copy = self.class.new.copy_from(self, copy_options)
239 copy = self.class.new.copy_from(self, copy_options)
240 copy.attributes = attributes if attributes
240 copy.attributes = attributes if attributes
241 copy
241 copy
242 end
242 end
243
243
244 # Returns true if the issue is a copy
244 # Returns true if the issue is a copy
245 def copy?
245 def copy?
246 @copied_from.present?
246 @copied_from.present?
247 end
247 end
248
248
249 # Moves/copies an issue to a new project and tracker
249 # Moves/copies an issue to a new project and tracker
250 # Returns the moved/copied issue on success, false on failure
250 # Returns the moved/copied issue on success, false on failure
251 def move_to_project(new_project, new_tracker=nil, options={})
251 def move_to_project(new_project, new_tracker=nil, options={})
252 ActiveSupport::Deprecation.warn "Issue#move_to_project is deprecated, use #project= instead."
252 ActiveSupport::Deprecation.warn "Issue#move_to_project is deprecated, use #project= instead."
253
253
254 if options[:copy]
254 if options[:copy]
255 issue = self.copy
255 issue = self.copy
256 else
256 else
257 issue = self
257 issue = self
258 end
258 end
259
259
260 issue.init_journal(User.current, options[:notes])
260 issue.init_journal(User.current, options[:notes])
261
261
262 # Preserve previous behaviour
262 # Preserve previous behaviour
263 # #move_to_project doesn't change tracker automatically
263 # #move_to_project doesn't change tracker automatically
264 issue.send :project=, new_project, true
264 issue.send :project=, new_project, true
265 if new_tracker
265 if new_tracker
266 issue.tracker = new_tracker
266 issue.tracker = new_tracker
267 end
267 end
268 # Allow bulk setting of attributes on the issue
268 # Allow bulk setting of attributes on the issue
269 if options[:attributes]
269 if options[:attributes]
270 issue.attributes = options[:attributes]
270 issue.attributes = options[:attributes]
271 end
271 end
272
272
273 issue.save ? issue : false
273 issue.save ? issue : false
274 end
274 end
275
275
276 def status_id=(status_id)
276 def status_id=(status_id)
277 if status_id.to_s != self.status_id.to_s
277 if status_id.to_s != self.status_id.to_s
278 self.status = (status_id.present? ? IssueStatus.find_by_id(status_id) : nil)
278 self.status = (status_id.present? ? IssueStatus.find_by_id(status_id) : nil)
279 end
279 end
280 self.status_id
280 self.status_id
281 end
281 end
282
282
283 # Sets the status.
283 # Sets the status.
284 def self.status=(status)
284 def self.status=(status)
285 if status != self.status
285 if status != self.status
286 @workflow_rule_by_attribute = nil
286 @workflow_rule_by_attribute = nil
287 end
287 end
288 association(:status).writer(status)
288 association(:status).writer(status)
289 end
289 end
290
290
291 def priority_id=(pid)
291 def priority_id=(pid)
292 self.priority = nil
292 self.priority = nil
293 write_attribute(:priority_id, pid)
293 write_attribute(:priority_id, pid)
294 end
294 end
295
295
296 def category_id=(cid)
296 def category_id=(cid)
297 self.category = nil
297 self.category = nil
298 write_attribute(:category_id, cid)
298 write_attribute(:category_id, cid)
299 end
299 end
300
300
301 def fixed_version_id=(vid)
301 def fixed_version_id=(vid)
302 self.fixed_version = nil
302 self.fixed_version = nil
303 write_attribute(:fixed_version_id, vid)
303 write_attribute(:fixed_version_id, vid)
304 end
304 end
305
305
306 def tracker_id=(tracker_id)
306 def tracker_id=(tracker_id)
307 if tracker_id.to_s != self.tracker_id.to_s
307 if tracker_id.to_s != self.tracker_id.to_s
308 self.tracker = (tracker_id.present? ? Tracker.find_by_id(tracker_id) : nil)
308 self.tracker = (tracker_id.present? ? Tracker.find_by_id(tracker_id) : nil)
309 end
309 end
310 self.tracker_id
310 self.tracker_id
311 end
311 end
312
312
313 # Sets the tracker.
313 # Sets the tracker.
314 # This will set the status to the default status of the new tracker if:
314 # This will set the status to the default status of the new tracker if:
315 # * the status was the default for the previous tracker
315 # * the status was the default for the previous tracker
316 # * or if the status was not part of the new tracker statuses
316 # * or if the status was not part of the new tracker statuses
317 # * or the status was nil
317 # * or the status was nil
318 def tracker=(tracker)
318 def tracker=(tracker)
319 if tracker != self.tracker
319 if tracker != self.tracker
320 if status == default_status
320 if status == default_status
321 self.status = nil
321 self.status = nil
322 elsif status && tracker && !tracker.issue_status_ids.include?(status.id)
322 elsif status && tracker && !tracker.issue_status_ids.include?(status.id)
323 self.status = nil
323 self.status = nil
324 end
324 end
325 @custom_field_values = nil
325 @custom_field_values = nil
326 @workflow_rule_by_attribute = nil
326 @workflow_rule_by_attribute = nil
327 end
327 end
328 association(:tracker).writer(tracker)
328 association(:tracker).writer(tracker)
329 self.status ||= default_status
329 self.status ||= default_status
330 self.tracker
330 self.tracker
331 end
331 end
332
332
333 def project_id=(project_id)
333 def project_id=(project_id)
334 if project_id.to_s != self.project_id.to_s
334 if project_id.to_s != self.project_id.to_s
335 self.project = (project_id.present? ? Project.find_by_id(project_id) : nil)
335 self.project = (project_id.present? ? Project.find_by_id(project_id) : nil)
336 end
336 end
337 self.project_id
337 self.project_id
338 end
338 end
339
339
340 # Sets the project.
340 # Sets the project.
341 # Unless keep_tracker argument is set to true, this will change the tracker
341 # Unless keep_tracker argument is set to true, this will change the tracker
342 # to the first tracker of the new project if the previous tracker is not part
342 # to the first tracker of the new project if the previous tracker is not part
343 # of the new project trackers.
343 # of the new project trackers.
344 # This will clear the fixed_version is it's no longer valid for the new project.
344 # This will clear the fixed_version is it's no longer valid for the new project.
345 # This will clear the parent issue if it's no longer valid for the new project.
345 # This will clear the parent issue if it's no longer valid for the new project.
346 # This will set the category to the category with the same name in the new
346 # This will set the category to the category with the same name in the new
347 # project if it exists, or clear it if it doesn't.
347 # project if it exists, or clear it if it doesn't.
348 def project=(project, keep_tracker=false)
348 def project=(project, keep_tracker=false)
349 project_was = self.project
349 project_was = self.project
350 association(:project).writer(project)
350 association(:project).writer(project)
351 if project_was && project && project_was != project
351 if project_was && project && project_was != project
352 @assignable_versions = nil
352 @assignable_versions = nil
353
353
354 unless keep_tracker || project.trackers.include?(tracker)
354 unless keep_tracker || project.trackers.include?(tracker)
355 self.tracker = project.trackers.first
355 self.tracker = project.trackers.first
356 end
356 end
357 # Reassign to the category with same name if any
357 # Reassign to the category with same name if any
358 if category
358 if category
359 self.category = project.issue_categories.find_by_name(category.name)
359 self.category = project.issue_categories.find_by_name(category.name)
360 end
360 end
361 # Keep the fixed_version if it's still valid in the new_project
361 # Keep the fixed_version if it's still valid in the new_project
362 if fixed_version && fixed_version.project != project && !project.shared_versions.include?(fixed_version)
362 if fixed_version && fixed_version.project != project && !project.shared_versions.include?(fixed_version)
363 self.fixed_version = nil
363 self.fixed_version = nil
364 end
364 end
365 # Clear the parent task if it's no longer valid
365 # Clear the parent task if it's no longer valid
366 unless valid_parent_project?
366 unless valid_parent_project?
367 self.parent_issue_id = nil
367 self.parent_issue_id = nil
368 end
368 end
369 @custom_field_values = nil
369 @custom_field_values = nil
370 @workflow_rule_by_attribute = nil
370 @workflow_rule_by_attribute = nil
371 end
371 end
372 self.project
372 self.project
373 end
373 end
374
374
375 def description=(arg)
375 def description=(arg)
376 if arg.is_a?(String)
376 if arg.is_a?(String)
377 arg = arg.gsub(/(\r\n|\n|\r)/, "\r\n")
377 arg = arg.gsub(/(\r\n|\n|\r)/, "\r\n")
378 end
378 end
379 write_attribute(:description, arg)
379 write_attribute(:description, arg)
380 end
380 end
381
381
382 # Overrides assign_attributes so that project and tracker get assigned first
382 # Overrides assign_attributes so that project and tracker get assigned first
383 def assign_attributes_with_project_and_tracker_first(new_attributes, *args)
383 def assign_attributes_with_project_and_tracker_first(new_attributes, *args)
384 return if new_attributes.nil?
384 return if new_attributes.nil?
385 attrs = new_attributes.dup
385 attrs = new_attributes.dup
386 attrs.stringify_keys!
386 attrs.stringify_keys!
387
387
388 %w(project project_id tracker tracker_id).each do |attr|
388 %w(project project_id tracker tracker_id).each do |attr|
389 if attrs.has_key?(attr)
389 if attrs.has_key?(attr)
390 send "#{attr}=", attrs.delete(attr)
390 send "#{attr}=", attrs.delete(attr)
391 end
391 end
392 end
392 end
393 send :assign_attributes_without_project_and_tracker_first, attrs, *args
393 send :assign_attributes_without_project_and_tracker_first, attrs, *args
394 end
394 end
395 # Do not redefine alias chain on reload (see #4838)
395 # Do not redefine alias chain on reload (see #4838)
396 alias_method_chain(:assign_attributes, :project_and_tracker_first) unless method_defined?(:assign_attributes_without_project_and_tracker_first)
396 alias_method_chain(:assign_attributes, :project_and_tracker_first) unless method_defined?(:assign_attributes_without_project_and_tracker_first)
397
397
398 def attributes=(new_attributes)
398 def attributes=(new_attributes)
399 assign_attributes new_attributes
399 assign_attributes new_attributes
400 end
400 end
401
401
402 def estimated_hours=(h)
402 def estimated_hours=(h)
403 write_attribute :estimated_hours, (h.is_a?(String) ? h.to_hours : h)
403 write_attribute :estimated_hours, (h.is_a?(String) ? h.to_hours : h)
404 end
404 end
405
405
406 safe_attributes 'project_id',
406 safe_attributes 'project_id',
407 :if => lambda {|issue, user|
407 :if => lambda {|issue, user|
408 if issue.new_record?
408 if issue.new_record?
409 issue.copy?
409 issue.copy?
410 elsif user.allowed_to?(:move_issues, issue.project)
410 elsif user.allowed_to?(:move_issues, issue.project)
411 Issue.allowed_target_projects_on_move.count > 1
411 Issue.allowed_target_projects_on_move.count > 1
412 end
412 end
413 }
413 }
414
414
415 safe_attributes 'tracker_id',
415 safe_attributes 'tracker_id',
416 'status_id',
416 'status_id',
417 'category_id',
417 'category_id',
418 'assigned_to_id',
418 'assigned_to_id',
419 'priority_id',
419 'priority_id',
420 'fixed_version_id',
420 'fixed_version_id',
421 'subject',
421 'subject',
422 'description',
422 'description',
423 'start_date',
423 'start_date',
424 'due_date',
424 'due_date',
425 'done_ratio',
425 'done_ratio',
426 'estimated_hours',
426 'estimated_hours',
427 'custom_field_values',
427 'custom_field_values',
428 'custom_fields',
428 'custom_fields',
429 'lock_version',
429 'lock_version',
430 'notes',
430 'notes',
431 :if => lambda {|issue, user| issue.new_record? || user.allowed_to?(:edit_issues, issue.project) }
431 :if => lambda {|issue, user| issue.new_record? || user.allowed_to?(:edit_issues, issue.project) }
432
432
433 safe_attributes 'status_id',
433 safe_attributes 'status_id',
434 'assigned_to_id',
434 'assigned_to_id',
435 'fixed_version_id',
435 'fixed_version_id',
436 'done_ratio',
436 'done_ratio',
437 'lock_version',
437 'lock_version',
438 'notes',
438 'notes',
439 :if => lambda {|issue, user| issue.new_statuses_allowed_to(user).any? }
439 :if => lambda {|issue, user| issue.new_statuses_allowed_to(user).any? }
440
440
441 safe_attributes 'notes',
441 safe_attributes 'notes',
442 :if => lambda {|issue, user| user.allowed_to?(:add_issue_notes, issue.project)}
442 :if => lambda {|issue, user| user.allowed_to?(:add_issue_notes, issue.project)}
443
443
444 safe_attributes 'private_notes',
444 safe_attributes 'private_notes',
445 :if => lambda {|issue, user| !issue.new_record? && user.allowed_to?(:set_notes_private, issue.project)}
445 :if => lambda {|issue, user| !issue.new_record? && user.allowed_to?(:set_notes_private, issue.project)}
446
446
447 safe_attributes 'watcher_user_ids',
447 safe_attributes 'watcher_user_ids',
448 :if => lambda {|issue, user| issue.new_record? && user.allowed_to?(:add_issue_watchers, issue.project)}
448 :if => lambda {|issue, user| issue.new_record? && user.allowed_to?(:add_issue_watchers, issue.project)}
449
449
450 safe_attributes 'is_private',
450 safe_attributes 'is_private',
451 :if => lambda {|issue, user|
451 :if => lambda {|issue, user|
452 user.allowed_to?(:set_issues_private, issue.project) ||
452 user.allowed_to?(:set_issues_private, issue.project) ||
453 (issue.author_id == user.id && user.allowed_to?(:set_own_issues_private, issue.project))
453 (issue.author_id == user.id && user.allowed_to?(:set_own_issues_private, issue.project))
454 }
454 }
455
455
456 safe_attributes 'parent_issue_id',
456 safe_attributes 'parent_issue_id',
457 :if => lambda {|issue, user| (issue.new_record? || user.allowed_to?(:edit_issues, issue.project)) &&
457 :if => lambda {|issue, user| (issue.new_record? || user.allowed_to?(:edit_issues, issue.project)) &&
458 user.allowed_to?(:manage_subtasks, issue.project)}
458 user.allowed_to?(:manage_subtasks, issue.project)}
459
459
460 def safe_attribute_names(user=nil)
460 def safe_attribute_names(user=nil)
461 names = super
461 names = super
462 names -= disabled_core_fields
462 names -= disabled_core_fields
463 names -= read_only_attribute_names(user)
463 names -= read_only_attribute_names(user)
464 names
464 names
465 end
465 end
466
466
467 # Safely sets attributes
467 # Safely sets attributes
468 # Should be called from controllers instead of #attributes=
468 # Should be called from controllers instead of #attributes=
469 # attr_accessible is too rough because we still want things like
469 # attr_accessible is too rough because we still want things like
470 # Issue.new(:project => foo) to work
470 # Issue.new(:project => foo) to work
471 def safe_attributes=(attrs, user=User.current)
471 def safe_attributes=(attrs, user=User.current)
472 return unless attrs.is_a?(Hash)
472 return unless attrs.is_a?(Hash)
473
473
474 attrs = attrs.deep_dup
474 attrs = attrs.deep_dup
475
475
476 # Project and Tracker must be set before since new_statuses_allowed_to depends on it.
476 # Project and Tracker must be set before since new_statuses_allowed_to depends on it.
477 if (p = attrs.delete('project_id')) && safe_attribute?('project_id')
477 if (p = attrs.delete('project_id')) && safe_attribute?('project_id')
478 if allowed_target_projects(user).where(:id => p.to_i).exists?
478 if allowed_target_projects(user).where(:id => p.to_i).exists?
479 self.project_id = p
479 self.project_id = p
480 end
480 end
481 end
481 end
482
482
483 if (t = attrs.delete('tracker_id')) && safe_attribute?('tracker_id')
483 if (t = attrs.delete('tracker_id')) && safe_attribute?('tracker_id')
484 self.tracker_id = t
484 self.tracker_id = t
485 end
485 end
486
486
487 if (s = attrs.delete('status_id')) && safe_attribute?('status_id')
487 if (s = attrs.delete('status_id')) && safe_attribute?('status_id')
488 if new_statuses_allowed_to(user).collect(&:id).include?(s.to_i)
488 if new_statuses_allowed_to(user).collect(&:id).include?(s.to_i)
489 self.status_id = s
489 self.status_id = s
490 end
490 end
491 end
491 end
492
492
493 attrs = delete_unsafe_attributes(attrs, user)
493 attrs = delete_unsafe_attributes(attrs, user)
494 return if attrs.empty?
494 return if attrs.empty?
495
495
496 unless leaf?
496 unless leaf?
497 attrs.reject! {|k,v| %w(priority_id done_ratio start_date due_date estimated_hours).include?(k)}
497 attrs.reject! {|k,v| %w(priority_id done_ratio start_date due_date estimated_hours).include?(k)}
498 end
498 end
499
499
500 if attrs['parent_issue_id'].present?
500 if attrs['parent_issue_id'].present?
501 s = attrs['parent_issue_id'].to_s
501 s = attrs['parent_issue_id'].to_s
502 unless (m = s.match(%r{\A#?(\d+)\z})) && (m[1] == parent_id.to_s || Issue.visible(user).exists?(m[1]))
502 unless (m = s.match(%r{\A#?(\d+)\z})) && (m[1] == parent_id.to_s || Issue.visible(user).exists?(m[1]))
503 @invalid_parent_issue_id = attrs.delete('parent_issue_id')
503 @invalid_parent_issue_id = attrs.delete('parent_issue_id')
504 end
504 end
505 end
505 end
506
506
507 if attrs['custom_field_values'].present?
507 if attrs['custom_field_values'].present?
508 editable_custom_field_ids = editable_custom_field_values(user).map {|v| v.custom_field_id.to_s}
508 editable_custom_field_ids = editable_custom_field_values(user).map {|v| v.custom_field_id.to_s}
509 attrs['custom_field_values'].select! {|k, v| editable_custom_field_ids.include?(k.to_s)}
509 attrs['custom_field_values'].select! {|k, v| editable_custom_field_ids.include?(k.to_s)}
510 end
510 end
511
511
512 if attrs['custom_fields'].present?
512 if attrs['custom_fields'].present?
513 editable_custom_field_ids = editable_custom_field_values(user).map {|v| v.custom_field_id.to_s}
513 editable_custom_field_ids = editable_custom_field_values(user).map {|v| v.custom_field_id.to_s}
514 attrs['custom_fields'].select! {|c| editable_custom_field_ids.include?(c['id'].to_s)}
514 attrs['custom_fields'].select! {|c| editable_custom_field_ids.include?(c['id'].to_s)}
515 end
515 end
516
516
517 # mass-assignment security bypass
517 # mass-assignment security bypass
518 assign_attributes attrs, :without_protection => true
518 assign_attributes attrs, :without_protection => true
519 end
519 end
520
520
521 def disabled_core_fields
521 def disabled_core_fields
522 tracker ? tracker.disabled_core_fields : []
522 tracker ? tracker.disabled_core_fields : []
523 end
523 end
524
524
525 # Returns the custom_field_values that can be edited by the given user
525 # Returns the custom_field_values that can be edited by the given user
526 def editable_custom_field_values(user=nil)
526 def editable_custom_field_values(user=nil)
527 visible_custom_field_values(user).reject do |value|
527 visible_custom_field_values(user).reject do |value|
528 read_only_attribute_names(user).include?(value.custom_field_id.to_s)
528 read_only_attribute_names(user).include?(value.custom_field_id.to_s)
529 end
529 end
530 end
530 end
531
531
532 # Returns the custom fields that can be edited by the given user
532 # Returns the custom fields that can be edited by the given user
533 def editable_custom_fields(user=nil)
533 def editable_custom_fields(user=nil)
534 editable_custom_field_values(user).map(&:custom_field).uniq
534 editable_custom_field_values(user).map(&:custom_field).uniq
535 end
535 end
536
536
537 # Returns the names of attributes that are read-only for user or the current user
537 # Returns the names of attributes that are read-only for user or the current user
538 # For users with multiple roles, the read-only fields are the intersection of
538 # For users with multiple roles, the read-only fields are the intersection of
539 # read-only fields of each role
539 # read-only fields of each role
540 # The result is an array of strings where sustom fields are represented with their ids
540 # The result is an array of strings where sustom fields are represented with their ids
541 #
541 #
542 # Examples:
542 # Examples:
543 # issue.read_only_attribute_names # => ['due_date', '2']
543 # issue.read_only_attribute_names # => ['due_date', '2']
544 # issue.read_only_attribute_names(user) # => []
544 # issue.read_only_attribute_names(user) # => []
545 def read_only_attribute_names(user=nil)
545 def read_only_attribute_names(user=nil)
546 workflow_rule_by_attribute(user).reject {|attr, rule| rule != 'readonly'}.keys
546 workflow_rule_by_attribute(user).reject {|attr, rule| rule != 'readonly'}.keys
547 end
547 end
548
548
549 # Returns the names of required attributes for user or the current user
549 # Returns the names of required attributes for user or the current user
550 # For users with multiple roles, the required fields are the intersection of
550 # For users with multiple roles, the required fields are the intersection of
551 # required fields of each role
551 # required fields of each role
552 # The result is an array of strings where sustom fields are represented with their ids
552 # The result is an array of strings where sustom fields are represented with their ids
553 #
553 #
554 # Examples:
554 # Examples:
555 # issue.required_attribute_names # => ['due_date', '2']
555 # issue.required_attribute_names # => ['due_date', '2']
556 # issue.required_attribute_names(user) # => []
556 # issue.required_attribute_names(user) # => []
557 def required_attribute_names(user=nil)
557 def required_attribute_names(user=nil)
558 workflow_rule_by_attribute(user).reject {|attr, rule| rule != 'required'}.keys
558 workflow_rule_by_attribute(user).reject {|attr, rule| rule != 'required'}.keys
559 end
559 end
560
560
561 # Returns true if the attribute is required for user
561 # Returns true if the attribute is required for user
562 def required_attribute?(name, user=nil)
562 def required_attribute?(name, user=nil)
563 required_attribute_names(user).include?(name.to_s)
563 required_attribute_names(user).include?(name.to_s)
564 end
564 end
565
565
566 # Returns a hash of the workflow rule by attribute for the given user
566 # Returns a hash of the workflow rule by attribute for the given user
567 #
567 #
568 # Examples:
568 # Examples:
569 # issue.workflow_rule_by_attribute # => {'due_date' => 'required', 'start_date' => 'readonly'}
569 # issue.workflow_rule_by_attribute # => {'due_date' => 'required', 'start_date' => 'readonly'}
570 def workflow_rule_by_attribute(user=nil)
570 def workflow_rule_by_attribute(user=nil)
571 return @workflow_rule_by_attribute if @workflow_rule_by_attribute && user.nil?
571 return @workflow_rule_by_attribute if @workflow_rule_by_attribute && user.nil?
572
572
573 user_real = user || User.current
573 user_real = user || User.current
574 roles = user_real.admin ? Role.all.to_a : user_real.roles_for_project(project)
574 roles = user_real.admin ? Role.all.to_a : user_real.roles_for_project(project)
575 return {} if roles.empty?
575 return {} if roles.empty?
576
576
577 result = {}
577 result = {}
578 workflow_permissions = WorkflowPermission.where(:tracker_id => tracker_id, :old_status_id => status_id, :role_id => roles.map(&:id)).to_a
578 workflow_permissions = WorkflowPermission.where(:tracker_id => tracker_id, :old_status_id => status_id, :role_id => roles.map(&:id)).to_a
579 if workflow_permissions.any?
579 if workflow_permissions.any?
580 workflow_rules = workflow_permissions.inject({}) do |h, wp|
580 workflow_rules = workflow_permissions.inject({}) do |h, wp|
581 h[wp.field_name] ||= []
581 h[wp.field_name] ||= []
582 h[wp.field_name] << wp.rule
582 h[wp.field_name] << wp.rule
583 h
583 h
584 end
584 end
585 workflow_rules.each do |attr, rules|
585 workflow_rules.each do |attr, rules|
586 next if rules.size < roles.size
586 next if rules.size < roles.size
587 uniq_rules = rules.uniq
587 uniq_rules = rules.uniq
588 if uniq_rules.size == 1
588 if uniq_rules.size == 1
589 result[attr] = uniq_rules.first
589 result[attr] = uniq_rules.first
590 else
590 else
591 result[attr] = 'required'
591 result[attr] = 'required'
592 end
592 end
593 end
593 end
594 end
594 end
595 @workflow_rule_by_attribute = result if user.nil?
595 @workflow_rule_by_attribute = result if user.nil?
596 result
596 result
597 end
597 end
598 private :workflow_rule_by_attribute
598 private :workflow_rule_by_attribute
599
599
600 def done_ratio
600 def done_ratio
601 if Issue.use_status_for_done_ratio? && status && status.default_done_ratio
601 if Issue.use_status_for_done_ratio? && status && status.default_done_ratio
602 status.default_done_ratio
602 status.default_done_ratio
603 else
603 else
604 read_attribute(:done_ratio)
604 read_attribute(:done_ratio)
605 end
605 end
606 end
606 end
607
607
608 def self.use_status_for_done_ratio?
608 def self.use_status_for_done_ratio?
609 Setting.issue_done_ratio == 'issue_status'
609 Setting.issue_done_ratio == 'issue_status'
610 end
610 end
611
611
612 def self.use_field_for_done_ratio?
612 def self.use_field_for_done_ratio?
613 Setting.issue_done_ratio == 'issue_field'
613 Setting.issue_done_ratio == 'issue_field'
614 end
614 end
615
615
616 def validate_issue
616 def validate_issue
617 if due_date && start_date && (start_date_changed? || due_date_changed?) && due_date < start_date
617 if due_date && start_date && (start_date_changed? || due_date_changed?) && due_date < start_date
618 errors.add :due_date, :greater_than_start_date
618 errors.add :due_date, :greater_than_start_date
619 end
619 end
620
620
621 if start_date && start_date_changed? && soonest_start && start_date < soonest_start
621 if start_date && start_date_changed? && soonest_start && start_date < soonest_start
622 errors.add :start_date, :earlier_than_minimum_start_date, :date => format_date(soonest_start)
622 errors.add :start_date, :earlier_than_minimum_start_date, :date => format_date(soonest_start)
623 end
623 end
624
624
625 if fixed_version
625 if fixed_version
626 if !assignable_versions.include?(fixed_version)
626 if !assignable_versions.include?(fixed_version)
627 errors.add :fixed_version_id, :inclusion
627 errors.add :fixed_version_id, :inclusion
628 elsif reopening? && fixed_version.closed?
628 elsif reopening? && fixed_version.closed?
629 errors.add :base, I18n.t(:error_can_not_reopen_issue_on_closed_version)
629 errors.add :base, I18n.t(:error_can_not_reopen_issue_on_closed_version)
630 end
630 end
631 end
631 end
632
632
633 # Checks that the issue can not be added/moved to a disabled tracker
633 # Checks that the issue can not be added/moved to a disabled tracker
634 if project && (tracker_id_changed? || project_id_changed?)
634 if project && (tracker_id_changed? || project_id_changed?)
635 unless project.trackers.include?(tracker)
635 unless project.trackers.include?(tracker)
636 errors.add :tracker_id, :inclusion
636 errors.add :tracker_id, :inclusion
637 end
637 end
638 end
638 end
639
639
640 # Checks parent issue assignment
640 # Checks parent issue assignment
641 if @invalid_parent_issue_id.present?
641 if @invalid_parent_issue_id.present?
642 errors.add :parent_issue_id, :invalid
642 errors.add :parent_issue_id, :invalid
643 elsif @parent_issue
643 elsif @parent_issue
644 if !valid_parent_project?(@parent_issue)
644 if !valid_parent_project?(@parent_issue)
645 errors.add :parent_issue_id, :invalid
645 errors.add :parent_issue_id, :invalid
646 elsif (@parent_issue != parent) && (all_dependent_issues.include?(@parent_issue) || @parent_issue.all_dependent_issues.include?(self))
646 elsif (@parent_issue != parent) && (all_dependent_issues.include?(@parent_issue) || @parent_issue.all_dependent_issues.include?(self))
647 errors.add :parent_issue_id, :invalid
647 errors.add :parent_issue_id, :invalid
648 elsif !new_record?
648 elsif !new_record?
649 # moving an existing issue
649 # moving an existing issue
650 if @parent_issue.root_id != root_id
650 if @parent_issue.root_id != root_id
651 # we can always move to another tree
651 # we can always move to another tree
652 elsif move_possible?(@parent_issue)
652 elsif move_possible?(@parent_issue)
653 # move accepted inside tree
653 # move accepted inside tree
654 else
654 else
655 errors.add :parent_issue_id, :invalid
655 errors.add :parent_issue_id, :invalid
656 end
656 end
657 end
657 end
658 end
658 end
659 end
659 end
660
660
661 # Validates the issue against additional workflow requirements
661 # Validates the issue against additional workflow requirements
662 def validate_required_fields
662 def validate_required_fields
663 user = new_record? ? author : current_journal.try(:user)
663 user = new_record? ? author : current_journal.try(:user)
664
664
665 required_attribute_names(user).each do |attribute|
665 required_attribute_names(user).each do |attribute|
666 if attribute =~ /^\d+$/
666 if attribute =~ /^\d+$/
667 attribute = attribute.to_i
667 attribute = attribute.to_i
668 v = custom_field_values.detect {|v| v.custom_field_id == attribute }
668 v = custom_field_values.detect {|v| v.custom_field_id == attribute }
669 if v && v.value.blank?
669 if v && v.value.blank?
670 errors.add :base, v.custom_field.name + ' ' + l('activerecord.errors.messages.blank')
670 errors.add :base, v.custom_field.name + ' ' + l('activerecord.errors.messages.blank')
671 end
671 end
672 else
672 else
673 if respond_to?(attribute) && send(attribute).blank? && !disabled_core_fields.include?(attribute)
673 if respond_to?(attribute) && send(attribute).blank? && !disabled_core_fields.include?(attribute)
674 errors.add attribute, :blank
674 errors.add attribute, :blank
675 end
675 end
676 end
676 end
677 end
677 end
678 end
678 end
679
679
680 # Set the done_ratio using the status if that setting is set. This will keep the done_ratios
680 # Set the done_ratio using the status if that setting is set. This will keep the done_ratios
681 # even if the user turns off the setting later
681 # even if the user turns off the setting later
682 def update_done_ratio_from_issue_status
682 def update_done_ratio_from_issue_status
683 if Issue.use_status_for_done_ratio? && status && status.default_done_ratio
683 if Issue.use_status_for_done_ratio? && status && status.default_done_ratio
684 self.done_ratio = status.default_done_ratio
684 self.done_ratio = status.default_done_ratio
685 end
685 end
686 end
686 end
687
687
688 def init_journal(user, notes = "")
688 def init_journal(user, notes = "")
689 @current_journal ||= Journal.new(:journalized => self, :user => user, :notes => notes)
689 @current_journal ||= Journal.new(:journalized => self, :user => user, :notes => notes)
690 if new_record?
691 @current_journal.notify = false
692 else
693 @attributes_before_change = attributes.dup
694 @custom_values_before_change = {}
695 self.custom_field_values.each {|c| @custom_values_before_change.store c.custom_field_id, c.value }
696 end
697 @current_journal
698 end
690 end
699
691
700 # Returns the current journal or nil if it's not initialized
692 # Returns the current journal or nil if it's not initialized
701 def current_journal
693 def current_journal
702 @current_journal
694 @current_journal
703 end
695 end
704
696
697 # Returns the names of attributes that are journalized when updating the issue
698 def journalized_attribute_names
699 Issue.column_names - %w(id root_id lft rgt lock_version created_on updated_on closed_on)
700 end
701
705 # Returns the id of the last journal or nil
702 # Returns the id of the last journal or nil
706 def last_journal_id
703 def last_journal_id
707 if new_record?
704 if new_record?
708 nil
705 nil
709 else
706 else
710 journals.maximum(:id)
707 journals.maximum(:id)
711 end
708 end
712 end
709 end
713
710
714 # Returns a scope for journals that have an id greater than journal_id
711 # Returns a scope for journals that have an id greater than journal_id
715 def journals_after(journal_id)
712 def journals_after(journal_id)
716 scope = journals.reorder("#{Journal.table_name}.id ASC")
713 scope = journals.reorder("#{Journal.table_name}.id ASC")
717 if journal_id.present?
714 if journal_id.present?
718 scope = scope.where("#{Journal.table_name}.id > ?", journal_id.to_i)
715 scope = scope.where("#{Journal.table_name}.id > ?", journal_id.to_i)
719 end
716 end
720 scope
717 scope
721 end
718 end
722
719
723 # Returns the initial status of the issue
720 # Returns the initial status of the issue
724 # Returns nil for a new issue
721 # Returns nil for a new issue
725 def status_was
722 def status_was
726 if status_id_changed?
723 if status_id_changed?
727 if status_id_was.to_i > 0
724 if status_id_was.to_i > 0
728 @status_was ||= IssueStatus.find_by_id(status_id_was)
725 @status_was ||= IssueStatus.find_by_id(status_id_was)
729 end
726 end
730 else
727 else
731 @status_was ||= status
728 @status_was ||= status
732 end
729 end
733 end
730 end
734
731
735 # Return true if the issue is closed, otherwise false
732 # Return true if the issue is closed, otherwise false
736 def closed?
733 def closed?
737 status.present? && status.is_closed?
734 status.present? && status.is_closed?
738 end
735 end
739
736
740 # Returns true if the issue was closed when loaded
737 # Returns true if the issue was closed when loaded
741 def was_closed?
738 def was_closed?
742 status_was.present? && status_was.is_closed?
739 status_was.present? && status_was.is_closed?
743 end
740 end
744
741
745 # Return true if the issue is being reopened
742 # Return true if the issue is being reopened
746 def reopening?
743 def reopening?
747 if new_record?
744 if new_record?
748 false
745 false
749 else
746 else
750 status_id_changed? && !closed? && was_closed?
747 status_id_changed? && !closed? && was_closed?
751 end
748 end
752 end
749 end
753 alias :reopened? :reopening?
750 alias :reopened? :reopening?
754
751
755 # Return true if the issue is being closed
752 # Return true if the issue is being closed
756 def closing?
753 def closing?
757 if new_record?
754 if new_record?
758 closed?
755 closed?
759 else
756 else
760 status_id_changed? && closed? && !was_closed?
757 status_id_changed? && closed? && !was_closed?
761 end
758 end
762 end
759 end
763
760
764 # Returns true if the issue is overdue
761 # Returns true if the issue is overdue
765 def overdue?
762 def overdue?
766 due_date.present? && (due_date < Date.today) && !closed?
763 due_date.present? && (due_date < Date.today) && !closed?
767 end
764 end
768
765
769 # Is the amount of work done less than it should for the due date
766 # Is the amount of work done less than it should for the due date
770 def behind_schedule?
767 def behind_schedule?
771 return false if start_date.nil? || due_date.nil?
768 return false if start_date.nil? || due_date.nil?
772 done_date = start_date + ((due_date - start_date + 1) * done_ratio / 100).floor
769 done_date = start_date + ((due_date - start_date + 1) * done_ratio / 100).floor
773 return done_date <= Date.today
770 return done_date <= Date.today
774 end
771 end
775
772
776 # Does this issue have children?
773 # Does this issue have children?
777 def children?
774 def children?
778 !leaf?
775 !leaf?
779 end
776 end
780
777
781 # Users the issue can be assigned to
778 # Users the issue can be assigned to
782 def assignable_users
779 def assignable_users
783 users = project.assignable_users.to_a
780 users = project.assignable_users.to_a
784 users << author if author
781 users << author if author
785 users << assigned_to if assigned_to
782 users << assigned_to if assigned_to
786 users.uniq.sort
783 users.uniq.sort
787 end
784 end
788
785
789 # Versions that the issue can be assigned to
786 # Versions that the issue can be assigned to
790 def assignable_versions
787 def assignable_versions
791 return @assignable_versions if @assignable_versions
788 return @assignable_versions if @assignable_versions
792
789
793 versions = project.shared_versions.open.to_a
790 versions = project.shared_versions.open.to_a
794 if fixed_version
791 if fixed_version
795 if fixed_version_id_changed?
792 if fixed_version_id_changed?
796 # nothing to do
793 # nothing to do
797 elsif project_id_changed?
794 elsif project_id_changed?
798 if project.shared_versions.include?(fixed_version)
795 if project.shared_versions.include?(fixed_version)
799 versions << fixed_version
796 versions << fixed_version
800 end
797 end
801 else
798 else
802 versions << fixed_version
799 versions << fixed_version
803 end
800 end
804 end
801 end
805 @assignable_versions = versions.uniq.sort
802 @assignable_versions = versions.uniq.sort
806 end
803 end
807
804
808 # Returns true if this issue is blocked by another issue that is still open
805 # Returns true if this issue is blocked by another issue that is still open
809 def blocked?
806 def blocked?
810 !relations_to.detect {|ir| ir.relation_type == 'blocks' && !ir.issue_from.closed?}.nil?
807 !relations_to.detect {|ir| ir.relation_type == 'blocks' && !ir.issue_from.closed?}.nil?
811 end
808 end
812
809
813 # Returns the default status of the issue based on its tracker
810 # Returns the default status of the issue based on its tracker
814 # Returns nil if tracker is nil
811 # Returns nil if tracker is nil
815 def default_status
812 def default_status
816 tracker.try(:default_status)
813 tracker.try(:default_status)
817 end
814 end
818
815
819 # Returns an array of statuses that user is able to apply
816 # Returns an array of statuses that user is able to apply
820 def new_statuses_allowed_to(user=User.current, include_default=false)
817 def new_statuses_allowed_to(user=User.current, include_default=false)
821 if new_record? && @copied_from
818 if new_record? && @copied_from
822 [default_status, @copied_from.status].compact.uniq.sort
819 [default_status, @copied_from.status].compact.uniq.sort
823 else
820 else
824 initial_status = nil
821 initial_status = nil
825 if new_record?
822 if new_record?
826 initial_status = default_status
823 initial_status = default_status
827 elsif tracker_id_changed?
824 elsif tracker_id_changed?
828 if Tracker.where(:id => tracker_id_was, :default_status_id => status_id_was).any?
825 if Tracker.where(:id => tracker_id_was, :default_status_id => status_id_was).any?
829 initial_status = default_status
826 initial_status = default_status
830 elsif tracker.issue_status_ids.include?(status_id_was)
827 elsif tracker.issue_status_ids.include?(status_id_was)
831 initial_status = IssueStatus.find_by_id(status_id_was)
828 initial_status = IssueStatus.find_by_id(status_id_was)
832 else
829 else
833 initial_status = default_status
830 initial_status = default_status
834 end
831 end
835 else
832 else
836 initial_status = status_was
833 initial_status = status_was
837 end
834 end
838
835
839 initial_assigned_to_id = assigned_to_id_changed? ? assigned_to_id_was : assigned_to_id
836 initial_assigned_to_id = assigned_to_id_changed? ? assigned_to_id_was : assigned_to_id
840 assignee_transitions_allowed = initial_assigned_to_id.present? &&
837 assignee_transitions_allowed = initial_assigned_to_id.present? &&
841 (user.id == initial_assigned_to_id || user.group_ids.include?(initial_assigned_to_id))
838 (user.id == initial_assigned_to_id || user.group_ids.include?(initial_assigned_to_id))
842
839
843 statuses = []
840 statuses = []
844 if initial_status
841 if initial_status
845 statuses += initial_status.find_new_statuses_allowed_to(
842 statuses += initial_status.find_new_statuses_allowed_to(
846 user.admin ? Role.all.to_a : user.roles_for_project(project),
843 user.admin ? Role.all.to_a : user.roles_for_project(project),
847 tracker,
844 tracker,
848 author == user,
845 author == user,
849 assignee_transitions_allowed
846 assignee_transitions_allowed
850 )
847 )
851 end
848 end
852 statuses << initial_status unless statuses.empty?
849 statuses << initial_status unless statuses.empty?
853 statuses << default_status if include_default
850 statuses << default_status if include_default
854 statuses = statuses.compact.uniq.sort
851 statuses = statuses.compact.uniq.sort
855 if blocked?
852 if blocked?
856 statuses.reject!(&:is_closed?)
853 statuses.reject!(&:is_closed?)
857 end
854 end
858 statuses
855 statuses
859 end
856 end
860 end
857 end
861
858
862 # Returns the previous assignee if changed
859 # Returns the previous assignee if changed
863 def assigned_to_was
860 def assigned_to_was
864 # assigned_to_id_was is reset before after_save callbacks
861 # assigned_to_id_was is reset before after_save callbacks
865 user_id = @previous_assigned_to_id || assigned_to_id_was
862 user_id = @previous_assigned_to_id || assigned_to_id_was
866 if user_id && user_id != assigned_to_id
863 if user_id && user_id != assigned_to_id
867 @assigned_to_was ||= User.find_by_id(user_id)
864 @assigned_to_was ||= User.find_by_id(user_id)
868 end
865 end
869 end
866 end
870
867
871 # Returns the users that should be notified
868 # Returns the users that should be notified
872 def notified_users
869 def notified_users
873 notified = []
870 notified = []
874 # Author and assignee are always notified unless they have been
871 # Author and assignee are always notified unless they have been
875 # locked or don't want to be notified
872 # locked or don't want to be notified
876 notified << author if author
873 notified << author if author
877 if assigned_to
874 if assigned_to
878 notified += (assigned_to.is_a?(Group) ? assigned_to.users : [assigned_to])
875 notified += (assigned_to.is_a?(Group) ? assigned_to.users : [assigned_to])
879 end
876 end
880 if assigned_to_was
877 if assigned_to_was
881 notified += (assigned_to_was.is_a?(Group) ? assigned_to_was.users : [assigned_to_was])
878 notified += (assigned_to_was.is_a?(Group) ? assigned_to_was.users : [assigned_to_was])
882 end
879 end
883 notified = notified.select {|u| u.active? && u.notify_about?(self)}
880 notified = notified.select {|u| u.active? && u.notify_about?(self)}
884
881
885 notified += project.notified_users
882 notified += project.notified_users
886 notified.uniq!
883 notified.uniq!
887 # Remove users that can not view the issue
884 # Remove users that can not view the issue
888 notified.reject! {|user| !visible?(user)}
885 notified.reject! {|user| !visible?(user)}
889 notified
886 notified
890 end
887 end
891
888
892 # Returns the email addresses that should be notified
889 # Returns the email addresses that should be notified
893 def recipients
890 def recipients
894 notified_users.collect(&:mail)
891 notified_users.collect(&:mail)
895 end
892 end
896
893
897 def each_notification(users, &block)
894 def each_notification(users, &block)
898 if users.any?
895 if users.any?
899 if custom_field_values.detect {|value| !value.custom_field.visible?}
896 if custom_field_values.detect {|value| !value.custom_field.visible?}
900 users_by_custom_field_visibility = users.group_by do |user|
897 users_by_custom_field_visibility = users.group_by do |user|
901 visible_custom_field_values(user).map(&:custom_field_id).sort
898 visible_custom_field_values(user).map(&:custom_field_id).sort
902 end
899 end
903 users_by_custom_field_visibility.values.each do |users|
900 users_by_custom_field_visibility.values.each do |users|
904 yield(users)
901 yield(users)
905 end
902 end
906 else
903 else
907 yield(users)
904 yield(users)
908 end
905 end
909 end
906 end
910 end
907 end
911
908
912 # Returns the number of hours spent on this issue
909 # Returns the number of hours spent on this issue
913 def spent_hours
910 def spent_hours
914 @spent_hours ||= time_entries.sum(:hours) || 0
911 @spent_hours ||= time_entries.sum(:hours) || 0
915 end
912 end
916
913
917 # Returns the total number of hours spent on this issue and its descendants
914 # Returns the total number of hours spent on this issue and its descendants
918 #
915 #
919 # Example:
916 # Example:
920 # spent_hours => 0.0
917 # spent_hours => 0.0
921 # spent_hours => 50.2
918 # spent_hours => 50.2
922 def total_spent_hours
919 def total_spent_hours
923 @total_spent_hours ||=
920 @total_spent_hours ||=
924 self_and_descendants.
921 self_and_descendants.
925 joins("LEFT JOIN #{TimeEntry.table_name} ON #{TimeEntry.table_name}.issue_id = #{Issue.table_name}.id").
922 joins("LEFT JOIN #{TimeEntry.table_name} ON #{TimeEntry.table_name}.issue_id = #{Issue.table_name}.id").
926 sum("#{TimeEntry.table_name}.hours").to_f || 0.0
923 sum("#{TimeEntry.table_name}.hours").to_f || 0.0
927 end
924 end
928
925
929 def relations
926 def relations
930 @relations ||= IssueRelation::Relations.new(self, (relations_from + relations_to).sort)
927 @relations ||= IssueRelation::Relations.new(self, (relations_from + relations_to).sort)
931 end
928 end
932
929
933 # Preloads relations for a collection of issues
930 # Preloads relations for a collection of issues
934 def self.load_relations(issues)
931 def self.load_relations(issues)
935 if issues.any?
932 if issues.any?
936 relations = IssueRelation.where("issue_from_id IN (:ids) OR issue_to_id IN (:ids)", :ids => issues.map(&:id)).all
933 relations = IssueRelation.where("issue_from_id IN (:ids) OR issue_to_id IN (:ids)", :ids => issues.map(&:id)).all
937 issues.each do |issue|
934 issues.each do |issue|
938 issue.instance_variable_set "@relations", relations.select {|r| r.issue_from_id == issue.id || r.issue_to_id == issue.id}
935 issue.instance_variable_set "@relations", relations.select {|r| r.issue_from_id == issue.id || r.issue_to_id == issue.id}
939 end
936 end
940 end
937 end
941 end
938 end
942
939
943 # Preloads visible spent time for a collection of issues
940 # Preloads visible spent time for a collection of issues
944 def self.load_visible_spent_hours(issues, user=User.current)
941 def self.load_visible_spent_hours(issues, user=User.current)
945 if issues.any?
942 if issues.any?
946 hours_by_issue_id = TimeEntry.visible(user).group(:issue_id).sum(:hours)
943 hours_by_issue_id = TimeEntry.visible(user).group(:issue_id).sum(:hours)
947 issues.each do |issue|
944 issues.each do |issue|
948 issue.instance_variable_set "@spent_hours", (hours_by_issue_id[issue.id] || 0)
945 issue.instance_variable_set "@spent_hours", (hours_by_issue_id[issue.id] || 0)
949 end
946 end
950 end
947 end
951 end
948 end
952
949
953 # Preloads visible relations for a collection of issues
950 # Preloads visible relations for a collection of issues
954 def self.load_visible_relations(issues, user=User.current)
951 def self.load_visible_relations(issues, user=User.current)
955 if issues.any?
952 if issues.any?
956 issue_ids = issues.map(&:id)
953 issue_ids = issues.map(&:id)
957 # Relations with issue_from in given issues and visible issue_to
954 # Relations with issue_from in given issues and visible issue_to
958 relations_from = IssueRelation.joins(:issue_to => :project).
955 relations_from = IssueRelation.joins(:issue_to => :project).
959 where(visible_condition(user)).where(:issue_from_id => issue_ids).to_a
956 where(visible_condition(user)).where(:issue_from_id => issue_ids).to_a
960 # Relations with issue_to in given issues and visible issue_from
957 # Relations with issue_to in given issues and visible issue_from
961 relations_to = IssueRelation.joins(:issue_from => :project).
958 relations_to = IssueRelation.joins(:issue_from => :project).
962 where(visible_condition(user)).
959 where(visible_condition(user)).
963 where(:issue_to_id => issue_ids).to_a
960 where(:issue_to_id => issue_ids).to_a
964 issues.each do |issue|
961 issues.each do |issue|
965 relations =
962 relations =
966 relations_from.select {|relation| relation.issue_from_id == issue.id} +
963 relations_from.select {|relation| relation.issue_from_id == issue.id} +
967 relations_to.select {|relation| relation.issue_to_id == issue.id}
964 relations_to.select {|relation| relation.issue_to_id == issue.id}
968
965
969 issue.instance_variable_set "@relations", IssueRelation::Relations.new(issue, relations.sort)
966 issue.instance_variable_set "@relations", IssueRelation::Relations.new(issue, relations.sort)
970 end
967 end
971 end
968 end
972 end
969 end
973
970
974 # Finds an issue relation given its id.
971 # Finds an issue relation given its id.
975 def find_relation(relation_id)
972 def find_relation(relation_id)
976 IssueRelation.where("issue_to_id = ? OR issue_from_id = ?", id, id).find(relation_id)
973 IssueRelation.where("issue_to_id = ? OR issue_from_id = ?", id, id).find(relation_id)
977 end
974 end
978
975
979 # Returns all the other issues that depend on the issue
976 # Returns all the other issues that depend on the issue
980 # The algorithm is a modified breadth first search (bfs)
977 # The algorithm is a modified breadth first search (bfs)
981 def all_dependent_issues(except=[])
978 def all_dependent_issues(except=[])
982 # The found dependencies
979 # The found dependencies
983 dependencies = []
980 dependencies = []
984
981
985 # The visited flag for every node (issue) used by the breadth first search
982 # The visited flag for every node (issue) used by the breadth first search
986 eNOT_DISCOVERED = 0 # The issue is "new" to the algorithm, it has not seen it before.
983 eNOT_DISCOVERED = 0 # The issue is "new" to the algorithm, it has not seen it before.
987
984
988 ePROCESS_ALL = 1 # The issue is added to the queue. Process both children and relations of
985 ePROCESS_ALL = 1 # The issue is added to the queue. Process both children and relations of
989 # the issue when it is processed.
986 # the issue when it is processed.
990
987
991 ePROCESS_RELATIONS_ONLY = 2 # The issue was added to the queue and will be output as dependent issue,
988 ePROCESS_RELATIONS_ONLY = 2 # The issue was added to the queue and will be output as dependent issue,
992 # but its children will not be added to the queue when it is processed.
989 # but its children will not be added to the queue when it is processed.
993
990
994 eRELATIONS_PROCESSED = 3 # The related issues, the parent issue and the issue itself have been added to
991 eRELATIONS_PROCESSED = 3 # The related issues, the parent issue and the issue itself have been added to
995 # the queue, but its children have not been added.
992 # the queue, but its children have not been added.
996
993
997 ePROCESS_CHILDREN_ONLY = 4 # The relations and the parent of the issue have been added to the queue, but
994 ePROCESS_CHILDREN_ONLY = 4 # The relations and the parent of the issue have been added to the queue, but
998 # the children still need to be processed.
995 # the children still need to be processed.
999
996
1000 eALL_PROCESSED = 5 # The issue and all its children, its parent and its related issues have been
997 eALL_PROCESSED = 5 # The issue and all its children, its parent and its related issues have been
1001 # added as dependent issues. It needs no further processing.
998 # added as dependent issues. It needs no further processing.
1002
999
1003 issue_status = Hash.new(eNOT_DISCOVERED)
1000 issue_status = Hash.new(eNOT_DISCOVERED)
1004
1001
1005 # The queue
1002 # The queue
1006 queue = []
1003 queue = []
1007
1004
1008 # Initialize the bfs, add start node (self) to the queue
1005 # Initialize the bfs, add start node (self) to the queue
1009 queue << self
1006 queue << self
1010 issue_status[self] = ePROCESS_ALL
1007 issue_status[self] = ePROCESS_ALL
1011
1008
1012 while (!queue.empty?) do
1009 while (!queue.empty?) do
1013 current_issue = queue.shift
1010 current_issue = queue.shift
1014 current_issue_status = issue_status[current_issue]
1011 current_issue_status = issue_status[current_issue]
1015 dependencies << current_issue
1012 dependencies << current_issue
1016
1013
1017 # Add parent to queue, if not already in it.
1014 # Add parent to queue, if not already in it.
1018 parent = current_issue.parent
1015 parent = current_issue.parent
1019 parent_status = issue_status[parent]
1016 parent_status = issue_status[parent]
1020
1017
1021 if parent && (parent_status == eNOT_DISCOVERED) && !except.include?(parent)
1018 if parent && (parent_status == eNOT_DISCOVERED) && !except.include?(parent)
1022 queue << parent
1019 queue << parent
1023 issue_status[parent] = ePROCESS_RELATIONS_ONLY
1020 issue_status[parent] = ePROCESS_RELATIONS_ONLY
1024 end
1021 end
1025
1022
1026 # Add children to queue, but only if they are not already in it and
1023 # Add children to queue, but only if they are not already in it and
1027 # the children of the current node need to be processed.
1024 # the children of the current node need to be processed.
1028 if (current_issue_status == ePROCESS_CHILDREN_ONLY || current_issue_status == ePROCESS_ALL)
1025 if (current_issue_status == ePROCESS_CHILDREN_ONLY || current_issue_status == ePROCESS_ALL)
1029 current_issue.children.each do |child|
1026 current_issue.children.each do |child|
1030 next if except.include?(child)
1027 next if except.include?(child)
1031
1028
1032 if (issue_status[child] == eNOT_DISCOVERED)
1029 if (issue_status[child] == eNOT_DISCOVERED)
1033 queue << child
1030 queue << child
1034 issue_status[child] = ePROCESS_ALL
1031 issue_status[child] = ePROCESS_ALL
1035 elsif (issue_status[child] == eRELATIONS_PROCESSED)
1032 elsif (issue_status[child] == eRELATIONS_PROCESSED)
1036 queue << child
1033 queue << child
1037 issue_status[child] = ePROCESS_CHILDREN_ONLY
1034 issue_status[child] = ePROCESS_CHILDREN_ONLY
1038 elsif (issue_status[child] == ePROCESS_RELATIONS_ONLY)
1035 elsif (issue_status[child] == ePROCESS_RELATIONS_ONLY)
1039 queue << child
1036 queue << child
1040 issue_status[child] = ePROCESS_ALL
1037 issue_status[child] = ePROCESS_ALL
1041 end
1038 end
1042 end
1039 end
1043 end
1040 end
1044
1041
1045 # Add related issues to the queue, if they are not already in it.
1042 # Add related issues to the queue, if they are not already in it.
1046 current_issue.relations_from.map(&:issue_to).each do |related_issue|
1043 current_issue.relations_from.map(&:issue_to).each do |related_issue|
1047 next if except.include?(related_issue)
1044 next if except.include?(related_issue)
1048
1045
1049 if (issue_status[related_issue] == eNOT_DISCOVERED)
1046 if (issue_status[related_issue] == eNOT_DISCOVERED)
1050 queue << related_issue
1047 queue << related_issue
1051 issue_status[related_issue] = ePROCESS_ALL
1048 issue_status[related_issue] = ePROCESS_ALL
1052 elsif (issue_status[related_issue] == eRELATIONS_PROCESSED)
1049 elsif (issue_status[related_issue] == eRELATIONS_PROCESSED)
1053 queue << related_issue
1050 queue << related_issue
1054 issue_status[related_issue] = ePROCESS_CHILDREN_ONLY
1051 issue_status[related_issue] = ePROCESS_CHILDREN_ONLY
1055 elsif (issue_status[related_issue] == ePROCESS_RELATIONS_ONLY)
1052 elsif (issue_status[related_issue] == ePROCESS_RELATIONS_ONLY)
1056 queue << related_issue
1053 queue << related_issue
1057 issue_status[related_issue] = ePROCESS_ALL
1054 issue_status[related_issue] = ePROCESS_ALL
1058 end
1055 end
1059 end
1056 end
1060
1057
1061 # Set new status for current issue
1058 # Set new status for current issue
1062 if (current_issue_status == ePROCESS_ALL) || (current_issue_status == ePROCESS_CHILDREN_ONLY)
1059 if (current_issue_status == ePROCESS_ALL) || (current_issue_status == ePROCESS_CHILDREN_ONLY)
1063 issue_status[current_issue] = eALL_PROCESSED
1060 issue_status[current_issue] = eALL_PROCESSED
1064 elsif (current_issue_status == ePROCESS_RELATIONS_ONLY)
1061 elsif (current_issue_status == ePROCESS_RELATIONS_ONLY)
1065 issue_status[current_issue] = eRELATIONS_PROCESSED
1062 issue_status[current_issue] = eRELATIONS_PROCESSED
1066 end
1063 end
1067 end # while
1064 end # while
1068
1065
1069 # Remove the issues from the "except" parameter from the result array
1066 # Remove the issues from the "except" parameter from the result array
1070 dependencies -= except
1067 dependencies -= except
1071 dependencies.delete(self)
1068 dependencies.delete(self)
1072
1069
1073 dependencies
1070 dependencies
1074 end
1071 end
1075
1072
1076 # Returns an array of issues that duplicate this one
1073 # Returns an array of issues that duplicate this one
1077 def duplicates
1074 def duplicates
1078 relations_to.select {|r| r.relation_type == IssueRelation::TYPE_DUPLICATES}.collect {|r| r.issue_from}
1075 relations_to.select {|r| r.relation_type == IssueRelation::TYPE_DUPLICATES}.collect {|r| r.issue_from}
1079 end
1076 end
1080
1077
1081 # Returns the due date or the target due date if any
1078 # Returns the due date or the target due date if any
1082 # Used on gantt chart
1079 # Used on gantt chart
1083 def due_before
1080 def due_before
1084 due_date || (fixed_version ? fixed_version.effective_date : nil)
1081 due_date || (fixed_version ? fixed_version.effective_date : nil)
1085 end
1082 end
1086
1083
1087 # Returns the time scheduled for this issue.
1084 # Returns the time scheduled for this issue.
1088 #
1085 #
1089 # Example:
1086 # Example:
1090 # Start Date: 2/26/09, End Date: 3/04/09
1087 # Start Date: 2/26/09, End Date: 3/04/09
1091 # duration => 6
1088 # duration => 6
1092 def duration
1089 def duration
1093 (start_date && due_date) ? due_date - start_date : 0
1090 (start_date && due_date) ? due_date - start_date : 0
1094 end
1091 end
1095
1092
1096 # Returns the duration in working days
1093 # Returns the duration in working days
1097 def working_duration
1094 def working_duration
1098 (start_date && due_date) ? working_days(start_date, due_date) : 0
1095 (start_date && due_date) ? working_days(start_date, due_date) : 0
1099 end
1096 end
1100
1097
1101 def soonest_start(reload=false)
1098 def soonest_start(reload=false)
1102 @soonest_start = nil if reload
1099 @soonest_start = nil if reload
1103 @soonest_start ||= (
1100 @soonest_start ||= (
1104 relations_to(reload).collect{|relation| relation.successor_soonest_start} +
1101 relations_to(reload).collect{|relation| relation.successor_soonest_start} +
1105 [(@parent_issue || parent).try(:soonest_start)]
1102 [(@parent_issue || parent).try(:soonest_start)]
1106 ).compact.max
1103 ).compact.max
1107 end
1104 end
1108
1105
1109 # Sets start_date on the given date or the next working day
1106 # Sets start_date on the given date or the next working day
1110 # and changes due_date to keep the same working duration.
1107 # and changes due_date to keep the same working duration.
1111 def reschedule_on(date)
1108 def reschedule_on(date)
1112 wd = working_duration
1109 wd = working_duration
1113 date = next_working_date(date)
1110 date = next_working_date(date)
1114 self.start_date = date
1111 self.start_date = date
1115 self.due_date = add_working_days(date, wd)
1112 self.due_date = add_working_days(date, wd)
1116 end
1113 end
1117
1114
1118 # Reschedules the issue on the given date or the next working day and saves the record.
1115 # Reschedules the issue on the given date or the next working day and saves the record.
1119 # If the issue is a parent task, this is done by rescheduling its subtasks.
1116 # If the issue is a parent task, this is done by rescheduling its subtasks.
1120 def reschedule_on!(date)
1117 def reschedule_on!(date)
1121 return if date.nil?
1118 return if date.nil?
1122 if leaf?
1119 if leaf?
1123 if start_date.nil? || start_date != date
1120 if start_date.nil? || start_date != date
1124 if start_date && start_date > date
1121 if start_date && start_date > date
1125 # Issue can not be moved earlier than its soonest start date
1122 # Issue can not be moved earlier than its soonest start date
1126 date = [soonest_start(true), date].compact.max
1123 date = [soonest_start(true), date].compact.max
1127 end
1124 end
1128 reschedule_on(date)
1125 reschedule_on(date)
1129 begin
1126 begin
1130 save
1127 save
1131 rescue ActiveRecord::StaleObjectError
1128 rescue ActiveRecord::StaleObjectError
1132 reload
1129 reload
1133 reschedule_on(date)
1130 reschedule_on(date)
1134 save
1131 save
1135 end
1132 end
1136 end
1133 end
1137 else
1134 else
1138 leaves.each do |leaf|
1135 leaves.each do |leaf|
1139 if leaf.start_date
1136 if leaf.start_date
1140 # Only move subtask if it starts at the same date as the parent
1137 # Only move subtask if it starts at the same date as the parent
1141 # or if it starts before the given date
1138 # or if it starts before the given date
1142 if start_date == leaf.start_date || date > leaf.start_date
1139 if start_date == leaf.start_date || date > leaf.start_date
1143 leaf.reschedule_on!(date)
1140 leaf.reschedule_on!(date)
1144 end
1141 end
1145 else
1142 else
1146 leaf.reschedule_on!(date)
1143 leaf.reschedule_on!(date)
1147 end
1144 end
1148 end
1145 end
1149 end
1146 end
1150 end
1147 end
1151
1148
1152 def <=>(issue)
1149 def <=>(issue)
1153 if issue.nil?
1150 if issue.nil?
1154 -1
1151 -1
1155 elsif root_id != issue.root_id
1152 elsif root_id != issue.root_id
1156 (root_id || 0) <=> (issue.root_id || 0)
1153 (root_id || 0) <=> (issue.root_id || 0)
1157 else
1154 else
1158 (lft || 0) <=> (issue.lft || 0)
1155 (lft || 0) <=> (issue.lft || 0)
1159 end
1156 end
1160 end
1157 end
1161
1158
1162 def to_s
1159 def to_s
1163 "#{tracker} ##{id}: #{subject}"
1160 "#{tracker} ##{id}: #{subject}"
1164 end
1161 end
1165
1162
1166 # Returns a string of css classes that apply to the issue
1163 # Returns a string of css classes that apply to the issue
1167 def css_classes(user=User.current)
1164 def css_classes(user=User.current)
1168 s = "issue tracker-#{tracker_id} status-#{status_id} #{priority.try(:css_classes)}"
1165 s = "issue tracker-#{tracker_id} status-#{status_id} #{priority.try(:css_classes)}"
1169 s << ' closed' if closed?
1166 s << ' closed' if closed?
1170 s << ' overdue' if overdue?
1167 s << ' overdue' if overdue?
1171 s << ' child' if child?
1168 s << ' child' if child?
1172 s << ' parent' unless leaf?
1169 s << ' parent' unless leaf?
1173 s << ' private' if is_private?
1170 s << ' private' if is_private?
1174 if user.logged?
1171 if user.logged?
1175 s << ' created-by-me' if author_id == user.id
1172 s << ' created-by-me' if author_id == user.id
1176 s << ' assigned-to-me' if assigned_to_id == user.id
1173 s << ' assigned-to-me' if assigned_to_id == user.id
1177 s << ' assigned-to-my-group' if user.groups.any? {|g| g.id == assigned_to_id}
1174 s << ' assigned-to-my-group' if user.groups.any? {|g| g.id == assigned_to_id}
1178 end
1175 end
1179 s
1176 s
1180 end
1177 end
1181
1178
1182 # Unassigns issues from +version+ if it's no longer shared with issue's project
1179 # Unassigns issues from +version+ if it's no longer shared with issue's project
1183 def self.update_versions_from_sharing_change(version)
1180 def self.update_versions_from_sharing_change(version)
1184 # Update issues assigned to the version
1181 # Update issues assigned to the version
1185 update_versions(["#{Issue.table_name}.fixed_version_id = ?", version.id])
1182 update_versions(["#{Issue.table_name}.fixed_version_id = ?", version.id])
1186 end
1183 end
1187
1184
1188 # Unassigns issues from versions that are no longer shared
1185 # Unassigns issues from versions that are no longer shared
1189 # after +project+ was moved
1186 # after +project+ was moved
1190 def self.update_versions_from_hierarchy_change(project)
1187 def self.update_versions_from_hierarchy_change(project)
1191 moved_project_ids = project.self_and_descendants.reload.collect(&:id)
1188 moved_project_ids = project.self_and_descendants.reload.collect(&:id)
1192 # Update issues of the moved projects and issues assigned to a version of a moved project
1189 # Update issues of the moved projects and issues assigned to a version of a moved project
1193 Issue.update_versions(
1190 Issue.update_versions(
1194 ["#{Version.table_name}.project_id IN (?) OR #{Issue.table_name}.project_id IN (?)",
1191 ["#{Version.table_name}.project_id IN (?) OR #{Issue.table_name}.project_id IN (?)",
1195 moved_project_ids, moved_project_ids]
1192 moved_project_ids, moved_project_ids]
1196 )
1193 )
1197 end
1194 end
1198
1195
1199 def parent_issue_id=(arg)
1196 def parent_issue_id=(arg)
1200 s = arg.to_s.strip.presence
1197 s = arg.to_s.strip.presence
1201 if s && (m = s.match(%r{\A#?(\d+)\z})) && (@parent_issue = Issue.find_by_id(m[1]))
1198 if s && (m = s.match(%r{\A#?(\d+)\z})) && (@parent_issue = Issue.find_by_id(m[1]))
1202 @invalid_parent_issue_id = nil
1199 @invalid_parent_issue_id = nil
1203 elsif s.blank?
1200 elsif s.blank?
1204 @parent_issue = nil
1201 @parent_issue = nil
1205 @invalid_parent_issue_id = nil
1202 @invalid_parent_issue_id = nil
1206 else
1203 else
1207 @parent_issue = nil
1204 @parent_issue = nil
1208 @invalid_parent_issue_id = arg
1205 @invalid_parent_issue_id = arg
1209 end
1206 end
1210 end
1207 end
1211
1208
1212 def parent_issue_id
1209 def parent_issue_id
1213 if @invalid_parent_issue_id
1210 if @invalid_parent_issue_id
1214 @invalid_parent_issue_id
1211 @invalid_parent_issue_id
1215 elsif instance_variable_defined? :@parent_issue
1212 elsif instance_variable_defined? :@parent_issue
1216 @parent_issue.nil? ? nil : @parent_issue.id
1213 @parent_issue.nil? ? nil : @parent_issue.id
1217 else
1214 else
1218 parent_id
1215 parent_id
1219 end
1216 end
1220 end
1217 end
1221
1218
1222 # Returns true if issue's project is a valid
1219 # Returns true if issue's project is a valid
1223 # parent issue project
1220 # parent issue project
1224 def valid_parent_project?(issue=parent)
1221 def valid_parent_project?(issue=parent)
1225 return true if issue.nil? || issue.project_id == project_id
1222 return true if issue.nil? || issue.project_id == project_id
1226
1223
1227 case Setting.cross_project_subtasks
1224 case Setting.cross_project_subtasks
1228 when 'system'
1225 when 'system'
1229 true
1226 true
1230 when 'tree'
1227 when 'tree'
1231 issue.project.root == project.root
1228 issue.project.root == project.root
1232 when 'hierarchy'
1229 when 'hierarchy'
1233 issue.project.is_or_is_ancestor_of?(project) || issue.project.is_descendant_of?(project)
1230 issue.project.is_or_is_ancestor_of?(project) || issue.project.is_descendant_of?(project)
1234 when 'descendants'
1231 when 'descendants'
1235 issue.project.is_or_is_ancestor_of?(project)
1232 issue.project.is_or_is_ancestor_of?(project)
1236 else
1233 else
1237 false
1234 false
1238 end
1235 end
1239 end
1236 end
1240
1237
1241 # Returns an issue scope based on project and scope
1238 # Returns an issue scope based on project and scope
1242 def self.cross_project_scope(project, scope=nil)
1239 def self.cross_project_scope(project, scope=nil)
1243 if project.nil?
1240 if project.nil?
1244 return Issue
1241 return Issue
1245 end
1242 end
1246 case scope
1243 case scope
1247 when 'all', 'system'
1244 when 'all', 'system'
1248 Issue
1245 Issue
1249 when 'tree'
1246 when 'tree'
1250 Issue.joins(:project).where("(#{Project.table_name}.lft >= :lft AND #{Project.table_name}.rgt <= :rgt)",
1247 Issue.joins(:project).where("(#{Project.table_name}.lft >= :lft AND #{Project.table_name}.rgt <= :rgt)",
1251 :lft => project.root.lft, :rgt => project.root.rgt)
1248 :lft => project.root.lft, :rgt => project.root.rgt)
1252 when 'hierarchy'
1249 when 'hierarchy'
1253 Issue.joins(:project).where("(#{Project.table_name}.lft >= :lft AND #{Project.table_name}.rgt <= :rgt) OR (#{Project.table_name}.lft < :lft AND #{Project.table_name}.rgt > :rgt)",
1250 Issue.joins(:project).where("(#{Project.table_name}.lft >= :lft AND #{Project.table_name}.rgt <= :rgt) OR (#{Project.table_name}.lft < :lft AND #{Project.table_name}.rgt > :rgt)",
1254 :lft => project.lft, :rgt => project.rgt)
1251 :lft => project.lft, :rgt => project.rgt)
1255 when 'descendants'
1252 when 'descendants'
1256 Issue.joins(:project).where("(#{Project.table_name}.lft >= :lft AND #{Project.table_name}.rgt <= :rgt)",
1253 Issue.joins(:project).where("(#{Project.table_name}.lft >= :lft AND #{Project.table_name}.rgt <= :rgt)",
1257 :lft => project.lft, :rgt => project.rgt)
1254 :lft => project.lft, :rgt => project.rgt)
1258 else
1255 else
1259 Issue.where(:project_id => project.id)
1256 Issue.where(:project_id => project.id)
1260 end
1257 end
1261 end
1258 end
1262
1259
1263 def self.by_tracker(project)
1260 def self.by_tracker(project)
1264 count_and_group_by(:project => project, :association => :tracker)
1261 count_and_group_by(:project => project, :association => :tracker)
1265 end
1262 end
1266
1263
1267 def self.by_version(project)
1264 def self.by_version(project)
1268 count_and_group_by(:project => project, :association => :fixed_version)
1265 count_and_group_by(:project => project, :association => :fixed_version)
1269 end
1266 end
1270
1267
1271 def self.by_priority(project)
1268 def self.by_priority(project)
1272 count_and_group_by(:project => project, :association => :priority)
1269 count_and_group_by(:project => project, :association => :priority)
1273 end
1270 end
1274
1271
1275 def self.by_category(project)
1272 def self.by_category(project)
1276 count_and_group_by(:project => project, :association => :category)
1273 count_and_group_by(:project => project, :association => :category)
1277 end
1274 end
1278
1275
1279 def self.by_assigned_to(project)
1276 def self.by_assigned_to(project)
1280 count_and_group_by(:project => project, :association => :assigned_to)
1277 count_and_group_by(:project => project, :association => :assigned_to)
1281 end
1278 end
1282
1279
1283 def self.by_author(project)
1280 def self.by_author(project)
1284 count_and_group_by(:project => project, :association => :author)
1281 count_and_group_by(:project => project, :association => :author)
1285 end
1282 end
1286
1283
1287 def self.by_subproject(project)
1284 def self.by_subproject(project)
1288 r = count_and_group_by(:project => project, :with_subprojects => true, :association => :project)
1285 r = count_and_group_by(:project => project, :with_subprojects => true, :association => :project)
1289 r.reject {|r| r["project_id"] == project.id.to_s}
1286 r.reject {|r| r["project_id"] == project.id.to_s}
1290 end
1287 end
1291
1288
1292 # Query generator for selecting groups of issue counts for a project
1289 # Query generator for selecting groups of issue counts for a project
1293 # based on specific criteria
1290 # based on specific criteria
1294 #
1291 #
1295 # Options
1292 # Options
1296 # * project - Project to search in.
1293 # * project - Project to search in.
1297 # * with_subprojects - Includes subprojects issues if set to true.
1294 # * with_subprojects - Includes subprojects issues if set to true.
1298 # * association - Symbol. Association for grouping.
1295 # * association - Symbol. Association for grouping.
1299 def self.count_and_group_by(options)
1296 def self.count_and_group_by(options)
1300 assoc = reflect_on_association(options[:association])
1297 assoc = reflect_on_association(options[:association])
1301 select_field = assoc.foreign_key
1298 select_field = assoc.foreign_key
1302
1299
1303 Issue.
1300 Issue.
1304 visible(User.current, :project => options[:project], :with_subprojects => options[:with_subprojects]).
1301 visible(User.current, :project => options[:project], :with_subprojects => options[:with_subprojects]).
1305 joins(:status, assoc.name).
1302 joins(:status, assoc.name).
1306 group(:status_id, :is_closed, select_field).
1303 group(:status_id, :is_closed, select_field).
1307 count.
1304 count.
1308 map do |columns, total|
1305 map do |columns, total|
1309 status_id, is_closed, field_value = columns
1306 status_id, is_closed, field_value = columns
1310 is_closed = ['t', 'true', '1'].include?(is_closed.to_s)
1307 is_closed = ['t', 'true', '1'].include?(is_closed.to_s)
1311 {
1308 {
1312 "status_id" => status_id.to_s,
1309 "status_id" => status_id.to_s,
1313 "closed" => is_closed,
1310 "closed" => is_closed,
1314 select_field => field_value.to_s,
1311 select_field => field_value.to_s,
1315 "total" => total.to_s
1312 "total" => total.to_s
1316 }
1313 }
1317 end
1314 end
1318 end
1315 end
1319
1316
1320 # Returns a scope of projects that user can assign the issue to
1317 # Returns a scope of projects that user can assign the issue to
1321 def allowed_target_projects(user=User.current)
1318 def allowed_target_projects(user=User.current)
1322 if new_record?
1319 if new_record?
1323 Project.where(Project.allowed_to_condition(user, :add_issues))
1320 Project.where(Project.allowed_to_condition(user, :add_issues))
1324 else
1321 else
1325 self.class.allowed_target_projects_on_move(user)
1322 self.class.allowed_target_projects_on_move(user)
1326 end
1323 end
1327 end
1324 end
1328
1325
1329 # Returns a scope of projects that user can move issues to
1326 # Returns a scope of projects that user can move issues to
1330 def self.allowed_target_projects_on_move(user=User.current)
1327 def self.allowed_target_projects_on_move(user=User.current)
1331 Project.where(Project.allowed_to_condition(user, :move_issues))
1328 Project.where(Project.allowed_to_condition(user, :move_issues))
1332 end
1329 end
1333
1330
1334 private
1331 private
1335
1332
1336 def after_project_change
1333 def after_project_change
1337 # Update project_id on related time entries
1334 # Update project_id on related time entries
1338 TimeEntry.where({:issue_id => id}).update_all(["project_id = ?", project_id])
1335 TimeEntry.where({:issue_id => id}).update_all(["project_id = ?", project_id])
1339
1336
1340 # Delete issue relations
1337 # Delete issue relations
1341 unless Setting.cross_project_issue_relations?
1338 unless Setting.cross_project_issue_relations?
1342 relations_from.clear
1339 relations_from.clear
1343 relations_to.clear
1340 relations_to.clear
1344 end
1341 end
1345
1342
1346 # Move subtasks that were in the same project
1343 # Move subtasks that were in the same project
1347 children.each do |child|
1344 children.each do |child|
1348 next unless child.project_id == project_id_was
1345 next unless child.project_id == project_id_was
1349 # Change project and keep project
1346 # Change project and keep project
1350 child.send :project=, project, true
1347 child.send :project=, project, true
1351 unless child.save
1348 unless child.save
1352 raise ActiveRecord::Rollback
1349 raise ActiveRecord::Rollback
1353 end
1350 end
1354 end
1351 end
1355 end
1352 end
1356
1353
1357 # Callback for after the creation of an issue by copy
1354 # Callback for after the creation of an issue by copy
1358 # * adds a "copied to" relation with the copied issue
1355 # * adds a "copied to" relation with the copied issue
1359 # * copies subtasks from the copied issue
1356 # * copies subtasks from the copied issue
1360 def after_create_from_copy
1357 def after_create_from_copy
1361 return unless copy? && !@after_create_from_copy_handled
1358 return unless copy? && !@after_create_from_copy_handled
1362
1359
1363 if (@copied_from.project_id == project_id || Setting.cross_project_issue_relations?) && @copy_options[:link] != false
1360 if (@copied_from.project_id == project_id || Setting.cross_project_issue_relations?) && @copy_options[:link] != false
1364 if @current_journal
1361 if @current_journal
1365 @copied_from.init_journal(@current_journal.user)
1362 @copied_from.init_journal(@current_journal.user)
1366 end
1363 end
1367 relation = IssueRelation.new(:issue_from => @copied_from, :issue_to => self, :relation_type => IssueRelation::TYPE_COPIED_TO)
1364 relation = IssueRelation.new(:issue_from => @copied_from, :issue_to => self, :relation_type => IssueRelation::TYPE_COPIED_TO)
1368 unless relation.save
1365 unless relation.save
1369 logger.error "Could not create relation while copying ##{@copied_from.id} to ##{id} due to validation errors: #{relation.errors.full_messages.join(', ')}" if logger
1366 logger.error "Could not create relation while copying ##{@copied_from.id} to ##{id} due to validation errors: #{relation.errors.full_messages.join(', ')}" if logger
1370 end
1367 end
1371 end
1368 end
1372
1369
1373 unless @copied_from.leaf? || @copy_options[:subtasks] == false
1370 unless @copied_from.leaf? || @copy_options[:subtasks] == false
1374 copy_options = (@copy_options || {}).merge(:subtasks => false)
1371 copy_options = (@copy_options || {}).merge(:subtasks => false)
1375 copied_issue_ids = {@copied_from.id => self.id}
1372 copied_issue_ids = {@copied_from.id => self.id}
1376 @copied_from.reload.descendants.reorder("#{Issue.table_name}.lft").each do |child|
1373 @copied_from.reload.descendants.reorder("#{Issue.table_name}.lft").each do |child|
1377 # Do not copy self when copying an issue as a descendant of the copied issue
1374 # Do not copy self when copying an issue as a descendant of the copied issue
1378 next if child == self
1375 next if child == self
1379 # Do not copy subtasks of issues that were not copied
1376 # Do not copy subtasks of issues that were not copied
1380 next unless copied_issue_ids[child.parent_id]
1377 next unless copied_issue_ids[child.parent_id]
1381 # Do not copy subtasks that are not visible to avoid potential disclosure of private data
1378 # Do not copy subtasks that are not visible to avoid potential disclosure of private data
1382 unless child.visible?
1379 unless child.visible?
1383 logger.error "Subtask ##{child.id} was not copied during ##{@copied_from.id} copy because it is not visible to the current user" if logger
1380 logger.error "Subtask ##{child.id} was not copied during ##{@copied_from.id} copy because it is not visible to the current user" if logger
1384 next
1381 next
1385 end
1382 end
1386 copy = Issue.new.copy_from(child, copy_options)
1383 copy = Issue.new.copy_from(child, copy_options)
1387 if @current_journal
1384 if @current_journal
1388 copy.init_journal(@current_journal.user)
1385 copy.init_journal(@current_journal.user)
1389 end
1386 end
1390 copy.author = author
1387 copy.author = author
1391 copy.project = project
1388 copy.project = project
1392 copy.parent_issue_id = copied_issue_ids[child.parent_id]
1389 copy.parent_issue_id = copied_issue_ids[child.parent_id]
1393 unless copy.save
1390 unless copy.save
1394 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
1391 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
1395 next
1392 next
1396 end
1393 end
1397 copied_issue_ids[child.id] = copy.id
1394 copied_issue_ids[child.id] = copy.id
1398 end
1395 end
1399 end
1396 end
1400 @after_create_from_copy_handled = true
1397 @after_create_from_copy_handled = true
1401 end
1398 end
1402
1399
1403 def update_nested_set_attributes
1400 def update_nested_set_attributes
1404 if root_id.nil?
1401 if root_id.nil?
1405 # issue was just created
1402 # issue was just created
1406 self.root_id = (@parent_issue.nil? ? id : @parent_issue.root_id)
1403 self.root_id = (@parent_issue.nil? ? id : @parent_issue.root_id)
1407 Issue.where(["id = ?", id]).update_all(["root_id = ?", root_id])
1404 Issue.where(["id = ?", id]).update_all(["root_id = ?", root_id])
1408 if @parent_issue
1405 if @parent_issue
1409 move_to_child_of(@parent_issue)
1406 move_to_child_of(@parent_issue)
1410 end
1407 end
1411 elsif parent_issue_id != parent_id
1408 elsif parent_issue_id != parent_id
1412 update_nested_set_attributes_on_parent_change
1409 update_nested_set_attributes_on_parent_change
1413 end
1410 end
1414 remove_instance_variable(:@parent_issue) if instance_variable_defined?(:@parent_issue)
1411 remove_instance_variable(:@parent_issue) if instance_variable_defined?(:@parent_issue)
1415 end
1412 end
1416
1413
1417 # Updates the nested set for when an existing issue is moved
1414 # Updates the nested set for when an existing issue is moved
1418 def update_nested_set_attributes_on_parent_change
1415 def update_nested_set_attributes_on_parent_change
1419 former_parent_id = parent_id
1416 former_parent_id = parent_id
1420 # moving an existing issue
1417 # moving an existing issue
1421 if @parent_issue && @parent_issue.root_id == root_id
1418 if @parent_issue && @parent_issue.root_id == root_id
1422 # inside the same tree
1419 # inside the same tree
1423 move_to_child_of(@parent_issue)
1420 move_to_child_of(@parent_issue)
1424 else
1421 else
1425 # to another tree
1422 # to another tree
1426 unless root?
1423 unless root?
1427 move_to_right_of(root)
1424 move_to_right_of(root)
1428 end
1425 end
1429 old_root_id = root_id
1426 old_root_id = root_id
1430 in_tenacious_transaction do
1427 in_tenacious_transaction do
1431 @parent_issue.reload_nested_set if @parent_issue
1428 @parent_issue.reload_nested_set if @parent_issue
1432 self.reload_nested_set
1429 self.reload_nested_set
1433 self.root_id = (@parent_issue.nil? ? id : @parent_issue.root_id)
1430 self.root_id = (@parent_issue.nil? ? id : @parent_issue.root_id)
1434 cond = ["root_id = ? AND lft >= ? AND rgt <= ? ", old_root_id, lft, rgt]
1431 cond = ["root_id = ? AND lft >= ? AND rgt <= ? ", old_root_id, lft, rgt]
1435 self.class.base_class.select('id').lock(true).where(cond)
1432 self.class.base_class.select('id').lock(true).where(cond)
1436 offset = rdm_right_most_bound + 1 - lft
1433 offset = rdm_right_most_bound + 1 - lft
1437 Issue.where(cond).
1434 Issue.where(cond).
1438 update_all(["root_id = ?, lft = lft + ?, rgt = rgt + ?", root_id, offset, offset])
1435 update_all(["root_id = ?, lft = lft + ?, rgt = rgt + ?", root_id, offset, offset])
1439 end
1436 end
1440 if @parent_issue
1437 if @parent_issue
1441 move_to_child_of(@parent_issue)
1438 move_to_child_of(@parent_issue)
1442 end
1439 end
1443 end
1440 end
1444 # delete invalid relations of all descendants
1441 # delete invalid relations of all descendants
1445 self_and_descendants.each do |issue|
1442 self_and_descendants.each do |issue|
1446 issue.relations.each do |relation|
1443 issue.relations.each do |relation|
1447 relation.destroy unless relation.valid?
1444 relation.destroy unless relation.valid?
1448 end
1445 end
1449 end
1446 end
1450 # update former parent
1447 # update former parent
1451 recalculate_attributes_for(former_parent_id) if former_parent_id
1448 recalculate_attributes_for(former_parent_id) if former_parent_id
1452 end
1449 end
1453
1450
1454 def rdm_right_most_bound
1451 def rdm_right_most_bound
1455 right_most_node =
1452 right_most_node =
1456 self.class.base_class.unscoped.
1453 self.class.base_class.unscoped.
1457 order("#{quoted_right_column_full_name} desc").limit(1).lock(true).first
1454 order("#{quoted_right_column_full_name} desc").limit(1).lock(true).first
1458 right_most_node ? (right_most_node[right_column_name] || 0 ) : 0
1455 right_most_node ? (right_most_node[right_column_name] || 0 ) : 0
1459 end
1456 end
1460 private :rdm_right_most_bound
1457 private :rdm_right_most_bound
1461
1458
1462 def update_parent_attributes
1459 def update_parent_attributes
1463 recalculate_attributes_for(parent_id) if parent_id
1460 recalculate_attributes_for(parent_id) if parent_id
1464 end
1461 end
1465
1462
1466 def recalculate_attributes_for(issue_id)
1463 def recalculate_attributes_for(issue_id)
1467 if issue_id && p = Issue.find_by_id(issue_id)
1464 if issue_id && p = Issue.find_by_id(issue_id)
1468 # priority = highest priority of children
1465 # priority = highest priority of children
1469 if priority_position = p.children.joins(:priority).maximum("#{IssuePriority.table_name}.position")
1466 if priority_position = p.children.joins(:priority).maximum("#{IssuePriority.table_name}.position")
1470 p.priority = IssuePriority.find_by_position(priority_position)
1467 p.priority = IssuePriority.find_by_position(priority_position)
1471 end
1468 end
1472
1469
1473 # start/due dates = lowest/highest dates of children
1470 # start/due dates = lowest/highest dates of children
1474 p.start_date = p.children.minimum(:start_date)
1471 p.start_date = p.children.minimum(:start_date)
1475 p.due_date = p.children.maximum(:due_date)
1472 p.due_date = p.children.maximum(:due_date)
1476 if p.start_date && p.due_date && p.due_date < p.start_date
1473 if p.start_date && p.due_date && p.due_date < p.start_date
1477 p.start_date, p.due_date = p.due_date, p.start_date
1474 p.start_date, p.due_date = p.due_date, p.start_date
1478 end
1475 end
1479
1476
1480 # done ratio = weighted average ratio of leaves
1477 # done ratio = weighted average ratio of leaves
1481 unless Issue.use_status_for_done_ratio? && p.status && p.status.default_done_ratio
1478 unless Issue.use_status_for_done_ratio? && p.status && p.status.default_done_ratio
1482 leaves_count = p.leaves.count
1479 leaves_count = p.leaves.count
1483 if leaves_count > 0
1480 if leaves_count > 0
1484 average = p.leaves.where("estimated_hours > 0").average(:estimated_hours).to_f
1481 average = p.leaves.where("estimated_hours > 0").average(:estimated_hours).to_f
1485 if average == 0
1482 if average == 0
1486 average = 1
1483 average = 1
1487 end
1484 end
1488 done = p.leaves.joins(:status).
1485 done = p.leaves.joins(:status).
1489 sum("COALESCE(CASE WHEN estimated_hours > 0 THEN estimated_hours ELSE NULL END, #{average}) " +
1486 sum("COALESCE(CASE WHEN estimated_hours > 0 THEN estimated_hours ELSE NULL END, #{average}) " +
1490 "* (CASE WHEN is_closed = #{self.class.connection.quoted_true} THEN 100 ELSE COALESCE(done_ratio, 0) END)").to_f
1487 "* (CASE WHEN is_closed = #{self.class.connection.quoted_true} THEN 100 ELSE COALESCE(done_ratio, 0) END)").to_f
1491 progress = done / (average * leaves_count)
1488 progress = done / (average * leaves_count)
1492 p.done_ratio = progress.round
1489 p.done_ratio = progress.round
1493 end
1490 end
1494 end
1491 end
1495
1492
1496 # estimate = sum of leaves estimates
1493 # estimate = sum of leaves estimates
1497 p.estimated_hours = p.leaves.sum(:estimated_hours).to_f
1494 p.estimated_hours = p.leaves.sum(:estimated_hours).to_f
1498 p.estimated_hours = nil if p.estimated_hours == 0.0
1495 p.estimated_hours = nil if p.estimated_hours == 0.0
1499
1496
1500 # ancestors will be recursively updated
1497 # ancestors will be recursively updated
1501 p.save(:validate => false)
1498 p.save(:validate => false)
1502 end
1499 end
1503 end
1500 end
1504
1501
1505 # Update issues so their versions are not pointing to a
1502 # Update issues so their versions are not pointing to a
1506 # fixed_version that is not shared with the issue's project
1503 # fixed_version that is not shared with the issue's project
1507 def self.update_versions(conditions=nil)
1504 def self.update_versions(conditions=nil)
1508 # Only need to update issues with a fixed_version from
1505 # Only need to update issues with a fixed_version from
1509 # a different project and that is not systemwide shared
1506 # a different project and that is not systemwide shared
1510 Issue.joins(:project, :fixed_version).
1507 Issue.joins(:project, :fixed_version).
1511 where("#{Issue.table_name}.fixed_version_id IS NOT NULL" +
1508 where("#{Issue.table_name}.fixed_version_id IS NOT NULL" +
1512 " AND #{Issue.table_name}.project_id <> #{Version.table_name}.project_id" +
1509 " AND #{Issue.table_name}.project_id <> #{Version.table_name}.project_id" +
1513 " AND #{Version.table_name}.sharing <> 'system'").
1510 " AND #{Version.table_name}.sharing <> 'system'").
1514 where(conditions).each do |issue|
1511 where(conditions).each do |issue|
1515 next if issue.project.nil? || issue.fixed_version.nil?
1512 next if issue.project.nil? || issue.fixed_version.nil?
1516 unless issue.project.shared_versions.include?(issue.fixed_version)
1513 unless issue.project.shared_versions.include?(issue.fixed_version)
1517 issue.init_journal(User.current)
1514 issue.init_journal(User.current)
1518 issue.fixed_version = nil
1515 issue.fixed_version = nil
1519 issue.save
1516 issue.save
1520 end
1517 end
1521 end
1518 end
1522 end
1519 end
1523
1520
1524 # Callback on file attachment
1521 # Callback on file attachment
1525 def attachment_added(obj)
1522 def attachment_added(attachment)
1526 if @current_journal && !obj.new_record?
1523 if current_journal && !attachment.new_record?
1527 @current_journal.details << JournalDetail.new(:property => 'attachment', :prop_key => obj.id, :value => obj.filename)
1524 current_journal.journalize_attachment(attachment, :added)
1528 end
1525 end
1529 end
1526 end
1530
1527
1531 # Callback on attachment deletion
1528 # Callback on attachment deletion
1532 def attachment_removed(obj)
1529 def attachment_removed(attachment)
1533 if @current_journal && !obj.new_record?
1530 if current_journal && !attachment.new_record?
1534 @current_journal.details << JournalDetail.new(:property => 'attachment', :prop_key => obj.id, :old_value => obj.filename)
1531 current_journal.journalize_attachment(attachment, :removed)
1535 @current_journal.save
1532 current_journal.save
1536 end
1533 end
1537 end
1534 end
1538
1535
1539 # Called after a relation is added
1536 # Called after a relation is added
1540 def relation_added(relation)
1537 def relation_added(relation)
1541 if @current_journal
1538 if current_journal
1542 @current_journal.details << JournalDetail.new(
1539 current_journal.journalize_relation(relation, :added)
1543 :property => 'relation',
1540 current_journal.save
1544 :prop_key => relation.relation_type_for(self),
1545 :value => relation.other_issue(self).try(:id)
1546 )
1547 @current_journal.save
1548 end
1541 end
1549 end
1542 end
1550
1543
1551 # Called after a relation is removed
1544 # Called after a relation is removed
1552 def relation_removed(relation)
1545 def relation_removed(relation)
1553 if @current_journal
1546 if current_journal
1554 @current_journal.details << JournalDetail.new(
1547 current_journal.journalize_relation(relation, :removed)
1555 :property => 'relation',
1548 current_journal.save
1556 :prop_key => relation.relation_type_for(self),
1557 :old_value => relation.other_issue(self).try(:id)
1558 )
1559 @current_journal.save
1560 end
1549 end
1561 end
1550 end
1562
1551
1563 # Default assignment based on category
1552 # Default assignment based on category
1564 def default_assign
1553 def default_assign
1565 if assigned_to.nil? && category && category.assigned_to
1554 if assigned_to.nil? && category && category.assigned_to
1566 self.assigned_to = category.assigned_to
1555 self.assigned_to = category.assigned_to
1567 end
1556 end
1568 end
1557 end
1569
1558
1570 # Updates start/due dates of following issues
1559 # Updates start/due dates of following issues
1571 def reschedule_following_issues
1560 def reschedule_following_issues
1572 if start_date_changed? || due_date_changed?
1561 if start_date_changed? || due_date_changed?
1573 relations_from.each do |relation|
1562 relations_from.each do |relation|
1574 relation.set_issue_to_dates
1563 relation.set_issue_to_dates
1575 end
1564 end
1576 end
1565 end
1577 end
1566 end
1578
1567
1579 # Closes duplicates if the issue is being closed
1568 # Closes duplicates if the issue is being closed
1580 def close_duplicates
1569 def close_duplicates
1581 if closing?
1570 if closing?
1582 duplicates.each do |duplicate|
1571 duplicates.each do |duplicate|
1583 # Reload is needed in case the duplicate was updated by a previous duplicate
1572 # Reload is needed in case the duplicate was updated by a previous duplicate
1584 duplicate.reload
1573 duplicate.reload
1585 # Don't re-close it if it's already closed
1574 # Don't re-close it if it's already closed
1586 next if duplicate.closed?
1575 next if duplicate.closed?
1587 # Same user and notes
1576 # Same user and notes
1588 if @current_journal
1577 if @current_journal
1589 duplicate.init_journal(@current_journal.user, @current_journal.notes)
1578 duplicate.init_journal(@current_journal.user, @current_journal.notes)
1590 end
1579 end
1591 duplicate.update_attribute :status, self.status
1580 duplicate.update_attribute :status, self.status
1592 end
1581 end
1593 end
1582 end
1594 end
1583 end
1595
1584
1596 # Make sure updated_on is updated when adding a note and set updated_on now
1585 # Make sure updated_on is updated when adding a note and set updated_on now
1597 # so we can set closed_on with the same value on closing
1586 # so we can set closed_on with the same value on closing
1598 def force_updated_on_change
1587 def force_updated_on_change
1599 if @current_journal || changed?
1588 if @current_journal || changed?
1600 self.updated_on = current_time_from_proper_timezone
1589 self.updated_on = current_time_from_proper_timezone
1601 if new_record?
1590 if new_record?
1602 self.created_on = updated_on
1591 self.created_on = updated_on
1603 end
1592 end
1604 end
1593 end
1605 end
1594 end
1606
1595
1607 # Callback for setting closed_on when the issue is closed.
1596 # Callback for setting closed_on when the issue is closed.
1608 # The closed_on attribute stores the time of the last closing
1597 # The closed_on attribute stores the time of the last closing
1609 # and is preserved when the issue is reopened.
1598 # and is preserved when the issue is reopened.
1610 def update_closed_on
1599 def update_closed_on
1611 if closing?
1600 if closing?
1612 self.closed_on = updated_on
1601 self.closed_on = updated_on
1613 end
1602 end
1614 end
1603 end
1615
1604
1616 # Saves the changes in a Journal
1605 # Saves the changes in a Journal
1617 # Called after_save
1606 # Called after_save
1618 def create_journal
1607 def create_journal
1619 if @current_journal
1608 if current_journal
1620 # attributes changes
1609 current_journal.save
1621 if @attributes_before_change
1622 (Issue.column_names - %w(id root_id lft rgt lock_version created_on updated_on closed_on)).each {|c|
1623 before = @attributes_before_change[c]
1624 after = send(c)
1625 next if before == after || (before.blank? && after.blank?)
1626 @current_journal.details << JournalDetail.new(:property => 'attr',
1627 :prop_key => c,
1628 :old_value => before,
1629 :value => after)
1630 }
1631 end
1632 if @custom_values_before_change
1633 # custom fields changes
1634 custom_field_values.each {|c|
1635 before = @custom_values_before_change[c.custom_field_id]
1636 after = c.value
1637 next if before == after || (before.blank? && after.blank?)
1638
1639 if before.is_a?(Array) || after.is_a?(Array)
1640 before = [before] unless before.is_a?(Array)
1641 after = [after] unless after.is_a?(Array)
1642
1643 # values removed
1644 (before - after).reject(&:blank?).each do |value|
1645 @current_journal.details << JournalDetail.new(:property => 'cf',
1646 :prop_key => c.custom_field_id,
1647 :old_value => value,
1648 :value => nil)
1649 end
1650 # values added
1651 (after - before).reject(&:blank?).each do |value|
1652 @current_journal.details << JournalDetail.new(:property => 'cf',
1653 :prop_key => c.custom_field_id,
1654 :old_value => nil,
1655 :value => value)
1656 end
1657 else
1658 @current_journal.details << JournalDetail.new(:property => 'cf',
1659 :prop_key => c.custom_field_id,
1660 :old_value => before,
1661 :value => after)
1662 end
1663 }
1664 end
1665 @current_journal.save
1666 # reset current journal
1667 init_journal @current_journal.user, @current_journal.notes
1668 end
1610 end
1669 end
1611 end
1670
1612
1671 def send_notification
1613 def send_notification
1672 if Setting.notified_events.include?('issue_added')
1614 if Setting.notified_events.include?('issue_added')
1673 Mailer.deliver_issue_add(self)
1615 Mailer.deliver_issue_add(self)
1674 end
1616 end
1675 end
1617 end
1676
1618
1677 # Stores the previous assignee so we can still have access
1619 # Stores the previous assignee so we can still have access
1678 # to it during after_save callbacks (assigned_to_id_was is reset)
1620 # to it during after_save callbacks (assigned_to_id_was is reset)
1679 def set_assigned_to_was
1621 def set_assigned_to_was
1680 @previous_assigned_to_id = assigned_to_id_was
1622 @previous_assigned_to_id = assigned_to_id_was
1681 end
1623 end
1682
1624
1683 # Clears the previous assignee at the end of after_save callbacks
1625 # Clears the previous assignee at the end of after_save callbacks
1684 def clear_assigned_to_was
1626 def clear_assigned_to_was
1685 @assigned_to_was = nil
1627 @assigned_to_was = nil
1686 @previous_assigned_to_id = nil
1628 @previous_assigned_to_id = nil
1687 end
1629 end
1688 end
1630 end
@@ -1,200 +1,302
1 # Redmine - project management software
1 # Redmine - project management software
2 # Copyright (C) 2006-2014 Jean-Philippe Lang
2 # Copyright (C) 2006-2014 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.
8 #
8 #
9 # This program is distributed in the hope that it will be useful,
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 # GNU General Public License for more details.
12 # GNU General Public License for more details.
13 #
13 #
14 # You should have received a copy of the GNU General Public License
14 # You should have received a copy of the GNU General Public License
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 Journal < ActiveRecord::Base
18 class Journal < ActiveRecord::Base
19 belongs_to :journalized, :polymorphic => true
19 belongs_to :journalized, :polymorphic => true
20 # added as a quick fix to allow eager loading of the polymorphic association
20 # added as a quick fix to allow eager loading of the polymorphic association
21 # since always associated to an issue, for now
21 # since always associated to an issue, for now
22 belongs_to :issue, :foreign_key => :journalized_id
22 belongs_to :issue, :foreign_key => :journalized_id
23
23
24 belongs_to :user
24 belongs_to :user
25 has_many :details, :class_name => "JournalDetail", :dependent => :delete_all
25 has_many :details, :class_name => "JournalDetail", :dependent => :delete_all
26 attr_accessor :indice
26 attr_accessor :indice
27 attr_protected :id
27 attr_protected :id
28
28
29 acts_as_event :title => Proc.new {|o| status = ((s = o.new_status) ? " (#{s})" : nil); "#{o.issue.tracker} ##{o.issue.id}#{status}: #{o.issue.subject}" },
29 acts_as_event :title => Proc.new {|o| status = ((s = o.new_status) ? " (#{s})" : nil); "#{o.issue.tracker} ##{o.issue.id}#{status}: #{o.issue.subject}" },
30 :description => :notes,
30 :description => :notes,
31 :author => :user,
31 :author => :user,
32 :group => :issue,
32 :group => :issue,
33 :type => Proc.new {|o| (s = o.new_status) ? (s.is_closed? ? 'issue-closed' : 'issue-edit') : 'issue-note' },
33 :type => Proc.new {|o| (s = o.new_status) ? (s.is_closed? ? 'issue-closed' : 'issue-edit') : 'issue-note' },
34 :url => Proc.new {|o| {:controller => 'issues', :action => 'show', :id => o.issue.id, :anchor => "change-#{o.id}"}}
34 :url => Proc.new {|o| {:controller => 'issues', :action => 'show', :id => o.issue.id, :anchor => "change-#{o.id}"}}
35
35
36 acts_as_activity_provider :type => 'issues',
36 acts_as_activity_provider :type => 'issues',
37 :author_key => :user_id,
37 :author_key => :user_id,
38 :scope => preload({:issue => :project}, :user).
38 :scope => preload({:issue => :project}, :user).
39 joins("LEFT OUTER JOIN #{JournalDetail.table_name} ON #{JournalDetail.table_name}.journal_id = #{Journal.table_name}.id").
39 joins("LEFT OUTER JOIN #{JournalDetail.table_name} ON #{JournalDetail.table_name}.journal_id = #{Journal.table_name}.id").
40 where("#{Journal.table_name}.journalized_type = 'Issue' AND" +
40 where("#{Journal.table_name}.journalized_type = 'Issue' AND" +
41 " (#{JournalDetail.table_name}.prop_key = 'status_id' OR #{Journal.table_name}.notes <> '')")
41 " (#{JournalDetail.table_name}.prop_key = 'status_id' OR #{Journal.table_name}.notes <> '')")
42
42
43 before_create :split_private_notes
43 before_create :split_private_notes
44 after_create :send_notification
44 after_create :send_notification
45
45
46 scope :visible, lambda {|*args|
46 scope :visible, lambda {|*args|
47 user = args.shift || User.current
47 user = args.shift || User.current
48 joins(:issue => :project).
48 joins(:issue => :project).
49 where(Issue.visible_condition(user, *args)).
49 where(Issue.visible_condition(user, *args)).
50 where("(#{Journal.table_name}.private_notes = ? OR (#{Project.allowed_to_condition(user, :view_private_notes, *args)}))", false)
50 where("(#{Journal.table_name}.private_notes = ? OR (#{Project.allowed_to_condition(user, :view_private_notes, *args)}))", false)
51 }
51 }
52
52
53 def initialize(*args)
54 super
55 if journalized && journalized.new_record?
56 self.notify = false
57 end
58 start
59 end
60
53 def save(*args)
61 def save(*args)
62 journalize_changes
54 # Do not save an empty journal
63 # Do not save an empty journal
55 (details.empty? && notes.blank?) ? false : super
64 (details.empty? && notes.blank?) ? false : super
56 end
65 end
57
66
58 # Returns journal details that are visible to user
67 # Returns journal details that are visible to user
59 def visible_details(user=User.current)
68 def visible_details(user=User.current)
60 details.select do |detail|
69 details.select do |detail|
61 if detail.property == 'cf'
70 if detail.property == 'cf'
62 detail.custom_field && detail.custom_field.visible_by?(project, user)
71 detail.custom_field && detail.custom_field.visible_by?(project, user)
63 elsif detail.property == 'relation'
72 elsif detail.property == 'relation'
64 Issue.find_by_id(detail.value || detail.old_value).try(:visible?, user)
73 Issue.find_by_id(detail.value || detail.old_value).try(:visible?, user)
65 else
74 else
66 true
75 true
67 end
76 end
68 end
77 end
69 end
78 end
70
79
71 def each_notification(users, &block)
80 def each_notification(users, &block)
72 if users.any?
81 if users.any?
73 users_by_details_visibility = users.group_by do |user|
82 users_by_details_visibility = users.group_by do |user|
74 visible_details(user)
83 visible_details(user)
75 end
84 end
76 users_by_details_visibility.each do |visible_details, users|
85 users_by_details_visibility.each do |visible_details, users|
77 if notes? || visible_details.any?
86 if notes? || visible_details.any?
78 yield(users)
87 yield(users)
79 end
88 end
80 end
89 end
81 end
90 end
82 end
91 end
83
92
84 # Returns the JournalDetail for the given attribute, or nil if the attribute
93 # Returns the JournalDetail for the given attribute, or nil if the attribute
85 # was not updated
94 # was not updated
86 def detail_for_attribute(attribute)
95 def detail_for_attribute(attribute)
87 details.detect {|detail| detail.prop_key == attribute}
96 details.detect {|detail| detail.prop_key == attribute}
88 end
97 end
89
98
90 # Returns the new status if the journal contains a status change, otherwise nil
99 # Returns the new status if the journal contains a status change, otherwise nil
91 def new_status
100 def new_status
92 s = new_value_for('status_id')
101 s = new_value_for('status_id')
93 s ? IssueStatus.find_by_id(s.to_i) : nil
102 s ? IssueStatus.find_by_id(s.to_i) : nil
94 end
103 end
95
104
96 def new_value_for(prop)
105 def new_value_for(prop)
97 detail_for_attribute(prop).try(:value)
106 detail_for_attribute(prop).try(:value)
98 end
107 end
99
108
100 def editable_by?(usr)
109 def editable_by?(usr)
101 usr && usr.logged? && (usr.allowed_to?(:edit_issue_notes, project) || (self.user == usr && usr.allowed_to?(:edit_own_issue_notes, project)))
110 usr && usr.logged? && (usr.allowed_to?(:edit_issue_notes, project) || (self.user == usr && usr.allowed_to?(:edit_own_issue_notes, project)))
102 end
111 end
103
112
104 def project
113 def project
105 journalized.respond_to?(:project) ? journalized.project : nil
114 journalized.respond_to?(:project) ? journalized.project : nil
106 end
115 end
107
116
108 def attachments
117 def attachments
109 journalized.respond_to?(:attachments) ? journalized.attachments : nil
118 journalized.respond_to?(:attachments) ? journalized.attachments : nil
110 end
119 end
111
120
112 # Returns a string of css classes
121 # Returns a string of css classes
113 def css_classes
122 def css_classes
114 s = 'journal'
123 s = 'journal'
115 s << ' has-notes' unless notes.blank?
124 s << ' has-notes' unless notes.blank?
116 s << ' has-details' unless details.blank?
125 s << ' has-details' unless details.blank?
117 s << ' private-notes' if private_notes?
126 s << ' private-notes' if private_notes?
118 s
127 s
119 end
128 end
120
129
121 def notify?
130 def notify?
122 @notify != false
131 @notify != false
123 end
132 end
124
133
125 def notify=(arg)
134 def notify=(arg)
126 @notify = arg
135 @notify = arg
127 end
136 end
128
137
129 def notified_users
138 def notified_users
130 notified = journalized.notified_users
139 notified = journalized.notified_users
131 if private_notes?
140 if private_notes?
132 notified = notified.select {|user| user.allowed_to?(:view_private_notes, journalized.project)}
141 notified = notified.select {|user| user.allowed_to?(:view_private_notes, journalized.project)}
133 end
142 end
134 notified
143 notified
135 end
144 end
136
145
137 def recipients
146 def recipients
138 notified_users.map(&:mail)
147 notified_users.map(&:mail)
139 end
148 end
140
149
141 def notified_watchers
150 def notified_watchers
142 notified = journalized.notified_watchers
151 notified = journalized.notified_watchers
143 if private_notes?
152 if private_notes?
144 notified = notified.select {|user| user.allowed_to?(:view_private_notes, journalized.project)}
153 notified = notified.select {|user| user.allowed_to?(:view_private_notes, journalized.project)}
145 end
154 end
146 notified
155 notified
147 end
156 end
148
157
149 def watcher_recipients
158 def watcher_recipients
150 notified_watchers.map(&:mail)
159 notified_watchers.map(&:mail)
151 end
160 end
152
161
153 # Sets @custom_field instance variable on journals details using a single query
162 # Sets @custom_field instance variable on journals details using a single query
154 def self.preload_journals_details_custom_fields(journals)
163 def self.preload_journals_details_custom_fields(journals)
155 field_ids = journals.map(&:details).flatten.select {|d| d.property == 'cf'}.map(&:prop_key).uniq
164 field_ids = journals.map(&:details).flatten.select {|d| d.property == 'cf'}.map(&:prop_key).uniq
156 if field_ids.any?
165 if field_ids.any?
157 fields_by_id = CustomField.where(:id => field_ids).inject({}) {|h, f| h[f.id] = f; h}
166 fields_by_id = CustomField.where(:id => field_ids).inject({}) {|h, f| h[f.id] = f; h}
158 journals.each do |journal|
167 journals.each do |journal|
159 journal.details.each do |detail|
168 journal.details.each do |detail|
160 if detail.property == 'cf'
169 if detail.property == 'cf'
161 detail.instance_variable_set "@custom_field", fields_by_id[detail.prop_key.to_i]
170 detail.instance_variable_set "@custom_field", fields_by_id[detail.prop_key.to_i]
162 end
171 end
163 end
172 end
164 end
173 end
165 end
174 end
166 journals
175 journals
167 end
176 end
168
177
178 # Stores the values of the attributes and custom fields of the journalized object
179 def start
180 if journalized
181 @attributes_before_change = journalized.journalized_attribute_names.inject({}) do |h, attribute|
182 h[attribute] = journalized.send(attribute)
183 h
184 end
185 @custom_values_before_change = journalized.custom_field_values.inject({}) do |h, c|
186 h[c.custom_field_id] = c.value
187 h
188 end
189 end
190 self
191 end
192
193 # Adds a journal detail for an attachment that was added or removed
194 def journalize_attachment(attachment, added_or_removed)
195 key = (added_or_removed == :removed ? :old_value : :value)
196 details << JournalDetail.new(
197 :property => 'attachment',
198 :prop_key => attachment.id,
199 key => attachment.filename
200 )
201 end
202
203 # Adds a journal detail for an issue relation that was added or removed
204 def journalize_relation(relation, added_or_removed)
205 key = (added_or_removed == :removed ? :old_value : :value)
206 details << JournalDetail.new(
207 :property => 'relation',
208 :prop_key => relation.relation_type_for(journalized),
209 key => relation.other_issue(journalized).try(:id)
210 )
211 end
212
169 private
213 private
170
214
215 # Generates journal details for attribute and custom field changes
216 def journalize_changes
217 # attributes changes
218 if @attributes_before_change
219 journalized.journalized_attribute_names.each {|attribute|
220 before = @attributes_before_change[attribute]
221 after = journalized.send(attribute)
222 next if before == after || (before.blank? && after.blank?)
223 add_attribute_detail(attribute, before, after)
224 }
225 end
226 if @custom_values_before_change
227 # custom fields changes
228 journalized.custom_field_values.each {|c|
229 before = @custom_values_before_change[c.custom_field_id]
230 after = c.value
231 next if before == after || (before.blank? && after.blank?)
232
233 if before.is_a?(Array) || after.is_a?(Array)
234 before = [before] unless before.is_a?(Array)
235 after = [after] unless after.is_a?(Array)
236
237 # values removed
238 (before - after).reject(&:blank?).each do |value|
239 add_custom_value_detail(c, value, nil)
240 end
241 # values added
242 (after - before).reject(&:blank?).each do |value|
243 add_custom_value_detail(c, nil, value)
244 end
245 else
246 add_custom_value_detail(c, before, after)
247 end
248 }
249 end
250 start
251 end
252
253 # Adds a journal detail for an attribute change
254 def add_attribute_detail(attribute, old_value, value)
255 add_detail('attr', attribute, old_value, value)
256 end
257
258 # Adds a journal detail for a custom field value change
259 def add_custom_value_detail(custom_value, old_value, value)
260 add_detail('cf', custom_value.custom_field_id, old_value, value)
261 end
262
263 # Adds a journal detail
264 def add_detail(property, prop_key, old_value, value)
265 details << JournalDetail.new(
266 :property => property,
267 :prop_key => prop_key,
268 :old_value => old_value,
269 :value => value
270 )
271 end
272
171 def split_private_notes
273 def split_private_notes
172 if private_notes?
274 if private_notes?
173 if notes.present?
275 if notes.present?
174 if details.any?
276 if details.any?
175 # Split the journal (notes/changes) so we don't have half-private journals
277 # Split the journal (notes/changes) so we don't have half-private journals
176 journal = Journal.new(:journalized => journalized, :user => user, :notes => nil, :private_notes => false)
278 journal = Journal.new(:journalized => journalized, :user => user, :notes => nil, :private_notes => false)
177 journal.details = details
279 journal.details = details
178 journal.save
280 journal.save
179 self.details = []
281 self.details = []
180 self.created_on = journal.created_on
282 self.created_on = journal.created_on
181 end
283 end
182 else
284 else
183 # Blank notes should not be private
285 # Blank notes should not be private
184 self.private_notes = false
286 self.private_notes = false
185 end
287 end
186 end
288 end
187 true
289 true
188 end
290 end
189
291
190 def send_notification
292 def send_notification
191 if notify? && (Setting.notified_events.include?('issue_updated') ||
293 if notify? && (Setting.notified_events.include?('issue_updated') ||
192 (Setting.notified_events.include?('issue_note_added') && notes.present?) ||
294 (Setting.notified_events.include?('issue_note_added') && notes.present?) ||
193 (Setting.notified_events.include?('issue_status_updated') && new_status.present?) ||
295 (Setting.notified_events.include?('issue_status_updated') && new_status.present?) ||
194 (Setting.notified_events.include?('issue_assigned_to_updated') && detail_for_attribute('assigned_to_id').present?) ||
296 (Setting.notified_events.include?('issue_assigned_to_updated') && detail_for_attribute('assigned_to_id').present?) ||
195 (Setting.notified_events.include?('issue_priority_updated') && new_value_for('priority_id').present?)
297 (Setting.notified_events.include?('issue_priority_updated') && new_value_for('priority_id').present?)
196 )
298 )
197 Mailer.deliver_issue_edit(self)
299 Mailer.deliver_issue_edit(self)
198 end
300 end
199 end
301 end
200 end
302 end
General Comments 0
You need to be logged in to leave comments. Login now