##// END OF EJS Templates
Make the 'duplicates of' relation asymmetric:...
Jean-Philippe Lang -
r1474:7042879811ab
parent child
Show More
@@ -1,256 +1,256
1 1 # redMine - project management software
2 2 # Copyright (C) 2006-2007 Jean-Philippe Lang
3 3 #
4 4 # This program is free software; you can redistribute it and/or
5 5 # modify it under the terms of the GNU General Public License
6 6 # as published by the Free Software Foundation; either version 2
7 7 # of the License, or (at your option) any later version.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU General Public License
15 15 # along with this program; if not, write to the Free Software
16 16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17 17
18 18 class Issue < ActiveRecord::Base
19 19 belongs_to :project
20 20 belongs_to :tracker
21 21 belongs_to :status, :class_name => 'IssueStatus', :foreign_key => 'status_id'
22 22 belongs_to :author, :class_name => 'User', :foreign_key => 'author_id'
23 23 belongs_to :assigned_to, :class_name => 'User', :foreign_key => 'assigned_to_id'
24 24 belongs_to :fixed_version, :class_name => 'Version', :foreign_key => 'fixed_version_id'
25 25 belongs_to :priority, :class_name => 'Enumeration', :foreign_key => 'priority_id'
26 26 belongs_to :category, :class_name => 'IssueCategory', :foreign_key => 'category_id'
27 27
28 28 has_many :journals, :as => :journalized, :dependent => :destroy
29 29 has_many :attachments, :as => :container, :dependent => :destroy
30 30 has_many :time_entries, :dependent => :delete_all
31 31 has_many :custom_values, :dependent => :delete_all, :as => :customized
32 32 has_many :custom_fields, :through => :custom_values
33 33 has_and_belongs_to_many :changesets, :order => "revision ASC"
34 34
35 35 has_many :relations_from, :class_name => 'IssueRelation', :foreign_key => 'issue_from_id', :dependent => :delete_all
36 36 has_many :relations_to, :class_name => 'IssueRelation', :foreign_key => 'issue_to_id', :dependent => :delete_all
37 37
38 38 acts_as_watchable
39 39 acts_as_searchable :columns => ['subject', "#{table_name}.description"], :include => :project, :with => {:journal => :issue}
40 40 acts_as_event :title => Proc.new {|o| "#{o.tracker.name} ##{o.id}: #{o.subject}"},
41 41 :url => Proc.new {|o| {:controller => 'issues', :action => 'show', :id => o.id}}
42 42
43 43 validates_presence_of :subject, :description, :priority, :project, :tracker, :author, :status
44 44 validates_length_of :subject, :maximum => 255
45 45 validates_inclusion_of :done_ratio, :in => 0..100
46 46 validates_numericality_of :estimated_hours, :allow_nil => true
47 47 validates_associated :custom_values, :on => :update
48 48
49 49 def after_initialize
50 50 if new_record?
51 51 # set default values for new records only
52 52 self.status ||= IssueStatus.default
53 53 self.priority ||= Enumeration.default('IPRI')
54 54 end
55 55 end
56 56
57 57 def copy_from(arg)
58 58 issue = arg.is_a?(Issue) ? arg : Issue.find(arg)
59 59 self.attributes = issue.attributes.dup
60 60 self.custom_values = issue.custom_values.collect {|v| v.clone}
61 61 self
62 62 end
63 63
64 64 # Move an issue to a new project and tracker
65 65 def move_to(new_project, new_tracker = nil)
66 66 transaction do
67 67 if new_project && project_id != new_project.id
68 68 # delete issue relations
69 69 unless Setting.cross_project_issue_relations?
70 70 self.relations_from.clear
71 71 self.relations_to.clear
72 72 end
73 73 # issue is moved to another project
74 74 self.category = nil
75 75 self.fixed_version = nil
76 76 self.project = new_project
77 77 end
78 78 if new_tracker
79 79 self.tracker = new_tracker
80 80 end
81 81 if save
82 82 # Manually update project_id on related time entries
83 83 TimeEntry.update_all("project_id = #{new_project.id}", {:issue_id => id})
84 84 else
85 85 rollback_db_transaction
86 86 return false
87 87 end
88 88 end
89 89 return true
90 90 end
91 91
92 92 def priority_id=(pid)
93 93 self.priority = nil
94 94 write_attribute(:priority_id, pid)
95 95 end
96 96
97 97 def estimated_hours=(h)
98 98 write_attribute :estimated_hours, (h.is_a?(String) ? h.to_hours : h)
99 99 end
100 100
101 101 def validate
102 102 if self.due_date.nil? && @attributes['due_date'] && !@attributes['due_date'].empty?
103 103 errors.add :due_date, :activerecord_error_not_a_date
104 104 end
105 105
106 106 if self.due_date and self.start_date and self.due_date < self.start_date
107 107 errors.add :due_date, :activerecord_error_greater_than_start_date
108 108 end
109 109
110 110 if start_date && soonest_start && start_date < soonest_start
111 111 errors.add :start_date, :activerecord_error_invalid
112 112 end
113 113 end
114 114
115 115 def validate_on_create
116 116 errors.add :tracker_id, :activerecord_error_invalid unless project.trackers.include?(tracker)
117 117 end
118 118
119 119 def before_create
120 120 # default assignment based on category
121 121 if assigned_to.nil? && category && category.assigned_to
122 122 self.assigned_to = category.assigned_to
123 123 end
124 124 end
125 125
126 126 def before_save
127 127 if @current_journal
128 128 # attributes changes
129 129 (Issue.column_names - %w(id description)).each {|c|
130 130 @current_journal.details << JournalDetail.new(:property => 'attr',
131 131 :prop_key => c,
132 132 :old_value => @issue_before_change.send(c),
133 133 :value => send(c)) unless send(c)==@issue_before_change.send(c)
134 134 }
135 135 # custom fields changes
136 136 custom_values.each {|c|
137 137 next if (@custom_values_before_change[c.custom_field_id]==c.value ||
138 138 (@custom_values_before_change[c.custom_field_id].blank? && c.value.blank?))
139 139 @current_journal.details << JournalDetail.new(:property => 'cf',
140 140 :prop_key => c.custom_field_id,
141 141 :old_value => @custom_values_before_change[c.custom_field_id],
142 142 :value => c.value)
143 143 }
144 144 @current_journal.save
145 145 end
146 146 # Save the issue even if the journal is not saved (because empty)
147 147 true
148 148 end
149 149
150 150 def after_save
151 151 # Reload is needed in order to get the right status
152 152 reload
153 153
154 154 # Update start/due dates of following issues
155 155 relations_from.each(&:set_issue_to_dates)
156 156
157 157 # Close duplicates if the issue was closed
158 158 if @issue_before_change && !@issue_before_change.closed? && self.closed?
159 159 duplicates.each do |duplicate|
160 160 # Reload is need in case the duplicate was updated by a previous duplicate
161 161 duplicate.reload
162 162 # Don't re-close it if it's already closed
163 163 next if duplicate.closed?
164 164 # Same user and notes
165 165 duplicate.init_journal(@current_journal.user, @current_journal.notes)
166 166 duplicate.update_attribute :status, self.status
167 167 end
168 168 end
169 169 end
170 170
171 171 def custom_value_for(custom_field)
172 172 self.custom_values.each {|v| return v if v.custom_field_id == custom_field.id }
173 173 return nil
174 174 end
175 175
176 176 def init_journal(user, notes = "")
177 177 @current_journal ||= Journal.new(:journalized => self, :user => user, :notes => notes)
178 178 @issue_before_change = self.clone
179 179 @issue_before_change.status = self.status
180 180 @custom_values_before_change = {}
181 181 self.custom_values.each {|c| @custom_values_before_change.store c.custom_field_id, c.value }
182 182 @current_journal
183 183 end
184 184
185 185 # Return true if the issue is closed, otherwise false
186 186 def closed?
187 187 self.status.is_closed?
188 188 end
189 189
190 190 # Users the issue can be assigned to
191 191 def assignable_users
192 192 project.assignable_users
193 193 end
194 194
195 195 # Returns an array of status that user is able to apply
196 196 def new_statuses_allowed_to(user)
197 197 statuses = status.find_new_statuses_allowed_to(user.role_for_project(project), tracker)
198 198 statuses << status unless statuses.empty?
199 199 statuses.uniq.sort
200 200 end
201 201
202 202 # Returns the mail adresses of users that should be notified for the issue
203 203 def recipients
204 204 recipients = project.recipients
205 205 # Author and assignee are always notified unless they have been locked
206 206 recipients << author.mail if author && author.active?
207 207 recipients << assigned_to.mail if assigned_to && assigned_to.active?
208 208 recipients.compact.uniq
209 209 end
210 210
211 211 def spent_hours
212 212 @spent_hours ||= time_entries.sum(:hours) || 0
213 213 end
214 214
215 215 def relations
216 216 (relations_from + relations_to).sort
217 217 end
218 218
219 219 def all_dependent_issues
220 220 dependencies = []
221 221 relations_from.each do |relation|
222 222 dependencies << relation.issue_to
223 223 dependencies += relation.issue_to.all_dependent_issues
224 224 end
225 225 dependencies
226 226 end
227 227
228 # Returns an array of the duplicate issues
228 # Returns an array of issues that duplicate this one
229 229 def duplicates
230 relations.select {|r| r.relation_type == IssueRelation::TYPE_DUPLICATES}.collect {|r| r.other_issue(self)}
230 relations_to.select {|r| r.relation_type == IssueRelation::TYPE_DUPLICATES}.collect {|r| r.issue_from}
231 231 end
232 232
233 233 # Returns the due date or the target due date if any
234 234 # Used on gantt chart
235 235 def due_before
236 236 due_date || (fixed_version ? fixed_version.effective_date : nil)
237 237 end
238 238
239 239 def duration
240 240 (start_date && due_date) ? due_date - start_date : 0
241 241 end
242 242
243 243 def soonest_start
244 244 @soonest_start ||= relations_to.collect{|relation| relation.successor_soonest_start}.compact.min
245 245 end
246 246
247 247 def self.visible_by(usr)
248 248 with_scope(:find => { :conditions => Project.visible_by(usr) }) do
249 249 yield
250 250 end
251 251 end
252 252
253 253 def to_s
254 254 "#{tracker} ##{id}: #{subject}"
255 255 end
256 256 end
@@ -1,79 +1,79
1 1 # redMine - project management software
2 2 # Copyright (C) 2006-2007 Jean-Philippe Lang
3 3 #
4 4 # This program is free software; you can redistribute it and/or
5 5 # modify it under the terms of the GNU General Public License
6 6 # as published by the Free Software Foundation; either version 2
7 7 # of the License, or (at your option) any later version.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU General Public License
15 15 # along with this program; if not, write to the Free Software
16 16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17 17
18 18 class IssueRelation < ActiveRecord::Base
19 19 belongs_to :issue_from, :class_name => 'Issue', :foreign_key => 'issue_from_id'
20 20 belongs_to :issue_to, :class_name => 'Issue', :foreign_key => 'issue_to_id'
21 21
22 22 TYPE_RELATES = "relates"
23 23 TYPE_DUPLICATES = "duplicates"
24 24 TYPE_BLOCKS = "blocks"
25 25 TYPE_PRECEDES = "precedes"
26 26
27 27 TYPES = { TYPE_RELATES => { :name => :label_relates_to, :sym_name => :label_relates_to, :order => 1 },
28 TYPE_DUPLICATES => { :name => :label_duplicates, :sym_name => :label_duplicates, :order => 2 },
28 TYPE_DUPLICATES => { :name => :label_duplicates, :sym_name => :label_duplicated_by, :order => 2 },
29 29 TYPE_BLOCKS => { :name => :label_blocks, :sym_name => :label_blocked_by, :order => 3 },
30 30 TYPE_PRECEDES => { :name => :label_precedes, :sym_name => :label_follows, :order => 4 },
31 31 }.freeze
32 32
33 33 validates_presence_of :issue_from, :issue_to, :relation_type
34 34 validates_inclusion_of :relation_type, :in => TYPES.keys
35 35 validates_numericality_of :delay, :allow_nil => true
36 36 validates_uniqueness_of :issue_to_id, :scope => :issue_from_id
37 37
38 38 def validate
39 39 if issue_from && issue_to
40 40 errors.add :issue_to_id, :activerecord_error_invalid if issue_from_id == issue_to_id
41 41 errors.add :issue_to_id, :activerecord_error_not_same_project unless issue_from.project_id == issue_to.project_id || Setting.cross_project_issue_relations?
42 42 errors.add_to_base :activerecord_error_circular_dependency if issue_to.all_dependent_issues.include? issue_from
43 43 end
44 44 end
45 45
46 46 def other_issue(issue)
47 47 (self.issue_from_id == issue.id) ? issue_to : issue_from
48 48 end
49 49
50 50 def label_for(issue)
51 51 TYPES[relation_type] ? TYPES[relation_type][(self.issue_from_id == issue.id) ? :name : :sym_name] : :unknow
52 52 end
53 53
54 54 def before_save
55 55 if TYPE_PRECEDES == relation_type
56 56 self.delay ||= 0
57 57 else
58 58 self.delay = nil
59 59 end
60 60 set_issue_to_dates
61 61 end
62 62
63 63 def set_issue_to_dates
64 64 soonest_start = self.successor_soonest_start
65 65 if soonest_start && (!issue_to.start_date || issue_to.start_date < soonest_start)
66 66 issue_to.start_date, issue_to.due_date = successor_soonest_start, successor_soonest_start + issue_to.duration
67 67 issue_to.save
68 68 end
69 69 end
70 70
71 71 def successor_soonest_start
72 72 return nil unless (TYPE_PRECEDES == self.relation_type) && (issue_from.start_date || issue_from.due_date)
73 73 (issue_from.due_date || issue_from.start_date) + 1 + delay
74 74 end
75 75
76 76 def <=>(relation)
77 77 TYPES[self.relation_type][:order] <=> TYPES[relation.relation_type][:order]
78 78 end
79 79 end
@@ -1,88 +1,108
1 1 # redMine - project management software
2 2 # Copyright (C) 2006-2007 Jean-Philippe Lang
3 3 #
4 4 # This program is free software; you can redistribute it and/or
5 5 # modify it under the terms of the GNU General Public License
6 6 # as published by the Free Software Foundation; either version 2
7 7 # of the License, or (at your option) any later version.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU General Public License
15 15 # along with this program; if not, write to the Free Software
16 16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17 17
18 18 require File.dirname(__FILE__) + '/../test_helper'
19 19
20 20 class IssueTest < Test::Unit::TestCase
21 21 fixtures :projects, :users, :members, :trackers, :projects_trackers, :issue_statuses, :issue_categories, :enumerations, :issues, :custom_fields, :custom_values, :time_entries
22 22
23 23 def test_create
24 24 issue = Issue.new(:project_id => 1, :tracker_id => 1, :author_id => 3, :status_id => 1, :priority => Enumeration.get_values('IPRI').first, :subject => 'test_create', :description => 'IssueTest#test_create', :estimated_hours => '1:30')
25 25 assert issue.save
26 26 issue.reload
27 27 assert_equal 1.5, issue.estimated_hours
28 28 end
29 29
30 30 def test_category_based_assignment
31 31 issue = Issue.create(:project_id => 1, :tracker_id => 1, :author_id => 3, :status_id => 1, :priority => Enumeration.get_values('IPRI').first, :subject => 'Assignment test', :description => 'Assignment test', :category_id => 1)
32 32 assert_equal IssueCategory.find(1).assigned_to, issue.assigned_to
33 33 end
34 34
35 35 def test_copy
36 36 issue = Issue.new.copy_from(1)
37 37 assert issue.save
38 38 issue.reload
39 39 orig = Issue.find(1)
40 40 assert_equal orig.subject, issue.subject
41 41 assert_equal orig.tracker, issue.tracker
42 42 assert_equal orig.custom_values.first.value, issue.custom_values.first.value
43 43 end
44 44
45 def test_close_duplicates
45 def test_should_close_duplicates
46 46 # Create 3 issues
47 47 issue1 = Issue.new(:project_id => 1, :tracker_id => 1, :author_id => 1, :status_id => 1, :priority => Enumeration.get_values('IPRI').first, :subject => 'Duplicates test', :description => 'Duplicates test')
48 48 assert issue1.save
49 49 issue2 = issue1.clone
50 50 assert issue2.save
51 51 issue3 = issue1.clone
52 52 assert issue3.save
53 53
54 54 # 2 is a dupe of 1
55 IssueRelation.create(:issue_from => issue1, :issue_to => issue2, :relation_type => IssueRelation::TYPE_DUPLICATES)
55 IssueRelation.create(:issue_from => issue2, :issue_to => issue1, :relation_type => IssueRelation::TYPE_DUPLICATES)
56 56 # And 3 is a dupe of 2
57 IssueRelation.create(:issue_from => issue2, :issue_to => issue3, :relation_type => IssueRelation::TYPE_DUPLICATES)
57 IssueRelation.create(:issue_from => issue3, :issue_to => issue2, :relation_type => IssueRelation::TYPE_DUPLICATES)
58 58 # And 3 is a dupe of 1 (circular duplicates)
59 IssueRelation.create(:issue_from => issue1, :issue_to => issue3, :relation_type => IssueRelation::TYPE_DUPLICATES)
60
59 IssueRelation.create(:issue_from => issue3, :issue_to => issue1, :relation_type => IssueRelation::TYPE_DUPLICATES)
60
61 61 assert issue1.reload.duplicates.include?(issue2)
62 62
63 63 # Closing issue 1
64 64 issue1.init_journal(User.find(:first), "Closing issue1")
65 65 issue1.status = IssueStatus.find :first, :conditions => {:is_closed => true}
66 66 assert issue1.save
67 67 # 2 and 3 should be also closed
68 68 assert issue2.reload.closed?
69 69 assert issue3.reload.closed?
70 70 end
71 71
72 def test_should_not_close_duplicated_issue
73 # Create 3 issues
74 issue1 = Issue.new(:project_id => 1, :tracker_id => 1, :author_id => 1, :status_id => 1, :priority => Enumeration.get_values('IPRI').first, :subject => 'Duplicates test', :description => 'Duplicates test')
75 assert issue1.save
76 issue2 = issue1.clone
77 assert issue2.save
78
79 # 2 is a dupe of 1
80 IssueRelation.create(:issue_from => issue2, :issue_to => issue1, :relation_type => IssueRelation::TYPE_DUPLICATES)
81 # 2 is a dup of 1 but 1 is not a duplicate of 2
82 assert !issue2.reload.duplicates.include?(issue1)
83
84 # Closing issue 2
85 issue2.init_journal(User.find(:first), "Closing issue2")
86 issue2.status = IssueStatus.find :first, :conditions => {:is_closed => true}
87 assert issue2.save
88 # 1 should not be also closed
89 assert !issue1.reload.closed?
90 end
91
72 92 def test_move_to_another_project
73 93 issue = Issue.find(1)
74 94 assert issue.move_to(Project.find(2))
75 95 issue.reload
76 96 assert_equal 2, issue.project_id
77 97 # Category removed
78 98 assert_nil issue.category
79 99 # Make sure time entries were move to the target project
80 100 assert_equal 2, issue.time_entries.first.project_id
81 101 end
82 102
83 103 def test_issue_destroy
84 104 Issue.find(1).destroy
85 105 assert_nil Issue.find_by_id(1)
86 106 assert_nil TimeEntry.find_by_issue_id(1)
87 107 end
88 108 end
General Comments 0
You need to be logged in to leave comments. Login now