comparison app/models/issue.rb @ 1295:622f24f53b42 redmine-2.3

Update to Redmine SVN revision 11972 on 2.3-stable branch
author Chris Cannam
date Fri, 14 Jun 2013 09:02:21 +0100
parents 3e4c3460b6ca
children 4f746d8966dd
comparison
equal deleted inserted replaced
1294:3e4c3460b6ca 1295:622f24f53b42
1 # Redmine - project management software 1 # Redmine - project management software
2 # Copyright (C) 2006-2012 Jean-Philippe Lang 2 # Copyright (C) 2006-2013 Jean-Philippe Lang
3 # 3 #
4 # This program is free software; you can redistribute it and/or 4 # This program is free software; you can redistribute it and/or
5 # modify it under the terms of the GNU General Public License 5 # modify it under the terms of the GNU General Public License
6 # as published by the Free Software Foundation; either version 2 6 # as published by the Free Software Foundation; either version 2
7 # of the License, or (at your option) any later version. 7 # of the License, or (at your option) any later version.
65 65
66 validates_presence_of :subject, :priority, :project, :tracker, :author, :status 66 validates_presence_of :subject, :priority, :project, :tracker, :author, :status
67 67
68 validates_length_of :subject, :maximum => 255 68 validates_length_of :subject, :maximum => 255
69 validates_inclusion_of :done_ratio, :in => 0..100 69 validates_inclusion_of :done_ratio, :in => 0..100
70 validates_numericality_of :estimated_hours, :allow_nil => true 70 validates :estimated_hours, :numericality => {:greater_than_or_equal_to => 0, :allow_nil => true, :message => :invalid}
71 validates :start_date, :date => true
72 validates :due_date, :date => true
71 validate :validate_issue, :validate_required_fields 73 validate :validate_issue, :validate_required_fields
72 74
73 scope :visible, 75 scope :visible, lambda {|*args|
74 lambda {|*args| { :include => :project, 76 includes(:project).where(Issue.visible_condition(args.shift || User.current, *args))
75 :conditions => Issue.visible_condition(args.shift || User.current, *args) } } 77 }
76 78
77 scope :open, lambda {|*args| 79 scope :open, lambda {|*args|
78 is_closed = args.size > 0 ? !args.first : false 80 is_closed = args.size > 0 ? !args.first : false
79 {:conditions => ["#{IssueStatus.table_name}.is_closed = ?", is_closed], :include => :status} 81 includes(:status).where("#{IssueStatus.table_name}.is_closed = ?", is_closed)
80 } 82 }
81 83
82 scope :recently_updated, :order => "#{Issue.table_name}.updated_on DESC" 84 scope :recently_updated, lambda { order("#{Issue.table_name}.updated_on DESC") }
83 scope :on_active_project, :include => [:status, :project, :tracker], 85 scope :on_active_project, lambda {
84 :conditions => ["#{Project.table_name}.status=#{Project::STATUS_ACTIVE}"] 86 includes(:status, :project, :tracker).where("#{Project.table_name}.status = ?", Project::STATUS_ACTIVE)
87 }
88 scope :fixed_version, lambda {|versions|
89 ids = [versions].flatten.compact.map {|v| v.is_a?(Version) ? v.id : v}
90 ids.any? ? where(:fixed_version_id => ids) : where('1=0')
91 }
85 92
86 before_create :default_assign 93 before_create :default_assign
87 before_save :close_duplicates, :update_done_ratio_from_issue_status, :force_updated_on_change 94 before_save :close_duplicates, :update_done_ratio_from_issue_status, :force_updated_on_change, :update_closed_on
88 after_save {|issue| issue.send :after_project_change if !issue.id_changed? && issue.project_id_changed?} 95 after_save {|issue| issue.send :after_project_change if !issue.id_changed? && issue.project_id_changed?}
89 after_save :reschedule_following_issues, :update_nested_set_attributes, :update_parent_attributes, :create_journal 96 after_save :reschedule_following_issues, :update_nested_set_attributes, :update_parent_attributes, :create_journal
90 # Should be after_create but would be called before previous after_save callbacks 97 # Should be after_create but would be called before previous after_save callbacks
91 after_save :after_create_from_copy 98 after_save :after_create_from_copy
92 after_destroy :update_parent_attributes 99 after_destroy :update_parent_attributes
131 !self.is_private? 138 !self.is_private?
132 end 139 end
133 end 140 end
134 end 141 end
135 142
143 # Returns true if user or current user is allowed to edit or add a note to the issue
144 def editable?(user=User.current)
145 user.allowed_to?(:edit_issues, project) || user.allowed_to?(:add_issue_notes, project)
146 end
147
136 def initialize(attributes=nil, *args) 148 def initialize(attributes=nil, *args)
137 super 149 super
138 if new_record? 150 if new_record?
139 # set default values for new records only 151 # set default values for new records only
140 self.status ||= IssueStatus.default 152 self.status ||= IssueStatus.default
141 self.priority ||= IssuePriority.default 153 self.priority ||= IssuePriority.default
142 self.watcher_user_ids = [] 154 self.watcher_user_ids = []
143 end 155 end
144 end 156 end
157
158 def create_or_update
159 super
160 ensure
161 @status_was = nil
162 end
163 private :create_or_update
145 164
146 # AR#Persistence#destroy would raise and RecordNotFound exception 165 # AR#Persistence#destroy would raise and RecordNotFound exception
147 # if the issue was already deleted or updated (non matching lock_version). 166 # if the issue was already deleted or updated (non matching lock_version).
148 # This is a problem when bulk deleting issues or deleting a project 167 # This is a problem when bulk deleting issues or deleting a project
149 # (because an issue may already be deleted if its parent was deleted 168 # (because an issue may already be deleted if its parent was deleted
163 end 182 end
164 # The issue was stale, retry to destroy 183 # The issue was stale, retry to destroy
165 super 184 super
166 end 185 end
167 186
187 alias :base_reload :reload
168 def reload(*args) 188 def reload(*args)
169 @workflow_rule_by_attribute = nil 189 @workflow_rule_by_attribute = nil
170 @assignable_versions = nil 190 @assignable_versions = nil
171 super 191 @relations = nil
192 base_reload(*args)
172 end 193 end
173 194
174 # Overrides Redmine::Acts::Customizable::InstanceMethods#available_custom_fields 195 # Overrides Redmine::Acts::Customizable::InstanceMethods#available_custom_fields
175 def available_custom_fields 196 def available_custom_fields
176 (project && tracker) ? (project.all_issue_custom_fields & tracker.custom_fields.all) : [] 197 (project && tracker) ? (project.all_issue_custom_fields & tracker.custom_fields.all) : []
524 def self.use_field_for_done_ratio? 545 def self.use_field_for_done_ratio?
525 Setting.issue_done_ratio == 'issue_field' 546 Setting.issue_done_ratio == 'issue_field'
526 end 547 end
527 548
528 def validate_issue 549 def validate_issue
529 if due_date.nil? && @attributes['due_date'].present?
530 errors.add :due_date, :not_a_date
531 end
532
533 if start_date.nil? && @attributes['start_date'].present?
534 errors.add :start_date, :not_a_date
535 end
536
537 if due_date && start_date && due_date < start_date 550 if due_date && start_date && due_date < start_date
538 errors.add :due_date, :greater_than_start_date 551 errors.add :due_date, :greater_than_start_date
539 end 552 end
540 553
541 if start_date && soonest_start && start_date < soonest_start 554 if start_date && soonest_start && start_date < soonest_start
560 # Checks parent issue assignment 573 # Checks parent issue assignment
561 if @invalid_parent_issue_id.present? 574 if @invalid_parent_issue_id.present?
562 errors.add :parent_issue_id, :invalid 575 errors.add :parent_issue_id, :invalid
563 elsif @parent_issue 576 elsif @parent_issue
564 if !valid_parent_project?(@parent_issue) 577 if !valid_parent_project?(@parent_issue)
578 errors.add :parent_issue_id, :invalid
579 elsif (@parent_issue != parent) && (all_dependent_issues.include?(@parent_issue) || @parent_issue.all_dependent_issues.include?(self))
565 errors.add :parent_issue_id, :invalid 580 errors.add :parent_issue_id, :invalid
566 elsif !new_record? 581 elsif !new_record?
567 # moving an existing issue 582 # moving an existing issue
568 if @parent_issue.root_id != root_id 583 if @parent_issue.root_id != root_id
569 # we can always move to another tree 584 # we can always move to another tree
631 scope = scope.where("#{Journal.table_name}.id > ?", journal_id.to_i) 646 scope = scope.where("#{Journal.table_name}.id > ?", journal_id.to_i)
632 end 647 end
633 scope 648 scope
634 end 649 end
635 650
651 # Returns the initial status of the issue
652 # Returns nil for a new issue
653 def status_was
654 if status_id_was && status_id_was.to_i > 0
655 @status_was ||= IssueStatus.find_by_id(status_id_was)
656 end
657 end
658
636 # Return true if the issue is closed, otherwise false 659 # Return true if the issue is closed, otherwise false
637 def closed? 660 def closed?
638 self.status.is_closed? 661 self.status.is_closed?
639 end 662 end
640 663
651 end 674 end
652 675
653 # Return true if the issue is being closed 676 # Return true if the issue is being closed
654 def closing? 677 def closing?
655 if !new_record? && status_id_changed? 678 if !new_record? && status_id_changed?
656 status_was = IssueStatus.find_by_id(status_id_was) 679 if status_was && status && !status_was.is_closed? && status.is_closed?
657 status_new = IssueStatus.find_by_id(status_id)
658 if status_was && status_new && !status_was.is_closed? && status_new.is_closed?
659 return true 680 return true
660 end 681 end
661 end 682 end
662 false 683 false
663 end 684 end
783 @total_spent_hours ||= self_and_descendants.sum("#{TimeEntry.table_name}.hours", 804 @total_spent_hours ||= self_and_descendants.sum("#{TimeEntry.table_name}.hours",
784 :joins => "LEFT JOIN #{TimeEntry.table_name} ON #{TimeEntry.table_name}.issue_id = #{Issue.table_name}.id").to_f || 0.0 805 :joins => "LEFT JOIN #{TimeEntry.table_name} ON #{TimeEntry.table_name}.issue_id = #{Issue.table_name}.id").to_f || 0.0
785 end 806 end
786 807
787 def relations 808 def relations
788 @relations ||= IssueRelations.new(self, (relations_from + relations_to).sort) 809 @relations ||= IssueRelation::Relations.new(self, (relations_from + relations_to).sort)
789 end 810 end
790 811
791 # Preloads relations for a collection of issues 812 # Preloads relations for a collection of issues
792 def self.load_relations(issues) 813 def self.load_relations(issues)
793 if issues.any? 814 if issues.any?
820 issues.each do |issue| 841 issues.each do |issue|
821 relations = 842 relations =
822 relations_from.select {|relation| relation.issue_from_id == issue.id} + 843 relations_from.select {|relation| relation.issue_from_id == issue.id} +
823 relations_to.select {|relation| relation.issue_to_id == issue.id} 844 relations_to.select {|relation| relation.issue_to_id == issue.id}
824 845
825 issue.instance_variable_set "@relations", IssueRelations.new(issue, relations.sort) 846 issue.instance_variable_set "@relations", IssueRelation::Relations.new(issue, relations.sort)
826 end 847 end
827 end 848 end
828 end 849 end
829 850
830 # Finds an issue relation given its id. 851 # Finds an issue relation given its id.
831 def find_relation(relation_id) 852 def find_relation(relation_id)
832 IssueRelation.find(relation_id, :conditions => ["issue_to_id = ? OR issue_from_id = ?", id, id]) 853 IssueRelation.find(relation_id, :conditions => ["issue_to_id = ? OR issue_from_id = ?", id, id])
833 end 854 end
834 855
856 # Returns all the other issues that depend on the issue
835 def all_dependent_issues(except=[]) 857 def all_dependent_issues(except=[])
836 except << self 858 except << self
837 dependencies = [] 859 dependencies = []
838 relations_from.each do |relation| 860 dependencies += relations_from.map(&:issue_to)
839 if relation.issue_to && !except.include?(relation.issue_to) 861 dependencies += children unless leaf?
840 dependencies << relation.issue_to 862 dependencies.compact!
841 dependencies += relation.issue_to.all_dependent_issues(except) 863 dependencies -= except
842 end 864 dependencies += dependencies.map {|issue| issue.all_dependent_issues(except)}.flatten
865 if parent
866 dependencies << parent
867 dependencies += parent.all_dependent_issues(except + parent.descendants)
843 end 868 end
844 dependencies 869 dependencies
845 end 870 end
846 871
847 # Returns an array of issues that duplicate this one 872 # Returns an array of issues that duplicate this one
871 896
872 def soonest_start(reload=false) 897 def soonest_start(reload=false)
873 @soonest_start = nil if reload 898 @soonest_start = nil if reload
874 @soonest_start ||= ( 899 @soonest_start ||= (
875 relations_to(reload).collect{|relation| relation.successor_soonest_start} + 900 relations_to(reload).collect{|relation| relation.successor_soonest_start} +
876 ancestors.collect(&:soonest_start) 901 [(@parent_issue || parent).try(:soonest_start)]
877 ).compact.max 902 ).compact.max
878 end 903 end
879 904
880 # Sets start_date on the given date or the next working day 905 # Sets start_date on the given date or the next working day
881 # and changes due_date to keep the same working duration. 906 # and changes due_date to keep the same working duration.
934 "#{tracker} ##{id}: #{subject}" 959 "#{tracker} ##{id}: #{subject}"
935 end 960 end
936 961
937 # Returns a string of css classes that apply to the issue 962 # Returns a string of css classes that apply to the issue
938 def css_classes 963 def css_classes
939 s = "issue status-#{status_id} #{priority.try(:css_classes)}" 964 s = "issue tracker-#{tracker_id} status-#{status_id} #{priority.try(:css_classes)}"
940 s << ' closed' if closed? 965 s << ' closed' if closed?
941 s << ' overdue' if overdue? 966 s << ' overdue' if overdue?
942 s << ' child' if child? 967 s << ' child' if child?
943 s << ' parent' unless leaf? 968 s << ' parent' unless leaf?
944 s << ' private' if is_private? 969 s << ' private' if is_private?
1003 else 1028 else
1004 parent_id 1029 parent_id
1005 end 1030 end
1006 end 1031 end
1007 1032
1008 # Returns true if issue's project is a valid 1033 # Returns true if issue's project is a valid
1009 # parent issue project 1034 # parent issue project
1010 def valid_parent_project?(issue=parent) 1035 def valid_parent_project?(issue=parent)
1011 return true if issue.nil? || issue.project_id == project_id 1036 return true if issue.nil? || issue.project_id == project_id
1012 1037
1013 case Setting.cross_project_subtasks 1038 case Setting.cross_project_subtasks
1014 when 'system' 1039 when 'system'
1126 logger.error "Could not create relation while copying ##{@copied_from.id} to ##{id} due to validation errors: #{relation.errors.full_messages.join(', ')}" if logger 1151 logger.error "Could not create relation while copying ##{@copied_from.id} to ##{id} due to validation errors: #{relation.errors.full_messages.join(', ')}" if logger
1127 end 1152 end
1128 end 1153 end
1129 1154
1130 unless @copied_from.leaf? || @copy_options[:subtasks] == false 1155 unless @copied_from.leaf? || @copy_options[:subtasks] == false
1131 @copied_from.children.each do |child| 1156 copy_options = (@copy_options || {}).merge(:subtasks => false)
1157 copied_issue_ids = {@copied_from.id => self.id}
1158 @copied_from.reload.descendants.reorder("#{Issue.table_name}.lft").each do |child|
1159 # Do not copy self when copying an issue as a descendant of the copied issue
1160 next if child == self
1161 # Do not copy subtasks of issues that were not copied
1162 next unless copied_issue_ids[child.parent_id]
1163 # Do not copy subtasks that are not visible to avoid potential disclosure of private data
1132 unless child.visible? 1164 unless child.visible?
1133 # Do not copy subtasks that are not visible to avoid potential disclosure of private data
1134 logger.error "Subtask ##{child.id} was not copied during ##{@copied_from.id} copy because it is not visible to the current user" if logger 1165 logger.error "Subtask ##{child.id} was not copied during ##{@copied_from.id} copy because it is not visible to the current user" if logger
1135 next 1166 next
1136 end 1167 end
1137 copy = Issue.new.copy_from(child, @copy_options) 1168 copy = Issue.new.copy_from(child, copy_options)
1138 copy.author = author 1169 copy.author = author
1139 copy.project = project 1170 copy.project = project
1140 copy.parent_issue_id = id 1171 copy.parent_issue_id = copied_issue_ids[child.parent_id]
1141 # Children subtasks are copied recursively
1142 unless copy.save 1172 unless copy.save
1143 logger.error "Could not copy subtask ##{child.id} while copying ##{@copied_from.id} to ##{id} due to validation errors: #{copy.errors.full_messages.join(', ')}" if logger 1173 logger.error "Could not copy subtask ##{child.id} while copying ##{@copied_from.id} to ##{id} due to validation errors: #{copy.errors.full_messages.join(', ')}" if logger
1144 end 1174 next
1175 end
1176 copied_issue_ids[child.id] = copy.id
1145 end 1177 end
1146 end 1178 end
1147 @after_create_from_copy_handled = true 1179 @after_create_from_copy_handled = true
1148 end 1180 end
1149 1181
1301 duplicate.update_attribute :status, self.status 1333 duplicate.update_attribute :status, self.status
1302 end 1334 end
1303 end 1335 end
1304 end 1336 end
1305 1337
1306 # Make sure updated_on is updated when adding a note 1338 # Make sure updated_on is updated when adding a note and set updated_on now
1339 # so we can set closed_on with the same value on closing
1307 def force_updated_on_change 1340 def force_updated_on_change
1308 if @current_journal 1341 if @current_journal || changed?
1309 self.updated_on = current_time_from_proper_timezone 1342 self.updated_on = current_time_from_proper_timezone
1343 if new_record?
1344 self.created_on = updated_on
1345 end
1346 end
1347 end
1348
1349 # Callback for setting closed_on when the issue is closed.
1350 # The closed_on attribute stores the time of the last closing
1351 # and is preserved when the issue is reopened.
1352 def update_closed_on
1353 if closing? || (new_record? && closed?)
1354 self.closed_on = updated_on
1310 end 1355 end
1311 end 1356 end
1312 1357
1313 # Saves the changes in a Journal 1358 # Saves the changes in a Journal
1314 # Called after_save 1359 # Called after_save
1315 def create_journal 1360 def create_journal
1316 if @current_journal 1361 if @current_journal
1317 # attributes changes 1362 # attributes changes
1318 if @attributes_before_change 1363 if @attributes_before_change
1319 (Issue.column_names - %w(id root_id lft rgt lock_version created_on updated_on)).each {|c| 1364 (Issue.column_names - %w(id root_id lft rgt lock_version created_on updated_on closed_on)).each {|c|
1320 before = @attributes_before_change[c] 1365 before = @attributes_before_change[c]
1321 after = send(c) 1366 after = send(c)
1322 next if before == after || (before.blank? && after.blank?) 1367 next if before == after || (before.blank? && after.blank?)
1323 @current_journal.details << JournalDetail.new(:property => 'attr', 1368 @current_journal.details << JournalDetail.new(:property => 'attr',
1324 :prop_key => c, 1369 :prop_key => c,