##// END OF EJS Templates
Do not notify users that are no longer allowed to view an issue (#3589, #4263)....
Jean-Philippe Lang -
r3007:c870a7b9ef35
parent child
Show More
@@ -1,356 +1,366
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 => 'IssuePriority', :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 :time_entries, :dependent => :delete_all
30 30 has_and_belongs_to_many :changesets, :order => "#{Changeset.table_name}.committed_on ASC, #{Changeset.table_name}.id ASC"
31 31
32 32 has_many :relations_from, :class_name => 'IssueRelation', :foreign_key => 'issue_from_id', :dependent => :delete_all
33 33 has_many :relations_to, :class_name => 'IssueRelation', :foreign_key => 'issue_to_id', :dependent => :delete_all
34 34
35 35 acts_as_attachable :after_remove => :attachment_removed
36 36 acts_as_customizable
37 37 acts_as_watchable
38 38 acts_as_searchable :columns => ['subject', "#{table_name}.description", "#{Journal.table_name}.notes"],
39 39 :include => [:project, :journals],
40 40 # sort by id so that limited eager loading doesn't break with postgresql
41 41 :order_column => "#{table_name}.id"
42 42 acts_as_event :title => Proc.new {|o| "#{o.tracker.name} ##{o.id} (#{o.status}): #{o.subject}"},
43 43 :url => Proc.new {|o| {:controller => 'issues', :action => 'show', :id => o.id}},
44 44 :type => Proc.new {|o| 'issue' + (o.closed? ? ' closed' : '') }
45 45
46 46 acts_as_activity_provider :find_options => {:include => [:project, :author, :tracker]},
47 47 :author_key => :author_id
48 48
49 49 validates_presence_of :subject, :priority, :project, :tracker, :author, :status
50 50 validates_length_of :subject, :maximum => 255
51 51 validates_inclusion_of :done_ratio, :in => 0..100
52 52 validates_numericality_of :estimated_hours, :allow_nil => true
53 53
54 54 named_scope :visible, lambda {|*args| { :include => :project,
55 55 :conditions => Project.allowed_to_condition(args.first || User.current, :view_issues) } }
56 56
57 57 named_scope :open, :conditions => ["#{IssueStatus.table_name}.is_closed = ?", false], :include => :status
58 58
59 59 after_save :create_journal
60 60
61 61 # Returns true if usr or current user is allowed to view the issue
62 62 def visible?(usr=nil)
63 63 (usr || User.current).allowed_to?(:view_issues, self.project)
64 64 end
65 65
66 66 def after_initialize
67 67 if new_record?
68 68 # set default values for new records only
69 69 self.status ||= IssueStatus.default
70 70 self.priority ||= IssuePriority.default
71 71 end
72 72 end
73 73
74 74 # Overrides Redmine::Acts::Customizable::InstanceMethods#available_custom_fields
75 75 def available_custom_fields
76 76 (project && tracker) ? project.all_issue_custom_fields.select {|c| tracker.custom_fields.include? c } : []
77 77 end
78 78
79 79 def copy_from(arg)
80 80 issue = arg.is_a?(Issue) ? arg : Issue.find(arg)
81 81 self.attributes = issue.attributes.dup.except("id", "created_on", "updated_on")
82 82 self.custom_values = issue.custom_values.collect {|v| v.clone}
83 83 self.status = issue.status
84 84 self
85 85 end
86 86
87 87 # Moves/copies an issue to a new project and tracker
88 88 # Returns the moved/copied issue on success, false on failure
89 89 def move_to(new_project, new_tracker = nil, options = {})
90 90 options ||= {}
91 91 issue = options[:copy] ? self.clone : self
92 92 transaction do
93 93 if new_project && issue.project_id != new_project.id
94 94 # delete issue relations
95 95 unless Setting.cross_project_issue_relations?
96 96 issue.relations_from.clear
97 97 issue.relations_to.clear
98 98 end
99 99 # issue is moved to another project
100 100 # reassign to the category with same name if any
101 101 new_category = issue.category.nil? ? nil : new_project.issue_categories.find_by_name(issue.category.name)
102 102 issue.category = new_category
103 103 issue.fixed_version = nil
104 104 issue.project = new_project
105 105 end
106 106 if new_tracker
107 107 issue.tracker = new_tracker
108 108 end
109 109 if options[:copy]
110 110 issue.custom_field_values = self.custom_field_values.inject({}) {|h,v| h[v.custom_field_id] = v.value; h}
111 111 issue.status = self.status
112 112 end
113 113 if issue.save
114 114 unless options[:copy]
115 115 # Manually update project_id on related time entries
116 116 TimeEntry.update_all("project_id = #{new_project.id}", {:issue_id => id})
117 117 end
118 118 else
119 119 Issue.connection.rollback_db_transaction
120 120 return false
121 121 end
122 122 end
123 123 return issue
124 124 end
125 125
126 126 def priority_id=(pid)
127 127 self.priority = nil
128 128 write_attribute(:priority_id, pid)
129 129 end
130 130
131 131 def tracker_id=(tid)
132 132 self.tracker = nil
133 133 write_attribute(:tracker_id, tid)
134 134 end
135 135
136 136 def estimated_hours=(h)
137 137 write_attribute :estimated_hours, (h.is_a?(String) ? h.to_hours : h)
138 138 end
139 139
140 140 def validate
141 141 if self.due_date.nil? && @attributes['due_date'] && !@attributes['due_date'].empty?
142 142 errors.add :due_date, :not_a_date
143 143 end
144 144
145 145 if self.due_date and self.start_date and self.due_date < self.start_date
146 146 errors.add :due_date, :greater_than_start_date
147 147 end
148 148
149 149 if start_date && soonest_start && start_date < soonest_start
150 150 errors.add :start_date, :invalid
151 151 end
152 152
153 153 if fixed_version
154 154 if !assignable_versions.include?(fixed_version)
155 155 errors.add :fixed_version_id, :inclusion
156 156 elsif reopened? && fixed_version.closed?
157 157 errors.add_to_base I18n.t(:error_can_not_reopen_issue_on_closed_version)
158 158 end
159 159 end
160 160
161 161 # Checks that the issue can not be added/moved to a disabled tracker
162 162 if project && (tracker_id_changed? || project_id_changed?)
163 163 unless project.trackers.include?(tracker)
164 164 errors.add :tracker_id, :inclusion
165 165 end
166 166 end
167 167 end
168 168
169 169 def before_create
170 170 # default assignment based on category
171 171 if assigned_to.nil? && category && category.assigned_to
172 172 self.assigned_to = category.assigned_to
173 173 end
174 174 end
175 175
176 176 def after_save
177 177 # Reload is needed in order to get the right status
178 178 reload
179 179
180 180 # Update start/due dates of following issues
181 181 relations_from.each(&:set_issue_to_dates)
182 182
183 183 # Close duplicates if the issue was closed
184 184 if @issue_before_change && !@issue_before_change.closed? && self.closed?
185 185 duplicates.each do |duplicate|
186 186 # Reload is need in case the duplicate was updated by a previous duplicate
187 187 duplicate.reload
188 188 # Don't re-close it if it's already closed
189 189 next if duplicate.closed?
190 190 # Same user and notes
191 191 duplicate.init_journal(@current_journal.user, @current_journal.notes)
192 192 duplicate.update_attribute :status, self.status
193 193 end
194 194 end
195 195 end
196 196
197 197 def init_journal(user, notes = "")
198 198 @current_journal ||= Journal.new(:journalized => self, :user => user, :notes => notes)
199 199 @issue_before_change = self.clone
200 200 @issue_before_change.status = self.status
201 201 @custom_values_before_change = {}
202 202 self.custom_values.each {|c| @custom_values_before_change.store c.custom_field_id, c.value }
203 203 # Make sure updated_on is updated when adding a note.
204 204 updated_on_will_change!
205 205 @current_journal
206 206 end
207 207
208 208 # Return true if the issue is closed, otherwise false
209 209 def closed?
210 210 self.status.is_closed?
211 211 end
212 212
213 213 # Return true if the issue is being reopened
214 214 def reopened?
215 215 if !new_record? && status_id_changed?
216 216 status_was = IssueStatus.find_by_id(status_id_was)
217 217 status_new = IssueStatus.find_by_id(status_id)
218 218 if status_was && status_new && status_was.is_closed? && !status_new.is_closed?
219 219 return true
220 220 end
221 221 end
222 222 false
223 223 end
224 224
225 225 # Returns true if the issue is overdue
226 226 def overdue?
227 227 !due_date.nil? && (due_date < Date.today) && !status.is_closed?
228 228 end
229 229
230 230 # Users the issue can be assigned to
231 231 def assignable_users
232 232 project.assignable_users
233 233 end
234 234
235 235 # Versions that the issue can be assigned to
236 236 def assignable_versions
237 237 @assignable_versions ||= (project.versions.open + [Version.find_by_id(fixed_version_id_was)]).compact.uniq.sort
238 238 end
239 239
240 240 # Returns true if this issue is blocked by another issue that is still open
241 241 def blocked?
242 242 !relations_to.detect {|ir| ir.relation_type == 'blocks' && !ir.issue_from.closed?}.nil?
243 243 end
244 244
245 245 # Returns an array of status that user is able to apply
246 246 def new_statuses_allowed_to(user)
247 247 statuses = status.find_new_statuses_allowed_to(user.roles_for_project(project), tracker)
248 248 statuses << status unless statuses.empty?
249 249 statuses = statuses.uniq.sort
250 250 blocked? ? statuses.reject {|s| s.is_closed?} : statuses
251 251 end
252 252
253 # Returns the mail adresses of users that should be notified for the issue
253 # Returns the mail adresses of users that should be notified
254 254 def recipients
255 recipients = project.recipients
255 notified = project.notified_users
256 256 # Author and assignee are always notified unless they have been locked
257 recipients << author.mail if author && author.active?
258 recipients << assigned_to.mail if assigned_to && assigned_to.active?
259 recipients.compact.uniq
257 notified << author if author && author.active?
258 notified << assigned_to if assigned_to && assigned_to.active?
259 notified.uniq!
260 # Remove users that can not view the issue
261 notified.reject! {|user| !visible?(user)}
262 notified.collect(&:mail)
263 end
264
265 # Returns the mail adresses of watchers that should be notified
266 def watcher_recipients
267 notified = watcher_users
268 notified.reject! {|user| !user.active? || !visible?(user)}
269 notified.collect(&:mail)
260 270 end
261 271
262 272 # Returns the total number of hours spent on this issue.
263 273 #
264 274 # Example:
265 275 # spent_hours => 0
266 276 # spent_hours => 50
267 277 def spent_hours
268 278 @spent_hours ||= time_entries.sum(:hours) || 0
269 279 end
270 280
271 281 def relations
272 282 (relations_from + relations_to).sort
273 283 end
274 284
275 285 def all_dependent_issues
276 286 dependencies = []
277 287 relations_from.each do |relation|
278 288 dependencies << relation.issue_to
279 289 dependencies += relation.issue_to.all_dependent_issues
280 290 end
281 291 dependencies
282 292 end
283 293
284 294 # Returns an array of issues that duplicate this one
285 295 def duplicates
286 296 relations_to.select {|r| r.relation_type == IssueRelation::TYPE_DUPLICATES}.collect {|r| r.issue_from}
287 297 end
288 298
289 299 # Returns the due date or the target due date if any
290 300 # Used on gantt chart
291 301 def due_before
292 302 due_date || (fixed_version ? fixed_version.effective_date : nil)
293 303 end
294 304
295 305 # Returns the time scheduled for this issue.
296 306 #
297 307 # Example:
298 308 # Start Date: 2/26/09, End Date: 3/04/09
299 309 # duration => 6
300 310 def duration
301 311 (start_date && due_date) ? due_date - start_date : 0
302 312 end
303 313
304 314 def soonest_start
305 315 @soonest_start ||= relations_to.collect{|relation| relation.successor_soonest_start}.compact.min
306 316 end
307 317
308 318 def to_s
309 319 "#{tracker} ##{id}: #{subject}"
310 320 end
311 321
312 322 # Returns a string of css classes that apply to the issue
313 323 def css_classes
314 324 s = "issue status-#{status.position} priority-#{priority.position}"
315 325 s << ' closed' if closed?
316 326 s << ' overdue' if overdue?
317 327 s << ' created-by-me' if User.current.logged? && author_id == User.current.id
318 328 s << ' assigned-to-me' if User.current.logged? && assigned_to_id == User.current.id
319 329 s
320 330 end
321 331
322 332 private
323 333
324 334 # Callback on attachment deletion
325 335 def attachment_removed(obj)
326 336 journal = init_journal(User.current)
327 337 journal.details << JournalDetail.new(:property => 'attachment',
328 338 :prop_key => obj.id,
329 339 :old_value => obj.filename)
330 340 journal.save
331 341 end
332 342
333 343 # Saves the changes in a Journal
334 344 # Called after_save
335 345 def create_journal
336 346 if @current_journal
337 347 # attributes changes
338 348 (Issue.column_names - %w(id description lock_version created_on updated_on)).each {|c|
339 349 @current_journal.details << JournalDetail.new(:property => 'attr',
340 350 :prop_key => c,
341 351 :old_value => @issue_before_change.send(c),
342 352 :value => send(c)) unless send(c)==@issue_before_change.send(c)
343 353 }
344 354 # custom fields changes
345 355 custom_values.each {|c|
346 356 next if (@custom_values_before_change[c.custom_field_id]==c.value ||
347 357 (@custom_values_before_change[c.custom_field_id].blank? && c.value.blank?))
348 358 @current_journal.details << JournalDetail.new(:property => 'cf',
349 359 :prop_key => c.custom_field_id,
350 360 :old_value => @custom_values_before_change[c.custom_field_id],
351 361 :value => c.value)
352 362 }
353 363 @current_journal.save
354 364 end
355 365 end
356 366 end
@@ -1,603 +1,608
1 1 # redMine - project management software
2 2 # Copyright (C) 2006 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 Project < ActiveRecord::Base
19 19 # Project statuses
20 20 STATUS_ACTIVE = 1
21 21 STATUS_ARCHIVED = 9
22 22
23 23 # Specific overidden Activities
24 24 has_many :time_entry_activities
25 25 has_many :members, :include => [:user, :roles], :conditions => "#{User.table_name}.type='User' AND #{User.table_name}.status=#{User::STATUS_ACTIVE}"
26 26 has_many :member_principals, :class_name => 'Member',
27 27 :include => :principal,
28 28 :conditions => "#{Principal.table_name}.type='Group' OR (#{Principal.table_name}.type='User' AND #{Principal.table_name}.status=#{User::STATUS_ACTIVE})"
29 29 has_many :users, :through => :members
30 30 has_many :principals, :through => :member_principals, :source => :principal
31 31
32 32 has_many :enabled_modules, :dependent => :delete_all
33 33 has_and_belongs_to_many :trackers, :order => "#{Tracker.table_name}.position"
34 34 has_many :issues, :dependent => :destroy, :order => "#{Issue.table_name}.created_on DESC", :include => [:status, :tracker]
35 35 has_many :issue_changes, :through => :issues, :source => :journals
36 36 has_many :versions, :dependent => :destroy, :order => "#{Version.table_name}.effective_date DESC, #{Version.table_name}.name DESC"
37 37 has_many :time_entries, :dependent => :delete_all
38 38 has_many :queries, :dependent => :delete_all
39 39 has_many :documents, :dependent => :destroy
40 40 has_many :news, :dependent => :delete_all, :include => :author
41 41 has_many :issue_categories, :dependent => :delete_all, :order => "#{IssueCategory.table_name}.name"
42 42 has_many :boards, :dependent => :destroy, :order => "position ASC"
43 43 has_one :repository, :dependent => :destroy
44 44 has_many :changesets, :through => :repository
45 45 has_one :wiki, :dependent => :destroy
46 46 # Custom field for the project issues
47 47 has_and_belongs_to_many :issue_custom_fields,
48 48 :class_name => 'IssueCustomField',
49 49 :order => "#{CustomField.table_name}.position",
50 50 :join_table => "#{table_name_prefix}custom_fields_projects#{table_name_suffix}",
51 51 :association_foreign_key => 'custom_field_id'
52 52
53 53 acts_as_nested_set :order => 'name', :dependent => :destroy
54 54 acts_as_attachable :view_permission => :view_files,
55 55 :delete_permission => :manage_files
56 56
57 57 acts_as_customizable
58 58 acts_as_searchable :columns => ['name', 'description'], :project_key => 'id', :permission => nil
59 59 acts_as_event :title => Proc.new {|o| "#{l(:label_project)}: #{o.name}"},
60 60 :url => Proc.new {|o| {:controller => 'projects', :action => 'show', :id => o.id}},
61 61 :author => nil
62 62
63 63 attr_protected :status, :enabled_module_names
64 64
65 65 validates_presence_of :name, :identifier
66 66 validates_uniqueness_of :name, :identifier
67 67 validates_associated :repository, :wiki
68 68 validates_length_of :name, :maximum => 30
69 69 validates_length_of :homepage, :maximum => 255
70 70 validates_length_of :identifier, :in => 1..20
71 71 # donwcase letters, digits, dashes but not digits only
72 72 validates_format_of :identifier, :with => /^(?!\d+$)[a-z0-9\-]*$/, :if => Proc.new { |p| p.identifier_changed? }
73 73 # reserved words
74 74 validates_exclusion_of :identifier, :in => %w( new )
75 75
76 76 before_destroy :delete_all_members
77 77
78 78 named_scope :has_module, lambda { |mod| { :conditions => ["#{Project.table_name}.id IN (SELECT em.project_id FROM #{EnabledModule.table_name} em WHERE em.name=?)", mod.to_s] } }
79 79 named_scope :active, { :conditions => "#{Project.table_name}.status = #{STATUS_ACTIVE}"}
80 80 named_scope :all_public, { :conditions => { :is_public => true } }
81 81 named_scope :visible, lambda { { :conditions => Project.visible_by(User.current) } }
82 82
83 83 def identifier=(identifier)
84 84 super unless identifier_frozen?
85 85 end
86 86
87 87 def identifier_frozen?
88 88 errors[:identifier].nil? && !(new_record? || identifier.blank?)
89 89 end
90 90
91 91 # returns latest created projects
92 92 # non public projects will be returned only if user is a member of those
93 93 def self.latest(user=nil, count=5)
94 94 find(:all, :limit => count, :conditions => visible_by(user), :order => "created_on DESC")
95 95 end
96 96
97 97 # Returns a SQL :conditions string used to find all active projects for the specified user.
98 98 #
99 99 # Examples:
100 100 # Projects.visible_by(admin) => "projects.status = 1"
101 101 # Projects.visible_by(normal_user) => "projects.status = 1 AND projects.is_public = 1"
102 102 def self.visible_by(user=nil)
103 103 user ||= User.current
104 104 if user && user.admin?
105 105 return "#{Project.table_name}.status=#{Project::STATUS_ACTIVE}"
106 106 elsif user && user.memberships.any?
107 107 return "#{Project.table_name}.status=#{Project::STATUS_ACTIVE} AND (#{Project.table_name}.is_public = #{connection.quoted_true} or #{Project.table_name}.id IN (#{user.memberships.collect{|m| m.project_id}.join(',')}))"
108 108 else
109 109 return "#{Project.table_name}.status=#{Project::STATUS_ACTIVE} AND #{Project.table_name}.is_public = #{connection.quoted_true}"
110 110 end
111 111 end
112 112
113 113 def self.allowed_to_condition(user, permission, options={})
114 114 statements = []
115 115 base_statement = "#{Project.table_name}.status=#{Project::STATUS_ACTIVE}"
116 116 if perm = Redmine::AccessControl.permission(permission)
117 117 unless perm.project_module.nil?
118 118 # If the permission belongs to a project module, make sure the module is enabled
119 119 base_statement << " AND #{Project.table_name}.id IN (SELECT em.project_id FROM #{EnabledModule.table_name} em WHERE em.name='#{perm.project_module}')"
120 120 end
121 121 end
122 122 if options[:project]
123 123 project_statement = "#{Project.table_name}.id = #{options[:project].id}"
124 124 project_statement << " OR (#{Project.table_name}.lft > #{options[:project].lft} AND #{Project.table_name}.rgt < #{options[:project].rgt})" if options[:with_subprojects]
125 125 base_statement = "(#{project_statement}) AND (#{base_statement})"
126 126 end
127 127 if user.admin?
128 128 # no restriction
129 129 else
130 130 statements << "1=0"
131 131 if user.logged?
132 132 if Role.non_member.allowed_to?(permission) && !options[:member]
133 133 statements << "#{Project.table_name}.is_public = #{connection.quoted_true}"
134 134 end
135 135 allowed_project_ids = user.memberships.select {|m| m.roles.detect {|role| role.allowed_to?(permission)}}.collect {|m| m.project_id}
136 136 statements << "#{Project.table_name}.id IN (#{allowed_project_ids.join(',')})" if allowed_project_ids.any?
137 137 else
138 138 if Role.anonymous.allowed_to?(permission) && !options[:member]
139 139 # anonymous user allowed on public project
140 140 statements << "#{Project.table_name}.is_public = #{connection.quoted_true}"
141 141 end
142 142 end
143 143 end
144 144 statements.empty? ? base_statement : "((#{base_statement}) AND (#{statements.join(' OR ')}))"
145 145 end
146 146
147 147 # Returns the Systemwide and project specific activities
148 148 def activities(include_inactive=false)
149 149 if include_inactive
150 150 return all_activities
151 151 else
152 152 return active_activities
153 153 end
154 154 end
155 155
156 156 # Will create a new Project specific Activity or update an existing one
157 157 #
158 158 # This will raise a ActiveRecord::Rollback if the TimeEntryActivity
159 159 # does not successfully save.
160 160 def update_or_create_time_entry_activity(id, activity_hash)
161 161 if activity_hash.respond_to?(:has_key?) && activity_hash.has_key?('parent_id')
162 162 self.create_time_entry_activity_if_needed(activity_hash)
163 163 else
164 164 activity = project.time_entry_activities.find_by_id(id.to_i)
165 165 activity.update_attributes(activity_hash) if activity
166 166 end
167 167 end
168 168
169 169 # Create a new TimeEntryActivity if it overrides a system TimeEntryActivity
170 170 #
171 171 # This will raise a ActiveRecord::Rollback if the TimeEntryActivity
172 172 # does not successfully save.
173 173 def create_time_entry_activity_if_needed(activity)
174 174 if activity['parent_id']
175 175
176 176 parent_activity = TimeEntryActivity.find(activity['parent_id'])
177 177 activity['name'] = parent_activity.name
178 178 activity['position'] = parent_activity.position
179 179
180 180 if Enumeration.overridding_change?(activity, parent_activity)
181 181 project_activity = self.time_entry_activities.create(activity)
182 182
183 183 if project_activity.new_record?
184 184 raise ActiveRecord::Rollback, "Overridding TimeEntryActivity was not successfully saved"
185 185 else
186 186 self.time_entries.update_all("activity_id = #{project_activity.id}", ["activity_id = ?", parent_activity.id])
187 187 end
188 188 end
189 189 end
190 190 end
191 191
192 192 # Returns a :conditions SQL string that can be used to find the issues associated with this project.
193 193 #
194 194 # Examples:
195 195 # project.project_condition(true) => "(projects.id = 1 OR (projects.lft > 1 AND projects.rgt < 10))"
196 196 # project.project_condition(false) => "projects.id = 1"
197 197 def project_condition(with_subprojects)
198 198 cond = "#{Project.table_name}.id = #{id}"
199 199 cond = "(#{cond} OR (#{Project.table_name}.lft > #{lft} AND #{Project.table_name}.rgt < #{rgt}))" if with_subprojects
200 200 cond
201 201 end
202 202
203 203 def self.find(*args)
204 204 if args.first && args.first.is_a?(String) && !args.first.match(/^\d*$/)
205 205 project = find_by_identifier(*args)
206 206 raise ActiveRecord::RecordNotFound, "Couldn't find Project with identifier=#{args.first}" if project.nil?
207 207 project
208 208 else
209 209 super
210 210 end
211 211 end
212 212
213 213 def to_param
214 214 # id is used for projects with a numeric identifier (compatibility)
215 215 @to_param ||= (identifier.to_s =~ %r{^\d*$} ? id : identifier)
216 216 end
217 217
218 218 def active?
219 219 self.status == STATUS_ACTIVE
220 220 end
221 221
222 222 # Archives the project and its descendants recursively
223 223 def archive
224 224 # Archive subprojects if any
225 225 children.each do |subproject|
226 226 subproject.archive
227 227 end
228 228 update_attribute :status, STATUS_ARCHIVED
229 229 end
230 230
231 231 # Unarchives the project
232 232 # All its ancestors must be active
233 233 def unarchive
234 234 return false if ancestors.detect {|a| !a.active?}
235 235 update_attribute :status, STATUS_ACTIVE
236 236 end
237 237
238 238 # Returns an array of projects the project can be moved to
239 239 # by the current user
240 240 def allowed_parents
241 241 return @allowed_parents if @allowed_parents
242 242 @allowed_parents = (Project.find(:all, :conditions => Project.allowed_to_condition(User.current, :add_project, :member => true)) - self_and_descendants)
243 243 unless parent.nil? || @allowed_parents.empty? || @allowed_parents.include?(parent)
244 244 @allowed_parents << parent
245 245 end
246 246 @allowed_parents
247 247 end
248 248
249 249 # Sets the parent of the project with authorization check
250 250 def set_allowed_parent!(p)
251 251 unless p.nil? || p.is_a?(Project)
252 252 if p.to_s.blank?
253 253 p = nil
254 254 else
255 255 p = Project.find_by_id(p)
256 256 return false unless p
257 257 end
258 258 end
259 259 if p.nil?
260 260 if !new_record? && allowed_parents.empty?
261 261 return false
262 262 end
263 263 elsif !allowed_parents.include?(p)
264 264 return false
265 265 end
266 266 set_parent!(p)
267 267 end
268 268
269 269 # Sets the parent of the project
270 270 # Argument can be either a Project, a String, a Fixnum or nil
271 271 def set_parent!(p)
272 272 unless p.nil? || p.is_a?(Project)
273 273 if p.to_s.blank?
274 274 p = nil
275 275 else
276 276 p = Project.find_by_id(p)
277 277 return false unless p
278 278 end
279 279 end
280 280 if p == parent && !p.nil?
281 281 # Nothing to do
282 282 true
283 283 elsif p.nil? || (p.active? && move_possible?(p))
284 284 # Insert the project so that target's children or root projects stay alphabetically sorted
285 285 sibs = (p.nil? ? self.class.roots : p.children)
286 286 to_be_inserted_before = sibs.detect {|c| c.name.to_s.downcase > name.to_s.downcase }
287 287 if to_be_inserted_before
288 288 move_to_left_of(to_be_inserted_before)
289 289 elsif p.nil?
290 290 if sibs.empty?
291 291 # move_to_root adds the project in first (ie. left) position
292 292 move_to_root
293 293 else
294 294 move_to_right_of(sibs.last) unless self == sibs.last
295 295 end
296 296 else
297 297 # move_to_child_of adds the project in last (ie.right) position
298 298 move_to_child_of(p)
299 299 end
300 300 true
301 301 else
302 302 # Can not move to the given target
303 303 false
304 304 end
305 305 end
306 306
307 307 # Returns an array of the trackers used by the project and its active sub projects
308 308 def rolled_up_trackers
309 309 @rolled_up_trackers ||=
310 310 Tracker.find(:all, :include => :projects,
311 311 :select => "DISTINCT #{Tracker.table_name}.*",
312 312 :conditions => ["#{Project.table_name}.lft >= ? AND #{Project.table_name}.rgt <= ? AND #{Project.table_name}.status = #{STATUS_ACTIVE}", lft, rgt],
313 313 :order => "#{Tracker.table_name}.position")
314 314 end
315 315
316 316 # Closes open and locked project versions that are completed
317 317 def close_completed_versions
318 318 Version.transaction do
319 319 versions.find(:all, :conditions => {:status => %w(open locked)}).each do |version|
320 320 if version.completed?
321 321 version.update_attribute(:status, 'closed')
322 322 end
323 323 end
324 324 end
325 325 end
326 326
327 327 # Returns a hash of project users grouped by role
328 328 def users_by_role
329 329 members.find(:all, :include => [:user, :roles]).inject({}) do |h, m|
330 330 m.roles.each do |r|
331 331 h[r] ||= []
332 332 h[r] << m.user
333 333 end
334 334 h
335 335 end
336 336 end
337 337
338 338 # Deletes all project's members
339 339 def delete_all_members
340 340 me, mr = Member.table_name, MemberRole.table_name
341 341 connection.delete("DELETE FROM #{mr} WHERE #{mr}.member_id IN (SELECT #{me}.id FROM #{me} WHERE #{me}.project_id = #{id})")
342 342 Member.delete_all(['project_id = ?', id])
343 343 end
344 344
345 345 # Users issues can be assigned to
346 346 def assignable_users
347 347 members.select {|m| m.roles.detect {|role| role.assignable?}}.collect {|m| m.user}.sort
348 348 end
349 349
350 350 # Returns the mail adresses of users that should be always notified on project events
351 351 def recipients
352 352 members.select {|m| m.mail_notification? || m.user.mail_notification?}.collect {|m| m.user.mail}
353 353 end
354 354
355 # Returns the users that should be notified on project events
356 def notified_users
357 members.select {|m| m.mail_notification? || m.user.mail_notification?}.collect {|m| m.user}
358 end
359
355 360 # Returns an array of all custom fields enabled for project issues
356 361 # (explictly associated custom fields and custom fields enabled for all projects)
357 362 def all_issue_custom_fields
358 363 @all_issue_custom_fields ||= (IssueCustomField.for_all + issue_custom_fields).uniq.sort
359 364 end
360 365
361 366 def project
362 367 self
363 368 end
364 369
365 370 def <=>(project)
366 371 name.downcase <=> project.name.downcase
367 372 end
368 373
369 374 def to_s
370 375 name
371 376 end
372 377
373 378 # Returns a short description of the projects (first lines)
374 379 def short_description(length = 255)
375 380 description.gsub(/^(.{#{length}}[^\n\r]*).*$/m, '\1...').strip if description
376 381 end
377 382
378 383 # Return true if this project is allowed to do the specified action.
379 384 # action can be:
380 385 # * a parameter-like Hash (eg. :controller => 'projects', :action => 'edit')
381 386 # * a permission Symbol (eg. :edit_project)
382 387 def allows_to?(action)
383 388 if action.is_a? Hash
384 389 allowed_actions.include? "#{action[:controller]}/#{action[:action]}"
385 390 else
386 391 allowed_permissions.include? action
387 392 end
388 393 end
389 394
390 395 def module_enabled?(module_name)
391 396 module_name = module_name.to_s
392 397 enabled_modules.detect {|m| m.name == module_name}
393 398 end
394 399
395 400 def enabled_module_names=(module_names)
396 401 if module_names && module_names.is_a?(Array)
397 402 module_names = module_names.collect(&:to_s)
398 403 # remove disabled modules
399 404 enabled_modules.each {|mod| mod.destroy unless module_names.include?(mod.name)}
400 405 # add new modules
401 406 module_names.reject {|name| module_enabled?(name)}.each {|name| enabled_modules << EnabledModule.new(:name => name)}
402 407 else
403 408 enabled_modules.clear
404 409 end
405 410 end
406 411
407 412 # Returns an auto-generated project identifier based on the last identifier used
408 413 def self.next_identifier
409 414 p = Project.find(:first, :order => 'created_on DESC')
410 415 p.nil? ? nil : p.identifier.to_s.succ
411 416 end
412 417
413 418 # Copies and saves the Project instance based on the +project+.
414 419 # Duplicates the source project's:
415 420 # * Wiki
416 421 # * Versions
417 422 # * Categories
418 423 # * Issues
419 424 # * Members
420 425 # * Queries
421 426 #
422 427 # Accepts an +options+ argument to specify what to copy
423 428 #
424 429 # Examples:
425 430 # project.copy(1) # => copies everything
426 431 # project.copy(1, :only => 'members') # => copies members only
427 432 # project.copy(1, :only => ['members', 'versions']) # => copies members and versions
428 433 def copy(project, options={})
429 434 project = project.is_a?(Project) ? project : Project.find(project)
430 435
431 436 to_be_copied = %w(wiki versions issue_categories issues members queries boards)
432 437 to_be_copied = to_be_copied & options[:only].to_a unless options[:only].nil?
433 438
434 439 Project.transaction do
435 440 if save
436 441 reload
437 442 to_be_copied.each do |name|
438 443 send "copy_#{name}", project
439 444 end
440 445 Redmine::Hook.call_hook(:model_project_copy_before_save, :source_project => project, :destination_project => self)
441 446 save
442 447 end
443 448 end
444 449 end
445 450
446 451
447 452 # Copies +project+ and returns the new instance. This will not save
448 453 # the copy
449 454 def self.copy_from(project)
450 455 begin
451 456 project = project.is_a?(Project) ? project : Project.find(project)
452 457 if project
453 458 # clear unique attributes
454 459 attributes = project.attributes.dup.except('id', 'name', 'identifier', 'status', 'parent_id', 'lft', 'rgt')
455 460 copy = Project.new(attributes)
456 461 copy.enabled_modules = project.enabled_modules
457 462 copy.trackers = project.trackers
458 463 copy.custom_values = project.custom_values.collect {|v| v.clone}
459 464 copy.issue_custom_fields = project.issue_custom_fields
460 465 return copy
461 466 else
462 467 return nil
463 468 end
464 469 rescue ActiveRecord::RecordNotFound
465 470 return nil
466 471 end
467 472 end
468 473
469 474 private
470 475
471 476 # Copies wiki from +project+
472 477 def copy_wiki(project)
473 478 # Check that the source project has a wiki first
474 479 unless project.wiki.nil?
475 480 self.wiki ||= Wiki.new
476 481 wiki.attributes = project.wiki.attributes.dup.except("id", "project_id")
477 482 project.wiki.pages.each do |page|
478 483 new_wiki_content = WikiContent.new(page.content.attributes.dup.except("id", "page_id", "updated_on"))
479 484 new_wiki_page = WikiPage.new(page.attributes.dup.except("id", "wiki_id", "created_on", "parent_id"))
480 485 new_wiki_page.content = new_wiki_content
481 486 wiki.pages << new_wiki_page
482 487 end
483 488 end
484 489 end
485 490
486 491 # Copies versions from +project+
487 492 def copy_versions(project)
488 493 project.versions.each do |version|
489 494 new_version = Version.new
490 495 new_version.attributes = version.attributes.dup.except("id", "project_id", "created_on", "updated_on")
491 496 self.versions << new_version
492 497 end
493 498 end
494 499
495 500 # Copies issue categories from +project+
496 501 def copy_issue_categories(project)
497 502 project.issue_categories.each do |issue_category|
498 503 new_issue_category = IssueCategory.new
499 504 new_issue_category.attributes = issue_category.attributes.dup.except("id", "project_id")
500 505 self.issue_categories << new_issue_category
501 506 end
502 507 end
503 508
504 509 # Copies issues from +project+
505 510 def copy_issues(project)
506 511 project.issues.each do |issue|
507 512 new_issue = Issue.new
508 513 new_issue.copy_from(issue)
509 514 # Reassign fixed_versions by name, since names are unique per
510 515 # project and the versions for self are not yet saved
511 516 if issue.fixed_version
512 517 new_issue.fixed_version = self.versions.select {|v| v.name == issue.fixed_version.name}.first
513 518 end
514 519 # Reassign the category by name, since names are unique per
515 520 # project and the categories for self are not yet saved
516 521 if issue.category
517 522 new_issue.category = self.issue_categories.select {|c| c.name == issue.category.name}.first
518 523 end
519 524 self.issues << new_issue
520 525 end
521 526 end
522 527
523 528 # Copies members from +project+
524 529 def copy_members(project)
525 530 project.members.each do |member|
526 531 new_member = Member.new
527 532 new_member.attributes = member.attributes.dup.except("id", "project_id", "created_on")
528 533 new_member.role_ids = member.role_ids.dup
529 534 new_member.project = self
530 535 self.members << new_member
531 536 end
532 537 end
533 538
534 539 # Copies queries from +project+
535 540 def copy_queries(project)
536 541 project.queries.each do |query|
537 542 new_query = Query.new
538 543 new_query.attributes = query.attributes.dup.except("id", "project_id", "sort_criteria")
539 544 new_query.sort_criteria = query.sort_criteria if query.sort_criteria
540 545 new_query.project = self
541 546 self.queries << new_query
542 547 end
543 548 end
544 549
545 550 # Copies boards from +project+
546 551 def copy_boards(project)
547 552 project.boards.each do |board|
548 553 new_board = Board.new
549 554 new_board.attributes = board.attributes.dup.except("id", "project_id", "topics_count", "messages_count", "last_message_id")
550 555 new_board.project = self
551 556 self.boards << new_board
552 557 end
553 558 end
554 559
555 560 def allowed_permissions
556 561 @allowed_permissions ||= begin
557 562 module_names = enabled_modules.collect {|m| m.name}
558 563 Redmine::AccessControl.modules_permissions(module_names).collect {|p| p.name}
559 564 end
560 565 end
561 566
562 567 def allowed_actions
563 568 @actions_allowed ||= allowed_permissions.inject([]) { |actions, permission| actions += Redmine::AccessControl.allowed_actions(permission) }.flatten
564 569 end
565 570
566 571 # Returns all the active Systemwide and project specific activities
567 572 def active_activities
568 573 overridden_activity_ids = self.time_entry_activities.active.collect(&:parent_id)
569 574
570 575 if overridden_activity_ids.empty?
571 576 return TimeEntryActivity.shared.active
572 577 else
573 578 return system_activities_and_project_overrides
574 579 end
575 580 end
576 581
577 582 # Returns all the Systemwide and project specific activities
578 583 # (inactive and active)
579 584 def all_activities
580 585 overridden_activity_ids = self.time_entry_activities.collect(&:parent_id)
581 586
582 587 if overridden_activity_ids.empty?
583 588 return TimeEntryActivity.shared
584 589 else
585 590 return system_activities_and_project_overrides(true)
586 591 end
587 592 end
588 593
589 594 # Returns the systemwide active activities merged with the project specific overrides
590 595 def system_activities_and_project_overrides(include_inactive=false)
591 596 if include_inactive
592 597 return TimeEntryActivity.shared.
593 598 find(:all,
594 599 :conditions => ["id NOT IN (?)", self.time_entry_activities.collect(&:parent_id)]) +
595 600 self.time_entry_activities
596 601 else
597 602 return TimeEntryActivity.shared.active.
598 603 find(:all,
599 604 :conditions => ["id NOT IN (?)", self.time_entry_activities.active.collect(&:parent_id)]) +
600 605 self.time_entry_activities.active
601 606 end
602 607 end
603 608 end
@@ -1,191 +1,191
1 1 ---
2 2 issues_001:
3 3 created_on: <%= 3.days.ago.to_date.to_s(:db) %>
4 4 project_id: 1
5 5 updated_on: <%= 1.day.ago.to_date.to_s(:db) %>
6 6 priority_id: 4
7 7 subject: Can't print recipes
8 8 id: 1
9 9 fixed_version_id:
10 10 category_id: 1
11 11 description: Unable to print recipes
12 12 tracker_id: 1
13 13 assigned_to_id:
14 14 author_id: 2
15 15 status_id: 1
16 16 start_date: <%= 1.day.ago.to_date.to_s(:db) %>
17 17 due_date: <%= 10.day.from_now.to_date.to_s(:db) %>
18 18 issues_002:
19 19 created_on: 2006-07-19 21:04:21 +02:00
20 20 project_id: 1
21 21 updated_on: 2006-07-19 21:09:50 +02:00
22 22 priority_id: 5
23 23 subject: Add ingredients categories
24 24 id: 2
25 25 fixed_version_id: 2
26 26 category_id:
27 27 description: Ingredients of the recipe should be classified by categories
28 28 tracker_id: 2
29 29 assigned_to_id: 3
30 30 author_id: 2
31 31 status_id: 2
32 32 start_date: <%= 2.day.ago.to_date.to_s(:db) %>
33 33 due_date:
34 34 issues_003:
35 35 created_on: 2006-07-19 21:07:27 +02:00
36 36 project_id: 1
37 37 updated_on: 2006-07-19 21:07:27 +02:00
38 38 priority_id: 4
39 39 subject: Error 281 when updating a recipe
40 40 id: 3
41 41 fixed_version_id:
42 42 category_id:
43 43 description: Error 281 is encountered when saving a recipe
44 44 tracker_id: 1
45 45 assigned_to_id: 3
46 46 author_id: 2
47 47 status_id: 1
48 48 start_date: <%= 1.day.from_now.to_date.to_s(:db) %>
49 49 due_date: <%= 40.day.ago.to_date.to_s(:db) %>
50 50 issues_004:
51 51 created_on: <%= 5.days.ago.to_date.to_s(:db) %>
52 52 project_id: 2
53 53 updated_on: <%= 2.days.ago.to_date.to_s(:db) %>
54 54 priority_id: 4
55 55 subject: Issue on project 2
56 56 id: 4
57 57 fixed_version_id:
58 58 category_id:
59 59 description: Issue on project 2
60 60 tracker_id: 1
61 61 assigned_to_id: 2
62 62 author_id: 2
63 63 status_id: 1
64 64 issues_005:
65 65 created_on: <%= 5.days.ago.to_date.to_s(:db) %>
66 66 project_id: 3
67 67 updated_on: <%= 2.days.ago.to_date.to_s(:db) %>
68 68 priority_id: 4
69 69 subject: Subproject issue
70 70 id: 5
71 71 fixed_version_id:
72 72 category_id:
73 73 description: This is an issue on a cookbook subproject
74 74 tracker_id: 1
75 75 assigned_to_id:
76 76 author_id: 2
77 77 status_id: 1
78 78 issues_006:
79 79 created_on: <%= 1.minute.ago.to_date.to_s(:db) %>
80 80 project_id: 5
81 81 updated_on: <%= 1.minute.ago.to_date.to_s(:db) %>
82 82 priority_id: 4
83 83 subject: Issue of a private subproject
84 84 id: 6
85 85 fixed_version_id:
86 86 category_id:
87 87 description: This is an issue of a private subproject of cookbook
88 88 tracker_id: 1
89 89 assigned_to_id:
90 90 author_id: 2
91 91 status_id: 1
92 92 start_date: <%= Date.today.to_s(:db) %>
93 93 due_date: <%= 1.days.from_now.to_date.to_s(:db) %>
94 94 issues_007:
95 95 created_on: <%= 10.days.ago.to_date.to_s(:db) %>
96 96 project_id: 1
97 97 updated_on: <%= 10.days.ago.to_date.to_s(:db) %>
98 98 priority_id: 5
99 99 subject: Issue due today
100 100 id: 7
101 101 fixed_version_id:
102 102 category_id:
103 103 description: This is an issue that is due today
104 104 tracker_id: 1
105 105 assigned_to_id:
106 106 author_id: 2
107 107 status_id: 1
108 108 start_date: <%= 10.days.ago.to_s(:db) %>
109 109 due_date: <%= Date.today.to_s(:db) %>
110 110 lock_version: 0
111 111 issues_008:
112 112 created_on: <%= 10.days.ago.to_date.to_s(:db) %>
113 113 project_id: 1
114 114 updated_on: <%= 10.days.ago.to_date.to_s(:db) %>
115 115 priority_id: 5
116 116 subject: Closed issue
117 117 id: 8
118 118 fixed_version_id:
119 119 category_id:
120 120 description: This is a closed issue.
121 121 tracker_id: 1
122 122 assigned_to_id:
123 123 author_id: 2
124 124 status_id: 5
125 125 start_date:
126 126 due_date:
127 127 lock_version: 0
128 128 issues_009:
129 129 created_on: <%= 1.minute.ago.to_date.to_s(:db) %>
130 130 project_id: 5
131 131 updated_on: <%= 1.minute.ago.to_date.to_s(:db) %>
132 132 priority_id: 5
133 133 subject: Blocked Issue
134 134 id: 9
135 135 fixed_version_id:
136 136 category_id:
137 137 description: This is an issue that is blocked by issue #10
138 138 tracker_id: 1
139 139 assigned_to_id:
140 140 author_id: 2
141 141 status_id: 1
142 142 start_date: <%= Date.today.to_s(:db) %>
143 143 due_date: <%= 1.days.from_now.to_date.to_s(:db) %>
144 144 issues_010:
145 145 created_on: <%= 1.minute.ago.to_date.to_s(:db) %>
146 146 project_id: 5
147 147 updated_on: <%= 1.minute.ago.to_date.to_s(:db) %>
148 148 priority_id: 5
149 149 subject: Issue Doing the Blocking
150 150 id: 10
151 151 fixed_version_id:
152 152 category_id:
153 153 description: This is an issue that blocks issue #9
154 154 tracker_id: 1
155 155 assigned_to_id:
156 156 author_id: 2
157 157 status_id: 1
158 158 start_date: <%= Date.today.to_s(:db) %>
159 159 due_date: <%= 1.days.from_now.to_date.to_s(:db) %>
160 160 issues_011:
161 161 created_on: <%= 3.days.ago.to_date.to_s(:db) %>
162 162 project_id: 1
163 163 updated_on: <%= 1.day.ago.to_date.to_s(:db) %>
164 164 priority_id: 5
165 165 subject: Closed issue on a closed version
166 166 id: 11
167 167 fixed_version_id: 1
168 168 category_id: 1
169 169 description:
170 170 tracker_id: 1
171 171 assigned_to_id:
172 172 author_id: 2
173 173 status_id: 5
174 174 start_date: <%= 1.day.ago.to_date.to_s(:db) %>
175 175 due_date:
176 176 issues_012:
177 177 created_on: <%= 3.days.ago.to_date.to_s(:db) %>
178 178 project_id: 1
179 179 updated_on: <%= 1.day.ago.to_date.to_s(:db) %>
180 180 priority_id: 5
181 181 subject: Closed issue on a locked version
182 182 id: 12
183 183 fixed_version_id: 2
184 184 category_id: 1
185 185 description:
186 186 tracker_id: 1
187 187 assigned_to_id:
188 author_id: 2
188 author_id: 3
189 189 status_id: 5
190 190 start_date: <%= 1.day.ago.to_date.to_s(:db) %>
191 191 due_date:
@@ -1,426 +1,443
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 < ActiveSupport::TestCase
21 21 fixtures :projects, :users, :members, :member_roles, :roles,
22 22 :trackers, :projects_trackers,
23 23 :versions,
24 24 :issue_statuses, :issue_categories, :issue_relations, :workflows,
25 25 :enumerations,
26 26 :issues,
27 27 :custom_fields, :custom_fields_projects, :custom_fields_trackers, :custom_values,
28 28 :time_entries
29 29
30 30 def test_create
31 31 issue = Issue.new(:project_id => 1, :tracker_id => 1, :author_id => 3, :status_id => 1, :priority => IssuePriority.all.first, :subject => 'test_create', :description => 'IssueTest#test_create', :estimated_hours => '1:30')
32 32 assert issue.save
33 33 issue.reload
34 34 assert_equal 1.5, issue.estimated_hours
35 35 end
36 36
37 37 def test_create_minimal
38 38 issue = Issue.new(:project_id => 1, :tracker_id => 1, :author_id => 3, :status_id => 1, :priority => IssuePriority.all.first, :subject => 'test_create')
39 39 assert issue.save
40 40 assert issue.description.nil?
41 41 end
42 42
43 43 def test_create_with_required_custom_field
44 44 field = IssueCustomField.find_by_name('Database')
45 45 field.update_attribute(:is_required, true)
46 46
47 47 issue = Issue.new(:project_id => 1, :tracker_id => 1, :author_id => 1, :status_id => 1, :subject => 'test_create', :description => 'IssueTest#test_create_with_required_custom_field')
48 48 assert issue.available_custom_fields.include?(field)
49 49 # No value for the custom field
50 50 assert !issue.save
51 51 assert_equal I18n.translate('activerecord.errors.messages.invalid'), issue.errors.on(:custom_values)
52 52 # Blank value
53 53 issue.custom_field_values = { field.id => '' }
54 54 assert !issue.save
55 55 assert_equal I18n.translate('activerecord.errors.messages.invalid'), issue.errors.on(:custom_values)
56 56 # Invalid value
57 57 issue.custom_field_values = { field.id => 'SQLServer' }
58 58 assert !issue.save
59 59 assert_equal I18n.translate('activerecord.errors.messages.invalid'), issue.errors.on(:custom_values)
60 60 # Valid value
61 61 issue.custom_field_values = { field.id => 'PostgreSQL' }
62 62 assert issue.save
63 63 issue.reload
64 64 assert_equal 'PostgreSQL', issue.custom_value_for(field).value
65 65 end
66 66
67 67 def test_visible_scope_for_anonymous
68 68 # Anonymous user should see issues of public projects only
69 69 issues = Issue.visible(User.anonymous).all
70 70 assert issues.any?
71 71 assert_nil issues.detect {|issue| !issue.project.is_public?}
72 72 # Anonymous user should not see issues without permission
73 73 Role.anonymous.remove_permission!(:view_issues)
74 74 issues = Issue.visible(User.anonymous).all
75 75 assert issues.empty?
76 76 end
77 77
78 78 def test_visible_scope_for_user
79 79 user = User.find(9)
80 80 assert user.projects.empty?
81 81 # Non member user should see issues of public projects only
82 82 issues = Issue.visible(user).all
83 83 assert issues.any?
84 84 assert_nil issues.detect {|issue| !issue.project.is_public?}
85 85 # Non member user should not see issues without permission
86 86 Role.non_member.remove_permission!(:view_issues)
87 87 user.reload
88 88 issues = Issue.visible(user).all
89 89 assert issues.empty?
90 90 # User should see issues of projects for which he has view_issues permissions only
91 91 Member.create!(:principal => user, :project_id => 2, :role_ids => [1])
92 92 user.reload
93 93 issues = Issue.visible(user).all
94 94 assert issues.any?
95 95 assert_nil issues.detect {|issue| issue.project_id != 2}
96 96 end
97 97
98 98 def test_visible_scope_for_admin
99 99 user = User.find(1)
100 100 user.members.each(&:destroy)
101 101 assert user.projects.empty?
102 102 issues = Issue.visible(user).all
103 103 assert issues.any?
104 104 # Admin should see issues on private projects that he does not belong to
105 105 assert issues.detect {|issue| !issue.project.is_public?}
106 106 end
107 107
108 108 def test_errors_full_messages_should_include_custom_fields_errors
109 109 field = IssueCustomField.find_by_name('Database')
110 110
111 111 issue = Issue.new(:project_id => 1, :tracker_id => 1, :author_id => 1, :status_id => 1, :subject => 'test_create', :description => 'IssueTest#test_create_with_required_custom_field')
112 112 assert issue.available_custom_fields.include?(field)
113 113 # Invalid value
114 114 issue.custom_field_values = { field.id => 'SQLServer' }
115 115
116 116 assert !issue.valid?
117 117 assert_equal 1, issue.errors.full_messages.size
118 118 assert_equal "Database #{I18n.translate('activerecord.errors.messages.inclusion')}", issue.errors.full_messages.first
119 119 end
120 120
121 121 def test_update_issue_with_required_custom_field
122 122 field = IssueCustomField.find_by_name('Database')
123 123 field.update_attribute(:is_required, true)
124 124
125 125 issue = Issue.find(1)
126 126 assert_nil issue.custom_value_for(field)
127 127 assert issue.available_custom_fields.include?(field)
128 128 # No change to custom values, issue can be saved
129 129 assert issue.save
130 130 # Blank value
131 131 issue.custom_field_values = { field.id => '' }
132 132 assert !issue.save
133 133 # Valid value
134 134 issue.custom_field_values = { field.id => 'PostgreSQL' }
135 135 assert issue.save
136 136 issue.reload
137 137 assert_equal 'PostgreSQL', issue.custom_value_for(field).value
138 138 end
139 139
140 140 def test_should_not_update_attributes_if_custom_fields_validation_fails
141 141 issue = Issue.find(1)
142 142 field = IssueCustomField.find_by_name('Database')
143 143 assert issue.available_custom_fields.include?(field)
144 144
145 145 issue.custom_field_values = { field.id => 'Invalid' }
146 146 issue.subject = 'Should be not be saved'
147 147 assert !issue.save
148 148
149 149 issue.reload
150 150 assert_equal "Can't print recipes", issue.subject
151 151 end
152 152
153 153 def test_should_not_recreate_custom_values_objects_on_update
154 154 field = IssueCustomField.find_by_name('Database')
155 155
156 156 issue = Issue.find(1)
157 157 issue.custom_field_values = { field.id => 'PostgreSQL' }
158 158 assert issue.save
159 159 custom_value = issue.custom_value_for(field)
160 160 issue.reload
161 161 issue.custom_field_values = { field.id => 'MySQL' }
162 162 assert issue.save
163 163 issue.reload
164 164 assert_equal custom_value.id, issue.custom_value_for(field).id
165 165 end
166 166
167 167 def test_should_update_issue_with_disabled_tracker
168 168 p = Project.find(1)
169 169 issue = Issue.find(1)
170 170
171 171 p.trackers.delete(issue.tracker)
172 172 assert !p.trackers.include?(issue.tracker)
173 173
174 174 issue.reload
175 175 issue.subject = 'New subject'
176 176 assert issue.save
177 177 end
178 178
179 179 def test_should_not_set_a_disabled_tracker
180 180 p = Project.find(1)
181 181 p.trackers.delete(Tracker.find(2))
182 182
183 183 issue = Issue.find(1)
184 184 issue.tracker_id = 2
185 185 issue.subject = 'New subject'
186 186 assert !issue.save
187 187 assert_not_nil issue.errors.on(:tracker_id)
188 188 end
189 189
190 190 def test_category_based_assignment
191 191 issue = Issue.create(:project_id => 1, :tracker_id => 1, :author_id => 3, :status_id => 1, :priority => IssuePriority.all.first, :subject => 'Assignment test', :description => 'Assignment test', :category_id => 1)
192 192 assert_equal IssueCategory.find(1).assigned_to, issue.assigned_to
193 193 end
194 194
195 195 def test_copy
196 196 issue = Issue.new.copy_from(1)
197 197 assert issue.save
198 198 issue.reload
199 199 orig = Issue.find(1)
200 200 assert_equal orig.subject, issue.subject
201 201 assert_equal orig.tracker, issue.tracker
202 202 assert_equal orig.custom_values.first.value, issue.custom_values.first.value
203 203 end
204 204
205 205 def test_copy_should_copy_status
206 206 orig = Issue.find(8)
207 207 assert orig.status != IssueStatus.default
208 208
209 209 issue = Issue.new.copy_from(orig)
210 210 assert issue.save
211 211 issue.reload
212 212 assert_equal orig.status, issue.status
213 213 end
214 214
215 215 def test_should_close_duplicates
216 216 # Create 3 issues
217 217 issue1 = Issue.new(:project_id => 1, :tracker_id => 1, :author_id => 1, :status_id => 1, :priority => IssuePriority.all.first, :subject => 'Duplicates test', :description => 'Duplicates test')
218 218 assert issue1.save
219 219 issue2 = issue1.clone
220 220 assert issue2.save
221 221 issue3 = issue1.clone
222 222 assert issue3.save
223 223
224 224 # 2 is a dupe of 1
225 225 IssueRelation.create(:issue_from => issue2, :issue_to => issue1, :relation_type => IssueRelation::TYPE_DUPLICATES)
226 226 # And 3 is a dupe of 2
227 227 IssueRelation.create(:issue_from => issue3, :issue_to => issue2, :relation_type => IssueRelation::TYPE_DUPLICATES)
228 228 # And 3 is a dupe of 1 (circular duplicates)
229 229 IssueRelation.create(:issue_from => issue3, :issue_to => issue1, :relation_type => IssueRelation::TYPE_DUPLICATES)
230 230
231 231 assert issue1.reload.duplicates.include?(issue2)
232 232
233 233 # Closing issue 1
234 234 issue1.init_journal(User.find(:first), "Closing issue1")
235 235 issue1.status = IssueStatus.find :first, :conditions => {:is_closed => true}
236 236 assert issue1.save
237 237 # 2 and 3 should be also closed
238 238 assert issue2.reload.closed?
239 239 assert issue3.reload.closed?
240 240 end
241 241
242 242 def test_should_not_close_duplicated_issue
243 243 # Create 3 issues
244 244 issue1 = Issue.new(:project_id => 1, :tracker_id => 1, :author_id => 1, :status_id => 1, :priority => IssuePriority.all.first, :subject => 'Duplicates test', :description => 'Duplicates test')
245 245 assert issue1.save
246 246 issue2 = issue1.clone
247 247 assert issue2.save
248 248
249 249 # 2 is a dupe of 1
250 250 IssueRelation.create(:issue_from => issue2, :issue_to => issue1, :relation_type => IssueRelation::TYPE_DUPLICATES)
251 251 # 2 is a dup of 1 but 1 is not a duplicate of 2
252 252 assert !issue2.reload.duplicates.include?(issue1)
253 253
254 254 # Closing issue 2
255 255 issue2.init_journal(User.find(:first), "Closing issue2")
256 256 issue2.status = IssueStatus.find :first, :conditions => {:is_closed => true}
257 257 assert issue2.save
258 258 # 1 should not be also closed
259 259 assert !issue1.reload.closed?
260 260 end
261 261
262 262 def test_assignable_versions
263 263 issue = Issue.new(:project_id => 1, :tracker_id => 1, :author_id => 1, :status_id => 1, :fixed_version_id => 1, :subject => 'New issue')
264 264 assert_equal ['open'], issue.assignable_versions.collect(&:status).uniq
265 265 end
266 266
267 267 def test_should_not_be_able_to_assign_a_new_issue_to_a_closed_version
268 268 issue = Issue.new(:project_id => 1, :tracker_id => 1, :author_id => 1, :status_id => 1, :fixed_version_id => 1, :subject => 'New issue')
269 269 assert !issue.save
270 270 assert_not_nil issue.errors.on(:fixed_version_id)
271 271 end
272 272
273 273 def test_should_not_be_able_to_assign_a_new_issue_to_a_locked_version
274 274 issue = Issue.new(:project_id => 1, :tracker_id => 1, :author_id => 1, :status_id => 1, :fixed_version_id => 2, :subject => 'New issue')
275 275 assert !issue.save
276 276 assert_not_nil issue.errors.on(:fixed_version_id)
277 277 end
278 278
279 279 def test_should_be_able_to_assign_a_new_issue_to_an_open_version
280 280 issue = Issue.new(:project_id => 1, :tracker_id => 1, :author_id => 1, :status_id => 1, :fixed_version_id => 3, :subject => 'New issue')
281 281 assert issue.save
282 282 end
283 283
284 284 def test_should_be_able_to_update_an_issue_assigned_to_a_closed_version
285 285 issue = Issue.find(11)
286 286 assert_equal 'closed', issue.fixed_version.status
287 287 issue.subject = 'Subject changed'
288 288 assert issue.save
289 289 end
290 290
291 291 def test_should_not_be_able_to_reopen_an_issue_assigned_to_a_closed_version
292 292 issue = Issue.find(11)
293 293 issue.status_id = 1
294 294 assert !issue.save
295 295 assert_not_nil issue.errors.on_base
296 296 end
297 297
298 298 def test_should_be_able_to_reopen_and_reassign_an_issue_assigned_to_a_closed_version
299 299 issue = Issue.find(11)
300 300 issue.status_id = 1
301 301 issue.fixed_version_id = 3
302 302 assert issue.save
303 303 end
304 304
305 305 def test_should_be_able_to_reopen_an_issue_assigned_to_a_locked_version
306 306 issue = Issue.find(12)
307 307 assert_equal 'locked', issue.fixed_version.status
308 308 issue.status_id = 1
309 309 assert issue.save
310 310 end
311 311
312 312 def test_move_to_another_project_with_same_category
313 313 issue = Issue.find(1)
314 314 assert issue.move_to(Project.find(2))
315 315 issue.reload
316 316 assert_equal 2, issue.project_id
317 317 # Category changes
318 318 assert_equal 4, issue.category_id
319 319 # Make sure time entries were move to the target project
320 320 assert_equal 2, issue.time_entries.first.project_id
321 321 end
322 322
323 323 def test_move_to_another_project_without_same_category
324 324 issue = Issue.find(2)
325 325 assert issue.move_to(Project.find(2))
326 326 issue.reload
327 327 assert_equal 2, issue.project_id
328 328 # Category cleared
329 329 assert_nil issue.category_id
330 330 end
331 331
332 332 def test_copy_to_the_same_project
333 333 issue = Issue.find(1)
334 334 copy = nil
335 335 assert_difference 'Issue.count' do
336 336 copy = issue.move_to(issue.project, nil, :copy => true)
337 337 end
338 338 assert_kind_of Issue, copy
339 339 assert_equal issue.project, copy.project
340 340 assert_equal "125", copy.custom_value_for(2).value
341 341 end
342 342
343 343 def test_copy_to_another_project_and_tracker
344 344 issue = Issue.find(1)
345 345 copy = nil
346 346 assert_difference 'Issue.count' do
347 347 copy = issue.move_to(Project.find(3), Tracker.find(2), :copy => true)
348 348 end
349 349 assert_kind_of Issue, copy
350 350 assert_equal Project.find(3), copy.project
351 351 assert_equal Tracker.find(2), copy.tracker
352 352 # Custom field #2 is not associated with target tracker
353 353 assert_nil copy.custom_value_for(2)
354 354 end
355 355
356 def test_recipients_should_not_include_users_that_cannot_view_the_issue
357 issue = Issue.find(12)
358 assert issue.recipients.include?(issue.author.mail)
359 # move the issue to a private project
360 copy = issue.move_to(Project.find(5), Tracker.find(2), :copy => true)
361 # author is not a member of project anymore
362 assert !copy.recipients.include?(copy.author.mail)
363 end
364
365 def test_watcher_recipients_should_not_include_users_that_cannot_view_the_issue
366 user = User.find(3)
367 issue = Issue.find(9)
368 Watcher.create!(:user => user, :watchable => issue)
369 assert issue.watched_by?(user)
370 assert !issue.watcher_recipients.include?(user.mail)
371 end
372
356 373 def test_issue_destroy
357 374 Issue.find(1).destroy
358 375 assert_nil Issue.find_by_id(1)
359 376 assert_nil TimeEntry.find_by_issue_id(1)
360 377 end
361 378
362 379 def test_blocked
363 380 blocked_issue = Issue.find(9)
364 381 blocking_issue = Issue.find(10)
365 382
366 383 assert blocked_issue.blocked?
367 384 assert !blocking_issue.blocked?
368 385 end
369 386
370 387 def test_blocked_issues_dont_allow_closed_statuses
371 388 blocked_issue = Issue.find(9)
372 389
373 390 allowed_statuses = blocked_issue.new_statuses_allowed_to(users(:users_002))
374 391 assert !allowed_statuses.empty?
375 392 closed_statuses = allowed_statuses.select {|st| st.is_closed?}
376 393 assert closed_statuses.empty?
377 394 end
378 395
379 396 def test_unblocked_issues_allow_closed_statuses
380 397 blocking_issue = Issue.find(10)
381 398
382 399 allowed_statuses = blocking_issue.new_statuses_allowed_to(users(:users_002))
383 400 assert !allowed_statuses.empty?
384 401 closed_statuses = allowed_statuses.select {|st| st.is_closed?}
385 402 assert !closed_statuses.empty?
386 403 end
387 404
388 405 def test_overdue
389 406 assert Issue.new(:due_date => 1.day.ago.to_date).overdue?
390 407 assert !Issue.new(:due_date => Date.today).overdue?
391 408 assert !Issue.new(:due_date => 1.day.from_now.to_date).overdue?
392 409 assert !Issue.new(:due_date => nil).overdue?
393 410 assert !Issue.new(:due_date => 1.day.ago.to_date, :status => IssueStatus.find(:first, :conditions => {:is_closed => true})).overdue?
394 411 end
395 412
396 413 def test_assignable_users
397 414 assert_kind_of User, Issue.find(1).assignable_users.first
398 415 end
399 416
400 417 def test_create_should_send_email_notification
401 418 ActionMailer::Base.deliveries.clear
402 419 issue = Issue.new(:project_id => 1, :tracker_id => 1, :author_id => 3, :status_id => 1, :priority => IssuePriority.all.first, :subject => 'test_create', :estimated_hours => '1:30')
403 420
404 421 assert issue.save
405 422 assert_equal 1, ActionMailer::Base.deliveries.size
406 423 end
407 424
408 425 def test_stale_issue_should_not_send_email_notification
409 426 ActionMailer::Base.deliveries.clear
410 427 issue = Issue.find(1)
411 428 stale = Issue.find(1)
412 429
413 430 issue.init_journal(User.find(1))
414 431 issue.subject = 'Subjet update'
415 432 assert issue.save
416 433 assert_equal 1, ActionMailer::Base.deliveries.size
417 434 ActionMailer::Base.deliveries.clear
418 435
419 436 stale.init_journal(User.find(1))
420 437 stale.subject = 'Another subjet update'
421 438 assert_raise ActiveRecord::StaleObjectError do
422 439 stale.save
423 440 end
424 441 assert ActionMailer::Base.deliveries.empty?
425 442 end
426 443 end
General Comments 0
You need to be logged in to leave comments. Login now