annotate app/models/.svn/text-base/issue.rb.svn-base @ 45:65d9e2cabaa3 luisf

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