Mercurial > hg > soundsoftware-site
comparison app/models/issue.rb @ 1298:4f746d8966dd redmine_2.3_integration
Merge from redmine-2.3 branch to create new branch redmine-2.3-integration
author | Chris Cannam |
---|---|
date | Fri, 14 Jun 2013 09:28:30 +0100 |
parents | 0a574315af3e 622f24f53b42 |
children |
comparison
equal
deleted
inserted
replaced
1297:0a574315af3e | 1298:4f746d8966dd |
---|---|
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, |