##// END OF EJS Templates
Dup attributes instead of issue object....
Jean-Philippe Lang -
r8224:8f2304385170
parent child
Show More
@@ -1,980 +1,979
1 1 # Redmine - project management software
2 2 # Copyright (C) 2006-2011 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 include Redmine::SafeAttributes
20 20
21 21 belongs_to :project
22 22 belongs_to :tracker
23 23 belongs_to :status, :class_name => 'IssueStatus', :foreign_key => 'status_id'
24 24 belongs_to :author, :class_name => 'User', :foreign_key => 'author_id'
25 25 belongs_to :assigned_to, :class_name => 'Principal', :foreign_key => 'assigned_to_id'
26 26 belongs_to :fixed_version, :class_name => 'Version', :foreign_key => 'fixed_version_id'
27 27 belongs_to :priority, :class_name => 'IssuePriority', :foreign_key => 'priority_id'
28 28 belongs_to :category, :class_name => 'IssueCategory', :foreign_key => 'category_id'
29 29
30 30 has_many :journals, :as => :journalized, :dependent => :destroy
31 31 has_many :time_entries, :dependent => :delete_all
32 32 has_and_belongs_to_many :changesets, :order => "#{Changeset.table_name}.committed_on ASC, #{Changeset.table_name}.id ASC"
33 33
34 34 has_many :relations_from, :class_name => 'IssueRelation', :foreign_key => 'issue_from_id', :dependent => :delete_all
35 35 has_many :relations_to, :class_name => 'IssueRelation', :foreign_key => 'issue_to_id', :dependent => :delete_all
36 36
37 37 acts_as_nested_set :scope => 'root_id', :dependent => :destroy
38 38 acts_as_attachable :after_add => :attachment_added, :after_remove => :attachment_removed
39 39 acts_as_customizable
40 40 acts_as_watchable
41 41 acts_as_searchable :columns => ['subject', "#{table_name}.description", "#{Journal.table_name}.notes"],
42 42 :include => [:project, :journals],
43 43 # sort by id so that limited eager loading doesn't break with postgresql
44 44 :order_column => "#{table_name}.id"
45 45 acts_as_event :title => Proc.new {|o| "#{o.tracker.name} ##{o.id} (#{o.status}): #{o.subject}"},
46 46 :url => Proc.new {|o| {:controller => 'issues', :action => 'show', :id => o.id}},
47 47 :type => Proc.new {|o| 'issue' + (o.closed? ? ' closed' : '') }
48 48
49 49 acts_as_activity_provider :find_options => {:include => [:project, :author, :tracker]},
50 50 :author_key => :author_id
51 51
52 52 DONE_RATIO_OPTIONS = %w(issue_field issue_status)
53 53
54 54 attr_reader :current_journal
55 55
56 56 validates_presence_of :subject, :priority, :project, :tracker, :author, :status
57 57
58 58 validates_length_of :subject, :maximum => 255
59 59 validates_inclusion_of :done_ratio, :in => 0..100
60 60 validates_numericality_of :estimated_hours, :allow_nil => true
61 61 validate :validate_issue
62 62
63 63 named_scope :visible, lambda {|*args| { :include => :project,
64 64 :conditions => Issue.visible_condition(args.shift || User.current, *args) } }
65 65
66 66 named_scope :open, lambda {|*args|
67 67 is_closed = args.size > 0 ? !args.first : false
68 68 {:conditions => ["#{IssueStatus.table_name}.is_closed = ?", is_closed], :include => :status}
69 69 }
70 70
71 71 named_scope :recently_updated, :order => "#{Issue.table_name}.updated_on DESC"
72 72 named_scope :with_limit, lambda { |limit| { :limit => limit} }
73 73 named_scope :on_active_project, :include => [:status, :project, :tracker],
74 74 :conditions => ["#{Project.table_name}.status=#{Project::STATUS_ACTIVE}"]
75 75
76 76 before_create :default_assign
77 77 before_save :close_duplicates, :update_done_ratio_from_issue_status
78 78 after_save :reschedule_following_issues, :update_nested_set_attributes, :update_parent_attributes, :create_journal
79 79 after_destroy :update_parent_attributes
80 80
81 81 # Returns a SQL conditions string used to find all issues visible by the specified user
82 82 def self.visible_condition(user, options={})
83 83 Project.allowed_to_condition(user, :view_issues, options) do |role, user|
84 84 case role.issues_visibility
85 85 when 'all'
86 86 nil
87 87 when 'default'
88 88 user_ids = [user.id] + user.groups.map(&:id)
89 89 "(#{table_name}.is_private = #{connection.quoted_false} OR #{table_name}.author_id = #{user.id} OR #{table_name}.assigned_to_id IN (#{user_ids.join(',')}))"
90 90 when 'own'
91 91 user_ids = [user.id] + user.groups.map(&:id)
92 92 "(#{table_name}.author_id = #{user.id} OR #{table_name}.assigned_to_id IN (#{user_ids.join(',')}))"
93 93 else
94 94 '1=0'
95 95 end
96 96 end
97 97 end
98 98
99 99 # Returns true if usr or current user is allowed to view the issue
100 100 def visible?(usr=nil)
101 101 (usr || User.current).allowed_to?(:view_issues, self.project) do |role, user|
102 102 case role.issues_visibility
103 103 when 'all'
104 104 true
105 105 when 'default'
106 106 !self.is_private? || self.author == user || user.is_or_belongs_to?(assigned_to)
107 107 when 'own'
108 108 self.author == user || user.is_or_belongs_to?(assigned_to)
109 109 else
110 110 false
111 111 end
112 112 end
113 113 end
114 114
115 115 def initialize(attributes=nil, *args)
116 116 super
117 117 if new_record?
118 118 # set default values for new records only
119 119 self.status ||= IssueStatus.default
120 120 self.priority ||= IssuePriority.default
121 121 end
122 122 end
123 123
124 124 # Overrides Redmine::Acts::Customizable::InstanceMethods#available_custom_fields
125 125 def available_custom_fields
126 126 (project && tracker) ? (project.all_issue_custom_fields & tracker.custom_fields.all) : []
127 127 end
128 128
129 129 def copy_from(arg)
130 130 issue = arg.is_a?(Issue) ? arg : Issue.visible.find(arg)
131 131 self.attributes = issue.attributes.dup.except("id", "root_id", "parent_id", "lft", "rgt", "created_on", "updated_on")
132 132 self.custom_field_values = issue.custom_field_values.inject({}) {|h,v| h[v.custom_field_id] = v.value; h}
133 133 self.status = issue.status
134 134 self
135 135 end
136 136
137 137 # Moves/copies an issue to a new project and tracker
138 138 # Returns the moved/copied issue on success, false on failure
139 139 def move_to_project(*args)
140 140 ret = Issue.transaction do
141 141 move_to_project_without_transaction(*args) || raise(ActiveRecord::Rollback)
142 142 end || false
143 143 end
144 144
145 145 def move_to_project_without_transaction(new_project, new_tracker = nil, options = {})
146 146 options ||= {}
147 147
148 148 if options[:copy]
149 149 issue = self.class.new.copy_from(self)
150 150 else
151 151 issue = self
152 152 issue.init_journal(User.current, options[:notes])
153 153 end
154 154
155 155 if new_project && issue.project_id != new_project.id
156 156 # delete issue relations
157 157 unless Setting.cross_project_issue_relations?
158 158 issue.relations_from.clear
159 159 issue.relations_to.clear
160 160 end
161 161 # issue is moved to another project
162 162 # reassign to the category with same name if any
163 163 new_category = issue.category.nil? ? nil : new_project.issue_categories.find_by_name(issue.category.name)
164 164 issue.category = new_category
165 165 # Keep the fixed_version if it's still valid in the new_project
166 166 unless new_project.shared_versions.include?(issue.fixed_version)
167 167 issue.fixed_version = nil
168 168 end
169 169 issue.project = new_project
170 170 if issue.parent && issue.parent.project_id != issue.project_id
171 171 issue.parent_issue_id = nil
172 172 end
173 173 end
174 174 if new_tracker
175 175 issue.tracker = new_tracker
176 176 issue.reset_custom_values!
177 177 end
178 178 if options[:copy]
179 179 issue.author = User.current
180 180 issue.custom_field_values = self.custom_field_values.inject({}) {|h,v| h[v.custom_field_id] = v.value; h}
181 181 issue.status = if options[:attributes] && options[:attributes][:status_id]
182 182 IssueStatus.find_by_id(options[:attributes][:status_id])
183 183 else
184 184 self.status
185 185 end
186 186 end
187 187 # Allow bulk setting of attributes on the issue
188 188 if options[:attributes]
189 189 issue.attributes = options[:attributes]
190 190 end
191 191 if options[:copy] && options[:notes].present?
192 192 issue.init_journal(User.current, options[:notes])
193 193 issue.current_journal.notify = false
194 194 end
195 195 if issue.save
196 196 unless options[:copy]
197 197 # Manually update project_id on related time entries
198 198 TimeEntry.update_all("project_id = #{new_project.id}", {:issue_id => id})
199 199
200 200 issue.children.each do |child|
201 201 unless child.move_to_project_without_transaction(new_project)
202 202 # Move failed and transaction was rollback'd
203 203 return false
204 204 end
205 205 end
206 206 end
207 207 else
208 208 return false
209 209 end
210 210 issue
211 211 end
212 212
213 213 def status_id=(sid)
214 214 self.status = nil
215 215 write_attribute(:status_id, sid)
216 216 end
217 217
218 218 def priority_id=(pid)
219 219 self.priority = nil
220 220 write_attribute(:priority_id, pid)
221 221 end
222 222
223 223 def tracker_id=(tid)
224 224 self.tracker = nil
225 225 result = write_attribute(:tracker_id, tid)
226 226 @custom_field_values = nil
227 227 result
228 228 end
229 229
230 230 def description=(arg)
231 231 if arg.is_a?(String)
232 232 arg = arg.gsub(/(\r\n|\n|\r)/, "\r\n")
233 233 end
234 234 write_attribute(:description, arg)
235 235 end
236 236
237 237 # Overrides attributes= so that project and tracker get assigned first
238 238 def attributes_with_project_and_tracker_first=(new_attributes, *args)
239 239 return if new_attributes.nil?
240 240 attrs = new_attributes.dup
241 241 attrs.stringify_keys!
242 242
243 243 %w(project project_id tracker tracker_id).each do |attr|
244 244 if attrs.has_key?(attr)
245 245 send "#{attr}=", attrs.delete(attr)
246 246 end
247 247 end
248 248 send :attributes_without_project_and_tracker_first=, attrs, *args
249 249 end
250 250 # Do not redefine alias chain on reload (see #4838)
251 251 alias_method_chain(:attributes=, :project_and_tracker_first) unless method_defined?(:attributes_without_project_and_tracker_first=)
252 252
253 253 def estimated_hours=(h)
254 254 write_attribute :estimated_hours, (h.is_a?(String) ? h.to_hours : h)
255 255 end
256 256
257 257 safe_attributes 'tracker_id',
258 258 'status_id',
259 259 'category_id',
260 260 'assigned_to_id',
261 261 'priority_id',
262 262 'fixed_version_id',
263 263 'subject',
264 264 'description',
265 265 'start_date',
266 266 'due_date',
267 267 'done_ratio',
268 268 'estimated_hours',
269 269 'custom_field_values',
270 270 'custom_fields',
271 271 'lock_version',
272 272 :if => lambda {|issue, user| issue.new_record? || user.allowed_to?(:edit_issues, issue.project) }
273 273
274 274 safe_attributes 'status_id',
275 275 'assigned_to_id',
276 276 'fixed_version_id',
277 277 'done_ratio',
278 278 'lock_version',
279 279 :if => lambda {|issue, user| issue.new_statuses_allowed_to(user).any? }
280 280
281 281 safe_attributes 'watcher_user_ids',
282 282 :if => lambda {|issue, user| issue.new_record? && user.allowed_to?(:add_issue_watchers, issue.project)}
283 283
284 284 safe_attributes 'is_private',
285 285 :if => lambda {|issue, user|
286 286 user.allowed_to?(:set_issues_private, issue.project) ||
287 287 (issue.author == user && user.allowed_to?(:set_own_issues_private, issue.project))
288 288 }
289 289
290 290 safe_attributes 'parent_issue_id',
291 291 :if => lambda {|issue, user| (issue.new_record? || user.allowed_to?(:edit_issues, issue.project)) &&
292 292 user.allowed_to?(:manage_subtasks, issue.project)}
293 293
294 294 # Safely sets attributes
295 295 # Should be called from controllers instead of #attributes=
296 296 # attr_accessible is too rough because we still want things like
297 297 # Issue.new(:project => foo) to work
298 298 # TODO: move workflow/permission checks from controllers to here
299 299 def safe_attributes=(attrs, user=User.current)
300 300 return unless attrs.is_a?(Hash)
301 301
302 302 # User can change issue attributes only if he has :edit permission or if a workflow transition is allowed
303 303 attrs = delete_unsafe_attributes(attrs, user)
304 304 return if attrs.empty?
305 305
306 306 # Tracker must be set before since new_statuses_allowed_to depends on it.
307 307 if t = attrs.delete('tracker_id')
308 308 self.tracker_id = t
309 309 end
310 310
311 311 if attrs['status_id']
312 312 unless new_statuses_allowed_to(user).collect(&:id).include?(attrs['status_id'].to_i)
313 313 attrs.delete('status_id')
314 314 end
315 315 end
316 316
317 317 unless leaf?
318 318 attrs.reject! {|k,v| %w(priority_id done_ratio start_date due_date estimated_hours).include?(k)}
319 319 end
320 320
321 321 if attrs['parent_issue_id'].present?
322 322 attrs.delete('parent_issue_id') unless Issue.visible(user).exists?(attrs['parent_issue_id'].to_i)
323 323 end
324 324
325 325 # mass-assignment security bypass
326 326 self.send :attributes=, attrs, false
327 327 end
328 328
329 329 def done_ratio
330 330 if Issue.use_status_for_done_ratio? && status && status.default_done_ratio
331 331 status.default_done_ratio
332 332 else
333 333 read_attribute(:done_ratio)
334 334 end
335 335 end
336 336
337 337 def self.use_status_for_done_ratio?
338 338 Setting.issue_done_ratio == 'issue_status'
339 339 end
340 340
341 341 def self.use_field_for_done_ratio?
342 342 Setting.issue_done_ratio == 'issue_field'
343 343 end
344 344
345 345 def validate_issue
346 346 if self.due_date.nil? && @attributes['due_date'] && !@attributes['due_date'].empty?
347 347 errors.add :due_date, :not_a_date
348 348 end
349 349
350 350 if self.due_date and self.start_date and self.due_date < self.start_date
351 351 errors.add :due_date, :greater_than_start_date
352 352 end
353 353
354 354 if start_date && soonest_start && start_date < soonest_start
355 355 errors.add :start_date, :invalid
356 356 end
357 357
358 358 if fixed_version
359 359 if !assignable_versions.include?(fixed_version)
360 360 errors.add :fixed_version_id, :inclusion
361 361 elsif reopened? && fixed_version.closed?
362 362 errors.add :base, I18n.t(:error_can_not_reopen_issue_on_closed_version)
363 363 end
364 364 end
365 365
366 366 # Checks that the issue can not be added/moved to a disabled tracker
367 367 if project && (tracker_id_changed? || project_id_changed?)
368 368 unless project.trackers.include?(tracker)
369 369 errors.add :tracker_id, :inclusion
370 370 end
371 371 end
372 372
373 373 # Checks parent issue assignment
374 374 if @parent_issue
375 375 if @parent_issue.project_id != project_id
376 376 errors.add :parent_issue_id, :not_same_project
377 377 elsif !new_record?
378 378 # moving an existing issue
379 379 if @parent_issue.root_id != root_id
380 380 # we can always move to another tree
381 381 elsif move_possible?(@parent_issue)
382 382 # move accepted inside tree
383 383 else
384 384 errors.add :parent_issue_id, :not_a_valid_parent
385 385 end
386 386 end
387 387 end
388 388 end
389 389
390 390 # Set the done_ratio using the status if that setting is set. This will keep the done_ratios
391 391 # even if the user turns off the setting later
392 392 def update_done_ratio_from_issue_status
393 393 if Issue.use_status_for_done_ratio? && status && status.default_done_ratio
394 394 self.done_ratio = status.default_done_ratio
395 395 end
396 396 end
397 397
398 398 def init_journal(user, notes = "")
399 399 @current_journal ||= Journal.new(:journalized => self, :user => user, :notes => notes)
400 @issue_before_change = self.clone
401 @issue_before_change.status = self.status
400 @attributes_before_change = attributes.dup
402 401 @custom_values_before_change = {}
403 402 self.custom_values.each {|c| @custom_values_before_change.store c.custom_field_id, c.value }
404 403 # Make sure updated_on is updated when adding a note.
405 404 updated_on_will_change!
406 405 @current_journal
407 406 end
408 407
409 408 # Return true if the issue is closed, otherwise false
410 409 def closed?
411 410 self.status.is_closed?
412 411 end
413 412
414 413 # Return true if the issue is being reopened
415 414 def reopened?
416 415 if !new_record? && status_id_changed?
417 416 status_was = IssueStatus.find_by_id(status_id_was)
418 417 status_new = IssueStatus.find_by_id(status_id)
419 418 if status_was && status_new && status_was.is_closed? && !status_new.is_closed?
420 419 return true
421 420 end
422 421 end
423 422 false
424 423 end
425 424
426 425 # Return true if the issue is being closed
427 426 def closing?
428 427 if !new_record? && status_id_changed?
429 428 status_was = IssueStatus.find_by_id(status_id_was)
430 429 status_new = IssueStatus.find_by_id(status_id)
431 430 if status_was && status_new && !status_was.is_closed? && status_new.is_closed?
432 431 return true
433 432 end
434 433 end
435 434 false
436 435 end
437 436
438 437 # Returns true if the issue is overdue
439 438 def overdue?
440 439 !due_date.nil? && (due_date < Date.today) && !status.is_closed?
441 440 end
442 441
443 442 # Is the amount of work done less than it should for the due date
444 443 def behind_schedule?
445 444 return false if start_date.nil? || due_date.nil?
446 445 done_date = start_date + ((due_date - start_date+1)* done_ratio/100).floor
447 446 return done_date <= Date.today
448 447 end
449 448
450 449 # Does this issue have children?
451 450 def children?
452 451 !leaf?
453 452 end
454 453
455 454 # Users the issue can be assigned to
456 455 def assignable_users
457 456 users = project.assignable_users
458 457 users << author if author
459 458 users << assigned_to if assigned_to
460 459 users.uniq.sort
461 460 end
462 461
463 462 # Versions that the issue can be assigned to
464 463 def assignable_versions
465 464 @assignable_versions ||= (project.shared_versions.open + [Version.find_by_id(fixed_version_id_was)]).compact.uniq.sort
466 465 end
467 466
468 467 # Returns true if this issue is blocked by another issue that is still open
469 468 def blocked?
470 469 !relations_to.detect {|ir| ir.relation_type == 'blocks' && !ir.issue_from.closed?}.nil?
471 470 end
472 471
473 472 # Returns an array of status that user is able to apply
474 473 def new_statuses_allowed_to(user, include_default=false)
475 474 statuses = status.find_new_statuses_allowed_to(
476 475 user.roles_for_project(project),
477 476 tracker,
478 477 author == user,
479 478 assigned_to_id_changed? ? assigned_to_id_was == user.id : assigned_to_id == user.id
480 479 )
481 480 statuses << status unless statuses.empty?
482 481 statuses << IssueStatus.default if include_default
483 482 statuses = statuses.uniq.sort
484 483 blocked? ? statuses.reject {|s| s.is_closed?} : statuses
485 484 end
486 485
487 486 # Returns the mail adresses of users that should be notified
488 487 def recipients
489 488 notified = project.notified_users
490 489 # Author and assignee are always notified unless they have been
491 490 # locked or don't want to be notified
492 491 notified << author if author && author.active? && author.notify_about?(self)
493 492 if assigned_to
494 493 if assigned_to.is_a?(Group)
495 494 notified += assigned_to.users.select {|u| u.active? && u.notify_about?(self)}
496 495 else
497 496 notified << assigned_to if assigned_to.active? && assigned_to.notify_about?(self)
498 497 end
499 498 end
500 499 notified.uniq!
501 500 # Remove users that can not view the issue
502 501 notified.reject! {|user| !visible?(user)}
503 502 notified.collect(&:mail)
504 503 end
505 504
506 505 # Returns the number of hours spent on this issue
507 506 def spent_hours
508 507 @spent_hours ||= time_entries.sum(:hours) || 0
509 508 end
510 509
511 510 # Returns the total number of hours spent on this issue and its descendants
512 511 #
513 512 # Example:
514 513 # spent_hours => 0.0
515 514 # spent_hours => 50.2
516 515 def total_spent_hours
517 516 @total_spent_hours ||= self_and_descendants.sum("#{TimeEntry.table_name}.hours",
518 517 :joins => "LEFT JOIN #{TimeEntry.table_name} ON #{TimeEntry.table_name}.issue_id = #{Issue.table_name}.id").to_f || 0.0
519 518 end
520 519
521 520 def relations
522 521 @relations ||= (relations_from + relations_to).sort
523 522 end
524 523
525 524 # Preloads relations for a collection of issues
526 525 def self.load_relations(issues)
527 526 if issues.any?
528 527 relations = IssueRelation.all(:conditions => ["issue_from_id IN (:ids) OR issue_to_id IN (:ids)", {:ids => issues.map(&:id)}])
529 528 issues.each do |issue|
530 529 issue.instance_variable_set "@relations", relations.select {|r| r.issue_from_id == issue.id || r.issue_to_id == issue.id}
531 530 end
532 531 end
533 532 end
534 533
535 534 # Preloads visible spent time for a collection of issues
536 535 def self.load_visible_spent_hours(issues, user=User.current)
537 536 if issues.any?
538 537 hours_by_issue_id = TimeEntry.visible(user).sum(:hours, :group => :issue_id)
539 538 issues.each do |issue|
540 539 issue.instance_variable_set "@spent_hours", (hours_by_issue_id[issue.id] || 0)
541 540 end
542 541 end
543 542 end
544 543
545 544 # Finds an issue relation given its id.
546 545 def find_relation(relation_id)
547 546 IssueRelation.find(relation_id, :conditions => ["issue_to_id = ? OR issue_from_id = ?", id, id])
548 547 end
549 548
550 549 def all_dependent_issues(except=[])
551 550 except << self
552 551 dependencies = []
553 552 relations_from.each do |relation|
554 553 if relation.issue_to && !except.include?(relation.issue_to)
555 554 dependencies << relation.issue_to
556 555 dependencies += relation.issue_to.all_dependent_issues(except)
557 556 end
558 557 end
559 558 dependencies
560 559 end
561 560
562 561 # Returns an array of issues that duplicate this one
563 562 def duplicates
564 563 relations_to.select {|r| r.relation_type == IssueRelation::TYPE_DUPLICATES}.collect {|r| r.issue_from}
565 564 end
566 565
567 566 # Returns the due date or the target due date if any
568 567 # Used on gantt chart
569 568 def due_before
570 569 due_date || (fixed_version ? fixed_version.effective_date : nil)
571 570 end
572 571
573 572 # Returns the time scheduled for this issue.
574 573 #
575 574 # Example:
576 575 # Start Date: 2/26/09, End Date: 3/04/09
577 576 # duration => 6
578 577 def duration
579 578 (start_date && due_date) ? due_date - start_date : 0
580 579 end
581 580
582 581 def soonest_start
583 582 @soonest_start ||= (
584 583 relations_to.collect{|relation| relation.successor_soonest_start} +
585 584 ancestors.collect(&:soonest_start)
586 585 ).compact.max
587 586 end
588 587
589 588 def reschedule_after(date)
590 589 return if date.nil?
591 590 if leaf?
592 591 if start_date.nil? || start_date < date
593 592 self.start_date, self.due_date = date, date + duration
594 593 save
595 594 end
596 595 else
597 596 leaves.each do |leaf|
598 597 leaf.reschedule_after(date)
599 598 end
600 599 end
601 600 end
602 601
603 602 def <=>(issue)
604 603 if issue.nil?
605 604 -1
606 605 elsif root_id != issue.root_id
607 606 (root_id || 0) <=> (issue.root_id || 0)
608 607 else
609 608 (lft || 0) <=> (issue.lft || 0)
610 609 end
611 610 end
612 611
613 612 def to_s
614 613 "#{tracker} ##{id}: #{subject}"
615 614 end
616 615
617 616 # Returns a string of css classes that apply to the issue
618 617 def css_classes
619 618 s = "issue status-#{status.position} priority-#{priority.position}"
620 619 s << ' closed' if closed?
621 620 s << ' overdue' if overdue?
622 621 s << ' child' if child?
623 622 s << ' parent' unless leaf?
624 623 s << ' private' if is_private?
625 624 s << ' created-by-me' if User.current.logged? && author_id == User.current.id
626 625 s << ' assigned-to-me' if User.current.logged? && assigned_to_id == User.current.id
627 626 s
628 627 end
629 628
630 629 # Saves an issue, time_entry, attachments, and a journal from the parameters
631 630 # Returns false if save fails
632 631 def save_issue_with_child_records(params, existing_time_entry=nil)
633 632 Issue.transaction do
634 633 if params[:time_entry] && (params[:time_entry][:hours].present? || params[:time_entry][:comments].present?) && User.current.allowed_to?(:log_time, project)
635 634 @time_entry = existing_time_entry || TimeEntry.new
636 635 @time_entry.project = project
637 636 @time_entry.issue = self
638 637 @time_entry.user = User.current
639 638 @time_entry.spent_on = User.current.today
640 639 @time_entry.attributes = params[:time_entry]
641 640 self.time_entries << @time_entry
642 641 end
643 642
644 643 if valid?
645 644 attachments = Attachment.attach_files(self, params[:attachments])
646 645 # TODO: Rename hook
647 646 Redmine::Hook.call_hook(:controller_issues_edit_before_save, { :params => params, :issue => self, :time_entry => @time_entry, :journal => @current_journal})
648 647 begin
649 648 if save
650 649 # TODO: Rename hook
651 650 Redmine::Hook.call_hook(:controller_issues_edit_after_save, { :params => params, :issue => self, :time_entry => @time_entry, :journal => @current_journal})
652 651 else
653 652 raise ActiveRecord::Rollback
654 653 end
655 654 rescue ActiveRecord::StaleObjectError
656 655 attachments[:files].each(&:destroy)
657 656 errors.add :base, l(:notice_locking_conflict)
658 657 raise ActiveRecord::Rollback
659 658 end
660 659 end
661 660 end
662 661 end
663 662
664 663 # Unassigns issues from +version+ if it's no longer shared with issue's project
665 664 def self.update_versions_from_sharing_change(version)
666 665 # Update issues assigned to the version
667 666 update_versions(["#{Issue.table_name}.fixed_version_id = ?", version.id])
668 667 end
669 668
670 669 # Unassigns issues from versions that are no longer shared
671 670 # after +project+ was moved
672 671 def self.update_versions_from_hierarchy_change(project)
673 672 moved_project_ids = project.self_and_descendants.reload.collect(&:id)
674 673 # Update issues of the moved projects and issues assigned to a version of a moved project
675 674 Issue.update_versions(["#{Version.table_name}.project_id IN (?) OR #{Issue.table_name}.project_id IN (?)", moved_project_ids, moved_project_ids])
676 675 end
677 676
678 677 def parent_issue_id=(arg)
679 678 parent_issue_id = arg.blank? ? nil : arg.to_i
680 679 if parent_issue_id && @parent_issue = Issue.find_by_id(parent_issue_id)
681 680 @parent_issue.id
682 681 else
683 682 @parent_issue = nil
684 683 nil
685 684 end
686 685 end
687 686
688 687 def parent_issue_id
689 688 if instance_variable_defined? :@parent_issue
690 689 @parent_issue.nil? ? nil : @parent_issue.id
691 690 else
692 691 parent_id
693 692 end
694 693 end
695 694
696 695 # Extracted from the ReportsController.
697 696 def self.by_tracker(project)
698 697 count_and_group_by(:project => project,
699 698 :field => 'tracker_id',
700 699 :joins => Tracker.table_name)
701 700 end
702 701
703 702 def self.by_version(project)
704 703 count_and_group_by(:project => project,
705 704 :field => 'fixed_version_id',
706 705 :joins => Version.table_name)
707 706 end
708 707
709 708 def self.by_priority(project)
710 709 count_and_group_by(:project => project,
711 710 :field => 'priority_id',
712 711 :joins => IssuePriority.table_name)
713 712 end
714 713
715 714 def self.by_category(project)
716 715 count_and_group_by(:project => project,
717 716 :field => 'category_id',
718 717 :joins => IssueCategory.table_name)
719 718 end
720 719
721 720 def self.by_assigned_to(project)
722 721 count_and_group_by(:project => project,
723 722 :field => 'assigned_to_id',
724 723 :joins => User.table_name)
725 724 end
726 725
727 726 def self.by_author(project)
728 727 count_and_group_by(:project => project,
729 728 :field => 'author_id',
730 729 :joins => User.table_name)
731 730 end
732 731
733 732 def self.by_subproject(project)
734 733 ActiveRecord::Base.connection.select_all("select s.id as status_id,
735 734 s.is_closed as closed,
736 735 #{Issue.table_name}.project_id as project_id,
737 736 count(#{Issue.table_name}.id) as total
738 737 from
739 738 #{Issue.table_name}, #{Project.table_name}, #{IssueStatus.table_name} s
740 739 where
741 740 #{Issue.table_name}.status_id=s.id
742 741 and #{Issue.table_name}.project_id = #{Project.table_name}.id
743 742 and #{visible_condition(User.current, :project => project, :with_subprojects => true)}
744 743 and #{Issue.table_name}.project_id <> #{project.id}
745 744 group by s.id, s.is_closed, #{Issue.table_name}.project_id") if project.descendants.active.any?
746 745 end
747 746 # End ReportsController extraction
748 747
749 748 # Returns an array of projects that current user can move issues to
750 749 def self.allowed_target_projects_on_move
751 750 projects = []
752 751 if User.current.admin?
753 752 # admin is allowed to move issues to any active (visible) project
754 753 projects = Project.visible.all
755 754 elsif User.current.logged?
756 755 if Role.non_member.allowed_to?(:move_issues)
757 756 projects = Project.visible.all
758 757 else
759 758 User.current.memberships.each {|m| projects << m.project if m.roles.detect {|r| r.allowed_to?(:move_issues)}}
760 759 end
761 760 end
762 761 projects
763 762 end
764 763
765 764 private
766 765
767 766 def update_nested_set_attributes
768 767 if root_id.nil?
769 768 # issue was just created
770 769 self.root_id = (@parent_issue.nil? ? id : @parent_issue.root_id)
771 770 set_default_left_and_right
772 771 Issue.update_all("root_id = #{root_id}, lft = #{lft}, rgt = #{rgt}", ["id = ?", id])
773 772 if @parent_issue
774 773 move_to_child_of(@parent_issue)
775 774 end
776 775 reload
777 776 elsif parent_issue_id != parent_id
778 777 former_parent_id = parent_id
779 778 # moving an existing issue
780 779 if @parent_issue && @parent_issue.root_id == root_id
781 780 # inside the same tree
782 781 move_to_child_of(@parent_issue)
783 782 else
784 783 # to another tree
785 784 unless root?
786 785 move_to_right_of(root)
787 786 reload
788 787 end
789 788 old_root_id = root_id
790 789 self.root_id = (@parent_issue.nil? ? id : @parent_issue.root_id )
791 790 target_maxright = nested_set_scope.maximum(right_column_name) || 0
792 791 offset = target_maxright + 1 - lft
793 792 Issue.update_all("root_id = #{root_id}, lft = lft + #{offset}, rgt = rgt + #{offset}",
794 793 ["root_id = ? AND lft >= ? AND rgt <= ? ", old_root_id, lft, rgt])
795 794 self[left_column_name] = lft + offset
796 795 self[right_column_name] = rgt + offset
797 796 if @parent_issue
798 797 move_to_child_of(@parent_issue)
799 798 end
800 799 end
801 800 reload
802 801 # delete invalid relations of all descendants
803 802 self_and_descendants.each do |issue|
804 803 issue.relations.each do |relation|
805 804 relation.destroy unless relation.valid?
806 805 end
807 806 end
808 807 # update former parent
809 808 recalculate_attributes_for(former_parent_id) if former_parent_id
810 809 end
811 810 remove_instance_variable(:@parent_issue) if instance_variable_defined?(:@parent_issue)
812 811 end
813 812
814 813 def update_parent_attributes
815 814 recalculate_attributes_for(parent_id) if parent_id
816 815 end
817 816
818 817 def recalculate_attributes_for(issue_id)
819 818 if issue_id && p = Issue.find_by_id(issue_id)
820 819 # priority = highest priority of children
821 820 if priority_position = p.children.maximum("#{IssuePriority.table_name}.position", :include => :priority)
822 821 p.priority = IssuePriority.find_by_position(priority_position)
823 822 end
824 823
825 824 # start/due dates = lowest/highest dates of children
826 825 p.start_date = p.children.minimum(:start_date)
827 826 p.due_date = p.children.maximum(:due_date)
828 827 if p.start_date && p.due_date && p.due_date < p.start_date
829 828 p.start_date, p.due_date = p.due_date, p.start_date
830 829 end
831 830
832 831 # done ratio = weighted average ratio of leaves
833 832 unless Issue.use_status_for_done_ratio? && p.status && p.status.default_done_ratio
834 833 leaves_count = p.leaves.count
835 834 if leaves_count > 0
836 835 average = p.leaves.average(:estimated_hours).to_f
837 836 if average == 0
838 837 average = 1
839 838 end
840 839 done = p.leaves.sum("COALESCE(estimated_hours, #{average}) * (CASE WHEN is_closed = #{connection.quoted_true} THEN 100 ELSE COALESCE(done_ratio, 0) END)", :include => :status).to_f
841 840 progress = done / (average * leaves_count)
842 841 p.done_ratio = progress.round
843 842 end
844 843 end
845 844
846 845 # estimate = sum of leaves estimates
847 846 p.estimated_hours = p.leaves.sum(:estimated_hours).to_f
848 847 p.estimated_hours = nil if p.estimated_hours == 0.0
849 848
850 849 # ancestors will be recursively updated
851 850 p.save(false)
852 851 end
853 852 end
854 853
855 854 # Update issues so their versions are not pointing to a
856 855 # fixed_version that is not shared with the issue's project
857 856 def self.update_versions(conditions=nil)
858 857 # Only need to update issues with a fixed_version from
859 858 # a different project and that is not systemwide shared
860 859 Issue.scoped(:conditions => conditions).all(
861 860 :conditions => "#{Issue.table_name}.fixed_version_id IS NOT NULL" +
862 861 " AND #{Issue.table_name}.project_id <> #{Version.table_name}.project_id" +
863 862 " AND #{Version.table_name}.sharing <> 'system'",
864 863 :include => [:project, :fixed_version]
865 864 ).each do |issue|
866 865 next if issue.project.nil? || issue.fixed_version.nil?
867 866 unless issue.project.shared_versions.include?(issue.fixed_version)
868 867 issue.init_journal(User.current)
869 868 issue.fixed_version = nil
870 869 issue.save
871 870 end
872 871 end
873 872 end
874 873
875 874 # Callback on attachment deletion
876 875 def attachment_added(obj)
877 876 if @current_journal && !obj.new_record?
878 877 @current_journal.details << JournalDetail.new(:property => 'attachment', :prop_key => obj.id, :value => obj.filename)
879 878 end
880 879 end
881 880
882 881 # Callback on attachment deletion
883 882 def attachment_removed(obj)
884 883 journal = init_journal(User.current)
885 884 journal.details << JournalDetail.new(:property => 'attachment',
886 885 :prop_key => obj.id,
887 886 :old_value => obj.filename)
888 887 journal.save
889 888 end
890 889
891 890 # Default assignment based on category
892 891 def default_assign
893 892 if assigned_to.nil? && category && category.assigned_to
894 893 self.assigned_to = category.assigned_to
895 894 end
896 895 end
897 896
898 897 # Updates start/due dates of following issues
899 898 def reschedule_following_issues
900 899 if start_date_changed? || due_date_changed?
901 900 relations_from.each do |relation|
902 901 relation.set_issue_to_dates
903 902 end
904 903 end
905 904 end
906 905
907 906 # Closes duplicates if the issue is being closed
908 907 def close_duplicates
909 908 if closing?
910 909 duplicates.each do |duplicate|
911 910 # Reload is need in case the duplicate was updated by a previous duplicate
912 911 duplicate.reload
913 912 # Don't re-close it if it's already closed
914 913 next if duplicate.closed?
915 914 # Same user and notes
916 915 if @current_journal
917 916 duplicate.init_journal(@current_journal.user, @current_journal.notes)
918 917 end
919 918 duplicate.update_attribute :status, self.status
920 919 end
921 920 end
922 921 end
923 922
924 923 # Saves the changes in a Journal
925 924 # Called after_save
926 925 def create_journal
927 926 if @current_journal
928 927 # attributes changes
929 928 (Issue.column_names - %w(id root_id lft rgt lock_version created_on updated_on)).each {|c|
930 before = @issue_before_change.send(c)
929 before = @attributes_before_change[c]
931 930 after = send(c)
932 931 next if before == after || (before.blank? && after.blank?)
933 932 @current_journal.details << JournalDetail.new(:property => 'attr',
934 933 :prop_key => c,
935 :old_value => @issue_before_change.send(c),
936 :value => send(c))
934 :old_value => before,
935 :value => after)
937 936 }
938 937 # custom fields changes
939 938 custom_values.each {|c|
940 939 next if (@custom_values_before_change[c.custom_field_id]==c.value ||
941 940 (@custom_values_before_change[c.custom_field_id].blank? && c.value.blank?))
942 941 @current_journal.details << JournalDetail.new(:property => 'cf',
943 942 :prop_key => c.custom_field_id,
944 943 :old_value => @custom_values_before_change[c.custom_field_id],
945 944 :value => c.value)
946 945 }
947 946 @current_journal.save
948 947 # reset current journal
949 948 init_journal @current_journal.user, @current_journal.notes
950 949 end
951 950 end
952 951
953 952 # Query generator for selecting groups of issue counts for a project
954 953 # based on specific criteria
955 954 #
956 955 # Options
957 956 # * project - Project to search in.
958 957 # * field - String. Issue field to key off of in the grouping.
959 958 # * joins - String. The table name to join against.
960 959 def self.count_and_group_by(options)
961 960 project = options.delete(:project)
962 961 select_field = options.delete(:field)
963 962 joins = options.delete(:joins)
964 963
965 964 where = "#{Issue.table_name}.#{select_field}=j.id"
966 965
967 966 ActiveRecord::Base.connection.select_all("select s.id as status_id,
968 967 s.is_closed as closed,
969 968 j.id as #{select_field},
970 969 count(#{Issue.table_name}.id) as total
971 970 from
972 971 #{Issue.table_name}, #{Project.table_name}, #{IssueStatus.table_name} s, #{joins} j
973 972 where
974 973 #{Issue.table_name}.status_id=s.id
975 974 and #{where}
976 975 and #{Issue.table_name}.project_id=#{Project.table_name}.id
977 976 and #{visible_condition(User.current, :project => project)}
978 977 group by s.id, s.is_closed, j.id")
979 978 end
980 979 end
General Comments 0
You need to be logged in to leave comments. Login now