annotate .svn/pristine/42/428612395d9016270f63f32ed9dffa1b755e4922.svn-base @ 1524:82fac3dcf466 redmine-2.5-integration

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