##// END OF EJS Templates
Notify previous assignee when assignee changes (#2694)....
Jean-Philippe Lang -
r8575:22317105f99e
parent child
Show More
@@ -1,1044 +1,1052
1 1 # Redmine - project management software
2 2 # Copyright (C) 2006-2011 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 include Redmine::SafeAttributes
20 20
21 21 belongs_to :project
22 22 belongs_to :tracker
23 23 belongs_to :status, :class_name => 'IssueStatus', :foreign_key => 'status_id'
24 24 belongs_to :author, :class_name => 'User', :foreign_key => 'author_id'
25 25 belongs_to :assigned_to, :class_name => 'Principal', :foreign_key => 'assigned_to_id'
26 26 belongs_to :fixed_version, :class_name => 'Version', :foreign_key => 'fixed_version_id'
27 27 belongs_to :priority, :class_name => 'IssuePriority', :foreign_key => 'priority_id'
28 28 belongs_to :category, :class_name => 'IssueCategory', :foreign_key => 'category_id'
29 29
30 30 has_many :journals, :as => :journalized, :dependent => :destroy
31 31 has_many :time_entries, :dependent => :delete_all
32 32 has_and_belongs_to_many :changesets, :order => "#{Changeset.table_name}.committed_on ASC, #{Changeset.table_name}.id ASC"
33 33
34 34 has_many :relations_from, :class_name => 'IssueRelation', :foreign_key => 'issue_from_id', :dependent => :delete_all
35 35 has_many :relations_to, :class_name => 'IssueRelation', :foreign_key => 'issue_to_id', :dependent => :delete_all
36 36
37 37 acts_as_nested_set :scope => 'root_id', :dependent => :destroy
38 38 acts_as_attachable :after_add => :attachment_added, :after_remove => :attachment_removed
39 39 acts_as_customizable
40 40 acts_as_watchable
41 41 acts_as_searchable :columns => ['subject', "#{table_name}.description", "#{Journal.table_name}.notes"],
42 42 :include => [:project, :journals],
43 43 # sort by id so that limited eager loading doesn't break with postgresql
44 44 :order_column => "#{table_name}.id"
45 45 acts_as_event :title => Proc.new {|o| "#{o.tracker.name} ##{o.id} (#{o.status}): #{o.subject}"},
46 46 :url => Proc.new {|o| {:controller => 'issues', :action => 'show', :id => o.id}},
47 47 :type => Proc.new {|o| 'issue' + (o.closed? ? ' closed' : '') }
48 48
49 49 acts_as_activity_provider :find_options => {:include => [:project, :author, :tracker]},
50 50 :author_key => :author_id
51 51
52 52 DONE_RATIO_OPTIONS = %w(issue_field issue_status)
53 53
54 54 attr_reader :current_journal
55 55
56 56 validates_presence_of :subject, :priority, :project, :tracker, :author, :status
57 57
58 58 validates_length_of :subject, :maximum => 255
59 59 validates_inclusion_of :done_ratio, :in => 0..100
60 60 validates_numericality_of :estimated_hours, :allow_nil => true
61 61 validate :validate_issue
62 62
63 63 named_scope :visible, lambda {|*args| { :include => :project,
64 64 :conditions => Issue.visible_condition(args.shift || User.current, *args) } }
65 65
66 66 named_scope :open, lambda {|*args|
67 67 is_closed = args.size > 0 ? !args.first : false
68 68 {:conditions => ["#{IssueStatus.table_name}.is_closed = ?", is_closed], :include => :status}
69 69 }
70 70
71 71 named_scope :recently_updated, :order => "#{Issue.table_name}.updated_on DESC"
72 72 named_scope :with_limit, lambda { |limit| { :limit => limit} }
73 73 named_scope :on_active_project, :include => [:status, :project, :tracker],
74 74 :conditions => ["#{Project.table_name}.status=#{Project::STATUS_ACTIVE}"]
75 75
76 76 before_create :default_assign
77 77 before_save :close_duplicates, :update_done_ratio_from_issue_status
78 78 after_save {|issue| issue.send :after_project_change if !issue.id_changed? && issue.project_id_changed?}
79 79 after_save :reschedule_following_issues, :update_nested_set_attributes, :update_parent_attributes, :create_journal
80 80 after_destroy :update_parent_attributes
81 81
82 82 # Returns a SQL conditions string used to find all issues visible by the specified user
83 83 def self.visible_condition(user, options={})
84 84 Project.allowed_to_condition(user, :view_issues, options) do |role, user|
85 85 case role.issues_visibility
86 86 when 'all'
87 87 nil
88 88 when 'default'
89 89 user_ids = [user.id] + user.groups.map(&:id)
90 90 "(#{table_name}.is_private = #{connection.quoted_false} OR #{table_name}.author_id = #{user.id} OR #{table_name}.assigned_to_id IN (#{user_ids.join(',')}))"
91 91 when 'own'
92 92 user_ids = [user.id] + user.groups.map(&:id)
93 93 "(#{table_name}.author_id = #{user.id} OR #{table_name}.assigned_to_id IN (#{user_ids.join(',')}))"
94 94 else
95 95 '1=0'
96 96 end
97 97 end
98 98 end
99 99
100 100 # Returns true if usr or current user is allowed to view the issue
101 101 def visible?(usr=nil)
102 102 (usr || User.current).allowed_to?(:view_issues, self.project) do |role, user|
103 103 case role.issues_visibility
104 104 when 'all'
105 105 true
106 106 when 'default'
107 107 !self.is_private? || self.author == user || user.is_or_belongs_to?(assigned_to)
108 108 when 'own'
109 109 self.author == user || user.is_or_belongs_to?(assigned_to)
110 110 else
111 111 false
112 112 end
113 113 end
114 114 end
115 115
116 116 def initialize(attributes=nil, *args)
117 117 super
118 118 if new_record?
119 119 # set default values for new records only
120 120 self.status ||= IssueStatus.default
121 121 self.priority ||= IssuePriority.default
122 122 self.watcher_user_ids = []
123 123 end
124 124 end
125 125
126 126 # Overrides Redmine::Acts::Customizable::InstanceMethods#available_custom_fields
127 127 def available_custom_fields
128 128 (project && tracker) ? (project.all_issue_custom_fields & tracker.custom_fields.all) : []
129 129 end
130 130
131 131 # Copies attributes from another issue, arg can be an id or an Issue
132 132 def copy_from(arg, options={})
133 133 issue = arg.is_a?(Issue) ? arg : Issue.visible.find(arg)
134 134 self.attributes = issue.attributes.dup.except("id", "root_id", "parent_id", "lft", "rgt", "created_on", "updated_on")
135 135 self.custom_field_values = issue.custom_field_values.inject({}) {|h,v| h[v.custom_field_id] = v.value; h}
136 136 self.status = issue.status
137 137 self.author = User.current
138 138 unless options[:attachments] == false
139 139 self.attachments = issue.attachments.map do |attachement|
140 140 attachement.copy(:container => self)
141 141 end
142 142 end
143 143 @copied_from = issue
144 144 self
145 145 end
146 146
147 147 # Returns an unsaved copy of the issue
148 148 def copy(attributes=nil)
149 149 copy = self.class.new.copy_from(self)
150 150 copy.attributes = attributes if attributes
151 151 copy
152 152 end
153 153
154 154 # Returns true if the issue is a copy
155 155 def copy?
156 156 @copied_from.present?
157 157 end
158 158
159 159 # Moves/copies an issue to a new project and tracker
160 160 # Returns the moved/copied issue on success, false on failure
161 161 def move_to_project(new_project, new_tracker=nil, options={})
162 162 ActiveSupport::Deprecation.warn "Issue#move_to_project is deprecated, use #project= instead."
163 163
164 164 if options[:copy]
165 165 issue = self.copy
166 166 else
167 167 issue = self
168 168 end
169 169
170 170 issue.init_journal(User.current, options[:notes])
171 171
172 172 # Preserve previous behaviour
173 173 # #move_to_project doesn't change tracker automatically
174 174 issue.send :project=, new_project, true
175 175 if new_tracker
176 176 issue.tracker = new_tracker
177 177 end
178 178 # Allow bulk setting of attributes on the issue
179 179 if options[:attributes]
180 180 issue.attributes = options[:attributes]
181 181 end
182 182
183 183 issue.save ? issue : false
184 184 end
185 185
186 186 def status_id=(sid)
187 187 self.status = nil
188 188 write_attribute(:status_id, sid)
189 189 end
190 190
191 191 def priority_id=(pid)
192 192 self.priority = nil
193 193 write_attribute(:priority_id, pid)
194 194 end
195 195
196 196 def category_id=(cid)
197 197 self.category = nil
198 198 write_attribute(:category_id, cid)
199 199 end
200 200
201 201 def fixed_version_id=(vid)
202 202 self.fixed_version = nil
203 203 write_attribute(:fixed_version_id, vid)
204 204 end
205 205
206 206 def tracker_id=(tid)
207 207 self.tracker = nil
208 208 result = write_attribute(:tracker_id, tid)
209 209 @custom_field_values = nil
210 210 result
211 211 end
212 212
213 213 def project_id=(project_id)
214 214 if project_id.to_s != self.project_id.to_s
215 215 self.project = (project_id.present? ? Project.find_by_id(project_id) : nil)
216 216 end
217 217 end
218 218
219 219 def project=(project, keep_tracker=false)
220 220 project_was = self.project
221 221 write_attribute(:project_id, project ? project.id : nil)
222 222 association_instance_set('project', project)
223 223 if project_was && project && project_was != project
224 224 unless keep_tracker || project.trackers.include?(tracker)
225 225 self.tracker = project.trackers.first
226 226 end
227 227 # Reassign to the category with same name if any
228 228 if category
229 229 self.category = project.issue_categories.find_by_name(category.name)
230 230 end
231 231 # Keep the fixed_version if it's still valid in the new_project
232 232 if fixed_version && fixed_version.project != project && !project.shared_versions.include?(fixed_version)
233 233 self.fixed_version = nil
234 234 end
235 235 if parent && parent.project_id != project_id
236 236 self.parent_issue_id = nil
237 237 end
238 238 @custom_field_values = nil
239 239 end
240 240 end
241 241
242 242 def description=(arg)
243 243 if arg.is_a?(String)
244 244 arg = arg.gsub(/(\r\n|\n|\r)/, "\r\n")
245 245 end
246 246 write_attribute(:description, arg)
247 247 end
248 248
249 249 # Overrides attributes= so that project and tracker get assigned first
250 250 def attributes_with_project_and_tracker_first=(new_attributes, *args)
251 251 return if new_attributes.nil?
252 252 attrs = new_attributes.dup
253 253 attrs.stringify_keys!
254 254
255 255 %w(project project_id tracker tracker_id).each do |attr|
256 256 if attrs.has_key?(attr)
257 257 send "#{attr}=", attrs.delete(attr)
258 258 end
259 259 end
260 260 send :attributes_without_project_and_tracker_first=, attrs, *args
261 261 end
262 262 # Do not redefine alias chain on reload (see #4838)
263 263 alias_method_chain(:attributes=, :project_and_tracker_first) unless method_defined?(:attributes_without_project_and_tracker_first=)
264 264
265 265 def estimated_hours=(h)
266 266 write_attribute :estimated_hours, (h.is_a?(String) ? h.to_hours : h)
267 267 end
268 268
269 269 safe_attributes 'project_id',
270 270 :if => lambda {|issue, user|
271 271 if issue.new_record?
272 272 issue.copy?
273 273 elsif user.allowed_to?(:move_issues, issue.project)
274 274 projects = Issue.allowed_target_projects_on_move(user)
275 275 projects.include?(issue.project) && projects.size > 1
276 276 end
277 277 }
278 278
279 279 safe_attributes 'tracker_id',
280 280 'status_id',
281 281 'category_id',
282 282 'assigned_to_id',
283 283 'priority_id',
284 284 'fixed_version_id',
285 285 'subject',
286 286 'description',
287 287 'start_date',
288 288 'due_date',
289 289 'done_ratio',
290 290 'estimated_hours',
291 291 'custom_field_values',
292 292 'custom_fields',
293 293 'lock_version',
294 294 :if => lambda {|issue, user| issue.new_record? || user.allowed_to?(:edit_issues, issue.project) }
295 295
296 296 safe_attributes 'status_id',
297 297 'assigned_to_id',
298 298 'fixed_version_id',
299 299 'done_ratio',
300 300 'lock_version',
301 301 :if => lambda {|issue, user| issue.new_statuses_allowed_to(user).any? }
302 302
303 303 safe_attributes 'watcher_user_ids',
304 304 :if => lambda {|issue, user| issue.new_record? && user.allowed_to?(:add_issue_watchers, issue.project)}
305 305
306 306 safe_attributes 'is_private',
307 307 :if => lambda {|issue, user|
308 308 user.allowed_to?(:set_issues_private, issue.project) ||
309 309 (issue.author == user && user.allowed_to?(:set_own_issues_private, issue.project))
310 310 }
311 311
312 312 safe_attributes 'parent_issue_id',
313 313 :if => lambda {|issue, user| (issue.new_record? || user.allowed_to?(:edit_issues, issue.project)) &&
314 314 user.allowed_to?(:manage_subtasks, issue.project)}
315 315
316 316 # Safely sets attributes
317 317 # Should be called from controllers instead of #attributes=
318 318 # attr_accessible is too rough because we still want things like
319 319 # Issue.new(:project => foo) to work
320 320 def safe_attributes=(attrs, user=User.current)
321 321 return unless attrs.is_a?(Hash)
322 322
323 323 # User can change issue attributes only if he has :edit permission or if a workflow transition is allowed
324 324 attrs = delete_unsafe_attributes(attrs, user)
325 325 return if attrs.empty?
326 326
327 327 # Project and Tracker must be set before since new_statuses_allowed_to depends on it.
328 328 if p = attrs.delete('project_id')
329 329 if allowed_target_projects(user).collect(&:id).include?(p.to_i)
330 330 self.project_id = p
331 331 end
332 332 end
333 333
334 334 if t = attrs.delete('tracker_id')
335 335 self.tracker_id = t
336 336 end
337 337
338 338 if attrs['status_id']
339 339 unless new_statuses_allowed_to(user).collect(&:id).include?(attrs['status_id'].to_i)
340 340 attrs.delete('status_id')
341 341 end
342 342 end
343 343
344 344 unless leaf?
345 345 attrs.reject! {|k,v| %w(priority_id done_ratio start_date due_date estimated_hours).include?(k)}
346 346 end
347 347
348 348 if attrs['parent_issue_id'].present?
349 349 attrs.delete('parent_issue_id') unless Issue.visible(user).exists?(attrs['parent_issue_id'].to_i)
350 350 end
351 351
352 352 # mass-assignment security bypass
353 353 self.send :attributes=, attrs, false
354 354 end
355 355
356 356 def done_ratio
357 357 if Issue.use_status_for_done_ratio? && status && status.default_done_ratio
358 358 status.default_done_ratio
359 359 else
360 360 read_attribute(:done_ratio)
361 361 end
362 362 end
363 363
364 364 def self.use_status_for_done_ratio?
365 365 Setting.issue_done_ratio == 'issue_status'
366 366 end
367 367
368 368 def self.use_field_for_done_ratio?
369 369 Setting.issue_done_ratio == 'issue_field'
370 370 end
371 371
372 372 def validate_issue
373 373 if self.due_date.nil? && @attributes['due_date'] && !@attributes['due_date'].empty?
374 374 errors.add :due_date, :not_a_date
375 375 end
376 376
377 377 if self.due_date and self.start_date and self.due_date < self.start_date
378 378 errors.add :due_date, :greater_than_start_date
379 379 end
380 380
381 381 if start_date && soonest_start && start_date < soonest_start
382 382 errors.add :start_date, :invalid
383 383 end
384 384
385 385 if fixed_version
386 386 if !assignable_versions.include?(fixed_version)
387 387 errors.add :fixed_version_id, :inclusion
388 388 elsif reopened? && fixed_version.closed?
389 389 errors.add :base, I18n.t(:error_can_not_reopen_issue_on_closed_version)
390 390 end
391 391 end
392 392
393 393 # Checks that the issue can not be added/moved to a disabled tracker
394 394 if project && (tracker_id_changed? || project_id_changed?)
395 395 unless project.trackers.include?(tracker)
396 396 errors.add :tracker_id, :inclusion
397 397 end
398 398 end
399 399
400 400 # Checks parent issue assignment
401 401 if @parent_issue
402 402 if @parent_issue.project_id != project_id
403 403 errors.add :parent_issue_id, :not_same_project
404 404 elsif !new_record?
405 405 # moving an existing issue
406 406 if @parent_issue.root_id != root_id
407 407 # we can always move to another tree
408 408 elsif move_possible?(@parent_issue)
409 409 # move accepted inside tree
410 410 else
411 411 errors.add :parent_issue_id, :not_a_valid_parent
412 412 end
413 413 end
414 414 end
415 415 end
416 416
417 417 # Set the done_ratio using the status if that setting is set. This will keep the done_ratios
418 418 # even if the user turns off the setting later
419 419 def update_done_ratio_from_issue_status
420 420 if Issue.use_status_for_done_ratio? && status && status.default_done_ratio
421 421 self.done_ratio = status.default_done_ratio
422 422 end
423 423 end
424 424
425 425 def init_journal(user, notes = "")
426 426 @current_journal ||= Journal.new(:journalized => self, :user => user, :notes => notes)
427 427 if new_record?
428 428 @current_journal.notify = false
429 429 else
430 430 @attributes_before_change = attributes.dup
431 431 @custom_values_before_change = {}
432 432 self.custom_values.each {|c| @custom_values_before_change.store c.custom_field_id, c.value }
433 433 end
434 434 # Make sure updated_on is updated when adding a note.
435 435 updated_on_will_change!
436 436 @current_journal
437 437 end
438 438
439 439 # Return true if the issue is closed, otherwise false
440 440 def closed?
441 441 self.status.is_closed?
442 442 end
443 443
444 444 # Return true if the issue is being reopened
445 445 def reopened?
446 446 if !new_record? && status_id_changed?
447 447 status_was = IssueStatus.find_by_id(status_id_was)
448 448 status_new = IssueStatus.find_by_id(status_id)
449 449 if status_was && status_new && status_was.is_closed? && !status_new.is_closed?
450 450 return true
451 451 end
452 452 end
453 453 false
454 454 end
455 455
456 456 # Return true if the issue is being closed
457 457 def closing?
458 458 if !new_record? && status_id_changed?
459 459 status_was = IssueStatus.find_by_id(status_id_was)
460 460 status_new = IssueStatus.find_by_id(status_id)
461 461 if status_was && status_new && !status_was.is_closed? && status_new.is_closed?
462 462 return true
463 463 end
464 464 end
465 465 false
466 466 end
467 467
468 468 # Returns true if the issue is overdue
469 469 def overdue?
470 470 !due_date.nil? && (due_date < Date.today) && !status.is_closed?
471 471 end
472 472
473 473 # Is the amount of work done less than it should for the due date
474 474 def behind_schedule?
475 475 return false if start_date.nil? || due_date.nil?
476 476 done_date = start_date + ((due_date - start_date+1)* done_ratio/100).floor
477 477 return done_date <= Date.today
478 478 end
479 479
480 480 # Does this issue have children?
481 481 def children?
482 482 !leaf?
483 483 end
484 484
485 485 # Users the issue can be assigned to
486 486 def assignable_users
487 487 users = project.assignable_users
488 488 users << author if author
489 489 users << assigned_to if assigned_to
490 490 users.uniq.sort
491 491 end
492 492
493 493 # Versions that the issue can be assigned to
494 494 def assignable_versions
495 495 @assignable_versions ||= (project.shared_versions.open + [Version.find_by_id(fixed_version_id_was)]).compact.uniq.sort
496 496 end
497 497
498 498 # Returns true if this issue is blocked by another issue that is still open
499 499 def blocked?
500 500 !relations_to.detect {|ir| ir.relation_type == 'blocks' && !ir.issue_from.closed?}.nil?
501 501 end
502 502
503 503 # Returns an array of status that user is able to apply
504 504 def new_statuses_allowed_to(user, include_default=false)
505 505 statuses = status.find_new_statuses_allowed_to(
506 506 user.roles_for_project(project),
507 507 tracker,
508 508 author == user,
509 509 assigned_to_id_changed? ? assigned_to_id_was == user.id : assigned_to_id == user.id
510 510 )
511 511 statuses << status unless statuses.empty?
512 512 statuses << IssueStatus.default if include_default
513 513 statuses = statuses.uniq.sort
514 514 blocked? ? statuses.reject {|s| s.is_closed?} : statuses
515 515 end
516 516
517 def assigned_to_was
518 if assigned_to_id_changed? && assigned_to_id_was.present?
519 @assigned_to_was ||= User.find_by_id(assigned_to_id_was)
520 end
521 end
522
517 523 # Returns the mail adresses of users that should be notified
518 524 def recipients
519 notified = project.notified_users
525 notified = []
520 526 # Author and assignee are always notified unless they have been
521 527 # locked or don't want to be notified
522 notified << author if author && author.active? && author.notify_about?(self)
528 notified << author if author
523 529 if assigned_to
524 if assigned_to.is_a?(Group)
525 notified += assigned_to.users.select {|u| u.active? && u.notify_about?(self)}
526 else
527 notified << assigned_to if assigned_to.active? && assigned_to.notify_about?(self)
528 end
530 notified += (assigned_to.is_a?(Group) ? assigned_to.users : [assigned_to])
531 end
532 if assigned_to_was
533 notified += (assigned_to_was.is_a?(Group) ? assigned_to_was.users : [assigned_to_was])
529 534 end
535 notified = notified.select {|u| u.active? && u.notify_about?(self)}
536
537 notified += project.notified_users
530 538 notified.uniq!
531 539 # Remove users that can not view the issue
532 540 notified.reject! {|user| !visible?(user)}
533 541 notified.collect(&:mail)
534 542 end
535 543
536 544 # Returns the number of hours spent on this issue
537 545 def spent_hours
538 546 @spent_hours ||= time_entries.sum(:hours) || 0
539 547 end
540 548
541 549 # Returns the total number of hours spent on this issue and its descendants
542 550 #
543 551 # Example:
544 552 # spent_hours => 0.0
545 553 # spent_hours => 50.2
546 554 def total_spent_hours
547 555 @total_spent_hours ||= self_and_descendants.sum("#{TimeEntry.table_name}.hours",
548 556 :joins => "LEFT JOIN #{TimeEntry.table_name} ON #{TimeEntry.table_name}.issue_id = #{Issue.table_name}.id").to_f || 0.0
549 557 end
550 558
551 559 def relations
552 560 @relations ||= (relations_from + relations_to).sort
553 561 end
554 562
555 563 # Preloads relations for a collection of issues
556 564 def self.load_relations(issues)
557 565 if issues.any?
558 566 relations = IssueRelation.all(:conditions => ["issue_from_id IN (:ids) OR issue_to_id IN (:ids)", {:ids => issues.map(&:id)}])
559 567 issues.each do |issue|
560 568 issue.instance_variable_set "@relations", relations.select {|r| r.issue_from_id == issue.id || r.issue_to_id == issue.id}
561 569 end
562 570 end
563 571 end
564 572
565 573 # Preloads visible spent time for a collection of issues
566 574 def self.load_visible_spent_hours(issues, user=User.current)
567 575 if issues.any?
568 576 hours_by_issue_id = TimeEntry.visible(user).sum(:hours, :group => :issue_id)
569 577 issues.each do |issue|
570 578 issue.instance_variable_set "@spent_hours", (hours_by_issue_id[issue.id] || 0)
571 579 end
572 580 end
573 581 end
574 582
575 583 # Finds an issue relation given its id.
576 584 def find_relation(relation_id)
577 585 IssueRelation.find(relation_id, :conditions => ["issue_to_id = ? OR issue_from_id = ?", id, id])
578 586 end
579 587
580 588 def all_dependent_issues(except=[])
581 589 except << self
582 590 dependencies = []
583 591 relations_from.each do |relation|
584 592 if relation.issue_to && !except.include?(relation.issue_to)
585 593 dependencies << relation.issue_to
586 594 dependencies += relation.issue_to.all_dependent_issues(except)
587 595 end
588 596 end
589 597 dependencies
590 598 end
591 599
592 600 # Returns an array of issues that duplicate this one
593 601 def duplicates
594 602 relations_to.select {|r| r.relation_type == IssueRelation::TYPE_DUPLICATES}.collect {|r| r.issue_from}
595 603 end
596 604
597 605 # Returns the due date or the target due date if any
598 606 # Used on gantt chart
599 607 def due_before
600 608 due_date || (fixed_version ? fixed_version.effective_date : nil)
601 609 end
602 610
603 611 # Returns the time scheduled for this issue.
604 612 #
605 613 # Example:
606 614 # Start Date: 2/26/09, End Date: 3/04/09
607 615 # duration => 6
608 616 def duration
609 617 (start_date && due_date) ? due_date - start_date : 0
610 618 end
611 619
612 620 def soonest_start
613 621 @soonest_start ||= (
614 622 relations_to.collect{|relation| relation.successor_soonest_start} +
615 623 ancestors.collect(&:soonest_start)
616 624 ).compact.max
617 625 end
618 626
619 627 def reschedule_after(date)
620 628 return if date.nil?
621 629 if leaf?
622 630 if start_date.nil? || start_date < date
623 631 self.start_date, self.due_date = date, date + duration
624 632 save
625 633 end
626 634 else
627 635 leaves.each do |leaf|
628 636 leaf.reschedule_after(date)
629 637 end
630 638 end
631 639 end
632 640
633 641 def <=>(issue)
634 642 if issue.nil?
635 643 -1
636 644 elsif root_id != issue.root_id
637 645 (root_id || 0) <=> (issue.root_id || 0)
638 646 else
639 647 (lft || 0) <=> (issue.lft || 0)
640 648 end
641 649 end
642 650
643 651 def to_s
644 652 "#{tracker} ##{id}: #{subject}"
645 653 end
646 654
647 655 # Returns a string of css classes that apply to the issue
648 656 def css_classes
649 657 s = "issue status-#{status.position} priority-#{priority.position}"
650 658 s << ' closed' if closed?
651 659 s << ' overdue' if overdue?
652 660 s << ' child' if child?
653 661 s << ' parent' unless leaf?
654 662 s << ' private' if is_private?
655 663 s << ' created-by-me' if User.current.logged? && author_id == User.current.id
656 664 s << ' assigned-to-me' if User.current.logged? && assigned_to_id == User.current.id
657 665 s
658 666 end
659 667
660 668 # Saves an issue, time_entry, attachments, and a journal from the parameters
661 669 # Returns false if save fails
662 670 def save_issue_with_child_records(params, existing_time_entry=nil)
663 671 Issue.transaction do
664 672 if params[:time_entry] && (params[:time_entry][:hours].present? || params[:time_entry][:comments].present?) && User.current.allowed_to?(:log_time, project)
665 673 @time_entry = existing_time_entry || TimeEntry.new
666 674 @time_entry.project = project
667 675 @time_entry.issue = self
668 676 @time_entry.user = User.current
669 677 @time_entry.spent_on = User.current.today
670 678 @time_entry.attributes = params[:time_entry]
671 679 self.time_entries << @time_entry
672 680 end
673 681
674 682 if valid?
675 683 attachments = Attachment.attach_files(self, params[:attachments])
676 684 # TODO: Rename hook
677 685 Redmine::Hook.call_hook(:controller_issues_edit_before_save, { :params => params, :issue => self, :time_entry => @time_entry, :journal => @current_journal})
678 686 begin
679 687 if save
680 688 # TODO: Rename hook
681 689 Redmine::Hook.call_hook(:controller_issues_edit_after_save, { :params => params, :issue => self, :time_entry => @time_entry, :journal => @current_journal})
682 690 else
683 691 raise ActiveRecord::Rollback
684 692 end
685 693 rescue ActiveRecord::StaleObjectError
686 694 attachments[:files].each(&:destroy)
687 695 errors.add :base, l(:notice_locking_conflict)
688 696 raise ActiveRecord::Rollback
689 697 end
690 698 end
691 699 end
692 700 end
693 701
694 702 # Unassigns issues from +version+ if it's no longer shared with issue's project
695 703 def self.update_versions_from_sharing_change(version)
696 704 # Update issues assigned to the version
697 705 update_versions(["#{Issue.table_name}.fixed_version_id = ?", version.id])
698 706 end
699 707
700 708 # Unassigns issues from versions that are no longer shared
701 709 # after +project+ was moved
702 710 def self.update_versions_from_hierarchy_change(project)
703 711 moved_project_ids = project.self_and_descendants.reload.collect(&:id)
704 712 # Update issues of the moved projects and issues assigned to a version of a moved project
705 713 Issue.update_versions(["#{Version.table_name}.project_id IN (?) OR #{Issue.table_name}.project_id IN (?)", moved_project_ids, moved_project_ids])
706 714 end
707 715
708 716 def parent_issue_id=(arg)
709 717 parent_issue_id = arg.blank? ? nil : arg.to_i
710 718 if parent_issue_id && @parent_issue = Issue.find_by_id(parent_issue_id)
711 719 @parent_issue.id
712 720 else
713 721 @parent_issue = nil
714 722 nil
715 723 end
716 724 end
717 725
718 726 def parent_issue_id
719 727 if instance_variable_defined? :@parent_issue
720 728 @parent_issue.nil? ? nil : @parent_issue.id
721 729 else
722 730 parent_id
723 731 end
724 732 end
725 733
726 734 # Extracted from the ReportsController.
727 735 def self.by_tracker(project)
728 736 count_and_group_by(:project => project,
729 737 :field => 'tracker_id',
730 738 :joins => Tracker.table_name)
731 739 end
732 740
733 741 def self.by_version(project)
734 742 count_and_group_by(:project => project,
735 743 :field => 'fixed_version_id',
736 744 :joins => Version.table_name)
737 745 end
738 746
739 747 def self.by_priority(project)
740 748 count_and_group_by(:project => project,
741 749 :field => 'priority_id',
742 750 :joins => IssuePriority.table_name)
743 751 end
744 752
745 753 def self.by_category(project)
746 754 count_and_group_by(:project => project,
747 755 :field => 'category_id',
748 756 :joins => IssueCategory.table_name)
749 757 end
750 758
751 759 def self.by_assigned_to(project)
752 760 count_and_group_by(:project => project,
753 761 :field => 'assigned_to_id',
754 762 :joins => User.table_name)
755 763 end
756 764
757 765 def self.by_author(project)
758 766 count_and_group_by(:project => project,
759 767 :field => 'author_id',
760 768 :joins => User.table_name)
761 769 end
762 770
763 771 def self.by_subproject(project)
764 772 ActiveRecord::Base.connection.select_all("select s.id as status_id,
765 773 s.is_closed as closed,
766 774 #{Issue.table_name}.project_id as project_id,
767 775 count(#{Issue.table_name}.id) as total
768 776 from
769 777 #{Issue.table_name}, #{Project.table_name}, #{IssueStatus.table_name} s
770 778 where
771 779 #{Issue.table_name}.status_id=s.id
772 780 and #{Issue.table_name}.project_id = #{Project.table_name}.id
773 781 and #{visible_condition(User.current, :project => project, :with_subprojects => true)}
774 782 and #{Issue.table_name}.project_id <> #{project.id}
775 783 group by s.id, s.is_closed, #{Issue.table_name}.project_id") if project.descendants.active.any?
776 784 end
777 785 # End ReportsController extraction
778 786
779 787 # Returns an array of projects that user can assign the issue to
780 788 def allowed_target_projects(user=User.current)
781 789 if new_record?
782 790 Project.all(:conditions => Project.allowed_to_condition(user, :add_issues))
783 791 else
784 792 self.class.allowed_target_projects_on_move(user)
785 793 end
786 794 end
787 795
788 796 # Returns an array of projects that user can move issues to
789 797 def self.allowed_target_projects_on_move(user=User.current)
790 798 projects = []
791 799 if user.admin?
792 800 # admin is allowed to move issues to any active (visible) project
793 801 projects = Project.visible(user).all
794 802 elsif user.logged?
795 803 if Role.non_member.allowed_to?(:move_issues)
796 804 projects = Project.visible(user).all
797 805 else
798 806 user.memberships.each {|m| projects << m.project if m.roles.detect {|r| r.allowed_to?(:move_issues)}}
799 807 end
800 808 end
801 809 projects
802 810 end
803 811
804 812 private
805 813
806 814 def after_project_change
807 815 # Update project_id on related time entries
808 816 TimeEntry.update_all(["project_id = ?", project_id], {:issue_id => id})
809 817
810 818 # Delete issue relations
811 819 unless Setting.cross_project_issue_relations?
812 820 relations_from.clear
813 821 relations_to.clear
814 822 end
815 823
816 824 # Move subtasks
817 825 children.each do |child|
818 826 # Change project and keep project
819 827 child.send :project=, project, true
820 828 unless child.save
821 829 raise ActiveRecord::Rollback
822 830 end
823 831 end
824 832 end
825 833
826 834 def update_nested_set_attributes
827 835 if root_id.nil?
828 836 # issue was just created
829 837 self.root_id = (@parent_issue.nil? ? id : @parent_issue.root_id)
830 838 set_default_left_and_right
831 839 Issue.update_all("root_id = #{root_id}, lft = #{lft}, rgt = #{rgt}", ["id = ?", id])
832 840 if @parent_issue
833 841 move_to_child_of(@parent_issue)
834 842 end
835 843 reload
836 844 elsif parent_issue_id != parent_id
837 845 former_parent_id = parent_id
838 846 # moving an existing issue
839 847 if @parent_issue && @parent_issue.root_id == root_id
840 848 # inside the same tree
841 849 move_to_child_of(@parent_issue)
842 850 else
843 851 # to another tree
844 852 unless root?
845 853 move_to_right_of(root)
846 854 reload
847 855 end
848 856 old_root_id = root_id
849 857 self.root_id = (@parent_issue.nil? ? id : @parent_issue.root_id )
850 858 target_maxright = nested_set_scope.maximum(right_column_name) || 0
851 859 offset = target_maxright + 1 - lft
852 860 Issue.update_all("root_id = #{root_id}, lft = lft + #{offset}, rgt = rgt + #{offset}",
853 861 ["root_id = ? AND lft >= ? AND rgt <= ? ", old_root_id, lft, rgt])
854 862 self[left_column_name] = lft + offset
855 863 self[right_column_name] = rgt + offset
856 864 if @parent_issue
857 865 move_to_child_of(@parent_issue)
858 866 end
859 867 end
860 868 reload
861 869 # delete invalid relations of all descendants
862 870 self_and_descendants.each do |issue|
863 871 issue.relations.each do |relation|
864 872 relation.destroy unless relation.valid?
865 873 end
866 874 end
867 875 # update former parent
868 876 recalculate_attributes_for(former_parent_id) if former_parent_id
869 877 end
870 878 remove_instance_variable(:@parent_issue) if instance_variable_defined?(:@parent_issue)
871 879 end
872 880
873 881 def update_parent_attributes
874 882 recalculate_attributes_for(parent_id) if parent_id
875 883 end
876 884
877 885 def recalculate_attributes_for(issue_id)
878 886 if issue_id && p = Issue.find_by_id(issue_id)
879 887 # priority = highest priority of children
880 888 if priority_position = p.children.maximum("#{IssuePriority.table_name}.position", :joins => :priority)
881 889 p.priority = IssuePriority.find_by_position(priority_position)
882 890 end
883 891
884 892 # start/due dates = lowest/highest dates of children
885 893 p.start_date = p.children.minimum(:start_date)
886 894 p.due_date = p.children.maximum(:due_date)
887 895 if p.start_date && p.due_date && p.due_date < p.start_date
888 896 p.start_date, p.due_date = p.due_date, p.start_date
889 897 end
890 898
891 899 # done ratio = weighted average ratio of leaves
892 900 unless Issue.use_status_for_done_ratio? && p.status && p.status.default_done_ratio
893 901 leaves_count = p.leaves.count
894 902 if leaves_count > 0
895 903 average = p.leaves.average(:estimated_hours).to_f
896 904 if average == 0
897 905 average = 1
898 906 end
899 907 done = p.leaves.sum("COALESCE(estimated_hours, #{average}) * (CASE WHEN is_closed = #{connection.quoted_true} THEN 100 ELSE COALESCE(done_ratio, 0) END)", :joins => :status).to_f
900 908 progress = done / (average * leaves_count)
901 909 p.done_ratio = progress.round
902 910 end
903 911 end
904 912
905 913 # estimate = sum of leaves estimates
906 914 p.estimated_hours = p.leaves.sum(:estimated_hours).to_f
907 915 p.estimated_hours = nil if p.estimated_hours == 0.0
908 916
909 917 # ancestors will be recursively updated
910 918 p.save(false)
911 919 end
912 920 end
913 921
914 922 # Update issues so their versions are not pointing to a
915 923 # fixed_version that is not shared with the issue's project
916 924 def self.update_versions(conditions=nil)
917 925 # Only need to update issues with a fixed_version from
918 926 # a different project and that is not systemwide shared
919 927 Issue.scoped(:conditions => conditions).all(
920 928 :conditions => "#{Issue.table_name}.fixed_version_id IS NOT NULL" +
921 929 " AND #{Issue.table_name}.project_id <> #{Version.table_name}.project_id" +
922 930 " AND #{Version.table_name}.sharing <> 'system'",
923 931 :include => [:project, :fixed_version]
924 932 ).each do |issue|
925 933 next if issue.project.nil? || issue.fixed_version.nil?
926 934 unless issue.project.shared_versions.include?(issue.fixed_version)
927 935 issue.init_journal(User.current)
928 936 issue.fixed_version = nil
929 937 issue.save
930 938 end
931 939 end
932 940 end
933 941
934 942 # Callback on attachment deletion
935 943 def attachment_added(obj)
936 944 if @current_journal && !obj.new_record?
937 945 @current_journal.details << JournalDetail.new(:property => 'attachment', :prop_key => obj.id, :value => obj.filename)
938 946 end
939 947 end
940 948
941 949 # Callback on attachment deletion
942 950 def attachment_removed(obj)
943 951 journal = init_journal(User.current)
944 952 journal.details << JournalDetail.new(:property => 'attachment',
945 953 :prop_key => obj.id,
946 954 :old_value => obj.filename)
947 955 journal.save
948 956 end
949 957
950 958 # Default assignment based on category
951 959 def default_assign
952 960 if assigned_to.nil? && category && category.assigned_to
953 961 self.assigned_to = category.assigned_to
954 962 end
955 963 end
956 964
957 965 # Updates start/due dates of following issues
958 966 def reschedule_following_issues
959 967 if start_date_changed? || due_date_changed?
960 968 relations_from.each do |relation|
961 969 relation.set_issue_to_dates
962 970 end
963 971 end
964 972 end
965 973
966 974 # Closes duplicates if the issue is being closed
967 975 def close_duplicates
968 976 if closing?
969 977 duplicates.each do |duplicate|
970 978 # Reload is need in case the duplicate was updated by a previous duplicate
971 979 duplicate.reload
972 980 # Don't re-close it if it's already closed
973 981 next if duplicate.closed?
974 982 # Same user and notes
975 983 if @current_journal
976 984 duplicate.init_journal(@current_journal.user, @current_journal.notes)
977 985 end
978 986 duplicate.update_attribute :status, self.status
979 987 end
980 988 end
981 989 end
982 990
983 991 # Saves the changes in a Journal
984 992 # Called after_save
985 993 def create_journal
986 994 if @current_journal
987 995 # attributes changes
988 996 if @attributes_before_change
989 997 (Issue.column_names - %w(id root_id lft rgt lock_version created_on updated_on)).each {|c|
990 998 before = @attributes_before_change[c]
991 999 after = send(c)
992 1000 next if before == after || (before.blank? && after.blank?)
993 1001 @current_journal.details << JournalDetail.new(:property => 'attr',
994 1002 :prop_key => c,
995 1003 :old_value => before,
996 1004 :value => after)
997 1005 }
998 1006 end
999 1007 if @custom_values_before_change
1000 1008 # custom fields changes
1001 1009 custom_values.each {|c|
1002 1010 before = @custom_values_before_change[c.custom_field_id]
1003 1011 after = c.value
1004 1012 next if before == after || (before.blank? && after.blank?)
1005 1013 @current_journal.details << JournalDetail.new(:property => 'cf',
1006 1014 :prop_key => c.custom_field_id,
1007 1015 :old_value => before,
1008 1016 :value => after)
1009 1017 }
1010 1018 end
1011 1019 @current_journal.save
1012 1020 # reset current journal
1013 1021 init_journal @current_journal.user, @current_journal.notes
1014 1022 end
1015 1023 end
1016 1024
1017 1025 # Query generator for selecting groups of issue counts for a project
1018 1026 # based on specific criteria
1019 1027 #
1020 1028 # Options
1021 1029 # * project - Project to search in.
1022 1030 # * field - String. Issue field to key off of in the grouping.
1023 1031 # * joins - String. The table name to join against.
1024 1032 def self.count_and_group_by(options)
1025 1033 project = options.delete(:project)
1026 1034 select_field = options.delete(:field)
1027 1035 joins = options.delete(:joins)
1028 1036
1029 1037 where = "#{Issue.table_name}.#{select_field}=j.id"
1030 1038
1031 1039 ActiveRecord::Base.connection.select_all("select s.id as status_id,
1032 1040 s.is_closed as closed,
1033 1041 j.id as #{select_field},
1034 1042 count(#{Issue.table_name}.id) as total
1035 1043 from
1036 1044 #{Issue.table_name}, #{Project.table_name}, #{IssueStatus.table_name} s, #{joins} j
1037 1045 where
1038 1046 #{Issue.table_name}.status_id=s.id
1039 1047 and #{where}
1040 1048 and #{Issue.table_name}.project_id=#{Project.table_name}.id
1041 1049 and #{visible_condition(User.current, :project => project)}
1042 1050 group by s.id, s.is_closed, j.id")
1043 1051 end
1044 1052 end
@@ -1,647 +1,647
1 1 # Redmine - project management software
2 2 # Copyright (C) 2006-2011 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 "digest/sha1"
19 19
20 20 class User < Principal
21 21 include Redmine::SafeAttributes
22 22
23 23 # Account statuses
24 24 STATUS_ANONYMOUS = 0
25 25 STATUS_ACTIVE = 1
26 26 STATUS_REGISTERED = 2
27 27 STATUS_LOCKED = 3
28 28
29 29 # Different ways of displaying/sorting users
30 30 USER_FORMATS = {
31 31 :firstname_lastname => {:string => '#{firstname} #{lastname}', :order => %w(firstname lastname id)},
32 32 :firstname => {:string => '#{firstname}', :order => %w(firstname id)},
33 33 :lastname_firstname => {:string => '#{lastname} #{firstname}', :order => %w(lastname firstname id)},
34 34 :lastname_coma_firstname => {:string => '#{lastname}, #{firstname}', :order => %w(lastname firstname id)},
35 35 :username => {:string => '#{login}', :order => %w(login id)},
36 36 }
37 37
38 38 MAIL_NOTIFICATION_OPTIONS = [
39 39 ['all', :label_user_mail_option_all],
40 40 ['selected', :label_user_mail_option_selected],
41 41 ['only_my_events', :label_user_mail_option_only_my_events],
42 42 ['only_assigned', :label_user_mail_option_only_assigned],
43 43 ['only_owner', :label_user_mail_option_only_owner],
44 44 ['none', :label_user_mail_option_none]
45 45 ]
46 46
47 47 has_and_belongs_to_many :groups, :after_add => Proc.new {|user, group| group.user_added(user)},
48 48 :after_remove => Proc.new {|user, group| group.user_removed(user)}
49 49 has_many :changesets, :dependent => :nullify
50 50 has_one :preference, :dependent => :destroy, :class_name => 'UserPreference'
51 51 has_one :rss_token, :class_name => 'Token', :conditions => "action='feeds'"
52 52 has_one :api_token, :class_name => 'Token', :conditions => "action='api'"
53 53 belongs_to :auth_source
54 54
55 55 # Active non-anonymous users scope
56 56 named_scope :active, :conditions => "#{User.table_name}.status = #{STATUS_ACTIVE}"
57 57 named_scope :logged, :conditions => "#{User.table_name}.status <> #{STATUS_ANONYMOUS}"
58 58 named_scope :status, lambda {|arg| arg.blank? ? {} : {:conditions => {:status => arg.to_i}} }
59 59 named_scope :like, lambda {|arg|
60 60 if arg.blank?
61 61 {}
62 62 else
63 63 pattern = "%#{arg.to_s.strip.downcase}%"
64 64 {:conditions => ["LOWER(login) LIKE :p OR LOWER(firstname) LIKE :p OR LOWER(lastname) LIKE :p OR LOWER(mail) LIKE :p", {:p => pattern}]}
65 65 end
66 66 }
67 67
68 68 acts_as_customizable
69 69
70 70 attr_accessor :password, :password_confirmation
71 71 attr_accessor :last_before_login_on
72 72 # Prevents unauthorized assignments
73 73 attr_protected :login, :admin, :password, :password_confirmation, :hashed_password
74 74
75 75 validates_presence_of :login, :firstname, :lastname, :mail, :if => Proc.new { |user| !user.is_a?(AnonymousUser) }
76 76 validates_uniqueness_of :login, :if => Proc.new { |user| !user.login.blank? }, :case_sensitive => false
77 77 validates_uniqueness_of :mail, :if => Proc.new { |user| !user.mail.blank? }, :case_sensitive => false
78 78 # Login must contain lettres, numbers, underscores only
79 79 validates_format_of :login, :with => /^[a-z0-9_\-@\.]*$/i
80 80 validates_length_of :login, :maximum => 30
81 81 validates_length_of :firstname, :lastname, :maximum => 30
82 82 validates_format_of :mail, :with => /^([^@\s]+)@((?:[-a-z0-9]+\.)+[a-z]{2,})$/i, :allow_blank => true
83 83 validates_length_of :mail, :maximum => 60, :allow_nil => true
84 84 validates_confirmation_of :password, :allow_nil => true
85 85 validates_inclusion_of :mail_notification, :in => MAIL_NOTIFICATION_OPTIONS.collect(&:first), :allow_blank => true
86 86 validate :validate_password_length
87 87
88 88 before_create :set_mail_notification
89 89 before_save :update_hashed_password
90 90 before_destroy :remove_references_before_destroy
91 91
92 92 named_scope :in_group, lambda {|group|
93 93 group_id = group.is_a?(Group) ? group.id : group.to_i
94 94 { :conditions => ["#{User.table_name}.id IN (SELECT gu.user_id FROM #{table_name_prefix}groups_users#{table_name_suffix} gu WHERE gu.group_id = ?)", group_id] }
95 95 }
96 96 named_scope :not_in_group, lambda {|group|
97 97 group_id = group.is_a?(Group) ? group.id : group.to_i
98 98 { :conditions => ["#{User.table_name}.id NOT IN (SELECT gu.user_id FROM #{table_name_prefix}groups_users#{table_name_suffix} gu WHERE gu.group_id = ?)", group_id] }
99 99 }
100 100
101 101 def set_mail_notification
102 102 self.mail_notification = Setting.default_notification_option if self.mail_notification.blank?
103 103 true
104 104 end
105 105
106 106 def update_hashed_password
107 107 # update hashed_password if password was set
108 108 if self.password && self.auth_source_id.blank?
109 109 salt_password(password)
110 110 end
111 111 end
112 112
113 113 def reload(*args)
114 114 @name = nil
115 115 @projects_by_role = nil
116 116 super
117 117 end
118 118
119 119 def mail=(arg)
120 120 write_attribute(:mail, arg.to_s.strip)
121 121 end
122 122
123 123 def identity_url=(url)
124 124 if url.blank?
125 125 write_attribute(:identity_url, '')
126 126 else
127 127 begin
128 128 write_attribute(:identity_url, OpenIdAuthentication.normalize_identifier(url))
129 129 rescue OpenIdAuthentication::InvalidOpenId
130 130 # Invlaid url, don't save
131 131 end
132 132 end
133 133 self.read_attribute(:identity_url)
134 134 end
135 135
136 136 # Returns the user that matches provided login and password, or nil
137 137 def self.try_to_login(login, password)
138 138 # Make sure no one can sign in with an empty password
139 139 return nil if password.to_s.empty?
140 140 user = find_by_login(login)
141 141 if user
142 142 # user is already in local database
143 143 return nil if !user.active?
144 144 if user.auth_source
145 145 # user has an external authentication method
146 146 return nil unless user.auth_source.authenticate(login, password)
147 147 else
148 148 # authentication with local password
149 149 return nil unless user.check_password?(password)
150 150 end
151 151 else
152 152 # user is not yet registered, try to authenticate with available sources
153 153 attrs = AuthSource.authenticate(login, password)
154 154 if attrs
155 155 user = new(attrs)
156 156 user.login = login
157 157 user.language = Setting.default_language
158 158 if user.save
159 159 user.reload
160 160 logger.info("User '#{user.login}' created from external auth source: #{user.auth_source.type} - #{user.auth_source.name}") if logger && user.auth_source
161 161 end
162 162 end
163 163 end
164 164 user.update_attribute(:last_login_on, Time.now) if user && !user.new_record?
165 165 user
166 166 rescue => text
167 167 raise text
168 168 end
169 169
170 170 # Returns the user who matches the given autologin +key+ or nil
171 171 def self.try_to_autologin(key)
172 172 tokens = Token.find_all_by_action_and_value('autologin', key)
173 173 # Make sure there's only 1 token that matches the key
174 174 if tokens.size == 1
175 175 token = tokens.first
176 176 if (token.created_on > Setting.autologin.to_i.day.ago) && token.user && token.user.active?
177 177 token.user.update_attribute(:last_login_on, Time.now)
178 178 token.user
179 179 end
180 180 end
181 181 end
182 182
183 183 def self.name_formatter(formatter = nil)
184 184 USER_FORMATS[formatter || Setting.user_format] || USER_FORMATS[:firstname_lastname]
185 185 end
186 186
187 187 # Returns an array of fields names than can be used to make an order statement for users
188 188 # according to how user names are displayed
189 189 # Examples:
190 190 #
191 191 # User.fields_for_order_statement => ['users.login', 'users.id']
192 192 # User.fields_for_order_statement('authors') => ['authors.login', 'authors.id']
193 193 def self.fields_for_order_statement(table=nil)
194 194 table ||= table_name
195 195 name_formatter[:order].map {|field| "#{table}.#{field}"}
196 196 end
197 197
198 198 # Return user's full name for display
199 199 def name(formatter = nil)
200 200 f = self.class.name_formatter(formatter)
201 201 if formatter
202 202 eval('"' + f[:string] + '"')
203 203 else
204 204 @name ||= eval('"' + f[:string] + '"')
205 205 end
206 206 end
207 207
208 208 def active?
209 209 self.status == STATUS_ACTIVE
210 210 end
211 211
212 212 def registered?
213 213 self.status == STATUS_REGISTERED
214 214 end
215 215
216 216 def locked?
217 217 self.status == STATUS_LOCKED
218 218 end
219 219
220 220 def activate
221 221 self.status = STATUS_ACTIVE
222 222 end
223 223
224 224 def register
225 225 self.status = STATUS_REGISTERED
226 226 end
227 227
228 228 def lock
229 229 self.status = STATUS_LOCKED
230 230 end
231 231
232 232 def activate!
233 233 update_attribute(:status, STATUS_ACTIVE)
234 234 end
235 235
236 236 def register!
237 237 update_attribute(:status, STATUS_REGISTERED)
238 238 end
239 239
240 240 def lock!
241 241 update_attribute(:status, STATUS_LOCKED)
242 242 end
243 243
244 244 # Returns true if +clear_password+ is the correct user's password, otherwise false
245 245 def check_password?(clear_password)
246 246 if auth_source_id.present?
247 247 auth_source.authenticate(self.login, clear_password)
248 248 else
249 249 User.hash_password("#{salt}#{User.hash_password clear_password}") == hashed_password
250 250 end
251 251 end
252 252
253 253 # Generates a random salt and computes hashed_password for +clear_password+
254 254 # The hashed password is stored in the following form: SHA1(salt + SHA1(password))
255 255 def salt_password(clear_password)
256 256 self.salt = User.generate_salt
257 257 self.hashed_password = User.hash_password("#{salt}#{User.hash_password clear_password}")
258 258 end
259 259
260 260 # Does the backend storage allow this user to change their password?
261 261 def change_password_allowed?
262 262 return true if auth_source.nil?
263 263 return auth_source.allow_password_changes?
264 264 end
265 265
266 266 # Generate and set a random password. Useful for automated user creation
267 267 # Based on Token#generate_token_value
268 268 #
269 269 def random_password
270 270 chars = ("a".."z").to_a + ("A".."Z").to_a + ("0".."9").to_a
271 271 password = ''
272 272 40.times { |i| password << chars[rand(chars.size-1)] }
273 273 self.password = password
274 274 self.password_confirmation = password
275 275 self
276 276 end
277 277
278 278 def pref
279 279 self.preference ||= UserPreference.new(:user => self)
280 280 end
281 281
282 282 def time_zone
283 283 @time_zone ||= (self.pref.time_zone.blank? ? nil : ActiveSupport::TimeZone[self.pref.time_zone])
284 284 end
285 285
286 286 def wants_comments_in_reverse_order?
287 287 self.pref[:comments_sorting] == 'desc'
288 288 end
289 289
290 290 # Return user's RSS key (a 40 chars long string), used to access feeds
291 291 def rss_key
292 292 token = self.rss_token || Token.create(:user => self, :action => 'feeds')
293 293 token.value
294 294 end
295 295
296 296 # Return user's API key (a 40 chars long string), used to access the API
297 297 def api_key
298 298 token = self.api_token || self.create_api_token(:action => 'api')
299 299 token.value
300 300 end
301 301
302 302 # Return an array of project ids for which the user has explicitly turned mail notifications on
303 303 def notified_projects_ids
304 304 @notified_projects_ids ||= memberships.select {|m| m.mail_notification?}.collect(&:project_id)
305 305 end
306 306
307 307 def notified_project_ids=(ids)
308 308 Member.update_all("mail_notification = #{connection.quoted_false}", ['user_id = ?', id])
309 309 Member.update_all("mail_notification = #{connection.quoted_true}", ['user_id = ? AND project_id IN (?)', id, ids]) if ids && !ids.empty?
310 310 @notified_projects_ids = nil
311 311 notified_projects_ids
312 312 end
313 313
314 314 def valid_notification_options
315 315 self.class.valid_notification_options(self)
316 316 end
317 317
318 318 # Only users that belong to more than 1 project can select projects for which they are notified
319 319 def self.valid_notification_options(user=nil)
320 320 # Note that @user.membership.size would fail since AR ignores
321 321 # :include association option when doing a count
322 322 if user.nil? || user.memberships.length < 1
323 323 MAIL_NOTIFICATION_OPTIONS.reject {|option| option.first == 'selected'}
324 324 else
325 325 MAIL_NOTIFICATION_OPTIONS
326 326 end
327 327 end
328 328
329 329 # Find a user account by matching the exact login and then a case-insensitive
330 330 # version. Exact matches will be given priority.
331 331 def self.find_by_login(login)
332 332 # force string comparison to be case sensitive on MySQL
333 333 type_cast = (ActiveRecord::Base.connection.adapter_name == 'MySQL') ? 'BINARY' : ''
334 334
335 335 # First look for an exact match
336 336 user = first(:conditions => ["#{type_cast} login = ?", login])
337 337 # Fail over to case-insensitive if none was found
338 338 user ||= first(:conditions => ["#{type_cast} LOWER(login) = ?", login.to_s.downcase])
339 339 end
340 340
341 341 def self.find_by_rss_key(key)
342 342 token = Token.find_by_value(key)
343 343 token && token.user.active? ? token.user : nil
344 344 end
345 345
346 346 def self.find_by_api_key(key)
347 347 token = Token.find_by_action_and_value('api', key)
348 348 token && token.user.active? ? token.user : nil
349 349 end
350 350
351 351 # Makes find_by_mail case-insensitive
352 352 def self.find_by_mail(mail)
353 353 find(:first, :conditions => ["LOWER(mail) = ?", mail.to_s.downcase])
354 354 end
355 355
356 356 def to_s
357 357 name
358 358 end
359 359
360 360 # Returns the current day according to user's time zone
361 361 def today
362 362 if time_zone.nil?
363 363 Date.today
364 364 else
365 365 Time.now.in_time_zone(time_zone).to_date
366 366 end
367 367 end
368 368
369 369 def logged?
370 370 true
371 371 end
372 372
373 373 def anonymous?
374 374 !logged?
375 375 end
376 376
377 377 # Return user's roles for project
378 378 def roles_for_project(project)
379 379 roles = []
380 380 # No role on archived projects
381 381 return roles unless project && project.active?
382 382 if logged?
383 383 # Find project membership
384 384 membership = memberships.detect {|m| m.project_id == project.id}
385 385 if membership
386 386 roles = membership.roles
387 387 else
388 388 @role_non_member ||= Role.non_member
389 389 roles << @role_non_member
390 390 end
391 391 else
392 392 @role_anonymous ||= Role.anonymous
393 393 roles << @role_anonymous
394 394 end
395 395 roles
396 396 end
397 397
398 398 # Return true if the user is a member of project
399 399 def member_of?(project)
400 400 !roles_for_project(project).detect {|role| role.member?}.nil?
401 401 end
402 402
403 403 # Returns a hash of user's projects grouped by roles
404 404 def projects_by_role
405 405 return @projects_by_role if @projects_by_role
406 406
407 407 @projects_by_role = Hash.new {|h,k| h[k]=[]}
408 408 memberships.each do |membership|
409 409 membership.roles.each do |role|
410 410 @projects_by_role[role] << membership.project if membership.project
411 411 end
412 412 end
413 413 @projects_by_role.each do |role, projects|
414 414 projects.uniq!
415 415 end
416 416
417 417 @projects_by_role
418 418 end
419 419
420 420 # Returns true if user is arg or belongs to arg
421 421 def is_or_belongs_to?(arg)
422 422 if arg.is_a?(User)
423 423 self == arg
424 424 elsif arg.is_a?(Group)
425 425 arg.users.include?(self)
426 426 else
427 427 false
428 428 end
429 429 end
430 430
431 431 # Return true if the user is allowed to do the specified action on a specific context
432 432 # Action can be:
433 433 # * a parameter-like Hash (eg. :controller => 'projects', :action => 'edit')
434 434 # * a permission Symbol (eg. :edit_project)
435 435 # Context can be:
436 436 # * a project : returns true if user is allowed to do the specified action on this project
437 437 # * an array of projects : returns true if user is allowed on every project
438 438 # * nil with options[:global] set : check if user has at least one role allowed for this action,
439 439 # or falls back to Non Member / Anonymous permissions depending if the user is logged
440 440 def allowed_to?(action, context, options={}, &block)
441 441 if context && context.is_a?(Project)
442 442 # No action allowed on archived projects
443 443 return false unless context.active?
444 444 # No action allowed on disabled modules
445 445 return false unless context.allows_to?(action)
446 446 # Admin users are authorized for anything else
447 447 return true if admin?
448 448
449 449 roles = roles_for_project(context)
450 450 return false unless roles
451 451 roles.detect {|role|
452 452 (context.is_public? || role.member?) &&
453 453 role.allowed_to?(action) &&
454 454 (block_given? ? yield(role, self) : true)
455 455 }
456 456 elsif context && context.is_a?(Array)
457 457 # Authorize if user is authorized on every element of the array
458 458 context.map do |project|
459 459 allowed_to?(action, project, options, &block)
460 460 end.inject do |memo,allowed|
461 461 memo && allowed
462 462 end
463 463 elsif options[:global]
464 464 # Admin users are always authorized
465 465 return true if admin?
466 466
467 467 # authorize if user has at least one role that has this permission
468 468 roles = memberships.collect {|m| m.roles}.flatten.uniq
469 469 roles << (self.logged? ? Role.non_member : Role.anonymous)
470 470 roles.detect {|role|
471 471 role.allowed_to?(action) &&
472 472 (block_given? ? yield(role, self) : true)
473 473 }
474 474 else
475 475 false
476 476 end
477 477 end
478 478
479 479 # Is the user allowed to do the specified action on any project?
480 480 # See allowed_to? for the actions and valid options.
481 481 def allowed_to_globally?(action, options, &block)
482 482 allowed_to?(action, nil, options.reverse_merge(:global => true), &block)
483 483 end
484 484
485 485 safe_attributes 'login',
486 486 'firstname',
487 487 'lastname',
488 488 'mail',
489 489 'mail_notification',
490 490 'language',
491 491 'custom_field_values',
492 492 'custom_fields',
493 493 'identity_url'
494 494
495 495 safe_attributes 'status',
496 496 'auth_source_id',
497 497 :if => lambda {|user, current_user| current_user.admin?}
498 498
499 499 safe_attributes 'group_ids',
500 500 :if => lambda {|user, current_user| current_user.admin? && !user.new_record?}
501 501
502 502 # Utility method to help check if a user should be notified about an
503 503 # event.
504 504 #
505 505 # TODO: only supports Issue events currently
506 506 def notify_about?(object)
507 507 case mail_notification
508 508 when 'all'
509 509 true
510 510 when 'selected'
511 511 # user receives notifications for created/assigned issues on unselected projects
512 if object.is_a?(Issue) && (object.author == self || is_or_belongs_to?(object.assigned_to))
512 if object.is_a?(Issue) && (object.author == self || is_or_belongs_to?(object.assigned_to) || is_or_belongs_to?(object.assigned_to_was))
513 513 true
514 514 else
515 515 false
516 516 end
517 517 when 'none'
518 518 false
519 519 when 'only_my_events'
520 if object.is_a?(Issue) && (object.author == self || is_or_belongs_to?(object.assigned_to))
520 if object.is_a?(Issue) && (object.author == self || is_or_belongs_to?(object.assigned_to) || is_or_belongs_to?(object.assigned_to_was))
521 521 true
522 522 else
523 523 false
524 524 end
525 525 when 'only_assigned'
526 if object.is_a?(Issue) && is_or_belongs_to?(object.assigned_to)
526 if object.is_a?(Issue) && (is_or_belongs_to?(object.assigned_to) || is_or_belongs_to?(object.assigned_to_was))
527 527 true
528 528 else
529 529 false
530 530 end
531 531 when 'only_owner'
532 532 if object.is_a?(Issue) && object.author == self
533 533 true
534 534 else
535 535 false
536 536 end
537 537 else
538 538 false
539 539 end
540 540 end
541 541
542 542 def self.current=(user)
543 543 @current_user = user
544 544 end
545 545
546 546 def self.current
547 547 @current_user ||= User.anonymous
548 548 end
549 549
550 550 # Returns the anonymous user. If the anonymous user does not exist, it is created. There can be only
551 551 # one anonymous user per database.
552 552 def self.anonymous
553 553 anonymous_user = AnonymousUser.find(:first)
554 554 if anonymous_user.nil?
555 555 anonymous_user = AnonymousUser.create(:lastname => 'Anonymous', :firstname => '', :mail => '', :login => '', :status => 0)
556 556 raise 'Unable to create the anonymous user.' if anonymous_user.new_record?
557 557 end
558 558 anonymous_user
559 559 end
560 560
561 561 # Salts all existing unsalted passwords
562 562 # It changes password storage scheme from SHA1(password) to SHA1(salt + SHA1(password))
563 563 # This method is used in the SaltPasswords migration and is to be kept as is
564 564 def self.salt_unsalted_passwords!
565 565 transaction do
566 566 User.find_each(:conditions => "salt IS NULL OR salt = ''") do |user|
567 567 next if user.hashed_password.blank?
568 568 salt = User.generate_salt
569 569 hashed_password = User.hash_password("#{salt}#{user.hashed_password}")
570 570 User.update_all("salt = '#{salt}', hashed_password = '#{hashed_password}'", ["id = ?", user.id] )
571 571 end
572 572 end
573 573 end
574 574
575 575 protected
576 576
577 577 def validate_password_length
578 578 # Password length validation based on setting
579 579 if !password.nil? && password.size < Setting.password_min_length.to_i
580 580 errors.add(:password, :too_short, :count => Setting.password_min_length.to_i)
581 581 end
582 582 end
583 583
584 584 private
585 585
586 586 # Removes references that are not handled by associations
587 587 # Things that are not deleted are reassociated with the anonymous user
588 588 def remove_references_before_destroy
589 589 return if self.id.nil?
590 590
591 591 substitute = User.anonymous
592 592 Attachment.update_all ['author_id = ?', substitute.id], ['author_id = ?', id]
593 593 Comment.update_all ['author_id = ?', substitute.id], ['author_id = ?', id]
594 594 Issue.update_all ['author_id = ?', substitute.id], ['author_id = ?', id]
595 595 Issue.update_all 'assigned_to_id = NULL', ['assigned_to_id = ?', id]
596 596 Journal.update_all ['user_id = ?', substitute.id], ['user_id = ?', id]
597 597 JournalDetail.update_all ['old_value = ?', substitute.id.to_s], ["property = 'attr' AND prop_key = 'assigned_to_id' AND old_value = ?", id.to_s]
598 598 JournalDetail.update_all ['value = ?', substitute.id.to_s], ["property = 'attr' AND prop_key = 'assigned_to_id' AND value = ?", id.to_s]
599 599 Message.update_all ['author_id = ?', substitute.id], ['author_id = ?', id]
600 600 News.update_all ['author_id = ?', substitute.id], ['author_id = ?', id]
601 601 # Remove private queries and keep public ones
602 602 ::Query.delete_all ['user_id = ? AND is_public = ?', id, false]
603 603 ::Query.update_all ['user_id = ?', substitute.id], ['user_id = ?', id]
604 604 TimeEntry.update_all ['user_id = ?', substitute.id], ['user_id = ?', id]
605 605 Token.delete_all ['user_id = ?', id]
606 606 Watcher.delete_all ['user_id = ?', id]
607 607 WikiContent.update_all ['author_id = ?', substitute.id], ['author_id = ?', id]
608 608 WikiContent::Version.update_all ['author_id = ?', substitute.id], ['author_id = ?', id]
609 609 end
610 610
611 611 # Return password digest
612 612 def self.hash_password(clear_password)
613 613 Digest::SHA1.hexdigest(clear_password || "")
614 614 end
615 615
616 616 # Returns a 128bits random salt as a hex string (32 chars long)
617 617 def self.generate_salt
618 618 ActiveSupport::SecureRandom.hex(16)
619 619 end
620 620
621 621 end
622 622
623 623 class AnonymousUser < User
624 624 validate :validate_anonymous_uniqueness, :on => :create
625 625
626 626 def validate_anonymous_uniqueness
627 627 # There should be only one AnonymousUser in the database
628 628 errors.add :base, 'An anonymous user already exists.' if AnonymousUser.find(:first)
629 629 end
630 630
631 631 def available_custom_fields
632 632 []
633 633 end
634 634
635 635 # Overrides a few properties
636 636 def logged?; false end
637 637 def admin; false end
638 638 def name(*args); I18n.t(:label_user_anonymous) end
639 639 def mail; nil end
640 640 def time_zone; nil end
641 641 def rss_key; nil end
642 642
643 643 # Anonymous user can not be destroyed
644 644 def destroy
645 645 false
646 646 end
647 647 end
@@ -1,1192 +1,1204
1 1 # Redmine - project management software
2 2 # Copyright (C) 2006-2011 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.expand_path('../../test_helper', __FILE__)
19 19
20 20 class IssueTest < ActiveSupport::TestCase
21 21 fixtures :projects, :users, :members, :member_roles, :roles,
22 22 :groups_users,
23 23 :trackers, :projects_trackers,
24 24 :enabled_modules,
25 25 :versions,
26 26 :issue_statuses, :issue_categories, :issue_relations, :workflows,
27 27 :enumerations,
28 28 :issues,
29 29 :custom_fields, :custom_fields_projects, :custom_fields_trackers, :custom_values,
30 30 :time_entries
31 31
32 32 def test_create
33 33 issue = Issue.new(:project_id => 1, :tracker_id => 1, :author_id => 3,
34 34 :status_id => 1, :priority => IssuePriority.all.first,
35 35 :subject => 'test_create',
36 36 :description => 'IssueTest#test_create', :estimated_hours => '1:30')
37 37 assert issue.save
38 38 issue.reload
39 39 assert_equal 1.5, issue.estimated_hours
40 40 end
41 41
42 42 def test_create_minimal
43 43 issue = Issue.new(:project_id => 1, :tracker_id => 1, :author_id => 3,
44 44 :status_id => 1, :priority => IssuePriority.all.first,
45 45 :subject => 'test_create')
46 46 assert issue.save
47 47 assert issue.description.nil?
48 48 end
49 49
50 50 def test_create_with_required_custom_field
51 51 field = IssueCustomField.find_by_name('Database')
52 52 field.update_attribute(:is_required, true)
53 53
54 54 issue = Issue.new(:project_id => 1, :tracker_id => 1, :author_id => 1,
55 55 :status_id => 1, :subject => 'test_create',
56 56 :description => 'IssueTest#test_create_with_required_custom_field')
57 57 assert issue.available_custom_fields.include?(field)
58 58 # No value for the custom field
59 59 assert !issue.save
60 60 assert_equal I18n.translate('activerecord.errors.messages.invalid'),
61 61 issue.errors[:custom_values].to_s
62 62 # Blank value
63 63 issue.custom_field_values = { field.id => '' }
64 64 assert !issue.save
65 65 assert_equal I18n.translate('activerecord.errors.messages.invalid'),
66 66 issue.errors[:custom_values].to_s
67 67 # Invalid value
68 68 issue.custom_field_values = { field.id => 'SQLServer' }
69 69 assert !issue.save
70 70 assert_equal I18n.translate('activerecord.errors.messages.invalid'),
71 71 issue.errors[:custom_values].to_s
72 72 # Valid value
73 73 issue.custom_field_values = { field.id => 'PostgreSQL' }
74 74 assert issue.save
75 75 issue.reload
76 76 assert_equal 'PostgreSQL', issue.custom_value_for(field).value
77 77 end
78 78
79 79 def test_create_with_group_assignment
80 80 with_settings :issue_group_assignment => '1' do
81 81 assert Issue.new(:project_id => 2, :tracker_id => 1, :author_id => 1,
82 82 :subject => 'Group assignment',
83 83 :assigned_to_id => 11).save
84 84 issue = Issue.first(:order => 'id DESC')
85 85 assert_kind_of Group, issue.assigned_to
86 86 assert_equal Group.find(11), issue.assigned_to
87 87 end
88 88 end
89 89
90 90 def assert_visibility_match(user, issues)
91 91 assert_equal issues.collect(&:id).sort, Issue.all.select {|issue| issue.visible?(user)}.collect(&:id).sort
92 92 end
93 93
94 94 def test_visible_scope_for_anonymous
95 95 # Anonymous user should see issues of public projects only
96 96 issues = Issue.visible(User.anonymous).all
97 97 assert issues.any?
98 98 assert_nil issues.detect {|issue| !issue.project.is_public?}
99 99 assert_nil issues.detect {|issue| issue.is_private?}
100 100 assert_visibility_match User.anonymous, issues
101 101 end
102 102
103 103 def test_visible_scope_for_anonymous_with_own_issues_visibility
104 104 Role.anonymous.update_attribute :issues_visibility, 'own'
105 105 Issue.create!(:project_id => 1, :tracker_id => 1,
106 106 :author_id => User.anonymous.id,
107 107 :subject => 'Issue by anonymous')
108 108
109 109 issues = Issue.visible(User.anonymous).all
110 110 assert issues.any?
111 111 assert_nil issues.detect {|issue| issue.author != User.anonymous}
112 112 assert_visibility_match User.anonymous, issues
113 113 end
114 114
115 115 def test_visible_scope_for_anonymous_without_view_issues_permissions
116 116 # Anonymous user should not see issues without permission
117 117 Role.anonymous.remove_permission!(:view_issues)
118 118 issues = Issue.visible(User.anonymous).all
119 119 assert issues.empty?
120 120 assert_visibility_match User.anonymous, issues
121 121 end
122 122
123 123 def test_visible_scope_for_non_member
124 124 user = User.find(9)
125 125 assert user.projects.empty?
126 126 # Non member user should see issues of public projects only
127 127 issues = Issue.visible(user).all
128 128 assert issues.any?
129 129 assert_nil issues.detect {|issue| !issue.project.is_public?}
130 130 assert_nil issues.detect {|issue| issue.is_private?}
131 131 assert_visibility_match user, issues
132 132 end
133 133
134 134 def test_visible_scope_for_non_member_with_own_issues_visibility
135 135 Role.non_member.update_attribute :issues_visibility, 'own'
136 136 Issue.create!(:project_id => 1, :tracker_id => 1, :author_id => 9, :subject => 'Issue by non member')
137 137 user = User.find(9)
138 138
139 139 issues = Issue.visible(user).all
140 140 assert issues.any?
141 141 assert_nil issues.detect {|issue| issue.author != user}
142 142 assert_visibility_match user, issues
143 143 end
144 144
145 145 def test_visible_scope_for_non_member_without_view_issues_permissions
146 146 # Non member user should not see issues without permission
147 147 Role.non_member.remove_permission!(:view_issues)
148 148 user = User.find(9)
149 149 assert user.projects.empty?
150 150 issues = Issue.visible(user).all
151 151 assert issues.empty?
152 152 assert_visibility_match user, issues
153 153 end
154 154
155 155 def test_visible_scope_for_member
156 156 user = User.find(9)
157 157 # User should see issues of projects for which he has view_issues permissions only
158 158 Role.non_member.remove_permission!(:view_issues)
159 159 Member.create!(:principal => user, :project_id => 3, :role_ids => [2])
160 160 issues = Issue.visible(user).all
161 161 assert issues.any?
162 162 assert_nil issues.detect {|issue| issue.project_id != 3}
163 163 assert_nil issues.detect {|issue| issue.is_private?}
164 164 assert_visibility_match user, issues
165 165 end
166 166
167 167 def test_visible_scope_for_member_with_groups_should_return_assigned_issues
168 168 user = User.find(8)
169 169 assert user.groups.any?
170 170 Member.create!(:principal => user.groups.first, :project_id => 1, :role_ids => [2])
171 171 Role.non_member.remove_permission!(:view_issues)
172 172
173 173 issue = Issue.create(:project_id => 1, :tracker_id => 1, :author_id => 3,
174 174 :status_id => 1, :priority => IssuePriority.all.first,
175 175 :subject => 'Assignment test',
176 176 :assigned_to => user.groups.first,
177 177 :is_private => true)
178 178
179 179 Role.find(2).update_attribute :issues_visibility, 'default'
180 180 issues = Issue.visible(User.find(8)).all
181 181 assert issues.any?
182 182 assert issues.include?(issue)
183 183
184 184 Role.find(2).update_attribute :issues_visibility, 'own'
185 185 issues = Issue.visible(User.find(8)).all
186 186 assert issues.any?
187 187 assert issues.include?(issue)
188 188 end
189 189
190 190 def test_visible_scope_for_admin
191 191 user = User.find(1)
192 192 user.members.each(&:destroy)
193 193 assert user.projects.empty?
194 194 issues = Issue.visible(user).all
195 195 assert issues.any?
196 196 # Admin should see issues on private projects that he does not belong to
197 197 assert issues.detect {|issue| !issue.project.is_public?}
198 198 # Admin should see private issues of other users
199 199 assert issues.detect {|issue| issue.is_private? && issue.author != user}
200 200 assert_visibility_match user, issues
201 201 end
202 202
203 203 def test_visible_scope_with_project
204 204 project = Project.find(1)
205 205 issues = Issue.visible(User.find(2), :project => project).all
206 206 projects = issues.collect(&:project).uniq
207 207 assert_equal 1, projects.size
208 208 assert_equal project, projects.first
209 209 end
210 210
211 211 def test_visible_scope_with_project_and_subprojects
212 212 project = Project.find(1)
213 213 issues = Issue.visible(User.find(2), :project => project, :with_subprojects => true).all
214 214 projects = issues.collect(&:project).uniq
215 215 assert projects.size > 1
216 216 assert_equal [], projects.select {|p| !p.is_or_is_descendant_of?(project)}
217 217 end
218 218
219 219 def test_visible_and_nested_set_scopes
220 220 assert_equal 0, Issue.find(1).descendants.visible.all.size
221 221 end
222 222
223 223 def test_open_scope
224 224 issues = Issue.open.all
225 225 assert_nil issues.detect(&:closed?)
226 226 end
227 227
228 228 def test_open_scope_with_arg
229 229 issues = Issue.open(false).all
230 230 assert_equal issues, issues.select(&:closed?)
231 231 end
232 232
233 233 def test_errors_full_messages_should_include_custom_fields_errors
234 234 field = IssueCustomField.find_by_name('Database')
235 235
236 236 issue = Issue.new(:project_id => 1, :tracker_id => 1, :author_id => 1,
237 237 :status_id => 1, :subject => 'test_create',
238 238 :description => 'IssueTest#test_create_with_required_custom_field')
239 239 assert issue.available_custom_fields.include?(field)
240 240 # Invalid value
241 241 issue.custom_field_values = { field.id => 'SQLServer' }
242 242
243 243 assert !issue.valid?
244 244 assert_equal 1, issue.errors.full_messages.size
245 245 assert_equal "Database #{I18n.translate('activerecord.errors.messages.inclusion')}",
246 246 issue.errors.full_messages.first
247 247 end
248 248
249 249 def test_update_issue_with_required_custom_field
250 250 field = IssueCustomField.find_by_name('Database')
251 251 field.update_attribute(:is_required, true)
252 252
253 253 issue = Issue.find(1)
254 254 assert_nil issue.custom_value_for(field)
255 255 assert issue.available_custom_fields.include?(field)
256 256 # No change to custom values, issue can be saved
257 257 assert issue.save
258 258 # Blank value
259 259 issue.custom_field_values = { field.id => '' }
260 260 assert !issue.save
261 261 # Valid value
262 262 issue.custom_field_values = { field.id => 'PostgreSQL' }
263 263 assert issue.save
264 264 issue.reload
265 265 assert_equal 'PostgreSQL', issue.custom_value_for(field).value
266 266 end
267 267
268 268 def test_should_not_update_attributes_if_custom_fields_validation_fails
269 269 issue = Issue.find(1)
270 270 field = IssueCustomField.find_by_name('Database')
271 271 assert issue.available_custom_fields.include?(field)
272 272
273 273 issue.custom_field_values = { field.id => 'Invalid' }
274 274 issue.subject = 'Should be not be saved'
275 275 assert !issue.save
276 276
277 277 issue.reload
278 278 assert_equal "Can't print recipes", issue.subject
279 279 end
280 280
281 281 def test_should_not_recreate_custom_values_objects_on_update
282 282 field = IssueCustomField.find_by_name('Database')
283 283
284 284 issue = Issue.find(1)
285 285 issue.custom_field_values = { field.id => 'PostgreSQL' }
286 286 assert issue.save
287 287 custom_value = issue.custom_value_for(field)
288 288 issue.reload
289 289 issue.custom_field_values = { field.id => 'MySQL' }
290 290 assert issue.save
291 291 issue.reload
292 292 assert_equal custom_value.id, issue.custom_value_for(field).id
293 293 end
294 294
295 295 def test_should_not_update_custom_fields_on_changing_tracker_with_different_custom_fields
296 296 issue = Issue.create!(:project_id => 1, :tracker_id => 1, :author_id => 1, :status_id => 1, :subject => 'Test', :custom_field_values => {'2' => 'Test'})
297 297 assert !Tracker.find(2).custom_field_ids.include?(2)
298 298
299 299 issue = Issue.find(issue.id)
300 300 issue.attributes = {:tracker_id => 2, :custom_field_values => {'1' => ''}}
301 301
302 302 issue = Issue.find(issue.id)
303 303 custom_value = issue.custom_value_for(2)
304 304 assert_not_nil custom_value
305 305 assert_equal 'Test', custom_value.value
306 306 end
307 307
308 308 def test_assigning_tracker_id_should_reload_custom_fields_values
309 309 issue = Issue.new(:project => Project.find(1))
310 310 assert issue.custom_field_values.empty?
311 311 issue.tracker_id = 1
312 312 assert issue.custom_field_values.any?
313 313 end
314 314
315 315 def test_assigning_attributes_should_assign_project_and_tracker_first
316 316 seq = sequence('seq')
317 317 issue = Issue.new
318 318 issue.expects(:project_id=).in_sequence(seq)
319 319 issue.expects(:tracker_id=).in_sequence(seq)
320 320 issue.expects(:subject=).in_sequence(seq)
321 321 issue.attributes = {:tracker_id => 2, :project_id => 1, :subject => 'Test'}
322 322 end
323 323
324 324 def test_assigning_tracker_and_custom_fields_should_assign_custom_fields
325 325 attributes = ActiveSupport::OrderedHash.new
326 326 attributes['custom_field_values'] = { '1' => 'MySQL' }
327 327 attributes['tracker_id'] = '1'
328 328 issue = Issue.new(:project => Project.find(1))
329 329 issue.attributes = attributes
330 330 assert_not_nil issue.custom_value_for(1)
331 331 assert_equal 'MySQL', issue.custom_value_for(1).value
332 332 end
333 333
334 334 def test_should_update_issue_with_disabled_tracker
335 335 p = Project.find(1)
336 336 issue = Issue.find(1)
337 337
338 338 p.trackers.delete(issue.tracker)
339 339 assert !p.trackers.include?(issue.tracker)
340 340
341 341 issue.reload
342 342 issue.subject = 'New subject'
343 343 assert issue.save
344 344 end
345 345
346 346 def test_should_not_set_a_disabled_tracker
347 347 p = Project.find(1)
348 348 p.trackers.delete(Tracker.find(2))
349 349
350 350 issue = Issue.find(1)
351 351 issue.tracker_id = 2
352 352 issue.subject = 'New subject'
353 353 assert !issue.save
354 354 assert_not_nil issue.errors[:tracker_id]
355 355 end
356 356
357 357 def test_category_based_assignment
358 358 issue = Issue.create(:project_id => 1, :tracker_id => 1, :author_id => 3,
359 359 :status_id => 1, :priority => IssuePriority.all.first,
360 360 :subject => 'Assignment test',
361 361 :description => 'Assignment test', :category_id => 1)
362 362 assert_equal IssueCategory.find(1).assigned_to, issue.assigned_to
363 363 end
364 364
365 365 def test_new_statuses_allowed_to
366 366 Workflow.delete_all
367 367
368 368 Workflow.create!(:role_id => 1, :tracker_id => 1, :old_status_id => 1, :new_status_id => 2, :author => false, :assignee => false)
369 369 Workflow.create!(:role_id => 1, :tracker_id => 1, :old_status_id => 1, :new_status_id => 3, :author => true, :assignee => false)
370 370 Workflow.create!(:role_id => 1, :tracker_id => 1, :old_status_id => 1, :new_status_id => 4, :author => false, :assignee => true)
371 371 Workflow.create!(:role_id => 1, :tracker_id => 1, :old_status_id => 1, :new_status_id => 5, :author => true, :assignee => true)
372 372 status = IssueStatus.find(1)
373 373 role = Role.find(1)
374 374 tracker = Tracker.find(1)
375 375 user = User.find(2)
376 376
377 377 issue = Issue.generate!(:tracker => tracker, :status => status, :project_id => 1)
378 378 assert_equal [1, 2], issue.new_statuses_allowed_to(user).map(&:id)
379 379
380 380 issue = Issue.generate!(:tracker => tracker, :status => status, :project_id => 1, :author => user)
381 381 assert_equal [1, 2, 3, 5], issue.new_statuses_allowed_to(user).map(&:id)
382 382
383 383 issue = Issue.generate!(:tracker => tracker, :status => status, :project_id => 1, :assigned_to => user)
384 384 assert_equal [1, 2, 4, 5], issue.new_statuses_allowed_to(user).map(&:id)
385 385
386 386 issue = Issue.generate!(:tracker => tracker, :status => status, :project_id => 1, :author => user, :assigned_to => user)
387 387 assert_equal [1, 2, 3, 4, 5], issue.new_statuses_allowed_to(user).map(&:id)
388 388 end
389 389
390 390 def test_copy
391 391 issue = Issue.new.copy_from(1)
392 392 assert issue.copy?
393 393 assert issue.save
394 394 issue.reload
395 395 orig = Issue.find(1)
396 396 assert_equal orig.subject, issue.subject
397 397 assert_equal orig.tracker, issue.tracker
398 398 assert_equal "125", issue.custom_value_for(2).value
399 399 end
400 400
401 401 def test_copy_should_copy_status
402 402 orig = Issue.find(8)
403 403 assert orig.status != IssueStatus.default
404 404
405 405 issue = Issue.new.copy_from(orig)
406 406 assert issue.save
407 407 issue.reload
408 408 assert_equal orig.status, issue.status
409 409 end
410 410
411 411 def test_should_not_call_after_project_change_on_creation
412 412 issue = Issue.new(:project_id => 1, :tracker_id => 1, :status_id => 1, :subject => 'Test', :author_id => 1)
413 413 issue.expects(:after_project_change).never
414 414 issue.save!
415 415 end
416 416
417 417 def test_should_not_call_after_project_change_on_update
418 418 issue = Issue.find(1)
419 419 issue.project = Project.find(1)
420 420 issue.subject = 'No project change'
421 421 issue.expects(:after_project_change).never
422 422 issue.save!
423 423 end
424 424
425 425 def test_should_call_after_project_change_on_project_change
426 426 issue = Issue.find(1)
427 427 issue.project = Project.find(2)
428 428 issue.expects(:after_project_change).once
429 429 issue.save!
430 430 end
431 431
432 432 def test_should_close_duplicates
433 433 # Create 3 issues
434 434 project = Project.find(1)
435 435 issue1 = Issue.generate_for_project!(project)
436 436 issue2 = Issue.generate_for_project!(project)
437 437 issue3 = Issue.generate_for_project!(project)
438 438
439 439 # 2 is a dupe of 1
440 440 IssueRelation.create!(:issue_from => issue2, :issue_to => issue1, :relation_type => IssueRelation::TYPE_DUPLICATES)
441 441 # And 3 is a dupe of 2
442 442 IssueRelation.create!(:issue_from => issue3, :issue_to => issue2, :relation_type => IssueRelation::TYPE_DUPLICATES)
443 443 # And 3 is a dupe of 1 (circular duplicates)
444 444 IssueRelation.create!(:issue_from => issue3, :issue_to => issue1, :relation_type => IssueRelation::TYPE_DUPLICATES)
445 445
446 446 assert issue1.reload.duplicates.include?(issue2)
447 447
448 448 # Closing issue 1
449 449 issue1.init_journal(User.find(:first), "Closing issue1")
450 450 issue1.status = IssueStatus.find :first, :conditions => {:is_closed => true}
451 451 assert issue1.save
452 452 # 2 and 3 should be also closed
453 453 assert issue2.reload.closed?
454 454 assert issue3.reload.closed?
455 455 end
456 456
457 457 def test_should_not_close_duplicated_issue
458 458 project = Project.find(1)
459 459 issue1 = Issue.generate_for_project!(project)
460 460 issue2 = Issue.generate_for_project!(project)
461 461
462 462 # 2 is a dupe of 1
463 463 IssueRelation.create(:issue_from => issue2, :issue_to => issue1, :relation_type => IssueRelation::TYPE_DUPLICATES)
464 464 # 2 is a dup of 1 but 1 is not a duplicate of 2
465 465 assert !issue2.reload.duplicates.include?(issue1)
466 466
467 467 # Closing issue 2
468 468 issue2.init_journal(User.find(:first), "Closing issue2")
469 469 issue2.status = IssueStatus.find :first, :conditions => {:is_closed => true}
470 470 assert issue2.save
471 471 # 1 should not be also closed
472 472 assert !issue1.reload.closed?
473 473 end
474 474
475 475 def test_assignable_versions
476 476 issue = Issue.new(:project_id => 1, :tracker_id => 1, :author_id => 1, :status_id => 1, :fixed_version_id => 1, :subject => 'New issue')
477 477 assert_equal ['open'], issue.assignable_versions.collect(&:status).uniq
478 478 end
479 479
480 480 def test_should_not_be_able_to_assign_a_new_issue_to_a_closed_version
481 481 issue = Issue.new(:project_id => 1, :tracker_id => 1, :author_id => 1, :status_id => 1, :fixed_version_id => 1, :subject => 'New issue')
482 482 assert !issue.save
483 483 assert_not_nil issue.errors[:fixed_version_id]
484 484 end
485 485
486 486 def test_should_not_be_able_to_assign_a_new_issue_to_a_locked_version
487 487 issue = Issue.new(:project_id => 1, :tracker_id => 1, :author_id => 1, :status_id => 1, :fixed_version_id => 2, :subject => 'New issue')
488 488 assert !issue.save
489 489 assert_not_nil issue.errors[:fixed_version_id]
490 490 end
491 491
492 492 def test_should_be_able_to_assign_a_new_issue_to_an_open_version
493 493 issue = Issue.new(:project_id => 1, :tracker_id => 1, :author_id => 1, :status_id => 1, :fixed_version_id => 3, :subject => 'New issue')
494 494 assert issue.save
495 495 end
496 496
497 497 def test_should_be_able_to_update_an_issue_assigned_to_a_closed_version
498 498 issue = Issue.find(11)
499 499 assert_equal 'closed', issue.fixed_version.status
500 500 issue.subject = 'Subject changed'
501 501 assert issue.save
502 502 end
503 503
504 504 def test_should_not_be_able_to_reopen_an_issue_assigned_to_a_closed_version
505 505 issue = Issue.find(11)
506 506 issue.status_id = 1
507 507 assert !issue.save
508 508 assert_not_nil issue.errors[:base]
509 509 end
510 510
511 511 def test_should_be_able_to_reopen_and_reassign_an_issue_assigned_to_a_closed_version
512 512 issue = Issue.find(11)
513 513 issue.status_id = 1
514 514 issue.fixed_version_id = 3
515 515 assert issue.save
516 516 end
517 517
518 518 def test_should_be_able_to_reopen_an_issue_assigned_to_a_locked_version
519 519 issue = Issue.find(12)
520 520 assert_equal 'locked', issue.fixed_version.status
521 521 issue.status_id = 1
522 522 assert issue.save
523 523 end
524 524
525 525 def test_move_to_another_project_with_same_category
526 526 issue = Issue.find(1)
527 527 issue.project = Project.find(2)
528 528 assert issue.save
529 529 issue.reload
530 530 assert_equal 2, issue.project_id
531 531 # Category changes
532 532 assert_equal 4, issue.category_id
533 533 # Make sure time entries were move to the target project
534 534 assert_equal 2, issue.time_entries.first.project_id
535 535 end
536 536
537 537 def test_move_to_another_project_without_same_category
538 538 issue = Issue.find(2)
539 539 issue.project = Project.find(2)
540 540 assert issue.save
541 541 issue.reload
542 542 assert_equal 2, issue.project_id
543 543 # Category cleared
544 544 assert_nil issue.category_id
545 545 end
546 546
547 547 def test_move_to_another_project_should_clear_fixed_version_when_not_shared
548 548 issue = Issue.find(1)
549 549 issue.update_attribute(:fixed_version_id, 1)
550 550 issue.project = Project.find(2)
551 551 assert issue.save
552 552 issue.reload
553 553 assert_equal 2, issue.project_id
554 554 # Cleared fixed_version
555 555 assert_equal nil, issue.fixed_version
556 556 end
557 557
558 558 def test_move_to_another_project_should_keep_fixed_version_when_shared_with_the_target_project
559 559 issue = Issue.find(1)
560 560 issue.update_attribute(:fixed_version_id, 4)
561 561 issue.project = Project.find(5)
562 562 assert issue.save
563 563 issue.reload
564 564 assert_equal 5, issue.project_id
565 565 # Keep fixed_version
566 566 assert_equal 4, issue.fixed_version_id
567 567 end
568 568
569 569 def test_move_to_another_project_should_clear_fixed_version_when_not_shared_with_the_target_project
570 570 issue = Issue.find(1)
571 571 issue.update_attribute(:fixed_version_id, 1)
572 572 issue.project = Project.find(5)
573 573 assert issue.save
574 574 issue.reload
575 575 assert_equal 5, issue.project_id
576 576 # Cleared fixed_version
577 577 assert_equal nil, issue.fixed_version
578 578 end
579 579
580 580 def test_move_to_another_project_should_keep_fixed_version_when_shared_systemwide
581 581 issue = Issue.find(1)
582 582 issue.update_attribute(:fixed_version_id, 7)
583 583 issue.project = Project.find(2)
584 584 assert issue.save
585 585 issue.reload
586 586 assert_equal 2, issue.project_id
587 587 # Keep fixed_version
588 588 assert_equal 7, issue.fixed_version_id
589 589 end
590 590
591 591 def test_move_to_another_project_with_disabled_tracker
592 592 issue = Issue.find(1)
593 593 target = Project.find(2)
594 594 target.tracker_ids = [3]
595 595 target.save
596 596 issue.project = target
597 597 assert issue.save
598 598 issue.reload
599 599 assert_equal 2, issue.project_id
600 600 assert_equal 3, issue.tracker_id
601 601 end
602 602
603 603 def test_copy_to_the_same_project
604 604 issue = Issue.find(1)
605 605 copy = issue.copy
606 606 assert_difference 'Issue.count' do
607 607 copy.save!
608 608 end
609 609 assert_kind_of Issue, copy
610 610 assert_equal issue.project, copy.project
611 611 assert_equal "125", copy.custom_value_for(2).value
612 612 end
613 613
614 614 def test_copy_to_another_project_and_tracker
615 615 issue = Issue.find(1)
616 616 copy = issue.copy(:project_id => 3, :tracker_id => 2)
617 617 assert_difference 'Issue.count' do
618 618 copy.save!
619 619 end
620 620 copy.reload
621 621 assert_kind_of Issue, copy
622 622 assert_equal Project.find(3), copy.project
623 623 assert_equal Tracker.find(2), copy.tracker
624 624 # Custom field #2 is not associated with target tracker
625 625 assert_nil copy.custom_value_for(2)
626 626 end
627 627
628 628 context "#copy" do
629 629 setup do
630 630 @issue = Issue.find(1)
631 631 end
632 632
633 633 should "not create a journal" do
634 634 copy = @issue.copy(:project_id => 3, :tracker_id => 2, :assigned_to_id => 3)
635 635 copy.save!
636 636 assert_equal 0, copy.reload.journals.size
637 637 end
638 638
639 639 should "allow assigned_to changes" do
640 640 copy = @issue.copy(:project_id => 3, :tracker_id => 2, :assigned_to_id => 3)
641 641 assert_equal 3, copy.assigned_to_id
642 642 end
643 643
644 644 should "allow status changes" do
645 645 copy = @issue.copy(:project_id => 3, :tracker_id => 2, :status_id => 2)
646 646 assert_equal 2, copy.status_id
647 647 end
648 648
649 649 should "allow start date changes" do
650 650 date = Date.today
651 651 copy = @issue.copy(:project_id => 3, :tracker_id => 2, :start_date => date)
652 652 assert_equal date, copy.start_date
653 653 end
654 654
655 655 should "allow due date changes" do
656 656 date = Date.today
657 657 copy = @issue.copy(:project_id => 3, :tracker_id => 2, :due_date => date)
658 658 assert_equal date, copy.due_date
659 659 end
660 660
661 661 should "set current user as author" do
662 662 User.current = User.find(9)
663 663 copy = @issue.copy(:project_id => 3, :tracker_id => 2)
664 664 assert_equal User.current, copy.author
665 665 end
666 666
667 667 should "create a journal with notes" do
668 668 date = Date.today
669 669 notes = "Notes added when copying"
670 670 copy = @issue.copy(:project_id => 3, :tracker_id => 2, :start_date => date)
671 671 copy.init_journal(User.current, notes)
672 672 copy.save!
673 673
674 674 assert_equal 1, copy.journals.size
675 675 journal = copy.journals.first
676 676 assert_equal 0, journal.details.size
677 677 assert_equal notes, journal.notes
678 678 end
679 679 end
680 680
681 def test_recipients_should_include_previous_assignee
682 user = User.find(3)
683 user.members.update_all ["mail_notification = ?", false]
684 user.update_attribute :mail_notification, 'only_assigned'
685
686 issue = Issue.find(2)
687 issue.assigned_to = nil
688 assert_include user.mail, issue.recipients
689 issue.save!
690 assert !issue.recipients.include?(user.mail)
691 end
692
681 693 def test_recipients_should_not_include_users_that_cannot_view_the_issue
682 694 issue = Issue.find(12)
683 695 assert issue.recipients.include?(issue.author.mail)
684 696 # copy the issue to a private project
685 697 copy = issue.copy(:project_id => 5, :tracker_id => 2)
686 698 # author is not a member of project anymore
687 699 assert !copy.recipients.include?(copy.author.mail)
688 700 end
689 701
690 702 def test_recipients_should_include_the_assigned_group_members
691 703 group_member = User.generate_with_protected!
692 704 group = Group.generate!
693 705 group.users << group_member
694 706
695 707 issue = Issue.find(12)
696 708 issue.assigned_to = group
697 709 assert issue.recipients.include?(group_member.mail)
698 710 end
699 711
700 712 def test_watcher_recipients_should_not_include_users_that_cannot_view_the_issue
701 713 user = User.find(3)
702 714 issue = Issue.find(9)
703 715 Watcher.create!(:user => user, :watchable => issue)
704 716 assert issue.watched_by?(user)
705 717 assert !issue.watcher_recipients.include?(user.mail)
706 718 end
707 719
708 720 def test_issue_destroy
709 721 Issue.find(1).destroy
710 722 assert_nil Issue.find_by_id(1)
711 723 assert_nil TimeEntry.find_by_issue_id(1)
712 724 end
713 725
714 726 def test_blocked
715 727 blocked_issue = Issue.find(9)
716 728 blocking_issue = Issue.find(10)
717 729
718 730 assert blocked_issue.blocked?
719 731 assert !blocking_issue.blocked?
720 732 end
721 733
722 734 def test_blocked_issues_dont_allow_closed_statuses
723 735 blocked_issue = Issue.find(9)
724 736
725 737 allowed_statuses = blocked_issue.new_statuses_allowed_to(users(:users_002))
726 738 assert !allowed_statuses.empty?
727 739 closed_statuses = allowed_statuses.select {|st| st.is_closed?}
728 740 assert closed_statuses.empty?
729 741 end
730 742
731 743 def test_unblocked_issues_allow_closed_statuses
732 744 blocking_issue = Issue.find(10)
733 745
734 746 allowed_statuses = blocking_issue.new_statuses_allowed_to(users(:users_002))
735 747 assert !allowed_statuses.empty?
736 748 closed_statuses = allowed_statuses.select {|st| st.is_closed?}
737 749 assert !closed_statuses.empty?
738 750 end
739 751
740 752 def test_rescheduling_an_issue_should_reschedule_following_issue
741 753 issue1 = Issue.create!(:project_id => 1, :tracker_id => 1, :author_id => 1, :status_id => 1, :subject => '-', :start_date => Date.today, :due_date => Date.today + 2)
742 754 issue2 = Issue.create!(:project_id => 1, :tracker_id => 1, :author_id => 1, :status_id => 1, :subject => '-', :start_date => Date.today, :due_date => Date.today + 2)
743 755 IssueRelation.create!(:issue_from => issue1, :issue_to => issue2, :relation_type => IssueRelation::TYPE_PRECEDES)
744 756 assert_equal issue1.due_date + 1, issue2.reload.start_date
745 757
746 758 issue1.due_date = Date.today + 5
747 759 issue1.save!
748 760 assert_equal issue1.due_date + 1, issue2.reload.start_date
749 761 end
750 762
751 763 def test_overdue
752 764 assert Issue.new(:due_date => 1.day.ago.to_date).overdue?
753 765 assert !Issue.new(:due_date => Date.today).overdue?
754 766 assert !Issue.new(:due_date => 1.day.from_now.to_date).overdue?
755 767 assert !Issue.new(:due_date => nil).overdue?
756 768 assert !Issue.new(:due_date => 1.day.ago.to_date, :status => IssueStatus.find(:first, :conditions => {:is_closed => true})).overdue?
757 769 end
758 770
759 771 context "#behind_schedule?" do
760 772 should "be false if the issue has no start_date" do
761 773 assert !Issue.new(:start_date => nil, :due_date => 1.day.from_now.to_date, :done_ratio => 0).behind_schedule?
762 774 end
763 775
764 776 should "be false if the issue has no end_date" do
765 777 assert !Issue.new(:start_date => 1.day.from_now.to_date, :due_date => nil, :done_ratio => 0).behind_schedule?
766 778 end
767 779
768 780 should "be false if the issue has more done than it's calendar time" do
769 781 assert !Issue.new(:start_date => 50.days.ago.to_date, :due_date => 50.days.from_now.to_date, :done_ratio => 90).behind_schedule?
770 782 end
771 783
772 784 should "be true if the issue hasn't been started at all" do
773 785 assert Issue.new(:start_date => 1.day.ago.to_date, :due_date => 1.day.from_now.to_date, :done_ratio => 0).behind_schedule?
774 786 end
775 787
776 788 should "be true if the issue has used more calendar time than it's done ratio" do
777 789 assert Issue.new(:start_date => 100.days.ago.to_date, :due_date => Date.today, :done_ratio => 90).behind_schedule?
778 790 end
779 791 end
780 792
781 793 context "#assignable_users" do
782 794 should "be Users" do
783 795 assert_kind_of User, Issue.find(1).assignable_users.first
784 796 end
785 797
786 798 should "include the issue author" do
787 799 project = Project.find(1)
788 800 non_project_member = User.generate!
789 801 issue = Issue.generate_for_project!(project, :author => non_project_member)
790 802
791 803 assert issue.assignable_users.include?(non_project_member)
792 804 end
793 805
794 806 should "include the current assignee" do
795 807 project = Project.find(1)
796 808 user = User.generate!
797 809 issue = Issue.generate_for_project!(project, :assigned_to => user)
798 810 user.lock!
799 811
800 812 assert Issue.find(issue.id).assignable_users.include?(user)
801 813 end
802 814
803 815 should "not show the issue author twice" do
804 816 assignable_user_ids = Issue.find(1).assignable_users.collect(&:id)
805 817 assert_equal 2, assignable_user_ids.length
806 818
807 819 assignable_user_ids.each do |user_id|
808 820 assert_equal 1, assignable_user_ids.select {|i| i == user_id}.length, "User #{user_id} appears more or less than once"
809 821 end
810 822 end
811 823
812 824 context "with issue_group_assignment" do
813 825 should "include groups" do
814 826 issue = Issue.new(:project => Project.find(2))
815 827
816 828 with_settings :issue_group_assignment => '1' do
817 829 assert_equal %w(Group User), issue.assignable_users.map {|a| a.class.name}.uniq.sort
818 830 assert issue.assignable_users.include?(Group.find(11))
819 831 end
820 832 end
821 833 end
822 834
823 835 context "without issue_group_assignment" do
824 836 should "not include groups" do
825 837 issue = Issue.new(:project => Project.find(2))
826 838
827 839 with_settings :issue_group_assignment => '0' do
828 840 assert_equal %w(User), issue.assignable_users.map {|a| a.class.name}.uniq.sort
829 841 assert !issue.assignable_users.include?(Group.find(11))
830 842 end
831 843 end
832 844 end
833 845 end
834 846
835 847 def test_create_should_send_email_notification
836 848 ActionMailer::Base.deliveries.clear
837 849 issue = Issue.new(:project_id => 1, :tracker_id => 1,
838 850 :author_id => 3, :status_id => 1,
839 851 :priority => IssuePriority.all.first,
840 852 :subject => 'test_create', :estimated_hours => '1:30')
841 853
842 854 assert issue.save
843 855 assert_equal 1, ActionMailer::Base.deliveries.size
844 856 end
845 857
846 858 def test_stale_issue_should_not_send_email_notification
847 859 ActionMailer::Base.deliveries.clear
848 860 issue = Issue.find(1)
849 861 stale = Issue.find(1)
850 862
851 863 issue.init_journal(User.find(1))
852 864 issue.subject = 'Subjet update'
853 865 assert issue.save
854 866 assert_equal 1, ActionMailer::Base.deliveries.size
855 867 ActionMailer::Base.deliveries.clear
856 868
857 869 stale.init_journal(User.find(1))
858 870 stale.subject = 'Another subjet update'
859 871 assert_raise ActiveRecord::StaleObjectError do
860 872 stale.save
861 873 end
862 874 assert ActionMailer::Base.deliveries.empty?
863 875 end
864 876
865 877 def test_journalized_description
866 878 IssueCustomField.delete_all
867 879
868 880 i = Issue.first
869 881 old_description = i.description
870 882 new_description = "This is the new description"
871 883
872 884 i.init_journal(User.find(2))
873 885 i.description = new_description
874 886 assert_difference 'Journal.count', 1 do
875 887 assert_difference 'JournalDetail.count', 1 do
876 888 i.save!
877 889 end
878 890 end
879 891
880 892 detail = JournalDetail.first(:order => 'id DESC')
881 893 assert_equal i, detail.journal.journalized
882 894 assert_equal 'attr', detail.property
883 895 assert_equal 'description', detail.prop_key
884 896 assert_equal old_description, detail.old_value
885 897 assert_equal new_description, detail.value
886 898 end
887 899
888 900 def test_blank_descriptions_should_not_be_journalized
889 901 IssueCustomField.delete_all
890 902 Issue.update_all("description = NULL", "id=1")
891 903
892 904 i = Issue.find(1)
893 905 i.init_journal(User.find(2))
894 906 i.subject = "blank description"
895 907 i.description = "\r\n"
896 908
897 909 assert_difference 'Journal.count', 1 do
898 910 assert_difference 'JournalDetail.count', 1 do
899 911 i.save!
900 912 end
901 913 end
902 914 end
903 915
904 916 def test_description_eol_should_be_normalized
905 917 i = Issue.new(:description => "CR \r LF \n CRLF \r\n")
906 918 assert_equal "CR \r\n LF \r\n CRLF \r\n", i.description
907 919 end
908 920
909 921 def test_saving_twice_should_not_duplicate_journal_details
910 922 i = Issue.find(:first)
911 923 i.init_journal(User.find(2), 'Some notes')
912 924 # initial changes
913 925 i.subject = 'New subject'
914 926 i.done_ratio = i.done_ratio + 10
915 927 assert_difference 'Journal.count' do
916 928 assert i.save
917 929 end
918 930 # 1 more change
919 931 i.priority = IssuePriority.find(:first, :conditions => ["id <> ?", i.priority_id])
920 932 assert_no_difference 'Journal.count' do
921 933 assert_difference 'JournalDetail.count', 1 do
922 934 i.save
923 935 end
924 936 end
925 937 # no more change
926 938 assert_no_difference 'Journal.count' do
927 939 assert_no_difference 'JournalDetail.count' do
928 940 i.save
929 941 end
930 942 end
931 943 end
932 944
933 945 def test_all_dependent_issues
934 946 IssueRelation.delete_all
935 947 assert IssueRelation.create!(:issue_from => Issue.find(1),
936 948 :issue_to => Issue.find(2),
937 949 :relation_type => IssueRelation::TYPE_PRECEDES)
938 950 assert IssueRelation.create!(:issue_from => Issue.find(2),
939 951 :issue_to => Issue.find(3),
940 952 :relation_type => IssueRelation::TYPE_PRECEDES)
941 953 assert IssueRelation.create!(:issue_from => Issue.find(3),
942 954 :issue_to => Issue.find(8),
943 955 :relation_type => IssueRelation::TYPE_PRECEDES)
944 956
945 957 assert_equal [2, 3, 8], Issue.find(1).all_dependent_issues.collect(&:id).sort
946 958 end
947 959
948 960 def test_all_dependent_issues_with_persistent_circular_dependency
949 961 IssueRelation.delete_all
950 962 assert IssueRelation.create!(:issue_from => Issue.find(1),
951 963 :issue_to => Issue.find(2),
952 964 :relation_type => IssueRelation::TYPE_PRECEDES)
953 965 assert IssueRelation.create!(:issue_from => Issue.find(2),
954 966 :issue_to => Issue.find(3),
955 967 :relation_type => IssueRelation::TYPE_PRECEDES)
956 968 # Validation skipping
957 969 assert IssueRelation.new(:issue_from => Issue.find(3),
958 970 :issue_to => Issue.find(1),
959 971 :relation_type => IssueRelation::TYPE_PRECEDES).save(false)
960 972
961 973 assert_equal [2, 3], Issue.find(1).all_dependent_issues.collect(&:id).sort
962 974 end
963 975
964 976 def test_all_dependent_issues_with_persistent_multiple_circular_dependencies
965 977 IssueRelation.delete_all
966 978 assert IssueRelation.create!(:issue_from => Issue.find(1),
967 979 :issue_to => Issue.find(2),
968 980 :relation_type => IssueRelation::TYPE_RELATES)
969 981 assert IssueRelation.create!(:issue_from => Issue.find(2),
970 982 :issue_to => Issue.find(3),
971 983 :relation_type => IssueRelation::TYPE_RELATES)
972 984 assert IssueRelation.create!(:issue_from => Issue.find(3),
973 985 :issue_to => Issue.find(8),
974 986 :relation_type => IssueRelation::TYPE_RELATES)
975 987 # Validation skipping
976 988 assert IssueRelation.new(:issue_from => Issue.find(8),
977 989 :issue_to => Issue.find(2),
978 990 :relation_type => IssueRelation::TYPE_RELATES).save(false)
979 991 assert IssueRelation.new(:issue_from => Issue.find(3),
980 992 :issue_to => Issue.find(1),
981 993 :relation_type => IssueRelation::TYPE_RELATES).save(false)
982 994
983 995 assert_equal [2, 3, 8], Issue.find(1).all_dependent_issues.collect(&:id).sort
984 996 end
985 997
986 998 context "#done_ratio" do
987 999 setup do
988 1000 @issue = Issue.find(1)
989 1001 @issue_status = IssueStatus.find(1)
990 1002 @issue_status.update_attribute(:default_done_ratio, 50)
991 1003 @issue2 = Issue.find(2)
992 1004 @issue_status2 = IssueStatus.find(2)
993 1005 @issue_status2.update_attribute(:default_done_ratio, 0)
994 1006 end
995 1007
996 1008 teardown do
997 1009 Setting.issue_done_ratio = 'issue_field'
998 1010 end
999 1011
1000 1012 context "with Setting.issue_done_ratio using the issue_field" do
1001 1013 setup do
1002 1014 Setting.issue_done_ratio = 'issue_field'
1003 1015 end
1004 1016
1005 1017 should "read the issue's field" do
1006 1018 assert_equal 0, @issue.done_ratio
1007 1019 assert_equal 30, @issue2.done_ratio
1008 1020 end
1009 1021 end
1010 1022
1011 1023 context "with Setting.issue_done_ratio using the issue_status" do
1012 1024 setup do
1013 1025 Setting.issue_done_ratio = 'issue_status'
1014 1026 end
1015 1027
1016 1028 should "read the Issue Status's default done ratio" do
1017 1029 assert_equal 50, @issue.done_ratio
1018 1030 assert_equal 0, @issue2.done_ratio
1019 1031 end
1020 1032 end
1021 1033 end
1022 1034
1023 1035 context "#update_done_ratio_from_issue_status" do
1024 1036 setup do
1025 1037 @issue = Issue.find(1)
1026 1038 @issue_status = IssueStatus.find(1)
1027 1039 @issue_status.update_attribute(:default_done_ratio, 50)
1028 1040 @issue2 = Issue.find(2)
1029 1041 @issue_status2 = IssueStatus.find(2)
1030 1042 @issue_status2.update_attribute(:default_done_ratio, 0)
1031 1043 end
1032 1044
1033 1045 context "with Setting.issue_done_ratio using the issue_field" do
1034 1046 setup do
1035 1047 Setting.issue_done_ratio = 'issue_field'
1036 1048 end
1037 1049
1038 1050 should "not change the issue" do
1039 1051 @issue.update_done_ratio_from_issue_status
1040 1052 @issue2.update_done_ratio_from_issue_status
1041 1053
1042 1054 assert_equal 0, @issue.read_attribute(:done_ratio)
1043 1055 assert_equal 30, @issue2.read_attribute(:done_ratio)
1044 1056 end
1045 1057 end
1046 1058
1047 1059 context "with Setting.issue_done_ratio using the issue_status" do
1048 1060 setup do
1049 1061 Setting.issue_done_ratio = 'issue_status'
1050 1062 end
1051 1063
1052 1064 should "change the issue's done ratio" do
1053 1065 @issue.update_done_ratio_from_issue_status
1054 1066 @issue2.update_done_ratio_from_issue_status
1055 1067
1056 1068 assert_equal 50, @issue.read_attribute(:done_ratio)
1057 1069 assert_equal 0, @issue2.read_attribute(:done_ratio)
1058 1070 end
1059 1071 end
1060 1072 end
1061 1073
1062 1074 test "#by_tracker" do
1063 1075 User.current = User.anonymous
1064 1076 groups = Issue.by_tracker(Project.find(1))
1065 1077 assert_equal 3, groups.size
1066 1078 assert_equal 7, groups.inject(0) {|sum, group| sum + group['total'].to_i}
1067 1079 end
1068 1080
1069 1081 test "#by_version" do
1070 1082 User.current = User.anonymous
1071 1083 groups = Issue.by_version(Project.find(1))
1072 1084 assert_equal 3, groups.size
1073 1085 assert_equal 3, groups.inject(0) {|sum, group| sum + group['total'].to_i}
1074 1086 end
1075 1087
1076 1088 test "#by_priority" do
1077 1089 User.current = User.anonymous
1078 1090 groups = Issue.by_priority(Project.find(1))
1079 1091 assert_equal 4, groups.size
1080 1092 assert_equal 7, groups.inject(0) {|sum, group| sum + group['total'].to_i}
1081 1093 end
1082 1094
1083 1095 test "#by_category" do
1084 1096 User.current = User.anonymous
1085 1097 groups = Issue.by_category(Project.find(1))
1086 1098 assert_equal 2, groups.size
1087 1099 assert_equal 3, groups.inject(0) {|sum, group| sum + group['total'].to_i}
1088 1100 end
1089 1101
1090 1102 test "#by_assigned_to" do
1091 1103 User.current = User.anonymous
1092 1104 groups = Issue.by_assigned_to(Project.find(1))
1093 1105 assert_equal 2, groups.size
1094 1106 assert_equal 2, groups.inject(0) {|sum, group| sum + group['total'].to_i}
1095 1107 end
1096 1108
1097 1109 test "#by_author" do
1098 1110 User.current = User.anonymous
1099 1111 groups = Issue.by_author(Project.find(1))
1100 1112 assert_equal 4, groups.size
1101 1113 assert_equal 7, groups.inject(0) {|sum, group| sum + group['total'].to_i}
1102 1114 end
1103 1115
1104 1116 test "#by_subproject" do
1105 1117 User.current = User.anonymous
1106 1118 groups = Issue.by_subproject(Project.find(1))
1107 1119 # Private descendant not visible
1108 1120 assert_equal 1, groups.size
1109 1121 assert_equal 2, groups.inject(0) {|sum, group| sum + group['total'].to_i}
1110 1122 end
1111 1123
1112 1124 context ".allowed_target_projects_on_move" do
1113 1125 should "return all active projects for admin users" do
1114 1126 User.current = User.find(1)
1115 1127 assert_equal Project.active.count, Issue.allowed_target_projects_on_move.size
1116 1128 end
1117 1129
1118 1130 should "return allowed projects for non admin users" do
1119 1131 User.current = User.find(2)
1120 1132 Role.non_member.remove_permission! :move_issues
1121 1133 assert_equal 3, Issue.allowed_target_projects_on_move.size
1122 1134
1123 1135 Role.non_member.add_permission! :move_issues
1124 1136 assert_equal Project.active.count, Issue.allowed_target_projects_on_move.size
1125 1137 end
1126 1138 end
1127 1139
1128 1140 def test_recently_updated_with_limit_scopes
1129 1141 #should return the last updated issue
1130 1142 assert_equal 1, Issue.recently_updated.with_limit(1).length
1131 1143 assert_equal Issue.find(:first, :order => "updated_on DESC"), Issue.recently_updated.with_limit(1).first
1132 1144 end
1133 1145
1134 1146 def test_on_active_projects_scope
1135 1147 assert Project.find(2).archive
1136 1148
1137 1149 before = Issue.on_active_project.length
1138 1150 # test inclusion to results
1139 1151 issue = Issue.generate_for_project!(Project.find(1), :tracker => Project.find(2).trackers.first)
1140 1152 assert_equal before + 1, Issue.on_active_project.length
1141 1153
1142 1154 # Move to an archived project
1143 1155 issue.project = Project.find(2)
1144 1156 assert issue.save
1145 1157 assert_equal before, Issue.on_active_project.length
1146 1158 end
1147 1159
1148 1160 context "Issue#recipients" do
1149 1161 setup do
1150 1162 @project = Project.find(1)
1151 1163 @author = User.generate_with_protected!
1152 1164 @assignee = User.generate_with_protected!
1153 1165 @issue = Issue.generate_for_project!(@project, :assigned_to => @assignee, :author => @author)
1154 1166 end
1155 1167
1156 1168 should "include project recipients" do
1157 1169 assert @project.recipients.present?
1158 1170 @project.recipients.each do |project_recipient|
1159 1171 assert @issue.recipients.include?(project_recipient)
1160 1172 end
1161 1173 end
1162 1174
1163 1175 should "include the author if the author is active" do
1164 1176 assert @issue.author, "No author set for Issue"
1165 1177 assert @issue.recipients.include?(@issue.author.mail)
1166 1178 end
1167 1179
1168 1180 should "include the assigned to user if the assigned to user is active" do
1169 1181 assert @issue.assigned_to, "No assigned_to set for Issue"
1170 1182 assert @issue.recipients.include?(@issue.assigned_to.mail)
1171 1183 end
1172 1184
1173 1185 should "not include users who opt out of all email" do
1174 1186 @author.update_attribute(:mail_notification, :none)
1175 1187
1176 1188 assert !@issue.recipients.include?(@issue.author.mail)
1177 1189 end
1178 1190
1179 1191 should "not include the issue author if they are only notified of assigned issues" do
1180 1192 @author.update_attribute(:mail_notification, :only_assigned)
1181 1193
1182 1194 assert !@issue.recipients.include?(@issue.author.mail)
1183 1195 end
1184 1196
1185 1197 should "not include the assigned user if they are only notified of owned issues" do
1186 1198 @assignee.update_attribute(:mail_notification, :only_owner)
1187 1199
1188 1200 assert !@issue.recipients.include?(@issue.assigned_to.mail)
1189 1201 end
1190 1202
1191 1203 end
1192 1204 end
General Comments 0
You need to be logged in to leave comments. Login now