##// END OF EJS Templates
Let user always see his private notes (#17632)....
Jean-Philippe Lang -
r15799:7b32a0371d55
parent child
Show More

The requested changes are too big and content was truncated. Show full diff

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