##// END OF EJS Templates
Merged r14083 (#19368)....
Jean-Philippe Lang -
r13713:b956621b013b
parent child
Show More
@@ -1,1563 +1,1568
1 1 # Redmine - project management software
2 2 # Copyright (C) 2006-2015 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 include Redmine::Utils::DateCalculation
21 21 include Redmine::I18n
22 22 before_save :set_parent_id
23 23 include Redmine::NestedSet::IssueNestedSet
24 24
25 25 belongs_to :project
26 26 belongs_to :tracker
27 27 belongs_to :status, :class_name => 'IssueStatus'
28 28 belongs_to :author, :class_name => 'User'
29 29 belongs_to :assigned_to, :class_name => 'Principal'
30 30 belongs_to :fixed_version, :class_name => 'Version'
31 31 belongs_to :priority, :class_name => 'IssuePriority'
32 32 belongs_to :category, :class_name => 'IssueCategory'
33 33
34 34 has_many :journals, :as => :journalized, :dependent => :destroy, :inverse_of => :journalized
35 35 has_many :visible_journals,
36 36 lambda {where(["(#{Journal.table_name}.private_notes = ? OR (#{Project.allowed_to_condition(User.current, :view_private_notes)}))", false])},
37 37 :class_name => 'Journal',
38 38 :as => :journalized
39 39
40 40 has_many :time_entries, :dependent => :destroy
41 41 has_and_belongs_to_many :changesets, lambda {order("#{Changeset.table_name}.committed_on ASC, #{Changeset.table_name}.id ASC")}
42 42
43 43 has_many :relations_from, :class_name => 'IssueRelation', :foreign_key => 'issue_from_id', :dependent => :delete_all
44 44 has_many :relations_to, :class_name => 'IssueRelation', :foreign_key => 'issue_to_id', :dependent => :delete_all
45 45
46 46 acts_as_attachable :after_add => :attachment_added, :after_remove => :attachment_removed
47 47 acts_as_customizable
48 48 acts_as_watchable
49 49 acts_as_searchable :columns => ['subject', "#{table_name}.description"],
50 50 :preload => [:project, :status, :tracker],
51 51 :scope => lambda {|options| options[:open_issues] ? self.open : self.all}
52 52
53 53 acts_as_event :title => Proc.new {|o| "#{o.tracker.name} ##{o.id} (#{o.status}): #{o.subject}"},
54 54 :url => Proc.new {|o| {:controller => 'issues', :action => 'show', :id => o.id}},
55 55 :type => Proc.new {|o| 'issue' + (o.closed? ? ' closed' : '') }
56 56
57 57 acts_as_activity_provider :scope => preload(:project, :author, :tracker),
58 58 :author_key => :author_id
59 59
60 60 DONE_RATIO_OPTIONS = %w(issue_field issue_status)
61 61
62 62 attr_reader :current_journal
63 63 delegate :notes, :notes=, :private_notes, :private_notes=, :to => :current_journal, :allow_nil => true
64 64
65 65 validates_presence_of :subject, :project, :tracker
66 66 validates_presence_of :priority, :if => Proc.new {|issue| issue.new_record? || issue.priority_id_changed?}
67 67 validates_presence_of :status, :if => Proc.new {|issue| issue.new_record? || issue.status_id_changed?}
68 68 validates_presence_of :author, :if => Proc.new {|issue| issue.new_record? || issue.author_id_changed?}
69 69
70 70 validates_length_of :subject, :maximum => 255
71 71 validates_inclusion_of :done_ratio, :in => 0..100
72 72 validates :estimated_hours, :numericality => {:greater_than_or_equal_to => 0, :allow_nil => true, :message => :invalid}
73 73 validates :start_date, :date => true
74 74 validates :due_date, :date => true
75 75 validate :validate_issue, :validate_required_fields
76 76 attr_protected :id
77 77
78 78 scope :visible, lambda {|*args|
79 79 joins(:project).
80 80 where(Issue.visible_condition(args.shift || User.current, *args))
81 81 }
82 82
83 83 scope :open, lambda {|*args|
84 84 is_closed = args.size > 0 ? !args.first : false
85 85 joins(:status).
86 86 where("#{IssueStatus.table_name}.is_closed = ?", is_closed)
87 87 }
88 88
89 89 scope :recently_updated, lambda { order("#{Issue.table_name}.updated_on DESC") }
90 90 scope :on_active_project, lambda {
91 91 joins(:project).
92 92 where("#{Project.table_name}.status = ?", Project::STATUS_ACTIVE)
93 93 }
94 94 scope :fixed_version, lambda {|versions|
95 95 ids = [versions].flatten.compact.map {|v| v.is_a?(Version) ? v.id : v}
96 96 ids.any? ? where(:fixed_version_id => ids) : where('1=0')
97 97 }
98 98
99 99 before_create :default_assign
100 100 before_save :close_duplicates, :update_done_ratio_from_issue_status,
101 101 :force_updated_on_change, :update_closed_on, :set_assigned_to_was
102 102 after_save {|issue| issue.send :after_project_change if !issue.id_changed? && issue.project_id_changed?}
103 103 after_save :reschedule_following_issues, :update_nested_set_attributes,
104 104 :update_parent_attributes, :create_journal
105 105 # Should be after_create but would be called before previous after_save callbacks
106 106 after_save :after_create_from_copy
107 107 after_destroy :update_parent_attributes
108 108 after_create :send_notification
109 109 # Keep it at the end of after_save callbacks
110 110 after_save :clear_assigned_to_was
111 111
112 112 # Returns a SQL conditions string used to find all issues visible by the specified user
113 113 def self.visible_condition(user, options={})
114 114 Project.allowed_to_condition(user, :view_issues, options) do |role, user|
115 115 if user.id && user.logged?
116 116 case role.issues_visibility
117 117 when 'all'
118 118 nil
119 119 when 'default'
120 120 user_ids = [user.id] + user.groups.map(&:id).compact
121 121 "(#{table_name}.is_private = #{connection.quoted_false} OR #{table_name}.author_id = #{user.id} OR #{table_name}.assigned_to_id IN (#{user_ids.join(',')}))"
122 122 when 'own'
123 123 user_ids = [user.id] + user.groups.map(&:id).compact
124 124 "(#{table_name}.author_id = #{user.id} OR #{table_name}.assigned_to_id IN (#{user_ids.join(',')}))"
125 125 else
126 126 '1=0'
127 127 end
128 128 else
129 129 "(#{table_name}.is_private = #{connection.quoted_false})"
130 130 end
131 131 end
132 132 end
133 133
134 134 # Returns true if usr or current user is allowed to view the issue
135 135 def visible?(usr=nil)
136 136 (usr || User.current).allowed_to?(:view_issues, self.project) do |role, user|
137 137 if user.logged?
138 138 case role.issues_visibility
139 139 when 'all'
140 140 true
141 141 when 'default'
142 142 !self.is_private? || (self.author == user || user.is_or_belongs_to?(assigned_to))
143 143 when 'own'
144 144 self.author == user || user.is_or_belongs_to?(assigned_to)
145 145 else
146 146 false
147 147 end
148 148 else
149 149 !self.is_private?
150 150 end
151 151 end
152 152 end
153 153
154 154 # Returns true if user or current user is allowed to edit or add a note to the issue
155 155 def editable?(user=User.current)
156 156 attributes_editable?(user) || user.allowed_to?(:add_issue_notes, project)
157 157 end
158 158
159 159 # Returns true if user or current user is allowed to edit the issue
160 160 def attributes_editable?(user=User.current)
161 161 user.allowed_to?(:edit_issues, project)
162 162 end
163 163
164 164 def initialize(attributes=nil, *args)
165 165 super
166 166 if new_record?
167 167 # set default values for new records only
168 168 self.priority ||= IssuePriority.default
169 169 self.watcher_user_ids = []
170 170 end
171 171 end
172 172
173 173 def create_or_update
174 174 super
175 175 ensure
176 176 @status_was = nil
177 177 end
178 178 private :create_or_update
179 179
180 180 # AR#Persistence#destroy would raise and RecordNotFound exception
181 181 # if the issue was already deleted or updated (non matching lock_version).
182 182 # This is a problem when bulk deleting issues or deleting a project
183 183 # (because an issue may already be deleted if its parent was deleted
184 184 # first).
185 185 # The issue is reloaded by the nested_set before being deleted so
186 186 # the lock_version condition should not be an issue but we handle it.
187 187 def destroy
188 188 super
189 189 rescue ActiveRecord::StaleObjectError, ActiveRecord::RecordNotFound
190 190 # Stale or already deleted
191 191 begin
192 192 reload
193 193 rescue ActiveRecord::RecordNotFound
194 194 # The issue was actually already deleted
195 195 @destroyed = true
196 196 return freeze
197 197 end
198 198 # The issue was stale, retry to destroy
199 199 super
200 200 end
201 201
202 202 alias :base_reload :reload
203 203 def reload(*args)
204 204 @workflow_rule_by_attribute = nil
205 205 @assignable_versions = nil
206 206 @relations = nil
207 207 @spent_hours = nil
208 208 base_reload(*args)
209 209 end
210 210
211 211 # Overrides Redmine::Acts::Customizable::InstanceMethods#available_custom_fields
212 212 def available_custom_fields
213 213 (project && tracker) ? (project.all_issue_custom_fields & tracker.custom_fields) : []
214 214 end
215 215
216 216 def visible_custom_field_values(user=nil)
217 217 user_real = user || User.current
218 218 custom_field_values.select do |value|
219 219 value.custom_field.visible_by?(project, user_real)
220 220 end
221 221 end
222 222
223 223 # Copies attributes from another issue, arg can be an id or an Issue
224 224 def copy_from(arg, options={})
225 225 issue = arg.is_a?(Issue) ? arg : Issue.visible.find(arg)
226 226 self.attributes = issue.attributes.dup.except("id", "root_id", "parent_id", "lft", "rgt", "created_on", "updated_on")
227 227 self.custom_field_values = issue.custom_field_values.inject({}) {|h,v| h[v.custom_field_id] = v.value; h}
228 228 self.status = issue.status
229 229 self.author = User.current
230 230 unless options[:attachments] == false
231 231 self.attachments = issue.attachments.map do |attachement|
232 232 attachement.copy(:container => self)
233 233 end
234 234 end
235 235 @copied_from = issue
236 236 @copy_options = options
237 237 self
238 238 end
239 239
240 240 # Returns an unsaved copy of the issue
241 241 def copy(attributes=nil, copy_options={})
242 242 copy = self.class.new.copy_from(self, copy_options)
243 243 copy.attributes = attributes if attributes
244 244 copy
245 245 end
246 246
247 247 # Returns true if the issue is a copy
248 248 def copy?
249 249 @copied_from.present?
250 250 end
251 251
252 252 def status_id=(status_id)
253 253 if status_id.to_s != self.status_id.to_s
254 254 self.status = (status_id.present? ? IssueStatus.find_by_id(status_id) : nil)
255 255 end
256 256 self.status_id
257 257 end
258 258
259 259 # Sets the status.
260 260 def status=(status)
261 261 if status != self.status
262 262 @workflow_rule_by_attribute = nil
263 263 end
264 264 association(:status).writer(status)
265 265 end
266 266
267 267 def priority_id=(pid)
268 268 self.priority = nil
269 269 write_attribute(:priority_id, pid)
270 270 end
271 271
272 272 def category_id=(cid)
273 273 self.category = nil
274 274 write_attribute(:category_id, cid)
275 275 end
276 276
277 277 def fixed_version_id=(vid)
278 278 self.fixed_version = nil
279 279 write_attribute(:fixed_version_id, vid)
280 280 end
281 281
282 282 def tracker_id=(tracker_id)
283 283 if tracker_id.to_s != self.tracker_id.to_s
284 284 self.tracker = (tracker_id.present? ? Tracker.find_by_id(tracker_id) : nil)
285 285 end
286 286 self.tracker_id
287 287 end
288 288
289 289 # Sets the tracker.
290 290 # This will set the status to the default status of the new tracker if:
291 291 # * the status was the default for the previous tracker
292 292 # * or if the status was not part of the new tracker statuses
293 293 # * or the status was nil
294 294 def tracker=(tracker)
295 295 if tracker != self.tracker
296 296 if status == default_status
297 297 self.status = nil
298 298 elsif status && tracker && !tracker.issue_status_ids.include?(status.id)
299 299 self.status = nil
300 300 end
301 301 @custom_field_values = nil
302 302 @workflow_rule_by_attribute = nil
303 303 end
304 304 association(:tracker).writer(tracker)
305 305 self.status ||= default_status
306 306 self.tracker
307 307 end
308 308
309 309 def project_id=(project_id)
310 310 if project_id.to_s != self.project_id.to_s
311 311 self.project = (project_id.present? ? Project.find_by_id(project_id) : nil)
312 312 end
313 313 self.project_id
314 314 end
315 315
316 316 # Sets the project.
317 317 # Unless keep_tracker argument is set to true, this will change the tracker
318 318 # to the first tracker of the new project if the previous tracker is not part
319 319 # of the new project trackers.
320 320 # This will clear the fixed_version is it's no longer valid for the new project.
321 321 # This will clear the parent issue if it's no longer valid for the new project.
322 322 # This will set the category to the category with the same name in the new
323 323 # project if it exists, or clear it if it doesn't.
324 324 def project=(project, keep_tracker=false)
325 325 project_was = self.project
326 326 association(:project).writer(project)
327 327 if project_was && project && project_was != project
328 328 @assignable_versions = nil
329 329
330 330 unless keep_tracker || project.trackers.include?(tracker)
331 331 self.tracker = project.trackers.first
332 332 end
333 333 # Reassign to the category with same name if any
334 334 if category
335 335 self.category = project.issue_categories.find_by_name(category.name)
336 336 end
337 337 # Keep the fixed_version if it's still valid in the new_project
338 338 if fixed_version && fixed_version.project != project && !project.shared_versions.include?(fixed_version)
339 339 self.fixed_version = nil
340 340 end
341 341 # Clear the parent task if it's no longer valid
342 342 unless valid_parent_project?
343 343 self.parent_issue_id = nil
344 344 end
345 345 @custom_field_values = nil
346 346 @workflow_rule_by_attribute = nil
347 347 end
348 348 self.project
349 349 end
350 350
351 351 def description=(arg)
352 352 if arg.is_a?(String)
353 353 arg = arg.gsub(/(\r\n|\n|\r)/, "\r\n")
354 354 end
355 355 write_attribute(:description, arg)
356 356 end
357 357
358 358 # Overrides assign_attributes so that project and tracker get assigned first
359 359 def assign_attributes_with_project_and_tracker_first(new_attributes, *args)
360 360 return if new_attributes.nil?
361 361 attrs = new_attributes.dup
362 362 attrs.stringify_keys!
363 363
364 364 %w(project project_id tracker tracker_id).each do |attr|
365 365 if attrs.has_key?(attr)
366 366 send "#{attr}=", attrs.delete(attr)
367 367 end
368 368 end
369 369 send :assign_attributes_without_project_and_tracker_first, attrs, *args
370 370 end
371 371 # Do not redefine alias chain on reload (see #4838)
372 372 alias_method_chain(:assign_attributes, :project_and_tracker_first) unless method_defined?(:assign_attributes_without_project_and_tracker_first)
373 373
374 374 def attributes=(new_attributes)
375 375 assign_attributes new_attributes
376 376 end
377 377
378 378 def estimated_hours=(h)
379 379 write_attribute :estimated_hours, (h.is_a?(String) ? h.to_hours : h)
380 380 end
381 381
382 382 safe_attributes 'project_id',
383 383 'tracker_id',
384 384 'status_id',
385 385 'category_id',
386 386 'assigned_to_id',
387 387 'priority_id',
388 388 'fixed_version_id',
389 389 'subject',
390 390 'description',
391 391 'start_date',
392 392 'due_date',
393 393 'done_ratio',
394 394 'estimated_hours',
395 395 'custom_field_values',
396 396 'custom_fields',
397 397 'lock_version',
398 398 'notes',
399 399 :if => lambda {|issue, user| issue.new_record? || user.allowed_to?(:edit_issues, issue.project) }
400 400
401 401 safe_attributes 'notes',
402 402 :if => lambda {|issue, user| user.allowed_to?(:add_issue_notes, issue.project)}
403 403
404 404 safe_attributes 'private_notes',
405 405 :if => lambda {|issue, user| !issue.new_record? && user.allowed_to?(:set_notes_private, issue.project)}
406 406
407 407 safe_attributes 'watcher_user_ids',
408 408 :if => lambda {|issue, user| issue.new_record? && user.allowed_to?(:add_issue_watchers, issue.project)}
409 409
410 410 safe_attributes 'is_private',
411 411 :if => lambda {|issue, user|
412 412 user.allowed_to?(:set_issues_private, issue.project) ||
413 413 (issue.author_id == user.id && user.allowed_to?(:set_own_issues_private, issue.project))
414 414 }
415 415
416 416 safe_attributes 'parent_issue_id',
417 417 :if => lambda {|issue, user| (issue.new_record? || user.allowed_to?(:edit_issues, issue.project)) &&
418 418 user.allowed_to?(:manage_subtasks, issue.project)}
419 419
420 420 def safe_attribute_names(user=nil)
421 421 names = super
422 422 names -= disabled_core_fields
423 423 names -= read_only_attribute_names(user)
424 424 if new_record?
425 425 # Make sure that project_id can always be set for new issues
426 426 names |= %w(project_id)
427 427 end
428 428 names
429 429 end
430 430
431 431 # Safely sets attributes
432 432 # Should be called from controllers instead of #attributes=
433 433 # attr_accessible is too rough because we still want things like
434 434 # Issue.new(:project => foo) to work
435 435 def safe_attributes=(attrs, user=User.current)
436 436 return unless attrs.is_a?(Hash)
437 437
438 438 attrs = attrs.deep_dup
439 439
440 440 # Project and Tracker must be set before since new_statuses_allowed_to depends on it.
441 441 if (p = attrs.delete('project_id')) && safe_attribute?('project_id')
442 442 if allowed_target_projects(user).where(:id => p.to_i).exists?
443 443 self.project_id = p
444 444 end
445 445 end
446 446
447 447 if (t = attrs.delete('tracker_id')) && safe_attribute?('tracker_id')
448 448 self.tracker_id = t
449 449 end
450 if project
451 # Set the default tracker to accept custom field values
452 # even if tracker is not specified
453 self.tracker ||= project.trackers.first
454 end
450 455
451 456 if (s = attrs.delete('status_id')) && safe_attribute?('status_id')
452 457 if new_statuses_allowed_to(user).collect(&:id).include?(s.to_i)
453 458 self.status_id = s
454 459 end
455 460 end
456 461
457 462 attrs = delete_unsafe_attributes(attrs, user)
458 463 return if attrs.empty?
459 464
460 465 unless leaf?
461 466 attrs.reject! {|k,v| %w(priority_id done_ratio start_date due_date estimated_hours).include?(k)}
462 467 end
463 468
464 469 if attrs['parent_issue_id'].present?
465 470 s = attrs['parent_issue_id'].to_s
466 471 unless (m = s.match(%r{\A#?(\d+)\z})) && (m[1] == parent_id.to_s || Issue.visible(user).exists?(m[1]))
467 472 @invalid_parent_issue_id = attrs.delete('parent_issue_id')
468 473 end
469 474 end
470 475
471 476 if attrs['custom_field_values'].present?
472 477 editable_custom_field_ids = editable_custom_field_values(user).map {|v| v.custom_field_id.to_s}
473 478 attrs['custom_field_values'].select! {|k, v| editable_custom_field_ids.include?(k.to_s)}
474 479 end
475 480
476 481 if attrs['custom_fields'].present?
477 482 editable_custom_field_ids = editable_custom_field_values(user).map {|v| v.custom_field_id.to_s}
478 483 attrs['custom_fields'].select! {|c| editable_custom_field_ids.include?(c['id'].to_s)}
479 484 end
480 485
481 486 # mass-assignment security bypass
482 487 assign_attributes attrs, :without_protection => true
483 488 end
484 489
485 490 def disabled_core_fields
486 491 tracker ? tracker.disabled_core_fields : []
487 492 end
488 493
489 494 # Returns the custom_field_values that can be edited by the given user
490 495 def editable_custom_field_values(user=nil)
491 496 visible_custom_field_values(user).reject do |value|
492 497 read_only_attribute_names(user).include?(value.custom_field_id.to_s)
493 498 end
494 499 end
495 500
496 501 # Returns the custom fields that can be edited by the given user
497 502 def editable_custom_fields(user=nil)
498 503 editable_custom_field_values(user).map(&:custom_field).uniq
499 504 end
500 505
501 506 # Returns the names of attributes that are read-only for user or the current user
502 507 # For users with multiple roles, the read-only fields are the intersection of
503 508 # read-only fields of each role
504 509 # The result is an array of strings where sustom fields are represented with their ids
505 510 #
506 511 # Examples:
507 512 # issue.read_only_attribute_names # => ['due_date', '2']
508 513 # issue.read_only_attribute_names(user) # => []
509 514 def read_only_attribute_names(user=nil)
510 515 workflow_rule_by_attribute(user).reject {|attr, rule| rule != 'readonly'}.keys
511 516 end
512 517
513 518 # Returns the names of required attributes for user or the current user
514 519 # For users with multiple roles, the required fields are the intersection of
515 520 # required fields of each role
516 521 # The result is an array of strings where sustom fields are represented with their ids
517 522 #
518 523 # Examples:
519 524 # issue.required_attribute_names # => ['due_date', '2']
520 525 # issue.required_attribute_names(user) # => []
521 526 def required_attribute_names(user=nil)
522 527 workflow_rule_by_attribute(user).reject {|attr, rule| rule != 'required'}.keys
523 528 end
524 529
525 530 # Returns true if the attribute is required for user
526 531 def required_attribute?(name, user=nil)
527 532 required_attribute_names(user).include?(name.to_s)
528 533 end
529 534
530 535 # Returns a hash of the workflow rule by attribute for the given user
531 536 #
532 537 # Examples:
533 538 # issue.workflow_rule_by_attribute # => {'due_date' => 'required', 'start_date' => 'readonly'}
534 539 def workflow_rule_by_attribute(user=nil)
535 540 return @workflow_rule_by_attribute if @workflow_rule_by_attribute && user.nil?
536 541
537 542 user_real = user || User.current
538 543 roles = user_real.admin ? Role.all.to_a : user_real.roles_for_project(project)
539 544 roles = roles.select(&:consider_workflow?)
540 545 return {} if roles.empty?
541 546
542 547 result = {}
543 548 workflow_permissions = WorkflowPermission.where(:tracker_id => tracker_id, :old_status_id => status_id, :role_id => roles.map(&:id)).to_a
544 549 if workflow_permissions.any?
545 550 workflow_rules = workflow_permissions.inject({}) do |h, wp|
546 551 h[wp.field_name] ||= []
547 552 h[wp.field_name] << wp.rule
548 553 h
549 554 end
550 555 workflow_rules.each do |attr, rules|
551 556 next if rules.size < roles.size
552 557 uniq_rules = rules.uniq
553 558 if uniq_rules.size == 1
554 559 result[attr] = uniq_rules.first
555 560 else
556 561 result[attr] = 'required'
557 562 end
558 563 end
559 564 end
560 565 @workflow_rule_by_attribute = result if user.nil?
561 566 result
562 567 end
563 568 private :workflow_rule_by_attribute
564 569
565 570 def done_ratio
566 571 if Issue.use_status_for_done_ratio? && status && status.default_done_ratio
567 572 status.default_done_ratio
568 573 else
569 574 read_attribute(:done_ratio)
570 575 end
571 576 end
572 577
573 578 def self.use_status_for_done_ratio?
574 579 Setting.issue_done_ratio == 'issue_status'
575 580 end
576 581
577 582 def self.use_field_for_done_ratio?
578 583 Setting.issue_done_ratio == 'issue_field'
579 584 end
580 585
581 586 def validate_issue
582 587 if due_date && start_date && (start_date_changed? || due_date_changed?) && due_date < start_date
583 588 errors.add :due_date, :greater_than_start_date
584 589 end
585 590
586 591 if start_date && start_date_changed? && soonest_start && start_date < soonest_start
587 592 errors.add :start_date, :earlier_than_minimum_start_date, :date => format_date(soonest_start)
588 593 end
589 594
590 595 if fixed_version
591 596 if !assignable_versions.include?(fixed_version)
592 597 errors.add :fixed_version_id, :inclusion
593 598 elsif reopening? && fixed_version.closed?
594 599 errors.add :base, I18n.t(:error_can_not_reopen_issue_on_closed_version)
595 600 end
596 601 end
597 602
598 603 # Checks that the issue can not be added/moved to a disabled tracker
599 604 if project && (tracker_id_changed? || project_id_changed?)
600 605 unless project.trackers.include?(tracker)
601 606 errors.add :tracker_id, :inclusion
602 607 end
603 608 end
604 609
605 610 # Checks parent issue assignment
606 611 if @invalid_parent_issue_id.present?
607 612 errors.add :parent_issue_id, :invalid
608 613 elsif @parent_issue
609 614 if !valid_parent_project?(@parent_issue)
610 615 errors.add :parent_issue_id, :invalid
611 616 elsif (@parent_issue != parent) && (all_dependent_issues.include?(@parent_issue) || @parent_issue.all_dependent_issues.include?(self))
612 617 errors.add :parent_issue_id, :invalid
613 618 elsif !new_record?
614 619 # moving an existing issue
615 620 if move_possible?(@parent_issue)
616 621 # move accepted
617 622 else
618 623 errors.add :parent_issue_id, :invalid
619 624 end
620 625 end
621 626 end
622 627 end
623 628
624 629 # Validates the issue against additional workflow requirements
625 630 def validate_required_fields
626 631 user = new_record? ? author : current_journal.try(:user)
627 632
628 633 required_attribute_names(user).each do |attribute|
629 634 if attribute =~ /^\d+$/
630 635 attribute = attribute.to_i
631 636 v = custom_field_values.detect {|v| v.custom_field_id == attribute }
632 637 if v && v.value.blank?
633 638 errors.add :base, v.custom_field.name + ' ' + l('activerecord.errors.messages.blank')
634 639 end
635 640 else
636 641 if respond_to?(attribute) && send(attribute).blank? && !disabled_core_fields.include?(attribute)
637 642 errors.add attribute, :blank
638 643 end
639 644 end
640 645 end
641 646 end
642 647
643 648 # Set the done_ratio using the status if that setting is set. This will keep the done_ratios
644 649 # even if the user turns off the setting later
645 650 def update_done_ratio_from_issue_status
646 651 if Issue.use_status_for_done_ratio? && status && status.default_done_ratio
647 652 self.done_ratio = status.default_done_ratio
648 653 end
649 654 end
650 655
651 656 def init_journal(user, notes = "")
652 657 @current_journal ||= Journal.new(:journalized => self, :user => user, :notes => notes)
653 658 end
654 659
655 660 # Returns the current journal or nil if it's not initialized
656 661 def current_journal
657 662 @current_journal
658 663 end
659 664
660 665 # Returns the names of attributes that are journalized when updating the issue
661 666 def journalized_attribute_names
662 667 Issue.column_names - %w(id root_id lft rgt lock_version created_on updated_on closed_on)
663 668 end
664 669
665 670 # Returns the id of the last journal or nil
666 671 def last_journal_id
667 672 if new_record?
668 673 nil
669 674 else
670 675 journals.maximum(:id)
671 676 end
672 677 end
673 678
674 679 # Returns a scope for journals that have an id greater than journal_id
675 680 def journals_after(journal_id)
676 681 scope = journals.reorder("#{Journal.table_name}.id ASC")
677 682 if journal_id.present?
678 683 scope = scope.where("#{Journal.table_name}.id > ?", journal_id.to_i)
679 684 end
680 685 scope
681 686 end
682 687
683 688 # Returns the initial status of the issue
684 689 # Returns nil for a new issue
685 690 def status_was
686 691 if status_id_changed?
687 692 if status_id_was.to_i > 0
688 693 @status_was ||= IssueStatus.find_by_id(status_id_was)
689 694 end
690 695 else
691 696 @status_was ||= status
692 697 end
693 698 end
694 699
695 700 # Return true if the issue is closed, otherwise false
696 701 def closed?
697 702 status.present? && status.is_closed?
698 703 end
699 704
700 705 # Returns true if the issue was closed when loaded
701 706 def was_closed?
702 707 status_was.present? && status_was.is_closed?
703 708 end
704 709
705 710 # Return true if the issue is being reopened
706 711 def reopening?
707 712 if new_record?
708 713 false
709 714 else
710 715 status_id_changed? && !closed? && was_closed?
711 716 end
712 717 end
713 718 alias :reopened? :reopening?
714 719
715 720 # Return true if the issue is being closed
716 721 def closing?
717 722 if new_record?
718 723 closed?
719 724 else
720 725 status_id_changed? && closed? && !was_closed?
721 726 end
722 727 end
723 728
724 729 # Returns true if the issue is overdue
725 730 def overdue?
726 731 due_date.present? && (due_date < Date.today) && !closed?
727 732 end
728 733
729 734 # Is the amount of work done less than it should for the due date
730 735 def behind_schedule?
731 736 return false if start_date.nil? || due_date.nil?
732 737 done_date = start_date + ((due_date - start_date + 1) * done_ratio / 100).floor
733 738 return done_date <= Date.today
734 739 end
735 740
736 741 # Does this issue have children?
737 742 def children?
738 743 !leaf?
739 744 end
740 745
741 746 # Users the issue can be assigned to
742 747 def assignable_users
743 748 users = project.assignable_users.to_a
744 749 users << author if author
745 750 users << assigned_to if assigned_to
746 751 users.uniq.sort
747 752 end
748 753
749 754 # Versions that the issue can be assigned to
750 755 def assignable_versions
751 756 return @assignable_versions if @assignable_versions
752 757
753 758 versions = project.shared_versions.open.to_a
754 759 if fixed_version
755 760 if fixed_version_id_changed?
756 761 # nothing to do
757 762 elsif project_id_changed?
758 763 if project.shared_versions.include?(fixed_version)
759 764 versions << fixed_version
760 765 end
761 766 else
762 767 versions << fixed_version
763 768 end
764 769 end
765 770 @assignable_versions = versions.uniq.sort
766 771 end
767 772
768 773 # Returns true if this issue is blocked by another issue that is still open
769 774 def blocked?
770 775 !relations_to.detect {|ir| ir.relation_type == 'blocks' && !ir.issue_from.closed?}.nil?
771 776 end
772 777
773 778 # Returns the default status of the issue based on its tracker
774 779 # Returns nil if tracker is nil
775 780 def default_status
776 781 tracker.try(:default_status)
777 782 end
778 783
779 784 # Returns an array of statuses that user is able to apply
780 785 def new_statuses_allowed_to(user=User.current, include_default=false)
781 786 if new_record? && @copied_from
782 787 [default_status, @copied_from.status].compact.uniq.sort
783 788 else
784 789 initial_status = nil
785 790 if new_record?
786 791 initial_status = default_status
787 792 elsif tracker_id_changed?
788 793 if Tracker.where(:id => tracker_id_was, :default_status_id => status_id_was).any?
789 794 initial_status = default_status
790 795 elsif tracker.issue_status_ids.include?(status_id_was)
791 796 initial_status = IssueStatus.find_by_id(status_id_was)
792 797 else
793 798 initial_status = default_status
794 799 end
795 800 else
796 801 initial_status = status_was
797 802 end
798 803
799 804 initial_assigned_to_id = assigned_to_id_changed? ? assigned_to_id_was : assigned_to_id
800 805 assignee_transitions_allowed = initial_assigned_to_id.present? &&
801 806 (user.id == initial_assigned_to_id || user.group_ids.include?(initial_assigned_to_id))
802 807
803 808 statuses = []
804 809 if initial_status
805 810 statuses += initial_status.find_new_statuses_allowed_to(
806 811 user.admin ? Role.all.to_a : user.roles_for_project(project),
807 812 tracker,
808 813 author == user,
809 814 assignee_transitions_allowed
810 815 )
811 816 end
812 817 statuses << initial_status unless statuses.empty?
813 818 statuses << default_status if include_default
814 819 statuses = statuses.compact.uniq.sort
815 820 if blocked?
816 821 statuses.reject!(&:is_closed?)
817 822 end
818 823 statuses
819 824 end
820 825 end
821 826
822 827 # Returns the previous assignee (user or group) if changed
823 828 def assigned_to_was
824 829 # assigned_to_id_was is reset before after_save callbacks
825 830 user_id = @previous_assigned_to_id || assigned_to_id_was
826 831 if user_id && user_id != assigned_to_id
827 832 @assigned_to_was ||= Principal.find_by_id(user_id)
828 833 end
829 834 end
830 835
831 836 # Returns the users that should be notified
832 837 def notified_users
833 838 notified = []
834 839 # Author and assignee are always notified unless they have been
835 840 # locked or don't want to be notified
836 841 notified << author if author
837 842 if assigned_to
838 843 notified += (assigned_to.is_a?(Group) ? assigned_to.users : [assigned_to])
839 844 end
840 845 if assigned_to_was
841 846 notified += (assigned_to_was.is_a?(Group) ? assigned_to_was.users : [assigned_to_was])
842 847 end
843 848 notified = notified.select {|u| u.active? && u.notify_about?(self)}
844 849
845 850 notified += project.notified_users
846 851 notified.uniq!
847 852 # Remove users that can not view the issue
848 853 notified.reject! {|user| !visible?(user)}
849 854 notified
850 855 end
851 856
852 857 # Returns the email addresses that should be notified
853 858 def recipients
854 859 notified_users.collect(&:mail)
855 860 end
856 861
857 862 def each_notification(users, &block)
858 863 if users.any?
859 864 if custom_field_values.detect {|value| !value.custom_field.visible?}
860 865 users_by_custom_field_visibility = users.group_by do |user|
861 866 visible_custom_field_values(user).map(&:custom_field_id).sort
862 867 end
863 868 users_by_custom_field_visibility.values.each do |users|
864 869 yield(users)
865 870 end
866 871 else
867 872 yield(users)
868 873 end
869 874 end
870 875 end
871 876
872 877 # Returns the number of hours spent on this issue
873 878 def spent_hours
874 879 @spent_hours ||= time_entries.sum(:hours) || 0
875 880 end
876 881
877 882 # Returns the total number of hours spent on this issue and its descendants
878 883 #
879 884 # Example:
880 885 # spent_hours => 0.0
881 886 # spent_hours => 50.2
882 887 def total_spent_hours
883 888 @total_spent_hours ||=
884 889 self_and_descendants.
885 890 joins("LEFT JOIN #{TimeEntry.table_name} ON #{TimeEntry.table_name}.issue_id = #{Issue.table_name}.id").
886 891 sum("#{TimeEntry.table_name}.hours").to_f || 0.0
887 892 end
888 893
889 894 def relations
890 895 @relations ||= IssueRelation::Relations.new(self, (relations_from + relations_to).sort)
891 896 end
892 897
893 898 # Preloads relations for a collection of issues
894 899 def self.load_relations(issues)
895 900 if issues.any?
896 901 relations = IssueRelation.where("issue_from_id IN (:ids) OR issue_to_id IN (:ids)", :ids => issues.map(&:id)).all
897 902 issues.each do |issue|
898 903 issue.instance_variable_set "@relations", relations.select {|r| r.issue_from_id == issue.id || r.issue_to_id == issue.id}
899 904 end
900 905 end
901 906 end
902 907
903 908 # Preloads visible spent time for a collection of issues
904 909 def self.load_visible_spent_hours(issues, user=User.current)
905 910 if issues.any?
906 911 hours_by_issue_id = TimeEntry.visible(user).group(:issue_id).sum(:hours)
907 912 issues.each do |issue|
908 913 issue.instance_variable_set "@spent_hours", (hours_by_issue_id[issue.id] || 0)
909 914 end
910 915 end
911 916 end
912 917
913 918 # Preloads visible relations for a collection of issues
914 919 def self.load_visible_relations(issues, user=User.current)
915 920 if issues.any?
916 921 issue_ids = issues.map(&:id)
917 922 # Relations with issue_from in given issues and visible issue_to
918 923 relations_from = IssueRelation.joins(:issue_to => :project).
919 924 where(visible_condition(user)).where(:issue_from_id => issue_ids).to_a
920 925 # Relations with issue_to in given issues and visible issue_from
921 926 relations_to = IssueRelation.joins(:issue_from => :project).
922 927 where(visible_condition(user)).
923 928 where(:issue_to_id => issue_ids).to_a
924 929 issues.each do |issue|
925 930 relations =
926 931 relations_from.select {|relation| relation.issue_from_id == issue.id} +
927 932 relations_to.select {|relation| relation.issue_to_id == issue.id}
928 933
929 934 issue.instance_variable_set "@relations", IssueRelation::Relations.new(issue, relations.sort)
930 935 end
931 936 end
932 937 end
933 938
934 939 # Finds an issue relation given its id.
935 940 def find_relation(relation_id)
936 941 IssueRelation.where("issue_to_id = ? OR issue_from_id = ?", id, id).find(relation_id)
937 942 end
938 943
939 944 # Returns all the other issues that depend on the issue
940 945 # The algorithm is a modified breadth first search (bfs)
941 946 def all_dependent_issues(except=[])
942 947 # The found dependencies
943 948 dependencies = []
944 949
945 950 # The visited flag for every node (issue) used by the breadth first search
946 951 eNOT_DISCOVERED = 0 # The issue is "new" to the algorithm, it has not seen it before.
947 952
948 953 ePROCESS_ALL = 1 # The issue is added to the queue. Process both children and relations of
949 954 # the issue when it is processed.
950 955
951 956 ePROCESS_RELATIONS_ONLY = 2 # The issue was added to the queue and will be output as dependent issue,
952 957 # but its children will not be added to the queue when it is processed.
953 958
954 959 eRELATIONS_PROCESSED = 3 # The related issues, the parent issue and the issue itself have been added to
955 960 # the queue, but its children have not been added.
956 961
957 962 ePROCESS_CHILDREN_ONLY = 4 # The relations and the parent of the issue have been added to the queue, but
958 963 # the children still need to be processed.
959 964
960 965 eALL_PROCESSED = 5 # The issue and all its children, its parent and its related issues have been
961 966 # added as dependent issues. It needs no further processing.
962 967
963 968 issue_status = Hash.new(eNOT_DISCOVERED)
964 969
965 970 # The queue
966 971 queue = []
967 972
968 973 # Initialize the bfs, add start node (self) to the queue
969 974 queue << self
970 975 issue_status[self] = ePROCESS_ALL
971 976
972 977 while (!queue.empty?) do
973 978 current_issue = queue.shift
974 979 current_issue_status = issue_status[current_issue]
975 980 dependencies << current_issue
976 981
977 982 # Add parent to queue, if not already in it.
978 983 parent = current_issue.parent
979 984 parent_status = issue_status[parent]
980 985
981 986 if parent && (parent_status == eNOT_DISCOVERED) && !except.include?(parent)
982 987 queue << parent
983 988 issue_status[parent] = ePROCESS_RELATIONS_ONLY
984 989 end
985 990
986 991 # Add children to queue, but only if they are not already in it and
987 992 # the children of the current node need to be processed.
988 993 if (current_issue_status == ePROCESS_CHILDREN_ONLY || current_issue_status == ePROCESS_ALL)
989 994 current_issue.children.each do |child|
990 995 next if except.include?(child)
991 996
992 997 if (issue_status[child] == eNOT_DISCOVERED)
993 998 queue << child
994 999 issue_status[child] = ePROCESS_ALL
995 1000 elsif (issue_status[child] == eRELATIONS_PROCESSED)
996 1001 queue << child
997 1002 issue_status[child] = ePROCESS_CHILDREN_ONLY
998 1003 elsif (issue_status[child] == ePROCESS_RELATIONS_ONLY)
999 1004 queue << child
1000 1005 issue_status[child] = ePROCESS_ALL
1001 1006 end
1002 1007 end
1003 1008 end
1004 1009
1005 1010 # Add related issues to the queue, if they are not already in it.
1006 1011 current_issue.relations_from.map(&:issue_to).each do |related_issue|
1007 1012 next if except.include?(related_issue)
1008 1013
1009 1014 if (issue_status[related_issue] == eNOT_DISCOVERED)
1010 1015 queue << related_issue
1011 1016 issue_status[related_issue] = ePROCESS_ALL
1012 1017 elsif (issue_status[related_issue] == eRELATIONS_PROCESSED)
1013 1018 queue << related_issue
1014 1019 issue_status[related_issue] = ePROCESS_CHILDREN_ONLY
1015 1020 elsif (issue_status[related_issue] == ePROCESS_RELATIONS_ONLY)
1016 1021 queue << related_issue
1017 1022 issue_status[related_issue] = ePROCESS_ALL
1018 1023 end
1019 1024 end
1020 1025
1021 1026 # Set new status for current issue
1022 1027 if (current_issue_status == ePROCESS_ALL) || (current_issue_status == ePROCESS_CHILDREN_ONLY)
1023 1028 issue_status[current_issue] = eALL_PROCESSED
1024 1029 elsif (current_issue_status == ePROCESS_RELATIONS_ONLY)
1025 1030 issue_status[current_issue] = eRELATIONS_PROCESSED
1026 1031 end
1027 1032 end # while
1028 1033
1029 1034 # Remove the issues from the "except" parameter from the result array
1030 1035 dependencies -= except
1031 1036 dependencies.delete(self)
1032 1037
1033 1038 dependencies
1034 1039 end
1035 1040
1036 1041 # Returns an array of issues that duplicate this one
1037 1042 def duplicates
1038 1043 relations_to.select {|r| r.relation_type == IssueRelation::TYPE_DUPLICATES}.collect {|r| r.issue_from}
1039 1044 end
1040 1045
1041 1046 # Returns the due date or the target due date if any
1042 1047 # Used on gantt chart
1043 1048 def due_before
1044 1049 due_date || (fixed_version ? fixed_version.effective_date : nil)
1045 1050 end
1046 1051
1047 1052 # Returns the time scheduled for this issue.
1048 1053 #
1049 1054 # Example:
1050 1055 # Start Date: 2/26/09, End Date: 3/04/09
1051 1056 # duration => 6
1052 1057 def duration
1053 1058 (start_date && due_date) ? due_date - start_date : 0
1054 1059 end
1055 1060
1056 1061 # Returns the duration in working days
1057 1062 def working_duration
1058 1063 (start_date && due_date) ? working_days(start_date, due_date) : 0
1059 1064 end
1060 1065
1061 1066 def soonest_start(reload=false)
1062 1067 @soonest_start = nil if reload
1063 1068 @soonest_start ||= (
1064 1069 relations_to(reload).collect{|relation| relation.successor_soonest_start} +
1065 1070 [(@parent_issue || parent).try(:soonest_start)]
1066 1071 ).compact.max
1067 1072 end
1068 1073
1069 1074 # Sets start_date on the given date or the next working day
1070 1075 # and changes due_date to keep the same working duration.
1071 1076 def reschedule_on(date)
1072 1077 wd = working_duration
1073 1078 date = next_working_date(date)
1074 1079 self.start_date = date
1075 1080 self.due_date = add_working_days(date, wd)
1076 1081 end
1077 1082
1078 1083 # Reschedules the issue on the given date or the next working day and saves the record.
1079 1084 # If the issue is a parent task, this is done by rescheduling its subtasks.
1080 1085 def reschedule_on!(date)
1081 1086 return if date.nil?
1082 1087 if leaf?
1083 1088 if start_date.nil? || start_date != date
1084 1089 if start_date && start_date > date
1085 1090 # Issue can not be moved earlier than its soonest start date
1086 1091 date = [soonest_start(true), date].compact.max
1087 1092 end
1088 1093 reschedule_on(date)
1089 1094 begin
1090 1095 save
1091 1096 rescue ActiveRecord::StaleObjectError
1092 1097 reload
1093 1098 reschedule_on(date)
1094 1099 save
1095 1100 end
1096 1101 end
1097 1102 else
1098 1103 leaves.each do |leaf|
1099 1104 if leaf.start_date
1100 1105 # Only move subtask if it starts at the same date as the parent
1101 1106 # or if it starts before the given date
1102 1107 if start_date == leaf.start_date || date > leaf.start_date
1103 1108 leaf.reschedule_on!(date)
1104 1109 end
1105 1110 else
1106 1111 leaf.reschedule_on!(date)
1107 1112 end
1108 1113 end
1109 1114 end
1110 1115 end
1111 1116
1112 1117 def <=>(issue)
1113 1118 if issue.nil?
1114 1119 -1
1115 1120 elsif root_id != issue.root_id
1116 1121 (root_id || 0) <=> (issue.root_id || 0)
1117 1122 else
1118 1123 (lft || 0) <=> (issue.lft || 0)
1119 1124 end
1120 1125 end
1121 1126
1122 1127 def to_s
1123 1128 "#{tracker} ##{id}: #{subject}"
1124 1129 end
1125 1130
1126 1131 # Returns a string of css classes that apply to the issue
1127 1132 def css_classes(user=User.current)
1128 1133 s = "issue tracker-#{tracker_id} status-#{status_id} #{priority.try(:css_classes)}"
1129 1134 s << ' closed' if closed?
1130 1135 s << ' overdue' if overdue?
1131 1136 s << ' child' if child?
1132 1137 s << ' parent' unless leaf?
1133 1138 s << ' private' if is_private?
1134 1139 if user.logged?
1135 1140 s << ' created-by-me' if author_id == user.id
1136 1141 s << ' assigned-to-me' if assigned_to_id == user.id
1137 1142 s << ' assigned-to-my-group' if user.groups.any? {|g| g.id == assigned_to_id}
1138 1143 end
1139 1144 s
1140 1145 end
1141 1146
1142 1147 # Unassigns issues from +version+ if it's no longer shared with issue's project
1143 1148 def self.update_versions_from_sharing_change(version)
1144 1149 # Update issues assigned to the version
1145 1150 update_versions(["#{Issue.table_name}.fixed_version_id = ?", version.id])
1146 1151 end
1147 1152
1148 1153 # Unassigns issues from versions that are no longer shared
1149 1154 # after +project+ was moved
1150 1155 def self.update_versions_from_hierarchy_change(project)
1151 1156 moved_project_ids = project.self_and_descendants.reload.collect(&:id)
1152 1157 # Update issues of the moved projects and issues assigned to a version of a moved project
1153 1158 Issue.update_versions(
1154 1159 ["#{Version.table_name}.project_id IN (?) OR #{Issue.table_name}.project_id IN (?)",
1155 1160 moved_project_ids, moved_project_ids]
1156 1161 )
1157 1162 end
1158 1163
1159 1164 def parent_issue_id=(arg)
1160 1165 s = arg.to_s.strip.presence
1161 1166 if s && (m = s.match(%r{\A#?(\d+)\z})) && (@parent_issue = Issue.find_by_id(m[1]))
1162 1167 @invalid_parent_issue_id = nil
1163 1168 elsif s.blank?
1164 1169 @parent_issue = nil
1165 1170 @invalid_parent_issue_id = nil
1166 1171 else
1167 1172 @parent_issue = nil
1168 1173 @invalid_parent_issue_id = arg
1169 1174 end
1170 1175 end
1171 1176
1172 1177 def parent_issue_id
1173 1178 if @invalid_parent_issue_id
1174 1179 @invalid_parent_issue_id
1175 1180 elsif instance_variable_defined? :@parent_issue
1176 1181 @parent_issue.nil? ? nil : @parent_issue.id
1177 1182 else
1178 1183 parent_id
1179 1184 end
1180 1185 end
1181 1186
1182 1187 def set_parent_id
1183 1188 self.parent_id = parent_issue_id
1184 1189 end
1185 1190
1186 1191 # Returns true if issue's project is a valid
1187 1192 # parent issue project
1188 1193 def valid_parent_project?(issue=parent)
1189 1194 return true if issue.nil? || issue.project_id == project_id
1190 1195
1191 1196 case Setting.cross_project_subtasks
1192 1197 when 'system'
1193 1198 true
1194 1199 when 'tree'
1195 1200 issue.project.root == project.root
1196 1201 when 'hierarchy'
1197 1202 issue.project.is_or_is_ancestor_of?(project) || issue.project.is_descendant_of?(project)
1198 1203 when 'descendants'
1199 1204 issue.project.is_or_is_ancestor_of?(project)
1200 1205 else
1201 1206 false
1202 1207 end
1203 1208 end
1204 1209
1205 1210 # Returns an issue scope based on project and scope
1206 1211 def self.cross_project_scope(project, scope=nil)
1207 1212 if project.nil?
1208 1213 return Issue
1209 1214 end
1210 1215 case scope
1211 1216 when 'all', 'system'
1212 1217 Issue
1213 1218 when 'tree'
1214 1219 Issue.joins(:project).where("(#{Project.table_name}.lft >= :lft AND #{Project.table_name}.rgt <= :rgt)",
1215 1220 :lft => project.root.lft, :rgt => project.root.rgt)
1216 1221 when 'hierarchy'
1217 1222 Issue.joins(:project).where("(#{Project.table_name}.lft >= :lft AND #{Project.table_name}.rgt <= :rgt) OR (#{Project.table_name}.lft < :lft AND #{Project.table_name}.rgt > :rgt)",
1218 1223 :lft => project.lft, :rgt => project.rgt)
1219 1224 when 'descendants'
1220 1225 Issue.joins(:project).where("(#{Project.table_name}.lft >= :lft AND #{Project.table_name}.rgt <= :rgt)",
1221 1226 :lft => project.lft, :rgt => project.rgt)
1222 1227 else
1223 1228 Issue.where(:project_id => project.id)
1224 1229 end
1225 1230 end
1226 1231
1227 1232 def self.by_tracker(project)
1228 1233 count_and_group_by(:project => project, :association => :tracker)
1229 1234 end
1230 1235
1231 1236 def self.by_version(project)
1232 1237 count_and_group_by(:project => project, :association => :fixed_version)
1233 1238 end
1234 1239
1235 1240 def self.by_priority(project)
1236 1241 count_and_group_by(:project => project, :association => :priority)
1237 1242 end
1238 1243
1239 1244 def self.by_category(project)
1240 1245 count_and_group_by(:project => project, :association => :category)
1241 1246 end
1242 1247
1243 1248 def self.by_assigned_to(project)
1244 1249 count_and_group_by(:project => project, :association => :assigned_to)
1245 1250 end
1246 1251
1247 1252 def self.by_author(project)
1248 1253 count_and_group_by(:project => project, :association => :author)
1249 1254 end
1250 1255
1251 1256 def self.by_subproject(project)
1252 1257 r = count_and_group_by(:project => project, :with_subprojects => true, :association => :project)
1253 1258 r.reject {|r| r["project_id"] == project.id.to_s}
1254 1259 end
1255 1260
1256 1261 # Query generator for selecting groups of issue counts for a project
1257 1262 # based on specific criteria
1258 1263 #
1259 1264 # Options
1260 1265 # * project - Project to search in.
1261 1266 # * with_subprojects - Includes subprojects issues if set to true.
1262 1267 # * association - Symbol. Association for grouping.
1263 1268 def self.count_and_group_by(options)
1264 1269 assoc = reflect_on_association(options[:association])
1265 1270 select_field = assoc.foreign_key
1266 1271
1267 1272 Issue.
1268 1273 visible(User.current, :project => options[:project], :with_subprojects => options[:with_subprojects]).
1269 1274 joins(:status, assoc.name).
1270 1275 group(:status_id, :is_closed, select_field).
1271 1276 count.
1272 1277 map do |columns, total|
1273 1278 status_id, is_closed, field_value = columns
1274 1279 is_closed = ['t', 'true', '1'].include?(is_closed.to_s)
1275 1280 {
1276 1281 "status_id" => status_id.to_s,
1277 1282 "closed" => is_closed,
1278 1283 select_field => field_value.to_s,
1279 1284 "total" => total.to_s
1280 1285 }
1281 1286 end
1282 1287 end
1283 1288
1284 1289 # Returns a scope of projects that user can assign the issue to
1285 1290 def allowed_target_projects(user=User.current)
1286 1291 current_project = new_record? ? nil : project
1287 1292 self.class.allowed_target_projects(user, current_project)
1288 1293 end
1289 1294
1290 1295 # Returns a scope of projects that user can assign issues to
1291 1296 # If current_project is given, it will be included in the scope
1292 1297 def self.allowed_target_projects(user=User.current, current_project=nil)
1293 1298 condition = Project.allowed_to_condition(user, :add_issues)
1294 1299 if current_project
1295 1300 condition = ["(#{condition}) OR #{Project.table_name}.id = ?", current_project.id]
1296 1301 end
1297 1302 Project.where(condition)
1298 1303 end
1299 1304
1300 1305 private
1301 1306
1302 1307 def after_project_change
1303 1308 # Update project_id on related time entries
1304 1309 TimeEntry.where({:issue_id => id}).update_all(["project_id = ?", project_id])
1305 1310
1306 1311 # Delete issue relations
1307 1312 unless Setting.cross_project_issue_relations?
1308 1313 relations_from.clear
1309 1314 relations_to.clear
1310 1315 end
1311 1316
1312 1317 # Move subtasks that were in the same project
1313 1318 children.each do |child|
1314 1319 next unless child.project_id == project_id_was
1315 1320 # Change project and keep project
1316 1321 child.send :project=, project, true
1317 1322 unless child.save
1318 1323 raise ActiveRecord::Rollback
1319 1324 end
1320 1325 end
1321 1326 end
1322 1327
1323 1328 # Callback for after the creation of an issue by copy
1324 1329 # * adds a "copied to" relation with the copied issue
1325 1330 # * copies subtasks from the copied issue
1326 1331 def after_create_from_copy
1327 1332 return unless copy? && !@after_create_from_copy_handled
1328 1333
1329 1334 if (@copied_from.project_id == project_id || Setting.cross_project_issue_relations?) && @copy_options[:link] != false
1330 1335 if @current_journal
1331 1336 @copied_from.init_journal(@current_journal.user)
1332 1337 end
1333 1338 relation = IssueRelation.new(:issue_from => @copied_from, :issue_to => self, :relation_type => IssueRelation::TYPE_COPIED_TO)
1334 1339 unless relation.save
1335 1340 logger.error "Could not create relation while copying ##{@copied_from.id} to ##{id} due to validation errors: #{relation.errors.full_messages.join(', ')}" if logger
1336 1341 end
1337 1342 end
1338 1343
1339 1344 unless @copied_from.leaf? || @copy_options[:subtasks] == false
1340 1345 copy_options = (@copy_options || {}).merge(:subtasks => false)
1341 1346 copied_issue_ids = {@copied_from.id => self.id}
1342 1347 @copied_from.reload.descendants.reorder("#{Issue.table_name}.lft").each do |child|
1343 1348 # Do not copy self when copying an issue as a descendant of the copied issue
1344 1349 next if child == self
1345 1350 # Do not copy subtasks of issues that were not copied
1346 1351 next unless copied_issue_ids[child.parent_id]
1347 1352 # Do not copy subtasks that are not visible to avoid potential disclosure of private data
1348 1353 unless child.visible?
1349 1354 logger.error "Subtask ##{child.id} was not copied during ##{@copied_from.id} copy because it is not visible to the current user" if logger
1350 1355 next
1351 1356 end
1352 1357 copy = Issue.new.copy_from(child, copy_options)
1353 1358 if @current_journal
1354 1359 copy.init_journal(@current_journal.user)
1355 1360 end
1356 1361 copy.author = author
1357 1362 copy.project = project
1358 1363 copy.parent_issue_id = copied_issue_ids[child.parent_id]
1359 1364 unless copy.save
1360 1365 logger.error "Could not copy subtask ##{child.id} while copying ##{@copied_from.id} to ##{id} due to validation errors: #{copy.errors.full_messages.join(', ')}" if logger
1361 1366 next
1362 1367 end
1363 1368 copied_issue_ids[child.id] = copy.id
1364 1369 end
1365 1370 end
1366 1371 @after_create_from_copy_handled = true
1367 1372 end
1368 1373
1369 1374 def update_nested_set_attributes
1370 1375 if parent_id_changed?
1371 1376 update_nested_set_attributes_on_parent_change
1372 1377 end
1373 1378 remove_instance_variable(:@parent_issue) if instance_variable_defined?(:@parent_issue)
1374 1379 end
1375 1380
1376 1381 # Updates the nested set for when an existing issue is moved
1377 1382 def update_nested_set_attributes_on_parent_change
1378 1383 former_parent_id = parent_id_was
1379 1384 # delete invalid relations of all descendants
1380 1385 self_and_descendants.each do |issue|
1381 1386 issue.relations.each do |relation|
1382 1387 relation.destroy unless relation.valid?
1383 1388 end
1384 1389 end
1385 1390 # update former parent
1386 1391 recalculate_attributes_for(former_parent_id) if former_parent_id
1387 1392 end
1388 1393
1389 1394 def update_parent_attributes
1390 1395 if parent_id
1391 1396 recalculate_attributes_for(parent_id)
1392 1397 association(:parent).reset
1393 1398 end
1394 1399 end
1395 1400
1396 1401 def recalculate_attributes_for(issue_id)
1397 1402 if issue_id && p = Issue.find_by_id(issue_id)
1398 1403 # priority = highest priority of children
1399 1404 if priority_position = p.children.joins(:priority).maximum("#{IssuePriority.table_name}.position")
1400 1405 p.priority = IssuePriority.find_by_position(priority_position)
1401 1406 end
1402 1407
1403 1408 # start/due dates = lowest/highest dates of children
1404 1409 p.start_date = p.children.minimum(:start_date)
1405 1410 p.due_date = p.children.maximum(:due_date)
1406 1411 if p.start_date && p.due_date && p.due_date < p.start_date
1407 1412 p.start_date, p.due_date = p.due_date, p.start_date
1408 1413 end
1409 1414
1410 1415 # done ratio = weighted average ratio of leaves
1411 1416 unless Issue.use_status_for_done_ratio? && p.status && p.status.default_done_ratio
1412 1417 leaves_count = p.leaves.count
1413 1418 if leaves_count > 0
1414 1419 average = p.leaves.where("estimated_hours > 0").average(:estimated_hours).to_f
1415 1420 if average == 0
1416 1421 average = 1
1417 1422 end
1418 1423 done = p.leaves.joins(:status).
1419 1424 sum("COALESCE(CASE WHEN estimated_hours > 0 THEN estimated_hours ELSE NULL END, #{average}) " +
1420 1425 "* (CASE WHEN is_closed = #{self.class.connection.quoted_true} THEN 100 ELSE COALESCE(done_ratio, 0) END)").to_f
1421 1426 progress = done / (average * leaves_count)
1422 1427 p.done_ratio = progress.round
1423 1428 end
1424 1429 end
1425 1430
1426 1431 # estimate = sum of leaves estimates
1427 1432 p.estimated_hours = p.leaves.sum(:estimated_hours).to_f
1428 1433 p.estimated_hours = nil if p.estimated_hours == 0.0
1429 1434
1430 1435 # ancestors will be recursively updated
1431 1436 p.save(:validate => false)
1432 1437 end
1433 1438 end
1434 1439
1435 1440 # Update issues so their versions are not pointing to a
1436 1441 # fixed_version that is not shared with the issue's project
1437 1442 def self.update_versions(conditions=nil)
1438 1443 # Only need to update issues with a fixed_version from
1439 1444 # a different project and that is not systemwide shared
1440 1445 Issue.joins(:project, :fixed_version).
1441 1446 where("#{Issue.table_name}.fixed_version_id IS NOT NULL" +
1442 1447 " AND #{Issue.table_name}.project_id <> #{Version.table_name}.project_id" +
1443 1448 " AND #{Version.table_name}.sharing <> 'system'").
1444 1449 where(conditions).each do |issue|
1445 1450 next if issue.project.nil? || issue.fixed_version.nil?
1446 1451 unless issue.project.shared_versions.include?(issue.fixed_version)
1447 1452 issue.init_journal(User.current)
1448 1453 issue.fixed_version = nil
1449 1454 issue.save
1450 1455 end
1451 1456 end
1452 1457 end
1453 1458
1454 1459 # Callback on file attachment
1455 1460 def attachment_added(attachment)
1456 1461 if current_journal && !attachment.new_record?
1457 1462 current_journal.journalize_attachment(attachment, :added)
1458 1463 end
1459 1464 end
1460 1465
1461 1466 # Callback on attachment deletion
1462 1467 def attachment_removed(attachment)
1463 1468 if current_journal && !attachment.new_record?
1464 1469 current_journal.journalize_attachment(attachment, :removed)
1465 1470 current_journal.save
1466 1471 end
1467 1472 end
1468 1473
1469 1474 # Called after a relation is added
1470 1475 def relation_added(relation)
1471 1476 if current_journal
1472 1477 current_journal.journalize_relation(relation, :added)
1473 1478 current_journal.save
1474 1479 end
1475 1480 end
1476 1481
1477 1482 # Called after a relation is removed
1478 1483 def relation_removed(relation)
1479 1484 if current_journal
1480 1485 current_journal.journalize_relation(relation, :removed)
1481 1486 current_journal.save
1482 1487 end
1483 1488 end
1484 1489
1485 1490 # Default assignment based on category
1486 1491 def default_assign
1487 1492 if assigned_to.nil? && category && category.assigned_to
1488 1493 self.assigned_to = category.assigned_to
1489 1494 end
1490 1495 end
1491 1496
1492 1497 # Updates start/due dates of following issues
1493 1498 def reschedule_following_issues
1494 1499 if start_date_changed? || due_date_changed?
1495 1500 relations_from.each do |relation|
1496 1501 relation.set_issue_to_dates
1497 1502 end
1498 1503 end
1499 1504 end
1500 1505
1501 1506 # Closes duplicates if the issue is being closed
1502 1507 def close_duplicates
1503 1508 if closing?
1504 1509 duplicates.each do |duplicate|
1505 1510 # Reload is needed in case the duplicate was updated by a previous duplicate
1506 1511 duplicate.reload
1507 1512 # Don't re-close it if it's already closed
1508 1513 next if duplicate.closed?
1509 1514 # Same user and notes
1510 1515 if @current_journal
1511 1516 duplicate.init_journal(@current_journal.user, @current_journal.notes)
1512 1517 end
1513 1518 duplicate.update_attribute :status, self.status
1514 1519 end
1515 1520 end
1516 1521 end
1517 1522
1518 1523 # Make sure updated_on is updated when adding a note and set updated_on now
1519 1524 # so we can set closed_on with the same value on closing
1520 1525 def force_updated_on_change
1521 1526 if @current_journal || changed?
1522 1527 self.updated_on = current_time_from_proper_timezone
1523 1528 if new_record?
1524 1529 self.created_on = updated_on
1525 1530 end
1526 1531 end
1527 1532 end
1528 1533
1529 1534 # Callback for setting closed_on when the issue is closed.
1530 1535 # The closed_on attribute stores the time of the last closing
1531 1536 # and is preserved when the issue is reopened.
1532 1537 def update_closed_on
1533 1538 if closing?
1534 1539 self.closed_on = updated_on
1535 1540 end
1536 1541 end
1537 1542
1538 1543 # Saves the changes in a Journal
1539 1544 # Called after_save
1540 1545 def create_journal
1541 1546 if current_journal
1542 1547 current_journal.save
1543 1548 end
1544 1549 end
1545 1550
1546 1551 def send_notification
1547 1552 if Setting.notified_events.include?('issue_added')
1548 1553 Mailer.deliver_issue_add(self)
1549 1554 end
1550 1555 end
1551 1556
1552 1557 # Stores the previous assignee so we can still have access
1553 1558 # to it during after_save callbacks (assigned_to_id_was is reset)
1554 1559 def set_assigned_to_was
1555 1560 @previous_assigned_to_id = assigned_to_id_was
1556 1561 end
1557 1562
1558 1563 # Clears the previous assignee at the end of after_save callbacks
1559 1564 def clear_assigned_to_was
1560 1565 @assigned_to_was = nil
1561 1566 @previous_assigned_to_id = nil
1562 1567 end
1563 1568 end
@@ -1,659 +1,688
1 1 # Redmine - project management software
2 2 # Copyright (C) 2006-2015 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 Redmine::ApiTest::IssuesTest < Redmine::ApiTest::Base
21 21 fixtures :projects,
22 22 :users,
23 23 :roles,
24 24 :members,
25 25 :member_roles,
26 26 :issues,
27 27 :issue_statuses,
28 28 :issue_relations,
29 29 :versions,
30 30 :trackers,
31 31 :projects_trackers,
32 32 :issue_categories,
33 33 :enabled_modules,
34 34 :enumerations,
35 35 :attachments,
36 36 :workflows,
37 37 :custom_fields,
38 38 :custom_values,
39 39 :custom_fields_projects,
40 40 :custom_fields_trackers,
41 41 :time_entries,
42 42 :journals,
43 43 :journal_details,
44 44 :queries,
45 45 :attachments
46 46
47 47 test "GET /issues.xml should contain metadata" do
48 48 get '/issues.xml'
49 49 assert_select 'issues[type=array][total_count=?][limit="25"][offset="0"]',
50 50 assigns(:issue_count).to_s
51 51 end
52 52
53 53 test "GET /issues.xml with nometa param should not contain metadata" do
54 54 get '/issues.xml?nometa=1'
55 55 assert_select 'issues[type=array]:not([total_count]):not([limit]):not([offset])'
56 56 end
57 57
58 58 test "GET /issues.xml with nometa header should not contain metadata" do
59 59 get '/issues.xml', {}, {'X-Redmine-Nometa' => '1'}
60 60 assert_select 'issues[type=array]:not([total_count]):not([limit]):not([offset])'
61 61 end
62 62
63 63 test "GET /issues.xml with offset and limit" do
64 64 get '/issues.xml?offset=2&limit=3'
65 65
66 66 assert_equal 3, assigns(:limit)
67 67 assert_equal 2, assigns(:offset)
68 68 assert_select 'issues issue', 3
69 69 end
70 70
71 71 test "GET /issues.xml with relations" do
72 72 get '/issues.xml?include=relations'
73 73
74 74 assert_response :success
75 75 assert_equal 'application/xml', @response.content_type
76 76
77 77 assert_select 'issue id', :text => '3' do
78 78 assert_select '~ relations relation', 1
79 79 assert_select '~ relations relation[id="2"][issue_id="2"][issue_to_id="3"][relation_type=relates]'
80 80 end
81 81
82 82 assert_select 'issue id', :text => '1' do
83 83 assert_select '~ relations'
84 84 assert_select '~ relations relation', 0
85 85 end
86 86 end
87 87
88 88 test "GET /issues.xml with invalid query params" do
89 89 get '/issues.xml', {:f => ['start_date'], :op => {:start_date => '='}}
90 90
91 91 assert_response :unprocessable_entity
92 92 assert_equal 'application/xml', @response.content_type
93 93 assert_select 'errors error', :text => "Start date cannot be blank"
94 94 end
95 95
96 96 test "GET /issues.xml with custom field filter" do
97 97 get '/issues.xml',
98 98 {:set_filter => 1, :f => ['cf_1'], :op => {:cf_1 => '='}, :v => {:cf_1 => ['MySQL']}}
99 99
100 100 expected_ids = Issue.visible.
101 101 joins(:custom_values).
102 102 where(:custom_values => {:custom_field_id => 1, :value => 'MySQL'}).map(&:id)
103 103 assert expected_ids.any?
104 104
105 105 assert_select 'issues > issue > id', :count => expected_ids.count do |ids|
106 106 ids.each { |id| assert expected_ids.delete(id.children.first.content.to_i) }
107 107 end
108 108 end
109 109
110 110 test "GET /issues.xml with custom field filter (shorthand method)" do
111 111 get '/issues.xml', {:cf_1 => 'MySQL'}
112 112
113 113 expected_ids = Issue.visible.
114 114 joins(:custom_values).
115 115 where(:custom_values => {:custom_field_id => 1, :value => 'MySQL'}).map(&:id)
116 116 assert expected_ids.any?
117 117
118 118 assert_select 'issues > issue > id', :count => expected_ids.count do |ids|
119 119 ids.each { |id| assert expected_ids.delete(id.children.first.content.to_i) }
120 120 end
121 121 end
122 122
123 123 def test_index_should_include_issue_attributes
124 124 get '/issues.xml'
125 125 assert_select 'issues>issue>is_private', :text => 'false'
126 126 end
127 127
128 128 def test_index_should_allow_timestamp_filtering
129 129 Issue.delete_all
130 130 Issue.generate!(:subject => '1').update_column(:updated_on, Time.parse("2014-01-02T10:25:00Z"))
131 131 Issue.generate!(:subject => '2').update_column(:updated_on, Time.parse("2014-01-02T12:13:00Z"))
132 132
133 133 get '/issues.xml',
134 134 {:set_filter => 1, :f => ['updated_on'], :op => {:updated_on => '<='},
135 135 :v => {:updated_on => ['2014-01-02T12:00:00Z']}}
136 136 assert_select 'issues>issue', :count => 1
137 137 assert_select 'issues>issue>subject', :text => '1'
138 138
139 139 get '/issues.xml',
140 140 {:set_filter => 1, :f => ['updated_on'], :op => {:updated_on => '>='},
141 141 :v => {:updated_on => ['2014-01-02T12:00:00Z']}}
142 142 assert_select 'issues>issue', :count => 1
143 143 assert_select 'issues>issue>subject', :text => '2'
144 144
145 145 get '/issues.xml',
146 146 {:set_filter => 1, :f => ['updated_on'], :op => {:updated_on => '>='},
147 147 :v => {:updated_on => ['2014-01-02T08:00:00Z']}}
148 148 assert_select 'issues>issue', :count => 2
149 149 end
150 150
151 151 test "GET /issues.xml with filter" do
152 152 get '/issues.xml?status_id=5'
153 153
154 154 expected_ids = Issue.visible.where(:status_id => 5).map(&:id)
155 155 assert expected_ids.any?
156 156
157 157 assert_select 'issues > issue > id', :count => expected_ids.count do |ids|
158 158 ids.each { |id| assert expected_ids.delete(id.children.first.content.to_i) }
159 159 end
160 160 end
161 161
162 162 test "GET /issues.json with filter" do
163 163 get '/issues.json?status_id=5'
164 164
165 165 json = ActiveSupport::JSON.decode(response.body)
166 166 status_ids_used = json['issues'].collect {|j| j['status']['id'] }
167 167 assert_equal 3, status_ids_used.length
168 168 assert status_ids_used.all? {|id| id == 5 }
169 169 end
170 170
171 171 test "GET /issues/:id.xml with journals" do
172 172 get '/issues/1.xml?include=journals'
173 173
174 174 assert_select 'issue journals[type=array]' do
175 175 assert_select 'journal[id="1"]' do
176 176 assert_select 'details[type=array]' do
177 177 assert_select 'detail[name=status_id]' do
178 178 assert_select 'old_value', :text => '1'
179 179 assert_select 'new_value', :text => '2'
180 180 end
181 181 end
182 182 end
183 183 end
184 184 end
185 185
186 186 test "GET /issues/:id.xml with journals should format timestamps in ISO 8601" do
187 187 get '/issues/1.xml?include=journals'
188 188
189 189 iso_date = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/
190 190 assert_select 'issue>created_on', :text => iso_date
191 191 assert_select 'issue>updated_on', :text => iso_date
192 192 assert_select 'issue journal>created_on', :text => iso_date
193 193 end
194 194
195 195 test "GET /issues/:id.xml with custom fields" do
196 196 get '/issues/3.xml'
197 197
198 198 assert_select 'issue custom_fields[type=array]' do
199 199 assert_select 'custom_field[id="1"]' do
200 200 assert_select 'value', :text => 'MySQL'
201 201 end
202 202 end
203 203 assert_nothing_raised do
204 204 Hash.from_xml(response.body).to_xml
205 205 end
206 206 end
207 207
208 208 test "GET /issues/:id.xml with multi custom fields" do
209 209 field = CustomField.find(1)
210 210 field.update_attribute :multiple, true
211 211 issue = Issue.find(3)
212 212 issue.custom_field_values = {1 => ['MySQL', 'Oracle']}
213 213 issue.save!
214 214
215 215 get '/issues/3.xml'
216 216 assert_response :success
217 217
218 218 assert_select 'issue custom_fields[type=array]' do
219 219 assert_select 'custom_field[id="1"]' do
220 220 assert_select 'value[type=array] value', 2
221 221 end
222 222 end
223 223 xml = Hash.from_xml(response.body)
224 224 custom_fields = xml['issue']['custom_fields']
225 225 assert_kind_of Array, custom_fields
226 226 field = custom_fields.detect {|f| f['id'] == '1'}
227 227 assert_kind_of Hash, field
228 228 assert_equal ['MySQL', 'Oracle'], field['value'].sort
229 229 end
230 230
231 231 test "GET /issues/:id.json with multi custom fields" do
232 232 field = CustomField.find(1)
233 233 field.update_attribute :multiple, true
234 234 issue = Issue.find(3)
235 235 issue.custom_field_values = {1 => ['MySQL', 'Oracle']}
236 236 issue.save!
237 237
238 238 get '/issues/3.json'
239 239 assert_response :success
240 240
241 241 json = ActiveSupport::JSON.decode(response.body)
242 242 custom_fields = json['issue']['custom_fields']
243 243 assert_kind_of Array, custom_fields
244 244 field = custom_fields.detect {|f| f['id'] == 1}
245 245 assert_kind_of Hash, field
246 246 assert_equal ['MySQL', 'Oracle'], field['value'].sort
247 247 end
248 248
249 249 test "GET /issues/:id.xml with empty value for multi custom field" do
250 250 field = CustomField.find(1)
251 251 field.update_attribute :multiple, true
252 252 issue = Issue.find(3)
253 253 issue.custom_field_values = {1 => ['']}
254 254 issue.save!
255 255
256 256 get '/issues/3.xml'
257 257
258 258 assert_select 'issue custom_fields[type=array]' do
259 259 assert_select 'custom_field[id="1"]' do
260 260 assert_select 'value[type=array]:empty'
261 261 end
262 262 end
263 263 xml = Hash.from_xml(response.body)
264 264 custom_fields = xml['issue']['custom_fields']
265 265 assert_kind_of Array, custom_fields
266 266 field = custom_fields.detect {|f| f['id'] == '1'}
267 267 assert_kind_of Hash, field
268 268 assert_equal [], field['value']
269 269 end
270 270
271 271 test "GET /issues/:id.json with empty value for multi custom field" do
272 272 field = CustomField.find(1)
273 273 field.update_attribute :multiple, true
274 274 issue = Issue.find(3)
275 275 issue.custom_field_values = {1 => ['']}
276 276 issue.save!
277 277
278 278 get '/issues/3.json'
279 279 assert_response :success
280 280 json = ActiveSupport::JSON.decode(response.body)
281 281 custom_fields = json['issue']['custom_fields']
282 282 assert_kind_of Array, custom_fields
283 283 field = custom_fields.detect {|f| f['id'] == 1}
284 284 assert_kind_of Hash, field
285 285 assert_equal [], field['value'].sort
286 286 end
287 287
288 288 test "GET /issues/:id.xml with attachments" do
289 289 get '/issues/3.xml?include=attachments'
290 290
291 291 assert_select 'issue attachments[type=array]' do
292 292 assert_select 'attachment', 4
293 293 assert_select 'attachment id', :text => '1' do
294 294 assert_select '~ filename', :text => 'error281.txt'
295 295 assert_select '~ content_url', :text => 'http://www.example.com/attachments/download/1/error281.txt'
296 296 end
297 297 end
298 298 end
299 299
300 300 test "GET /issues/:id.xml with subtasks" do
301 301 issue = Issue.generate_with_descendants!(:project_id => 1)
302 302 get "/issues/#{issue.id}.xml?include=children"
303 303
304 304 assert_select 'issue id', :text => issue.id.to_s do
305 305 assert_select '~ children[type=array] > issue', 2
306 306 assert_select '~ children[type=array] > issue > children', 1
307 307 end
308 308 end
309 309
310 310 test "GET /issues/:id.json with subtasks" do
311 311 issue = Issue.generate_with_descendants!(:project_id => 1)
312 312 get "/issues/#{issue.id}.json?include=children"
313 313
314 314 json = ActiveSupport::JSON.decode(response.body)
315 315 assert_equal 2, json['issue']['children'].size
316 316 assert_equal 1, json['issue']['children'].select {|child| child.key?('children')}.size
317 317 end
318 318
319 319 def test_show_should_include_issue_attributes
320 320 get '/issues/1.xml'
321 321 assert_select 'issue>is_private', :text => 'false'
322 322 end
323 323
324 324 test "GET /issues/:id.xml?include=watchers should include watchers" do
325 325 Watcher.create!(:user_id => 3, :watchable => Issue.find(1))
326 326
327 327 get '/issues/1.xml?include=watchers', {}, credentials('jsmith')
328 328
329 329 assert_response :ok
330 330 assert_equal 'application/xml', response.content_type
331 331 assert_select 'issue' do
332 332 assert_select 'watchers', Issue.find(1).watchers.count
333 333 assert_select 'watchers' do
334 334 assert_select 'user[id="3"]'
335 335 end
336 336 end
337 337 end
338 338
339 339 test "POST /issues.xml should create an issue with the attributes" do
340 340
341 341 payload = <<-XML
342 342 <?xml version="1.0" encoding="UTF-8" ?>
343 343 <issue>
344 344 <project_id>1</project_id>
345 345 <tracker_id>2</tracker_id>
346 346 <status_id>3</status_id>
347 347 <subject>API test</subject>
348 348 </issue>
349 349 XML
350 350
351 351 assert_difference('Issue.count') do
352 352 post '/issues.xml', payload, {"CONTENT_TYPE" => 'application/xml'}.merge(credentials('jsmith'))
353 353 end
354 354 issue = Issue.order('id DESC').first
355 355 assert_equal 1, issue.project_id
356 356 assert_equal 2, issue.tracker_id
357 357 assert_equal 3, issue.status_id
358 358 assert_equal 'API test', issue.subject
359 359
360 360 assert_response :created
361 361 assert_equal 'application/xml', @response.content_type
362 362 assert_select 'issue > id', :text => issue.id.to_s
363 363 end
364 364
365 365 test "POST /issues.xml with watcher_user_ids should create issue with watchers" do
366 366 assert_difference('Issue.count') do
367 367 post '/issues.xml',
368 368 {:issue => {:project_id => 1, :subject => 'Watchers',
369 369 :tracker_id => 2, :status_id => 3, :watcher_user_ids => [3, 1]}}, credentials('jsmith')
370 370 assert_response :created
371 371 end
372 372 issue = Issue.order('id desc').first
373 373 assert_equal 2, issue.watchers.size
374 374 assert_equal [1, 3], issue.watcher_user_ids.sort
375 375 end
376 376
377 377 test "POST /issues.xml with failure should return errors" do
378 378 assert_no_difference('Issue.count') do
379 379 post '/issues.xml', {:issue => {:project_id => 1}}, credentials('jsmith')
380 380 end
381 381
382 382 assert_select 'errors error', :text => "Subject cannot be blank"
383 383 end
384 384
385 385 test "POST /issues.json should create an issue with the attributes" do
386 386
387 387 payload = <<-JSON
388 388 {
389 389 "issue": {
390 390 "project_id": "1",
391 391 "tracker_id": "2",
392 392 "status_id": "3",
393 393 "subject": "API test"
394 394 }
395 395 }
396 396 JSON
397 397
398 398 assert_difference('Issue.count') do
399 399 post '/issues.json', payload, {"CONTENT_TYPE" => 'application/json'}.merge(credentials('jsmith'))
400 400 end
401 401
402 402 issue = Issue.order('id DESC').first
403 403 assert_equal 1, issue.project_id
404 404 assert_equal 2, issue.tracker_id
405 405 assert_equal 3, issue.status_id
406 406 assert_equal 'API test', issue.subject
407 407 end
408 408
409 test "POST /issues.json without tracker_id should accept custom fields" do
410 field = IssueCustomField.generate!(
411 :field_format => 'list',
412 :multiple => true,
413 :possible_values => ["V1", "V2", "V3"],
414 :default_value => "V2",
415 :is_for_all => true,
416 :trackers => Tracker.all.to_a
417 )
418
419 payload = <<-JSON
420 {
421 "issue": {
422 "project_id": "1",
423 "subject": "Multivalued custom field",
424 "custom_field_values":{"#{field.id}":["V1","V3"]}
425 }
426 }
427 JSON
428
429 assert_difference('Issue.count') do
430 post '/issues.json', payload, {"CONTENT_TYPE" => 'application/json'}.merge(credentials('jsmith'))
431 end
432
433 assert_response :created
434 issue = Issue.order('id DESC').first
435 assert_equal ["V1", "V3"], issue.custom_field_value(field)
436 end
437
409 438 test "POST /issues.json with failure should return errors" do
410 439 assert_no_difference('Issue.count') do
411 440 post '/issues.json', {:issue => {:project_id => 1}}, credentials('jsmith')
412 441 end
413 442
414 443 json = ActiveSupport::JSON.decode(response.body)
415 444 assert json['errors'].include?("Subject cannot be blank")
416 445 end
417 446
418 447 test "PUT /issues/:id.xml" do
419 448 assert_difference('Journal.count') do
420 449 put '/issues/6.xml',
421 450 {:issue => {:subject => 'API update', :notes => 'A new note'}},
422 451 credentials('jsmith')
423 452 end
424 453
425 454 issue = Issue.find(6)
426 455 assert_equal "API update", issue.subject
427 456 journal = Journal.last
428 457 assert_equal "A new note", journal.notes
429 458 end
430 459
431 460 test "PUT /issues/:id.xml with custom fields" do
432 461 put '/issues/3.xml',
433 462 {:issue => {:custom_fields => [
434 463 {'id' => '1', 'value' => 'PostgreSQL' },
435 464 {'id' => '2', 'value' => '150'}
436 465 ]}},
437 466 credentials('jsmith')
438 467
439 468 issue = Issue.find(3)
440 469 assert_equal '150', issue.custom_value_for(2).value
441 470 assert_equal 'PostgreSQL', issue.custom_value_for(1).value
442 471 end
443 472
444 473 test "PUT /issues/:id.xml with multi custom fields" do
445 474 field = CustomField.find(1)
446 475 field.update_attribute :multiple, true
447 476
448 477 put '/issues/3.xml',
449 478 {:issue => {:custom_fields => [
450 479 {'id' => '1', 'value' => ['MySQL', 'PostgreSQL'] },
451 480 {'id' => '2', 'value' => '150'}
452 481 ]}},
453 482 credentials('jsmith')
454 483
455 484 issue = Issue.find(3)
456 485 assert_equal '150', issue.custom_value_for(2).value
457 486 assert_equal ['MySQL', 'PostgreSQL'], issue.custom_field_value(1).sort
458 487 end
459 488
460 489 test "PUT /issues/:id.xml with project change" do
461 490 put '/issues/3.xml',
462 491 {:issue => {:project_id => 2, :subject => 'Project changed'}},
463 492 credentials('jsmith')
464 493
465 494 issue = Issue.find(3)
466 495 assert_equal 2, issue.project_id
467 496 assert_equal 'Project changed', issue.subject
468 497 end
469 498
470 499 test "PUT /issues/:id.xml with failed update" do
471 500 put '/issues/6.xml', {:issue => {:subject => ''}}, credentials('jsmith')
472 501
473 502 assert_response :unprocessable_entity
474 503 assert_select 'errors error', :text => "Subject cannot be blank"
475 504 end
476 505
477 506 test "PUT /issues/:id.json" do
478 507 assert_difference('Journal.count') do
479 508 put '/issues/6.json',
480 509 {:issue => {:subject => 'API update', :notes => 'A new note'}},
481 510 credentials('jsmith')
482 511
483 512 assert_response :ok
484 513 assert_equal '', response.body
485 514 end
486 515
487 516 issue = Issue.find(6)
488 517 assert_equal "API update", issue.subject
489 518 journal = Journal.last
490 519 assert_equal "A new note", journal.notes
491 520 end
492 521
493 522 test "PUT /issues/:id.json with failed update" do
494 523 put '/issues/6.json', {:issue => {:subject => ''}}, credentials('jsmith')
495 524
496 525 assert_response :unprocessable_entity
497 526 json = ActiveSupport::JSON.decode(response.body)
498 527 assert json['errors'].include?("Subject cannot be blank")
499 528 end
500 529
501 530 test "DELETE /issues/:id.xml" do
502 531 assert_difference('Issue.count', -1) do
503 532 delete '/issues/6.xml', {}, credentials('jsmith')
504 533
505 534 assert_response :ok
506 535 assert_equal '', response.body
507 536 end
508 537 assert_nil Issue.find_by_id(6)
509 538 end
510 539
511 540 test "DELETE /issues/:id.json" do
512 541 assert_difference('Issue.count', -1) do
513 542 delete '/issues/6.json', {}, credentials('jsmith')
514 543
515 544 assert_response :ok
516 545 assert_equal '', response.body
517 546 end
518 547 assert_nil Issue.find_by_id(6)
519 548 end
520 549
521 550 test "POST /issues/:id/watchers.xml should add watcher" do
522 551 assert_difference 'Watcher.count' do
523 552 post '/issues/1/watchers.xml', {:user_id => 3}, credentials('jsmith')
524 553
525 554 assert_response :ok
526 555 assert_equal '', response.body
527 556 end
528 557 watcher = Watcher.order('id desc').first
529 558 assert_equal Issue.find(1), watcher.watchable
530 559 assert_equal User.find(3), watcher.user
531 560 end
532 561
533 562 test "DELETE /issues/:id/watchers/:user_id.xml should remove watcher" do
534 563 Watcher.create!(:user_id => 3, :watchable => Issue.find(1))
535 564
536 565 assert_difference 'Watcher.count', -1 do
537 566 delete '/issues/1/watchers/3.xml', {}, credentials('jsmith')
538 567
539 568 assert_response :ok
540 569 assert_equal '', response.body
541 570 end
542 571 assert_equal false, Issue.find(1).watched_by?(User.find(3))
543 572 end
544 573
545 574 def test_create_issue_with_uploaded_file
546 575 token = xml_upload('test_create_with_upload', credentials('jsmith'))
547 576 attachment = Attachment.find_by_token(token)
548 577
549 578 # create the issue with the upload's token
550 579 assert_difference 'Issue.count' do
551 580 post '/issues.xml',
552 581 {:issue => {:project_id => 1, :subject => 'Uploaded file',
553 582 :uploads => [{:token => token, :filename => 'test.txt',
554 583 :content_type => 'text/plain'}]}},
555 584 credentials('jsmith')
556 585 assert_response :created
557 586 end
558 587 issue = Issue.order('id DESC').first
559 588 assert_equal 1, issue.attachments.count
560 589 assert_equal attachment, issue.attachments.first
561 590
562 591 attachment.reload
563 592 assert_equal 'test.txt', attachment.filename
564 593 assert_equal 'text/plain', attachment.content_type
565 594 assert_equal 'test_create_with_upload'.size, attachment.filesize
566 595 assert_equal 2, attachment.author_id
567 596
568 597 # get the issue with its attachments
569 598 get "/issues/#{issue.id}.xml", :include => 'attachments'
570 599 assert_response :success
571 600 xml = Hash.from_xml(response.body)
572 601 attachments = xml['issue']['attachments']
573 602 assert_kind_of Array, attachments
574 603 assert_equal 1, attachments.size
575 604 url = attachments.first['content_url']
576 605 assert_not_nil url
577 606
578 607 # download the attachment
579 608 get url
580 609 assert_response :success
581 610 assert_equal 'test_create_with_upload', response.body
582 611 end
583 612
584 613 def test_create_issue_with_multiple_uploaded_files_as_xml
585 614 token1 = xml_upload('File content 1', credentials('jsmith'))
586 615 token2 = xml_upload('File content 2', credentials('jsmith'))
587 616
588 617 payload = <<-XML
589 618 <?xml version="1.0" encoding="UTF-8" ?>
590 619 <issue>
591 620 <project_id>1</project_id>
592 621 <tracker_id>1</tracker_id>
593 622 <subject>Issue with multiple attachments</subject>
594 623 <uploads type="array">
595 624 <upload>
596 625 <token>#{token1}</token>
597 626 <filename>test1.txt</filename>
598 627 </upload>
599 628 <upload>
600 629 <token>#{token2}</token>
601 630 <filename>test1.txt</filename>
602 631 </upload>
603 632 </uploads>
604 633 </issue>
605 634 XML
606 635
607 636 assert_difference 'Issue.count' do
608 637 post '/issues.xml', payload, {"CONTENT_TYPE" => 'application/xml'}.merge(credentials('jsmith'))
609 638 assert_response :created
610 639 end
611 640 issue = Issue.order('id DESC').first
612 641 assert_equal 2, issue.attachments.count
613 642 end
614 643
615 644 def test_create_issue_with_multiple_uploaded_files_as_json
616 645 token1 = json_upload('File content 1', credentials('jsmith'))
617 646 token2 = json_upload('File content 2', credentials('jsmith'))
618 647
619 648 payload = <<-JSON
620 649 {
621 650 "issue": {
622 651 "project_id": "1",
623 652 "tracker_id": "1",
624 653 "subject": "Issue with multiple attachments",
625 654 "uploads": [
626 655 {"token": "#{token1}", "filename": "test1.txt"},
627 656 {"token": "#{token2}", "filename": "test2.txt"}
628 657 ]
629 658 }
630 659 }
631 660 JSON
632 661
633 662 assert_difference 'Issue.count' do
634 663 post '/issues.json', payload, {"CONTENT_TYPE" => 'application/json'}.merge(credentials('jsmith'))
635 664 assert_response :created
636 665 end
637 666 issue = Issue.order('id DESC').first
638 667 assert_equal 2, issue.attachments.count
639 668 end
640 669
641 670 def test_update_issue_with_uploaded_file
642 671 token = xml_upload('test_upload_with_upload', credentials('jsmith'))
643 672 attachment = Attachment.find_by_token(token)
644 673
645 674 # update the issue with the upload's token
646 675 assert_difference 'Journal.count' do
647 676 put '/issues/1.xml',
648 677 {:issue => {:notes => 'Attachment added',
649 678 :uploads => [{:token => token, :filename => 'test.txt',
650 679 :content_type => 'text/plain'}]}},
651 680 credentials('jsmith')
652 681 assert_response :ok
653 682 assert_equal '', @response.body
654 683 end
655 684
656 685 issue = Issue.find(1)
657 686 assert_include attachment, issue.attachments
658 687 end
659 688 end
General Comments 0
You need to be logged in to leave comments. Login now