##// END OF EJS Templates
Fixed that creating an issue without tracker_id attribute ignores custom field values (#19368)....
Jean-Philippe Lang -
r13701:5e1d042c40ac
parent child
Show More
@@ -1,1574 +1,1579
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 # Overrides Redmine::Acts::Customizable::InstanceMethods#validate_custom_field_values
644 649 # so that custom values that are not editable are not validated (eg. a custom field that
645 650 # is marked as required should not trigger a validation error if the user is not allowed
646 651 # to edit this field).
647 652 def validate_custom_field_values
648 653 user = new_record? ? author : current_journal.try(:user)
649 654 if new_record? || custom_field_values_changed?
650 655 editable_custom_field_values(user).each(&:validate_value)
651 656 end
652 657 end
653 658
654 659 # Set the done_ratio using the status if that setting is set. This will keep the done_ratios
655 660 # even if the user turns off the setting later
656 661 def update_done_ratio_from_issue_status
657 662 if Issue.use_status_for_done_ratio? && status && status.default_done_ratio
658 663 self.done_ratio = status.default_done_ratio
659 664 end
660 665 end
661 666
662 667 def init_journal(user, notes = "")
663 668 @current_journal ||= Journal.new(:journalized => self, :user => user, :notes => notes)
664 669 end
665 670
666 671 # Returns the current journal or nil if it's not initialized
667 672 def current_journal
668 673 @current_journal
669 674 end
670 675
671 676 # Returns the names of attributes that are journalized when updating the issue
672 677 def journalized_attribute_names
673 678 Issue.column_names - %w(id root_id lft rgt lock_version created_on updated_on closed_on)
674 679 end
675 680
676 681 # Returns the id of the last journal or nil
677 682 def last_journal_id
678 683 if new_record?
679 684 nil
680 685 else
681 686 journals.maximum(:id)
682 687 end
683 688 end
684 689
685 690 # Returns a scope for journals that have an id greater than journal_id
686 691 def journals_after(journal_id)
687 692 scope = journals.reorder("#{Journal.table_name}.id ASC")
688 693 if journal_id.present?
689 694 scope = scope.where("#{Journal.table_name}.id > ?", journal_id.to_i)
690 695 end
691 696 scope
692 697 end
693 698
694 699 # Returns the initial status of the issue
695 700 # Returns nil for a new issue
696 701 def status_was
697 702 if status_id_changed?
698 703 if status_id_was.to_i > 0
699 704 @status_was ||= IssueStatus.find_by_id(status_id_was)
700 705 end
701 706 else
702 707 @status_was ||= status
703 708 end
704 709 end
705 710
706 711 # Return true if the issue is closed, otherwise false
707 712 def closed?
708 713 status.present? && status.is_closed?
709 714 end
710 715
711 716 # Returns true if the issue was closed when loaded
712 717 def was_closed?
713 718 status_was.present? && status_was.is_closed?
714 719 end
715 720
716 721 # Return true if the issue is being reopened
717 722 def reopening?
718 723 if new_record?
719 724 false
720 725 else
721 726 status_id_changed? && !closed? && was_closed?
722 727 end
723 728 end
724 729 alias :reopened? :reopening?
725 730
726 731 # Return true if the issue is being closed
727 732 def closing?
728 733 if new_record?
729 734 closed?
730 735 else
731 736 status_id_changed? && closed? && !was_closed?
732 737 end
733 738 end
734 739
735 740 # Returns true if the issue is overdue
736 741 def overdue?
737 742 due_date.present? && (due_date < Date.today) && !closed?
738 743 end
739 744
740 745 # Is the amount of work done less than it should for the due date
741 746 def behind_schedule?
742 747 return false if start_date.nil? || due_date.nil?
743 748 done_date = start_date + ((due_date - start_date + 1) * done_ratio / 100).floor
744 749 return done_date <= Date.today
745 750 end
746 751
747 752 # Does this issue have children?
748 753 def children?
749 754 !leaf?
750 755 end
751 756
752 757 # Users the issue can be assigned to
753 758 def assignable_users
754 759 users = project.assignable_users.to_a
755 760 users << author if author
756 761 users << assigned_to if assigned_to
757 762 users.uniq.sort
758 763 end
759 764
760 765 # Versions that the issue can be assigned to
761 766 def assignable_versions
762 767 return @assignable_versions if @assignable_versions
763 768
764 769 versions = project.shared_versions.open.to_a
765 770 if fixed_version
766 771 if fixed_version_id_changed?
767 772 # nothing to do
768 773 elsif project_id_changed?
769 774 if project.shared_versions.include?(fixed_version)
770 775 versions << fixed_version
771 776 end
772 777 else
773 778 versions << fixed_version
774 779 end
775 780 end
776 781 @assignable_versions = versions.uniq.sort
777 782 end
778 783
779 784 # Returns true if this issue is blocked by another issue that is still open
780 785 def blocked?
781 786 !relations_to.detect {|ir| ir.relation_type == 'blocks' && !ir.issue_from.closed?}.nil?
782 787 end
783 788
784 789 # Returns the default status of the issue based on its tracker
785 790 # Returns nil if tracker is nil
786 791 def default_status
787 792 tracker.try(:default_status)
788 793 end
789 794
790 795 # Returns an array of statuses that user is able to apply
791 796 def new_statuses_allowed_to(user=User.current, include_default=false)
792 797 if new_record? && @copied_from
793 798 [default_status, @copied_from.status].compact.uniq.sort
794 799 else
795 800 initial_status = nil
796 801 if new_record?
797 802 initial_status = default_status
798 803 elsif tracker_id_changed?
799 804 if Tracker.where(:id => tracker_id_was, :default_status_id => status_id_was).any?
800 805 initial_status = default_status
801 806 elsif tracker.issue_status_ids.include?(status_id_was)
802 807 initial_status = IssueStatus.find_by_id(status_id_was)
803 808 else
804 809 initial_status = default_status
805 810 end
806 811 else
807 812 initial_status = status_was
808 813 end
809 814
810 815 initial_assigned_to_id = assigned_to_id_changed? ? assigned_to_id_was : assigned_to_id
811 816 assignee_transitions_allowed = initial_assigned_to_id.present? &&
812 817 (user.id == initial_assigned_to_id || user.group_ids.include?(initial_assigned_to_id))
813 818
814 819 statuses = []
815 820 if initial_status
816 821 statuses += initial_status.find_new_statuses_allowed_to(
817 822 user.admin ? Role.all.to_a : user.roles_for_project(project),
818 823 tracker,
819 824 author == user,
820 825 assignee_transitions_allowed
821 826 )
822 827 end
823 828 statuses << initial_status unless statuses.empty?
824 829 statuses << default_status if include_default
825 830 statuses = statuses.compact.uniq.sort
826 831 if blocked?
827 832 statuses.reject!(&:is_closed?)
828 833 end
829 834 statuses
830 835 end
831 836 end
832 837
833 838 # Returns the previous assignee (user or group) if changed
834 839 def assigned_to_was
835 840 # assigned_to_id_was is reset before after_save callbacks
836 841 user_id = @previous_assigned_to_id || assigned_to_id_was
837 842 if user_id && user_id != assigned_to_id
838 843 @assigned_to_was ||= Principal.find_by_id(user_id)
839 844 end
840 845 end
841 846
842 847 # Returns the users that should be notified
843 848 def notified_users
844 849 notified = []
845 850 # Author and assignee are always notified unless they have been
846 851 # locked or don't want to be notified
847 852 notified << author if author
848 853 if assigned_to
849 854 notified += (assigned_to.is_a?(Group) ? assigned_to.users : [assigned_to])
850 855 end
851 856 if assigned_to_was
852 857 notified += (assigned_to_was.is_a?(Group) ? assigned_to_was.users : [assigned_to_was])
853 858 end
854 859 notified = notified.select {|u| u.active? && u.notify_about?(self)}
855 860
856 861 notified += project.notified_users
857 862 notified.uniq!
858 863 # Remove users that can not view the issue
859 864 notified.reject! {|user| !visible?(user)}
860 865 notified
861 866 end
862 867
863 868 # Returns the email addresses that should be notified
864 869 def recipients
865 870 notified_users.collect(&:mail)
866 871 end
867 872
868 873 def each_notification(users, &block)
869 874 if users.any?
870 875 if custom_field_values.detect {|value| !value.custom_field.visible?}
871 876 users_by_custom_field_visibility = users.group_by do |user|
872 877 visible_custom_field_values(user).map(&:custom_field_id).sort
873 878 end
874 879 users_by_custom_field_visibility.values.each do |users|
875 880 yield(users)
876 881 end
877 882 else
878 883 yield(users)
879 884 end
880 885 end
881 886 end
882 887
883 888 # Returns the number of hours spent on this issue
884 889 def spent_hours
885 890 @spent_hours ||= time_entries.sum(:hours) || 0
886 891 end
887 892
888 893 # Returns the total number of hours spent on this issue and its descendants
889 894 #
890 895 # Example:
891 896 # spent_hours => 0.0
892 897 # spent_hours => 50.2
893 898 def total_spent_hours
894 899 @total_spent_hours ||=
895 900 self_and_descendants.
896 901 joins("LEFT JOIN #{TimeEntry.table_name} ON #{TimeEntry.table_name}.issue_id = #{Issue.table_name}.id").
897 902 sum("#{TimeEntry.table_name}.hours").to_f || 0.0
898 903 end
899 904
900 905 def relations
901 906 @relations ||= IssueRelation::Relations.new(self, (relations_from + relations_to).sort)
902 907 end
903 908
904 909 # Preloads relations for a collection of issues
905 910 def self.load_relations(issues)
906 911 if issues.any?
907 912 relations = IssueRelation.where("issue_from_id IN (:ids) OR issue_to_id IN (:ids)", :ids => issues.map(&:id)).all
908 913 issues.each do |issue|
909 914 issue.instance_variable_set "@relations", relations.select {|r| r.issue_from_id == issue.id || r.issue_to_id == issue.id}
910 915 end
911 916 end
912 917 end
913 918
914 919 # Preloads visible spent time for a collection of issues
915 920 def self.load_visible_spent_hours(issues, user=User.current)
916 921 if issues.any?
917 922 hours_by_issue_id = TimeEntry.visible(user).group(:issue_id).sum(:hours)
918 923 issues.each do |issue|
919 924 issue.instance_variable_set "@spent_hours", (hours_by_issue_id[issue.id] || 0)
920 925 end
921 926 end
922 927 end
923 928
924 929 # Preloads visible relations for a collection of issues
925 930 def self.load_visible_relations(issues, user=User.current)
926 931 if issues.any?
927 932 issue_ids = issues.map(&:id)
928 933 # Relations with issue_from in given issues and visible issue_to
929 934 relations_from = IssueRelation.joins(:issue_to => :project).
930 935 where(visible_condition(user)).where(:issue_from_id => issue_ids).to_a
931 936 # Relations with issue_to in given issues and visible issue_from
932 937 relations_to = IssueRelation.joins(:issue_from => :project).
933 938 where(visible_condition(user)).
934 939 where(:issue_to_id => issue_ids).to_a
935 940 issues.each do |issue|
936 941 relations =
937 942 relations_from.select {|relation| relation.issue_from_id == issue.id} +
938 943 relations_to.select {|relation| relation.issue_to_id == issue.id}
939 944
940 945 issue.instance_variable_set "@relations", IssueRelation::Relations.new(issue, relations.sort)
941 946 end
942 947 end
943 948 end
944 949
945 950 # Finds an issue relation given its id.
946 951 def find_relation(relation_id)
947 952 IssueRelation.where("issue_to_id = ? OR issue_from_id = ?", id, id).find(relation_id)
948 953 end
949 954
950 955 # Returns all the other issues that depend on the issue
951 956 # The algorithm is a modified breadth first search (bfs)
952 957 def all_dependent_issues(except=[])
953 958 # The found dependencies
954 959 dependencies = []
955 960
956 961 # The visited flag for every node (issue) used by the breadth first search
957 962 eNOT_DISCOVERED = 0 # The issue is "new" to the algorithm, it has not seen it before.
958 963
959 964 ePROCESS_ALL = 1 # The issue is added to the queue. Process both children and relations of
960 965 # the issue when it is processed.
961 966
962 967 ePROCESS_RELATIONS_ONLY = 2 # The issue was added to the queue and will be output as dependent issue,
963 968 # but its children will not be added to the queue when it is processed.
964 969
965 970 eRELATIONS_PROCESSED = 3 # The related issues, the parent issue and the issue itself have been added to
966 971 # the queue, but its children have not been added.
967 972
968 973 ePROCESS_CHILDREN_ONLY = 4 # The relations and the parent of the issue have been added to the queue, but
969 974 # the children still need to be processed.
970 975
971 976 eALL_PROCESSED = 5 # The issue and all its children, its parent and its related issues have been
972 977 # added as dependent issues. It needs no further processing.
973 978
974 979 issue_status = Hash.new(eNOT_DISCOVERED)
975 980
976 981 # The queue
977 982 queue = []
978 983
979 984 # Initialize the bfs, add start node (self) to the queue
980 985 queue << self
981 986 issue_status[self] = ePROCESS_ALL
982 987
983 988 while (!queue.empty?) do
984 989 current_issue = queue.shift
985 990 current_issue_status = issue_status[current_issue]
986 991 dependencies << current_issue
987 992
988 993 # Add parent to queue, if not already in it.
989 994 parent = current_issue.parent
990 995 parent_status = issue_status[parent]
991 996
992 997 if parent && (parent_status == eNOT_DISCOVERED) && !except.include?(parent)
993 998 queue << parent
994 999 issue_status[parent] = ePROCESS_RELATIONS_ONLY
995 1000 end
996 1001
997 1002 # Add children to queue, but only if they are not already in it and
998 1003 # the children of the current node need to be processed.
999 1004 if (current_issue_status == ePROCESS_CHILDREN_ONLY || current_issue_status == ePROCESS_ALL)
1000 1005 current_issue.children.each do |child|
1001 1006 next if except.include?(child)
1002 1007
1003 1008 if (issue_status[child] == eNOT_DISCOVERED)
1004 1009 queue << child
1005 1010 issue_status[child] = ePROCESS_ALL
1006 1011 elsif (issue_status[child] == eRELATIONS_PROCESSED)
1007 1012 queue << child
1008 1013 issue_status[child] = ePROCESS_CHILDREN_ONLY
1009 1014 elsif (issue_status[child] == ePROCESS_RELATIONS_ONLY)
1010 1015 queue << child
1011 1016 issue_status[child] = ePROCESS_ALL
1012 1017 end
1013 1018 end
1014 1019 end
1015 1020
1016 1021 # Add related issues to the queue, if they are not already in it.
1017 1022 current_issue.relations_from.map(&:issue_to).each do |related_issue|
1018 1023 next if except.include?(related_issue)
1019 1024
1020 1025 if (issue_status[related_issue] == eNOT_DISCOVERED)
1021 1026 queue << related_issue
1022 1027 issue_status[related_issue] = ePROCESS_ALL
1023 1028 elsif (issue_status[related_issue] == eRELATIONS_PROCESSED)
1024 1029 queue << related_issue
1025 1030 issue_status[related_issue] = ePROCESS_CHILDREN_ONLY
1026 1031 elsif (issue_status[related_issue] == ePROCESS_RELATIONS_ONLY)
1027 1032 queue << related_issue
1028 1033 issue_status[related_issue] = ePROCESS_ALL
1029 1034 end
1030 1035 end
1031 1036
1032 1037 # Set new status for current issue
1033 1038 if (current_issue_status == ePROCESS_ALL) || (current_issue_status == ePROCESS_CHILDREN_ONLY)
1034 1039 issue_status[current_issue] = eALL_PROCESSED
1035 1040 elsif (current_issue_status == ePROCESS_RELATIONS_ONLY)
1036 1041 issue_status[current_issue] = eRELATIONS_PROCESSED
1037 1042 end
1038 1043 end # while
1039 1044
1040 1045 # Remove the issues from the "except" parameter from the result array
1041 1046 dependencies -= except
1042 1047 dependencies.delete(self)
1043 1048
1044 1049 dependencies
1045 1050 end
1046 1051
1047 1052 # Returns an array of issues that duplicate this one
1048 1053 def duplicates
1049 1054 relations_to.select {|r| r.relation_type == IssueRelation::TYPE_DUPLICATES}.collect {|r| r.issue_from}
1050 1055 end
1051 1056
1052 1057 # Returns the due date or the target due date if any
1053 1058 # Used on gantt chart
1054 1059 def due_before
1055 1060 due_date || (fixed_version ? fixed_version.effective_date : nil)
1056 1061 end
1057 1062
1058 1063 # Returns the time scheduled for this issue.
1059 1064 #
1060 1065 # Example:
1061 1066 # Start Date: 2/26/09, End Date: 3/04/09
1062 1067 # duration => 6
1063 1068 def duration
1064 1069 (start_date && due_date) ? due_date - start_date : 0
1065 1070 end
1066 1071
1067 1072 # Returns the duration in working days
1068 1073 def working_duration
1069 1074 (start_date && due_date) ? working_days(start_date, due_date) : 0
1070 1075 end
1071 1076
1072 1077 def soonest_start(reload=false)
1073 1078 @soonest_start = nil if reload
1074 1079 @soonest_start ||= (
1075 1080 relations_to(reload).collect{|relation| relation.successor_soonest_start} +
1076 1081 [(@parent_issue || parent).try(:soonest_start)]
1077 1082 ).compact.max
1078 1083 end
1079 1084
1080 1085 # Sets start_date on the given date or the next working day
1081 1086 # and changes due_date to keep the same working duration.
1082 1087 def reschedule_on(date)
1083 1088 wd = working_duration
1084 1089 date = next_working_date(date)
1085 1090 self.start_date = date
1086 1091 self.due_date = add_working_days(date, wd)
1087 1092 end
1088 1093
1089 1094 # Reschedules the issue on the given date or the next working day and saves the record.
1090 1095 # If the issue is a parent task, this is done by rescheduling its subtasks.
1091 1096 def reschedule_on!(date)
1092 1097 return if date.nil?
1093 1098 if leaf?
1094 1099 if start_date.nil? || start_date != date
1095 1100 if start_date && start_date > date
1096 1101 # Issue can not be moved earlier than its soonest start date
1097 1102 date = [soonest_start(true), date].compact.max
1098 1103 end
1099 1104 reschedule_on(date)
1100 1105 begin
1101 1106 save
1102 1107 rescue ActiveRecord::StaleObjectError
1103 1108 reload
1104 1109 reschedule_on(date)
1105 1110 save
1106 1111 end
1107 1112 end
1108 1113 else
1109 1114 leaves.each do |leaf|
1110 1115 if leaf.start_date
1111 1116 # Only move subtask if it starts at the same date as the parent
1112 1117 # or if it starts before the given date
1113 1118 if start_date == leaf.start_date || date > leaf.start_date
1114 1119 leaf.reschedule_on!(date)
1115 1120 end
1116 1121 else
1117 1122 leaf.reschedule_on!(date)
1118 1123 end
1119 1124 end
1120 1125 end
1121 1126 end
1122 1127
1123 1128 def <=>(issue)
1124 1129 if issue.nil?
1125 1130 -1
1126 1131 elsif root_id != issue.root_id
1127 1132 (root_id || 0) <=> (issue.root_id || 0)
1128 1133 else
1129 1134 (lft || 0) <=> (issue.lft || 0)
1130 1135 end
1131 1136 end
1132 1137
1133 1138 def to_s
1134 1139 "#{tracker} ##{id}: #{subject}"
1135 1140 end
1136 1141
1137 1142 # Returns a string of css classes that apply to the issue
1138 1143 def css_classes(user=User.current)
1139 1144 s = "issue tracker-#{tracker_id} status-#{status_id} #{priority.try(:css_classes)}"
1140 1145 s << ' closed' if closed?
1141 1146 s << ' overdue' if overdue?
1142 1147 s << ' child' if child?
1143 1148 s << ' parent' unless leaf?
1144 1149 s << ' private' if is_private?
1145 1150 if user.logged?
1146 1151 s << ' created-by-me' if author_id == user.id
1147 1152 s << ' assigned-to-me' if assigned_to_id == user.id
1148 1153 s << ' assigned-to-my-group' if user.groups.any? {|g| g.id == assigned_to_id}
1149 1154 end
1150 1155 s
1151 1156 end
1152 1157
1153 1158 # Unassigns issues from +version+ if it's no longer shared with issue's project
1154 1159 def self.update_versions_from_sharing_change(version)
1155 1160 # Update issues assigned to the version
1156 1161 update_versions(["#{Issue.table_name}.fixed_version_id = ?", version.id])
1157 1162 end
1158 1163
1159 1164 # Unassigns issues from versions that are no longer shared
1160 1165 # after +project+ was moved
1161 1166 def self.update_versions_from_hierarchy_change(project)
1162 1167 moved_project_ids = project.self_and_descendants.reload.collect(&:id)
1163 1168 # Update issues of the moved projects and issues assigned to a version of a moved project
1164 1169 Issue.update_versions(
1165 1170 ["#{Version.table_name}.project_id IN (?) OR #{Issue.table_name}.project_id IN (?)",
1166 1171 moved_project_ids, moved_project_ids]
1167 1172 )
1168 1173 end
1169 1174
1170 1175 def parent_issue_id=(arg)
1171 1176 s = arg.to_s.strip.presence
1172 1177 if s && (m = s.match(%r{\A#?(\d+)\z})) && (@parent_issue = Issue.find_by_id(m[1]))
1173 1178 @invalid_parent_issue_id = nil
1174 1179 elsif s.blank?
1175 1180 @parent_issue = nil
1176 1181 @invalid_parent_issue_id = nil
1177 1182 else
1178 1183 @parent_issue = nil
1179 1184 @invalid_parent_issue_id = arg
1180 1185 end
1181 1186 end
1182 1187
1183 1188 def parent_issue_id
1184 1189 if @invalid_parent_issue_id
1185 1190 @invalid_parent_issue_id
1186 1191 elsif instance_variable_defined? :@parent_issue
1187 1192 @parent_issue.nil? ? nil : @parent_issue.id
1188 1193 else
1189 1194 parent_id
1190 1195 end
1191 1196 end
1192 1197
1193 1198 def set_parent_id
1194 1199 self.parent_id = parent_issue_id
1195 1200 end
1196 1201
1197 1202 # Returns true if issue's project is a valid
1198 1203 # parent issue project
1199 1204 def valid_parent_project?(issue=parent)
1200 1205 return true if issue.nil? || issue.project_id == project_id
1201 1206
1202 1207 case Setting.cross_project_subtasks
1203 1208 when 'system'
1204 1209 true
1205 1210 when 'tree'
1206 1211 issue.project.root == project.root
1207 1212 when 'hierarchy'
1208 1213 issue.project.is_or_is_ancestor_of?(project) || issue.project.is_descendant_of?(project)
1209 1214 when 'descendants'
1210 1215 issue.project.is_or_is_ancestor_of?(project)
1211 1216 else
1212 1217 false
1213 1218 end
1214 1219 end
1215 1220
1216 1221 # Returns an issue scope based on project and scope
1217 1222 def self.cross_project_scope(project, scope=nil)
1218 1223 if project.nil?
1219 1224 return Issue
1220 1225 end
1221 1226 case scope
1222 1227 when 'all', 'system'
1223 1228 Issue
1224 1229 when 'tree'
1225 1230 Issue.joins(:project).where("(#{Project.table_name}.lft >= :lft AND #{Project.table_name}.rgt <= :rgt)",
1226 1231 :lft => project.root.lft, :rgt => project.root.rgt)
1227 1232 when 'hierarchy'
1228 1233 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)",
1229 1234 :lft => project.lft, :rgt => project.rgt)
1230 1235 when 'descendants'
1231 1236 Issue.joins(:project).where("(#{Project.table_name}.lft >= :lft AND #{Project.table_name}.rgt <= :rgt)",
1232 1237 :lft => project.lft, :rgt => project.rgt)
1233 1238 else
1234 1239 Issue.where(:project_id => project.id)
1235 1240 end
1236 1241 end
1237 1242
1238 1243 def self.by_tracker(project)
1239 1244 count_and_group_by(:project => project, :association => :tracker)
1240 1245 end
1241 1246
1242 1247 def self.by_version(project)
1243 1248 count_and_group_by(:project => project, :association => :fixed_version)
1244 1249 end
1245 1250
1246 1251 def self.by_priority(project)
1247 1252 count_and_group_by(:project => project, :association => :priority)
1248 1253 end
1249 1254
1250 1255 def self.by_category(project)
1251 1256 count_and_group_by(:project => project, :association => :category)
1252 1257 end
1253 1258
1254 1259 def self.by_assigned_to(project)
1255 1260 count_and_group_by(:project => project, :association => :assigned_to)
1256 1261 end
1257 1262
1258 1263 def self.by_author(project)
1259 1264 count_and_group_by(:project => project, :association => :author)
1260 1265 end
1261 1266
1262 1267 def self.by_subproject(project)
1263 1268 r = count_and_group_by(:project => project, :with_subprojects => true, :association => :project)
1264 1269 r.reject {|r| r["project_id"] == project.id.to_s}
1265 1270 end
1266 1271
1267 1272 # Query generator for selecting groups of issue counts for a project
1268 1273 # based on specific criteria
1269 1274 #
1270 1275 # Options
1271 1276 # * project - Project to search in.
1272 1277 # * with_subprojects - Includes subprojects issues if set to true.
1273 1278 # * association - Symbol. Association for grouping.
1274 1279 def self.count_and_group_by(options)
1275 1280 assoc = reflect_on_association(options[:association])
1276 1281 select_field = assoc.foreign_key
1277 1282
1278 1283 Issue.
1279 1284 visible(User.current, :project => options[:project], :with_subprojects => options[:with_subprojects]).
1280 1285 joins(:status, assoc.name).
1281 1286 group(:status_id, :is_closed, select_field).
1282 1287 count.
1283 1288 map do |columns, total|
1284 1289 status_id, is_closed, field_value = columns
1285 1290 is_closed = ['t', 'true', '1'].include?(is_closed.to_s)
1286 1291 {
1287 1292 "status_id" => status_id.to_s,
1288 1293 "closed" => is_closed,
1289 1294 select_field => field_value.to_s,
1290 1295 "total" => total.to_s
1291 1296 }
1292 1297 end
1293 1298 end
1294 1299
1295 1300 # Returns a scope of projects that user can assign the issue to
1296 1301 def allowed_target_projects(user=User.current)
1297 1302 current_project = new_record? ? nil : project
1298 1303 self.class.allowed_target_projects(user, current_project)
1299 1304 end
1300 1305
1301 1306 # Returns a scope of projects that user can assign issues to
1302 1307 # If current_project is given, it will be included in the scope
1303 1308 def self.allowed_target_projects(user=User.current, current_project=nil)
1304 1309 condition = Project.allowed_to_condition(user, :add_issues)
1305 1310 if current_project
1306 1311 condition = ["(#{condition}) OR #{Project.table_name}.id = ?", current_project.id]
1307 1312 end
1308 1313 Project.where(condition)
1309 1314 end
1310 1315
1311 1316 private
1312 1317
1313 1318 def after_project_change
1314 1319 # Update project_id on related time entries
1315 1320 TimeEntry.where({:issue_id => id}).update_all(["project_id = ?", project_id])
1316 1321
1317 1322 # Delete issue relations
1318 1323 unless Setting.cross_project_issue_relations?
1319 1324 relations_from.clear
1320 1325 relations_to.clear
1321 1326 end
1322 1327
1323 1328 # Move subtasks that were in the same project
1324 1329 children.each do |child|
1325 1330 next unless child.project_id == project_id_was
1326 1331 # Change project and keep project
1327 1332 child.send :project=, project, true
1328 1333 unless child.save
1329 1334 raise ActiveRecord::Rollback
1330 1335 end
1331 1336 end
1332 1337 end
1333 1338
1334 1339 # Callback for after the creation of an issue by copy
1335 1340 # * adds a "copied to" relation with the copied issue
1336 1341 # * copies subtasks from the copied issue
1337 1342 def after_create_from_copy
1338 1343 return unless copy? && !@after_create_from_copy_handled
1339 1344
1340 1345 if (@copied_from.project_id == project_id || Setting.cross_project_issue_relations?) && @copy_options[:link] != false
1341 1346 if @current_journal
1342 1347 @copied_from.init_journal(@current_journal.user)
1343 1348 end
1344 1349 relation = IssueRelation.new(:issue_from => @copied_from, :issue_to => self, :relation_type => IssueRelation::TYPE_COPIED_TO)
1345 1350 unless relation.save
1346 1351 logger.error "Could not create relation while copying ##{@copied_from.id} to ##{id} due to validation errors: #{relation.errors.full_messages.join(', ')}" if logger
1347 1352 end
1348 1353 end
1349 1354
1350 1355 unless @copied_from.leaf? || @copy_options[:subtasks] == false
1351 1356 copy_options = (@copy_options || {}).merge(:subtasks => false)
1352 1357 copied_issue_ids = {@copied_from.id => self.id}
1353 1358 @copied_from.reload.descendants.reorder("#{Issue.table_name}.lft").each do |child|
1354 1359 # Do not copy self when copying an issue as a descendant of the copied issue
1355 1360 next if child == self
1356 1361 # Do not copy subtasks of issues that were not copied
1357 1362 next unless copied_issue_ids[child.parent_id]
1358 1363 # Do not copy subtasks that are not visible to avoid potential disclosure of private data
1359 1364 unless child.visible?
1360 1365 logger.error "Subtask ##{child.id} was not copied during ##{@copied_from.id} copy because it is not visible to the current user" if logger
1361 1366 next
1362 1367 end
1363 1368 copy = Issue.new.copy_from(child, copy_options)
1364 1369 if @current_journal
1365 1370 copy.init_journal(@current_journal.user)
1366 1371 end
1367 1372 copy.author = author
1368 1373 copy.project = project
1369 1374 copy.parent_issue_id = copied_issue_ids[child.parent_id]
1370 1375 unless copy.save
1371 1376 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
1372 1377 next
1373 1378 end
1374 1379 copied_issue_ids[child.id] = copy.id
1375 1380 end
1376 1381 end
1377 1382 @after_create_from_copy_handled = true
1378 1383 end
1379 1384
1380 1385 def update_nested_set_attributes
1381 1386 if parent_id_changed?
1382 1387 update_nested_set_attributes_on_parent_change
1383 1388 end
1384 1389 remove_instance_variable(:@parent_issue) if instance_variable_defined?(:@parent_issue)
1385 1390 end
1386 1391
1387 1392 # Updates the nested set for when an existing issue is moved
1388 1393 def update_nested_set_attributes_on_parent_change
1389 1394 former_parent_id = parent_id_was
1390 1395 # delete invalid relations of all descendants
1391 1396 self_and_descendants.each do |issue|
1392 1397 issue.relations.each do |relation|
1393 1398 relation.destroy unless relation.valid?
1394 1399 end
1395 1400 end
1396 1401 # update former parent
1397 1402 recalculate_attributes_for(former_parent_id) if former_parent_id
1398 1403 end
1399 1404
1400 1405 def update_parent_attributes
1401 1406 if parent_id
1402 1407 recalculate_attributes_for(parent_id)
1403 1408 association(:parent).reset
1404 1409 end
1405 1410 end
1406 1411
1407 1412 def recalculate_attributes_for(issue_id)
1408 1413 if issue_id && p = Issue.find_by_id(issue_id)
1409 1414 # priority = highest priority of children
1410 1415 if priority_position = p.children.joins(:priority).maximum("#{IssuePriority.table_name}.position")
1411 1416 p.priority = IssuePriority.find_by_position(priority_position)
1412 1417 end
1413 1418
1414 1419 # start/due dates = lowest/highest dates of children
1415 1420 p.start_date = p.children.minimum(:start_date)
1416 1421 p.due_date = p.children.maximum(:due_date)
1417 1422 if p.start_date && p.due_date && p.due_date < p.start_date
1418 1423 p.start_date, p.due_date = p.due_date, p.start_date
1419 1424 end
1420 1425
1421 1426 # done ratio = weighted average ratio of leaves
1422 1427 unless Issue.use_status_for_done_ratio? && p.status && p.status.default_done_ratio
1423 1428 leaves_count = p.leaves.count
1424 1429 if leaves_count > 0
1425 1430 average = p.leaves.where("estimated_hours > 0").average(:estimated_hours).to_f
1426 1431 if average == 0
1427 1432 average = 1
1428 1433 end
1429 1434 done = p.leaves.joins(:status).
1430 1435 sum("COALESCE(CASE WHEN estimated_hours > 0 THEN estimated_hours ELSE NULL END, #{average}) " +
1431 1436 "* (CASE WHEN is_closed = #{self.class.connection.quoted_true} THEN 100 ELSE COALESCE(done_ratio, 0) END)").to_f
1432 1437 progress = done / (average * leaves_count)
1433 1438 p.done_ratio = progress.round
1434 1439 end
1435 1440 end
1436 1441
1437 1442 # estimate = sum of leaves estimates
1438 1443 p.estimated_hours = p.leaves.sum(:estimated_hours).to_f
1439 1444 p.estimated_hours = nil if p.estimated_hours == 0.0
1440 1445
1441 1446 # ancestors will be recursively updated
1442 1447 p.save(:validate => false)
1443 1448 end
1444 1449 end
1445 1450
1446 1451 # Update issues so their versions are not pointing to a
1447 1452 # fixed_version that is not shared with the issue's project
1448 1453 def self.update_versions(conditions=nil)
1449 1454 # Only need to update issues with a fixed_version from
1450 1455 # a different project and that is not systemwide shared
1451 1456 Issue.joins(:project, :fixed_version).
1452 1457 where("#{Issue.table_name}.fixed_version_id IS NOT NULL" +
1453 1458 " AND #{Issue.table_name}.project_id <> #{Version.table_name}.project_id" +
1454 1459 " AND #{Version.table_name}.sharing <> 'system'").
1455 1460 where(conditions).each do |issue|
1456 1461 next if issue.project.nil? || issue.fixed_version.nil?
1457 1462 unless issue.project.shared_versions.include?(issue.fixed_version)
1458 1463 issue.init_journal(User.current)
1459 1464 issue.fixed_version = nil
1460 1465 issue.save
1461 1466 end
1462 1467 end
1463 1468 end
1464 1469
1465 1470 # Callback on file attachment
1466 1471 def attachment_added(attachment)
1467 1472 if current_journal && !attachment.new_record?
1468 1473 current_journal.journalize_attachment(attachment, :added)
1469 1474 end
1470 1475 end
1471 1476
1472 1477 # Callback on attachment deletion
1473 1478 def attachment_removed(attachment)
1474 1479 if current_journal && !attachment.new_record?
1475 1480 current_journal.journalize_attachment(attachment, :removed)
1476 1481 current_journal.save
1477 1482 end
1478 1483 end
1479 1484
1480 1485 # Called after a relation is added
1481 1486 def relation_added(relation)
1482 1487 if current_journal
1483 1488 current_journal.journalize_relation(relation, :added)
1484 1489 current_journal.save
1485 1490 end
1486 1491 end
1487 1492
1488 1493 # Called after a relation is removed
1489 1494 def relation_removed(relation)
1490 1495 if current_journal
1491 1496 current_journal.journalize_relation(relation, :removed)
1492 1497 current_journal.save
1493 1498 end
1494 1499 end
1495 1500
1496 1501 # Default assignment based on category
1497 1502 def default_assign
1498 1503 if assigned_to.nil? && category && category.assigned_to
1499 1504 self.assigned_to = category.assigned_to
1500 1505 end
1501 1506 end
1502 1507
1503 1508 # Updates start/due dates of following issues
1504 1509 def reschedule_following_issues
1505 1510 if start_date_changed? || due_date_changed?
1506 1511 relations_from.each do |relation|
1507 1512 relation.set_issue_to_dates
1508 1513 end
1509 1514 end
1510 1515 end
1511 1516
1512 1517 # Closes duplicates if the issue is being closed
1513 1518 def close_duplicates
1514 1519 if closing?
1515 1520 duplicates.each do |duplicate|
1516 1521 # Reload is needed in case the duplicate was updated by a previous duplicate
1517 1522 duplicate.reload
1518 1523 # Don't re-close it if it's already closed
1519 1524 next if duplicate.closed?
1520 1525 # Same user and notes
1521 1526 if @current_journal
1522 1527 duplicate.init_journal(@current_journal.user, @current_journal.notes)
1523 1528 end
1524 1529 duplicate.update_attribute :status, self.status
1525 1530 end
1526 1531 end
1527 1532 end
1528 1533
1529 1534 # Make sure updated_on is updated when adding a note and set updated_on now
1530 1535 # so we can set closed_on with the same value on closing
1531 1536 def force_updated_on_change
1532 1537 if @current_journal || changed?
1533 1538 self.updated_on = current_time_from_proper_timezone
1534 1539 if new_record?
1535 1540 self.created_on = updated_on
1536 1541 end
1537 1542 end
1538 1543 end
1539 1544
1540 1545 # Callback for setting closed_on when the issue is closed.
1541 1546 # The closed_on attribute stores the time of the last closing
1542 1547 # and is preserved when the issue is reopened.
1543 1548 def update_closed_on
1544 1549 if closing?
1545 1550 self.closed_on = updated_on
1546 1551 end
1547 1552 end
1548 1553
1549 1554 # Saves the changes in a Journal
1550 1555 # Called after_save
1551 1556 def create_journal
1552 1557 if current_journal
1553 1558 current_journal.save
1554 1559 end
1555 1560 end
1556 1561
1557 1562 def send_notification
1558 1563 if Setting.notified_events.include?('issue_added')
1559 1564 Mailer.deliver_issue_add(self)
1560 1565 end
1561 1566 end
1562 1567
1563 1568 # Stores the previous assignee so we can still have access
1564 1569 # to it during after_save callbacks (assigned_to_id_was is reset)
1565 1570 def set_assigned_to_was
1566 1571 @previous_assigned_to_id = assigned_to_id_was
1567 1572 end
1568 1573
1569 1574 # Clears the previous assignee at the end of after_save callbacks
1570 1575 def clear_assigned_to_was
1571 1576 @assigned_to_was = nil
1572 1577 @previous_assigned_to_id = nil
1573 1578 end
1574 1579 end
@@ -1,670 +1,699
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 notes only" do
471 500 assert_difference('Journal.count') do
472 501 put '/issues/6.xml',
473 502 {:issue => {:notes => 'Notes only'}},
474 503 credentials('jsmith')
475 504 end
476 505
477 506 journal = Journal.last
478 507 assert_equal "Notes only", journal.notes
479 508 end
480 509
481 510 test "PUT /issues/:id.xml with failed update" do
482 511 put '/issues/6.xml', {:issue => {:subject => ''}}, credentials('jsmith')
483 512
484 513 assert_response :unprocessable_entity
485 514 assert_select 'errors error', :text => "Subject cannot be blank"
486 515 end
487 516
488 517 test "PUT /issues/:id.json" do
489 518 assert_difference('Journal.count') do
490 519 put '/issues/6.json',
491 520 {:issue => {:subject => 'API update', :notes => 'A new note'}},
492 521 credentials('jsmith')
493 522
494 523 assert_response :ok
495 524 assert_equal '', response.body
496 525 end
497 526
498 527 issue = Issue.find(6)
499 528 assert_equal "API update", issue.subject
500 529 journal = Journal.last
501 530 assert_equal "A new note", journal.notes
502 531 end
503 532
504 533 test "PUT /issues/:id.json with failed update" do
505 534 put '/issues/6.json', {:issue => {:subject => ''}}, credentials('jsmith')
506 535
507 536 assert_response :unprocessable_entity
508 537 json = ActiveSupport::JSON.decode(response.body)
509 538 assert json['errors'].include?("Subject cannot be blank")
510 539 end
511 540
512 541 test "DELETE /issues/:id.xml" do
513 542 assert_difference('Issue.count', -1) do
514 543 delete '/issues/6.xml', {}, credentials('jsmith')
515 544
516 545 assert_response :ok
517 546 assert_equal '', response.body
518 547 end
519 548 assert_nil Issue.find_by_id(6)
520 549 end
521 550
522 551 test "DELETE /issues/:id.json" do
523 552 assert_difference('Issue.count', -1) do
524 553 delete '/issues/6.json', {}, credentials('jsmith')
525 554
526 555 assert_response :ok
527 556 assert_equal '', response.body
528 557 end
529 558 assert_nil Issue.find_by_id(6)
530 559 end
531 560
532 561 test "POST /issues/:id/watchers.xml should add watcher" do
533 562 assert_difference 'Watcher.count' do
534 563 post '/issues/1/watchers.xml', {:user_id => 3}, credentials('jsmith')
535 564
536 565 assert_response :ok
537 566 assert_equal '', response.body
538 567 end
539 568 watcher = Watcher.order('id desc').first
540 569 assert_equal Issue.find(1), watcher.watchable
541 570 assert_equal User.find(3), watcher.user
542 571 end
543 572
544 573 test "DELETE /issues/:id/watchers/:user_id.xml should remove watcher" do
545 574 Watcher.create!(:user_id => 3, :watchable => Issue.find(1))
546 575
547 576 assert_difference 'Watcher.count', -1 do
548 577 delete '/issues/1/watchers/3.xml', {}, credentials('jsmith')
549 578
550 579 assert_response :ok
551 580 assert_equal '', response.body
552 581 end
553 582 assert_equal false, Issue.find(1).watched_by?(User.find(3))
554 583 end
555 584
556 585 def test_create_issue_with_uploaded_file
557 586 token = xml_upload('test_create_with_upload', credentials('jsmith'))
558 587 attachment = Attachment.find_by_token(token)
559 588
560 589 # create the issue with the upload's token
561 590 assert_difference 'Issue.count' do
562 591 post '/issues.xml',
563 592 {:issue => {:project_id => 1, :subject => 'Uploaded file',
564 593 :uploads => [{:token => token, :filename => 'test.txt',
565 594 :content_type => 'text/plain'}]}},
566 595 credentials('jsmith')
567 596 assert_response :created
568 597 end
569 598 issue = Issue.order('id DESC').first
570 599 assert_equal 1, issue.attachments.count
571 600 assert_equal attachment, issue.attachments.first
572 601
573 602 attachment.reload
574 603 assert_equal 'test.txt', attachment.filename
575 604 assert_equal 'text/plain', attachment.content_type
576 605 assert_equal 'test_create_with_upload'.size, attachment.filesize
577 606 assert_equal 2, attachment.author_id
578 607
579 608 # get the issue with its attachments
580 609 get "/issues/#{issue.id}.xml", :include => 'attachments'
581 610 assert_response :success
582 611 xml = Hash.from_xml(response.body)
583 612 attachments = xml['issue']['attachments']
584 613 assert_kind_of Array, attachments
585 614 assert_equal 1, attachments.size
586 615 url = attachments.first['content_url']
587 616 assert_not_nil url
588 617
589 618 # download the attachment
590 619 get url
591 620 assert_response :success
592 621 assert_equal 'test_create_with_upload', response.body
593 622 end
594 623
595 624 def test_create_issue_with_multiple_uploaded_files_as_xml
596 625 token1 = xml_upload('File content 1', credentials('jsmith'))
597 626 token2 = xml_upload('File content 2', credentials('jsmith'))
598 627
599 628 payload = <<-XML
600 629 <?xml version="1.0" encoding="UTF-8" ?>
601 630 <issue>
602 631 <project_id>1</project_id>
603 632 <tracker_id>1</tracker_id>
604 633 <subject>Issue with multiple attachments</subject>
605 634 <uploads type="array">
606 635 <upload>
607 636 <token>#{token1}</token>
608 637 <filename>test1.txt</filename>
609 638 </upload>
610 639 <upload>
611 640 <token>#{token2}</token>
612 641 <filename>test1.txt</filename>
613 642 </upload>
614 643 </uploads>
615 644 </issue>
616 645 XML
617 646
618 647 assert_difference 'Issue.count' do
619 648 post '/issues.xml', payload, {"CONTENT_TYPE" => 'application/xml'}.merge(credentials('jsmith'))
620 649 assert_response :created
621 650 end
622 651 issue = Issue.order('id DESC').first
623 652 assert_equal 2, issue.attachments.count
624 653 end
625 654
626 655 def test_create_issue_with_multiple_uploaded_files_as_json
627 656 token1 = json_upload('File content 1', credentials('jsmith'))
628 657 token2 = json_upload('File content 2', credentials('jsmith'))
629 658
630 659 payload = <<-JSON
631 660 {
632 661 "issue": {
633 662 "project_id": "1",
634 663 "tracker_id": "1",
635 664 "subject": "Issue with multiple attachments",
636 665 "uploads": [
637 666 {"token": "#{token1}", "filename": "test1.txt"},
638 667 {"token": "#{token2}", "filename": "test2.txt"}
639 668 ]
640 669 }
641 670 }
642 671 JSON
643 672
644 673 assert_difference 'Issue.count' do
645 674 post '/issues.json', payload, {"CONTENT_TYPE" => 'application/json'}.merge(credentials('jsmith'))
646 675 assert_response :created
647 676 end
648 677 issue = Issue.order('id DESC').first
649 678 assert_equal 2, issue.attachments.count
650 679 end
651 680
652 681 def test_update_issue_with_uploaded_file
653 682 token = xml_upload('test_upload_with_upload', credentials('jsmith'))
654 683 attachment = Attachment.find_by_token(token)
655 684
656 685 # update the issue with the upload's token
657 686 assert_difference 'Journal.count' do
658 687 put '/issues/1.xml',
659 688 {:issue => {:notes => 'Attachment added',
660 689 :uploads => [{:token => token, :filename => 'test.txt',
661 690 :content_type => 'text/plain'}]}},
662 691 credentials('jsmith')
663 692 assert_response :ok
664 693 assert_equal '', @response.body
665 694 end
666 695
667 696 issue = Issue.find(1)
668 697 assert_include attachment, issue.attachments
669 698 end
670 699 end
General Comments 0
You need to be logged in to leave comments. Login now