##// END OF EJS Templates
Moves watchers filtering logic to ActsAsWatchable....
Jean-Philippe Lang -
r3054:6610bb6b6cbb
parent child
Show More
@@ -1,452 +1,445
1 1 # redMine - project management software
2 2 # Copyright (C) 2006-2007 Jean-Philippe Lang
3 3 #
4 4 # This program is free software; you can redistribute it and/or
5 5 # modify it under the terms of the GNU General Public License
6 6 # as published by the Free Software Foundation; either version 2
7 7 # of the License, or (at your option) any later version.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU General Public License
15 15 # along with this program; if not, write to the Free Software
16 16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17 17
18 18 class Issue < ActiveRecord::Base
19 19 belongs_to :project
20 20 belongs_to :tracker
21 21 belongs_to :status, :class_name => 'IssueStatus', :foreign_key => 'status_id'
22 22 belongs_to :author, :class_name => 'User', :foreign_key => 'author_id'
23 23 belongs_to :assigned_to, :class_name => 'User', :foreign_key => 'assigned_to_id'
24 24 belongs_to :fixed_version, :class_name => 'Version', :foreign_key => 'fixed_version_id'
25 25 belongs_to :priority, :class_name => 'IssuePriority', :foreign_key => 'priority_id'
26 26 belongs_to :category, :class_name => 'IssueCategory', :foreign_key => 'category_id'
27 27
28 28 has_many :journals, :as => :journalized, :dependent => :destroy
29 29 has_many :time_entries, :dependent => :delete_all
30 30 has_and_belongs_to_many :changesets, :order => "#{Changeset.table_name}.committed_on ASC, #{Changeset.table_name}.id ASC"
31 31
32 32 has_many :relations_from, :class_name => 'IssueRelation', :foreign_key => 'issue_from_id', :dependent => :delete_all
33 33 has_many :relations_to, :class_name => 'IssueRelation', :foreign_key => 'issue_to_id', :dependent => :delete_all
34 34
35 35 acts_as_attachable :after_remove => :attachment_removed
36 36 acts_as_customizable
37 37 acts_as_watchable
38 38 acts_as_searchable :columns => ['subject', "#{table_name}.description", "#{Journal.table_name}.notes"],
39 39 :include => [:project, :journals],
40 40 # sort by id so that limited eager loading doesn't break with postgresql
41 41 :order_column => "#{table_name}.id"
42 42 acts_as_event :title => Proc.new {|o| "#{o.tracker.name} ##{o.id} (#{o.status}): #{o.subject}"},
43 43 :url => Proc.new {|o| {:controller => 'issues', :action => 'show', :id => o.id}},
44 44 :type => Proc.new {|o| 'issue' + (o.closed? ? ' closed' : '') }
45 45
46 46 acts_as_activity_provider :find_options => {:include => [:project, :author, :tracker]},
47 47 :author_key => :author_id
48 48
49 49 DONE_RATIO_OPTIONS = %w(issue_field issue_status)
50 50
51 51 validates_presence_of :subject, :priority, :project, :tracker, :author, :status
52 52 validates_length_of :subject, :maximum => 255
53 53 validates_inclusion_of :done_ratio, :in => 0..100
54 54 validates_numericality_of :estimated_hours, :allow_nil => true
55 55
56 56 named_scope :visible, lambda {|*args| { :include => :project,
57 57 :conditions => Project.allowed_to_condition(args.first || User.current, :view_issues) } }
58 58
59 59 named_scope :open, :conditions => ["#{IssueStatus.table_name}.is_closed = ?", false], :include => :status
60 60
61 61 before_save :update_done_ratio_from_issue_status
62 62 after_save :create_journal
63 63
64 64 # Returns true if usr or current user is allowed to view the issue
65 65 def visible?(usr=nil)
66 66 (usr || User.current).allowed_to?(:view_issues, self.project)
67 67 end
68 68
69 69 def after_initialize
70 70 if new_record?
71 71 # set default values for new records only
72 72 self.status ||= IssueStatus.default
73 73 self.priority ||= IssuePriority.default
74 74 end
75 75 end
76 76
77 77 # Overrides Redmine::Acts::Customizable::InstanceMethods#available_custom_fields
78 78 def available_custom_fields
79 79 (project && tracker) ? project.all_issue_custom_fields.select {|c| tracker.custom_fields.include? c } : []
80 80 end
81 81
82 82 def copy_from(arg)
83 83 issue = arg.is_a?(Issue) ? arg : Issue.find(arg)
84 84 self.attributes = issue.attributes.dup.except("id", "created_on", "updated_on")
85 85 self.custom_values = issue.custom_values.collect {|v| v.clone}
86 86 self.status = issue.status
87 87 self
88 88 end
89 89
90 90 # Moves/copies an issue to a new project and tracker
91 91 # Returns the moved/copied issue on success, false on failure
92 92 def move_to(new_project, new_tracker = nil, options = {})
93 93 options ||= {}
94 94 issue = options[:copy] ? self.clone : self
95 95 transaction do
96 96 if new_project && issue.project_id != new_project.id
97 97 # delete issue relations
98 98 unless Setting.cross_project_issue_relations?
99 99 issue.relations_from.clear
100 100 issue.relations_to.clear
101 101 end
102 102 # issue is moved to another project
103 103 # reassign to the category with same name if any
104 104 new_category = issue.category.nil? ? nil : new_project.issue_categories.find_by_name(issue.category.name)
105 105 issue.category = new_category
106 106 # Keep the fixed_version if it's still valid in the new_project
107 107 unless new_project.shared_versions.include?(issue.fixed_version)
108 108 issue.fixed_version = nil
109 109 end
110 110 issue.project = new_project
111 111 end
112 112 if new_tracker
113 113 issue.tracker = new_tracker
114 114 end
115 115 if options[:copy]
116 116 issue.custom_field_values = self.custom_field_values.inject({}) {|h,v| h[v.custom_field_id] = v.value; h}
117 117 issue.status = if options[:attributes] && options[:attributes][:status_id]
118 118 IssueStatus.find_by_id(options[:attributes][:status_id])
119 119 else
120 120 self.status
121 121 end
122 122 end
123 123 # Allow bulk setting of attributes on the issue
124 124 if options[:attributes]
125 125 issue.attributes = options[:attributes]
126 126 end
127 127 if issue.save
128 128 unless options[:copy]
129 129 # Manually update project_id on related time entries
130 130 TimeEntry.update_all("project_id = #{new_project.id}", {:issue_id => id})
131 131 end
132 132 else
133 133 Issue.connection.rollback_db_transaction
134 134 return false
135 135 end
136 136 end
137 137 return issue
138 138 end
139 139
140 140 def priority_id=(pid)
141 141 self.priority = nil
142 142 write_attribute(:priority_id, pid)
143 143 end
144 144
145 145 def tracker_id=(tid)
146 146 self.tracker = nil
147 147 write_attribute(:tracker_id, tid)
148 148 result = write_attribute(:tracker_id, tid)
149 149 @custom_field_values = nil
150 150 result
151 151 end
152 152
153 153 # Overrides attributes= so that tracker_id gets assigned first
154 154 def attributes_with_tracker_first=(new_attributes, *args)
155 155 return if new_attributes.nil?
156 156 new_tracker_id = new_attributes['tracker_id'] || new_attributes[:tracker_id]
157 157 if new_tracker_id
158 158 self.tracker_id = new_tracker_id
159 159 end
160 160 self.attributes_without_tracker_first = new_attributes, *args
161 161 end
162 162 alias_method_chain :attributes=, :tracker_first
163 163
164 164 def estimated_hours=(h)
165 165 write_attribute :estimated_hours, (h.is_a?(String) ? h.to_hours : h)
166 166 end
167 167
168 168 def done_ratio
169 169 if Issue.use_status_for_done_ratio? && status && status.default_done_ratio?
170 170 status.default_done_ratio
171 171 else
172 172 read_attribute(:done_ratio)
173 173 end
174 174 end
175 175
176 176 def self.use_status_for_done_ratio?
177 177 Setting.issue_done_ratio == 'issue_status'
178 178 end
179 179
180 180 def self.use_field_for_done_ratio?
181 181 Setting.issue_done_ratio == 'issue_field'
182 182 end
183 183
184 184 def validate
185 185 if self.due_date.nil? && @attributes['due_date'] && !@attributes['due_date'].empty?
186 186 errors.add :due_date, :not_a_date
187 187 end
188 188
189 189 if self.due_date and self.start_date and self.due_date < self.start_date
190 190 errors.add :due_date, :greater_than_start_date
191 191 end
192 192
193 193 if start_date && soonest_start && start_date < soonest_start
194 194 errors.add :start_date, :invalid
195 195 end
196 196
197 197 if fixed_version
198 198 if !assignable_versions.include?(fixed_version)
199 199 errors.add :fixed_version_id, :inclusion
200 200 elsif reopened? && fixed_version.closed?
201 201 errors.add_to_base I18n.t(:error_can_not_reopen_issue_on_closed_version)
202 202 end
203 203 end
204 204
205 205 # Checks that the issue can not be added/moved to a disabled tracker
206 206 if project && (tracker_id_changed? || project_id_changed?)
207 207 unless project.trackers.include?(tracker)
208 208 errors.add :tracker_id, :inclusion
209 209 end
210 210 end
211 211 end
212 212
213 213 def before_create
214 214 # default assignment based on category
215 215 if assigned_to.nil? && category && category.assigned_to
216 216 self.assigned_to = category.assigned_to
217 217 end
218 218 end
219 219
220 220 # Set the done_ratio using the status if that setting is set. This will keep the done_ratios
221 221 # even if the user turns off the setting later
222 222 def update_done_ratio_from_issue_status
223 223 if Issue.use_status_for_done_ratio? && status && status.default_done_ratio?
224 224 self.done_ratio = status.default_done_ratio
225 225 end
226 226 end
227 227
228 228 def after_save
229 229 # Reload is needed in order to get the right status
230 230 reload
231 231
232 232 # Update start/due dates of following issues
233 233 relations_from.each(&:set_issue_to_dates)
234 234
235 235 # Close duplicates if the issue was closed
236 236 if @issue_before_change && !@issue_before_change.closed? && self.closed?
237 237 duplicates.each do |duplicate|
238 238 # Reload is need in case the duplicate was updated by a previous duplicate
239 239 duplicate.reload
240 240 # Don't re-close it if it's already closed
241 241 next if duplicate.closed?
242 242 # Same user and notes
243 243 duplicate.init_journal(@current_journal.user, @current_journal.notes)
244 244 duplicate.update_attribute :status, self.status
245 245 end
246 246 end
247 247 end
248 248
249 249 def init_journal(user, notes = "")
250 250 @current_journal ||= Journal.new(:journalized => self, :user => user, :notes => notes)
251 251 @issue_before_change = self.clone
252 252 @issue_before_change.status = self.status
253 253 @custom_values_before_change = {}
254 254 self.custom_values.each {|c| @custom_values_before_change.store c.custom_field_id, c.value }
255 255 # Make sure updated_on is updated when adding a note.
256 256 updated_on_will_change!
257 257 @current_journal
258 258 end
259 259
260 260 # Return true if the issue is closed, otherwise false
261 261 def closed?
262 262 self.status.is_closed?
263 263 end
264 264
265 265 # Return true if the issue is being reopened
266 266 def reopened?
267 267 if !new_record? && status_id_changed?
268 268 status_was = IssueStatus.find_by_id(status_id_was)
269 269 status_new = IssueStatus.find_by_id(status_id)
270 270 if status_was && status_new && status_was.is_closed? && !status_new.is_closed?
271 271 return true
272 272 end
273 273 end
274 274 false
275 275 end
276 276
277 277 # Returns true if the issue is overdue
278 278 def overdue?
279 279 !due_date.nil? && (due_date < Date.today) && !status.is_closed?
280 280 end
281 281
282 282 # Users the issue can be assigned to
283 283 def assignable_users
284 284 project.assignable_users
285 285 end
286 286
287 287 # Versions that the issue can be assigned to
288 288 def assignable_versions
289 289 @assignable_versions ||= (project.shared_versions.open + [Version.find_by_id(fixed_version_id_was)]).compact.uniq.sort
290 290 end
291 291
292 292 # Returns true if this issue is blocked by another issue that is still open
293 293 def blocked?
294 294 !relations_to.detect {|ir| ir.relation_type == 'blocks' && !ir.issue_from.closed?}.nil?
295 295 end
296 296
297 297 # Returns an array of status that user is able to apply
298 298 def new_statuses_allowed_to(user)
299 299 statuses = status.find_new_statuses_allowed_to(user.roles_for_project(project), tracker)
300 300 statuses << status unless statuses.empty?
301 301 statuses = statuses.uniq.sort
302 302 blocked? ? statuses.reject {|s| s.is_closed?} : statuses
303 303 end
304 304
305 305 # Returns the mail adresses of users that should be notified
306 306 def recipients
307 307 notified = project.notified_users
308 308 # Author and assignee are always notified unless they have been locked
309 309 notified << author if author && author.active?
310 310 notified << assigned_to if assigned_to && assigned_to.active?
311 311 notified.uniq!
312 312 # Remove users that can not view the issue
313 313 notified.reject! {|user| !visible?(user)}
314 314 notified.collect(&:mail)
315 315 end
316 316
317 # Returns the mail adresses of watchers that should be notified
318 def watcher_recipients
319 notified = watcher_users
320 notified.reject! {|user| !user.active? || !visible?(user)}
321 notified.collect(&:mail)
322 end
323
324 317 # Returns the total number of hours spent on this issue.
325 318 #
326 319 # Example:
327 320 # spent_hours => 0
328 321 # spent_hours => 50
329 322 def spent_hours
330 323 @spent_hours ||= time_entries.sum(:hours) || 0
331 324 end
332 325
333 326 def relations
334 327 (relations_from + relations_to).sort
335 328 end
336 329
337 330 def all_dependent_issues
338 331 dependencies = []
339 332 relations_from.each do |relation|
340 333 dependencies << relation.issue_to
341 334 dependencies += relation.issue_to.all_dependent_issues
342 335 end
343 336 dependencies
344 337 end
345 338
346 339 # Returns an array of issues that duplicate this one
347 340 def duplicates
348 341 relations_to.select {|r| r.relation_type == IssueRelation::TYPE_DUPLICATES}.collect {|r| r.issue_from}
349 342 end
350 343
351 344 # Returns the due date or the target due date if any
352 345 # Used on gantt chart
353 346 def due_before
354 347 due_date || (fixed_version ? fixed_version.effective_date : nil)
355 348 end
356 349
357 350 # Returns the time scheduled for this issue.
358 351 #
359 352 # Example:
360 353 # Start Date: 2/26/09, End Date: 3/04/09
361 354 # duration => 6
362 355 def duration
363 356 (start_date && due_date) ? due_date - start_date : 0
364 357 end
365 358
366 359 def soonest_start
367 360 @soonest_start ||= relations_to.collect{|relation| relation.successor_soonest_start}.compact.min
368 361 end
369 362
370 363 def to_s
371 364 "#{tracker} ##{id}: #{subject}"
372 365 end
373 366
374 367 # Returns a string of css classes that apply to the issue
375 368 def css_classes
376 369 s = "issue status-#{status.position} priority-#{priority.position}"
377 370 s << ' closed' if closed?
378 371 s << ' overdue' if overdue?
379 372 s << ' created-by-me' if User.current.logged? && author_id == User.current.id
380 373 s << ' assigned-to-me' if User.current.logged? && assigned_to_id == User.current.id
381 374 s
382 375 end
383 376
384 377 # Unassigns issues from +version+ if it's no longer shared with issue's project
385 378 def self.update_versions_from_sharing_change(version)
386 379 # Update issues assigned to the version
387 380 update_versions(["#{Issue.table_name}.fixed_version_id = ?", version.id])
388 381 end
389 382
390 383 # Unassigns issues from versions that are no longer shared
391 384 # after +project+ was moved
392 385 def self.update_versions_from_hierarchy_change(project)
393 386 moved_project_ids = project.self_and_descendants.reload.collect(&:id)
394 387 # Update issues of the moved projects and issues assigned to a version of a moved project
395 388 Issue.update_versions(["#{Version.table_name}.project_id IN (?) OR #{Issue.table_name}.project_id IN (?)", moved_project_ids, moved_project_ids])
396 389 end
397 390
398 391 private
399 392
400 393 # Update issues so their versions are not pointing to a
401 394 # fixed_version that is not shared with the issue's project
402 395 def self.update_versions(conditions=nil)
403 396 # Only need to update issues with a fixed_version from
404 397 # a different project and that is not systemwide shared
405 398 Issue.all(:conditions => merge_conditions("#{Issue.table_name}.fixed_version_id IS NOT NULL" +
406 399 " AND #{Issue.table_name}.project_id <> #{Version.table_name}.project_id" +
407 400 " AND #{Version.table_name}.sharing <> 'system'",
408 401 conditions),
409 402 :include => [:project, :fixed_version]
410 403 ).each do |issue|
411 404 next if issue.project.nil? || issue.fixed_version.nil?
412 405 unless issue.project.shared_versions.include?(issue.fixed_version)
413 406 issue.init_journal(User.current)
414 407 issue.fixed_version = nil
415 408 issue.save
416 409 end
417 410 end
418 411 end
419 412
420 413 # Callback on attachment deletion
421 414 def attachment_removed(obj)
422 415 journal = init_journal(User.current)
423 416 journal.details << JournalDetail.new(:property => 'attachment',
424 417 :prop_key => obj.id,
425 418 :old_value => obj.filename)
426 419 journal.save
427 420 end
428 421
429 422 # Saves the changes in a Journal
430 423 # Called after_save
431 424 def create_journal
432 425 if @current_journal
433 426 # attributes changes
434 427 (Issue.column_names - %w(id description lock_version created_on updated_on)).each {|c|
435 428 @current_journal.details << JournalDetail.new(:property => 'attr',
436 429 :prop_key => c,
437 430 :old_value => @issue_before_change.send(c),
438 431 :value => send(c)) unless send(c)==@issue_before_change.send(c)
439 432 }
440 433 # custom fields changes
441 434 custom_values.each {|c|
442 435 next if (@custom_values_before_change[c.custom_field_id]==c.value ||
443 436 (@custom_values_before_change[c.custom_field_id].blank? && c.value.blank?))
444 437 @current_journal.details << JournalDetail.new(:property => 'cf',
445 438 :prop_key => c.custom_field_id,
446 439 :old_value => @custom_values_before_change[c.custom_field_id],
447 440 :value => c.value)
448 441 }
449 442 @current_journal.save
450 443 end
451 444 end
452 445 end
@@ -1,70 +1,74
1 1 # ActsAsWatchable
2 2 module Redmine
3 3 module Acts
4 4 module Watchable
5 5 def self.included(base)
6 6 base.extend ClassMethods
7 7 end
8 8
9 9 module ClassMethods
10 10 def acts_as_watchable(options = {})
11 11 return if self.included_modules.include?(Redmine::Acts::Watchable::InstanceMethods)
12 12 send :include, Redmine::Acts::Watchable::InstanceMethods
13 13
14 14 class_eval do
15 15 has_many :watchers, :as => :watchable, :dependent => :delete_all
16 16 has_many :watcher_users, :through => :watchers, :source => :user
17 17
18 18 attr_protected :watcher_ids, :watcher_user_ids
19 19 end
20 20 end
21 21 end
22 22
23 23 module InstanceMethods
24 24 def self.included(base)
25 25 base.extend ClassMethods
26 26 end
27 27
28 28 # Returns an array of users that are proposed as watchers
29 29 def addable_watcher_users
30 30 self.project.users.sort - self.watcher_users
31 31 end
32 32
33 33 # Adds user as a watcher
34 34 def add_watcher(user)
35 35 self.watchers << Watcher.new(:user => user)
36 36 end
37 37
38 38 # Removes user from the watchers list
39 39 def remove_watcher(user)
40 40 return nil unless user && user.is_a?(User)
41 41 Watcher.delete_all "watchable_type = '#{self.class}' AND watchable_id = #{self.id} AND user_id = #{user.id}"
42 42 end
43 43
44 44 # Adds/removes watcher
45 45 def set_watcher(user, watching=true)
46 46 watching ? add_watcher(user) : remove_watcher(user)
47 47 end
48 48
49 49 # Returns true if object is watched by user
50 50 def watched_by?(user)
51 51 !!(user && self.watchers.detect {|w| w.user_id == user.id })
52 52 end
53 53
54 54 # Returns an array of watchers' email addresses
55 55 def watcher_recipients
56 self.watchers.collect { |w| w.user.mail if w.user.active? }.compact
56 notified = watchers.collect(&:user).select(&:active?)
57 if respond_to?(:visible?)
58 notified.reject! {|user| !visible?(user)}
59 end
60 notified.collect(&:mail).compact
57 61 end
58 62
59 63 module ClassMethods
60 64 # Returns the objects that are watched by user
61 65 def watched_by(user)
62 66 find(:all,
63 67 :include => :watchers,
64 68 :conditions => ["#{Watcher.table_name}.user_id = ?", user.id])
65 69 end
66 70 end
67 71 end
68 72 end
69 73 end
70 74 end
General Comments 0
You need to be logged in to leave comments. Login now