##// END OF EJS Templates
Support for updating custom fields using the received custom_fields array (#6345, #6403)....
Jean-Philippe Lang -
r4367:3e3315c103f2
parent child
Show More
@@ -1,886 +1,887
1 1 # redMine - project management software
2 2 # Copyright (C) 2006-2007 Jean-Philippe Lang
3 3 #
4 4 # This program is free software; you can redistribute it and/or
5 5 # modify it under the terms of the GNU General Public License
6 6 # as published by the Free Software Foundation; either version 2
7 7 # of the License, or (at your option) any later version.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU General Public License
15 15 # along with this program; if not, write to the Free Software
16 16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17 17
18 18 class Issue < ActiveRecord::Base
19 19 belongs_to :project
20 20 belongs_to :tracker
21 21 belongs_to :status, :class_name => 'IssueStatus', :foreign_key => 'status_id'
22 22 belongs_to :author, :class_name => 'User', :foreign_key => 'author_id'
23 23 belongs_to :assigned_to, :class_name => 'User', :foreign_key => 'assigned_to_id'
24 24 belongs_to :fixed_version, :class_name => 'Version', :foreign_key => 'fixed_version_id'
25 25 belongs_to :priority, :class_name => 'IssuePriority', :foreign_key => 'priority_id'
26 26 belongs_to :category, :class_name => 'IssueCategory', :foreign_key => 'category_id'
27 27
28 28 has_many :journals, :as => :journalized, :dependent => :destroy
29 29 has_many :time_entries, :dependent => :delete_all
30 30 has_and_belongs_to_many :changesets, :order => "#{Changeset.table_name}.committed_on ASC, #{Changeset.table_name}.id ASC"
31 31
32 32 has_many :relations_from, :class_name => 'IssueRelation', :foreign_key => 'issue_from_id', :dependent => :delete_all
33 33 has_many :relations_to, :class_name => 'IssueRelation', :foreign_key => 'issue_to_id', :dependent => :delete_all
34 34
35 35 acts_as_nested_set :scope => 'root_id'
36 36 acts_as_attachable :after_remove => :attachment_removed
37 37 acts_as_customizable
38 38 acts_as_watchable
39 39 acts_as_searchable :columns => ['subject', "#{table_name}.description", "#{Journal.table_name}.notes"],
40 40 :include => [:project, :journals],
41 41 # sort by id so that limited eager loading doesn't break with postgresql
42 42 :order_column => "#{table_name}.id"
43 43 acts_as_event :title => Proc.new {|o| "#{o.tracker.name} ##{o.id} (#{o.status}): #{o.subject}"},
44 44 :url => Proc.new {|o| {:controller => 'issues', :action => 'show', :id => o.id}},
45 45 :type => Proc.new {|o| 'issue' + (o.closed? ? ' closed' : '') }
46 46
47 47 acts_as_activity_provider :find_options => {:include => [:project, :author, :tracker]},
48 48 :author_key => :author_id
49 49
50 50 DONE_RATIO_OPTIONS = %w(issue_field issue_status)
51 51
52 52 attr_reader :current_journal
53 53
54 54 validates_presence_of :subject, :priority, :project, :tracker, :author, :status
55 55
56 56 validates_length_of :subject, :maximum => 255
57 57 validates_inclusion_of :done_ratio, :in => 0..100
58 58 validates_numericality_of :estimated_hours, :allow_nil => true
59 59
60 60 named_scope :visible, lambda {|*args| { :include => :project,
61 61 :conditions => Project.allowed_to_condition(args.first || User.current, :view_issues) } }
62 62
63 63 named_scope :open, :conditions => ["#{IssueStatus.table_name}.is_closed = ?", false], :include => :status
64 64
65 65 named_scope :recently_updated, :order => "#{Issue.table_name}.updated_on DESC"
66 66 named_scope :with_limit, lambda { |limit| { :limit => limit} }
67 67 named_scope :on_active_project, :include => [:status, :project, :tracker],
68 68 :conditions => ["#{Project.table_name}.status=#{Project::STATUS_ACTIVE}"]
69 69 named_scope :for_gantt, lambda {
70 70 {
71 71 :include => [:tracker, :status, :assigned_to, :priority, :project, :fixed_version]
72 72 }
73 73 }
74 74
75 75 named_scope :without_version, lambda {
76 76 {
77 77 :conditions => { :fixed_version_id => nil}
78 78 }
79 79 }
80 80
81 81 named_scope :with_query, lambda {|query|
82 82 {
83 83 :conditions => Query.merge_conditions(query.statement)
84 84 }
85 85 }
86 86
87 87 before_create :default_assign
88 88 before_save :close_duplicates, :update_done_ratio_from_issue_status
89 89 after_save :reschedule_following_issues, :update_nested_set_attributes, :update_parent_attributes, :create_journal
90 90 after_destroy :destroy_children
91 91 after_destroy :update_parent_attributes
92 92
93 93 # Returns true if usr or current user is allowed to view the issue
94 94 def visible?(usr=nil)
95 95 (usr || User.current).allowed_to?(:view_issues, self.project)
96 96 end
97 97
98 98 def after_initialize
99 99 if new_record?
100 100 # set default values for new records only
101 101 self.status ||= IssueStatus.default
102 102 self.priority ||= IssuePriority.default
103 103 end
104 104 end
105 105
106 106 # Overrides Redmine::Acts::Customizable::InstanceMethods#available_custom_fields
107 107 def available_custom_fields
108 108 (project && tracker) ? project.all_issue_custom_fields.select {|c| tracker.custom_fields.include? c } : []
109 109 end
110 110
111 111 def copy_from(arg)
112 112 issue = arg.is_a?(Issue) ? arg : Issue.visible.find(arg)
113 113 self.attributes = issue.attributes.dup.except("id", "root_id", "parent_id", "lft", "rgt", "created_on", "updated_on")
114 114 self.custom_field_values = issue.custom_field_values.inject({}) {|h,v| h[v.custom_field_id] = v.value; h}
115 115 self.status = issue.status
116 116 self
117 117 end
118 118
119 119 # Moves/copies an issue to a new project and tracker
120 120 # Returns the moved/copied issue on success, false on failure
121 121 def move_to_project(*args)
122 122 ret = Issue.transaction do
123 123 move_to_project_without_transaction(*args) || raise(ActiveRecord::Rollback)
124 124 end || false
125 125 end
126 126
127 127 def move_to_project_without_transaction(new_project, new_tracker = nil, options = {})
128 128 options ||= {}
129 129 issue = options[:copy] ? self.class.new.copy_from(self) : self
130 130
131 131 if new_project && issue.project_id != new_project.id
132 132 # delete issue relations
133 133 unless Setting.cross_project_issue_relations?
134 134 issue.relations_from.clear
135 135 issue.relations_to.clear
136 136 end
137 137 # issue is moved to another project
138 138 # reassign to the category with same name if any
139 139 new_category = issue.category.nil? ? nil : new_project.issue_categories.find_by_name(issue.category.name)
140 140 issue.category = new_category
141 141 # Keep the fixed_version if it's still valid in the new_project
142 142 unless new_project.shared_versions.include?(issue.fixed_version)
143 143 issue.fixed_version = nil
144 144 end
145 145 issue.project = new_project
146 146 if issue.parent && issue.parent.project_id != issue.project_id
147 147 issue.parent_issue_id = nil
148 148 end
149 149 end
150 150 if new_tracker
151 151 issue.tracker = new_tracker
152 152 issue.reset_custom_values!
153 153 end
154 154 if options[:copy]
155 155 issue.custom_field_values = self.custom_field_values.inject({}) {|h,v| h[v.custom_field_id] = v.value; h}
156 156 issue.status = if options[:attributes] && options[:attributes][:status_id]
157 157 IssueStatus.find_by_id(options[:attributes][:status_id])
158 158 else
159 159 self.status
160 160 end
161 161 end
162 162 # Allow bulk setting of attributes on the issue
163 163 if options[:attributes]
164 164 issue.attributes = options[:attributes]
165 165 end
166 166 if issue.save
167 167 unless options[:copy]
168 168 # Manually update project_id on related time entries
169 169 TimeEntry.update_all("project_id = #{new_project.id}", {:issue_id => id})
170 170
171 171 issue.children.each do |child|
172 172 unless child.move_to_project_without_transaction(new_project)
173 173 # Move failed and transaction was rollback'd
174 174 return false
175 175 end
176 176 end
177 177 end
178 178 else
179 179 return false
180 180 end
181 181 issue
182 182 end
183 183
184 184 def status_id=(sid)
185 185 self.status = nil
186 186 write_attribute(:status_id, sid)
187 187 end
188 188
189 189 def priority_id=(pid)
190 190 self.priority = nil
191 191 write_attribute(:priority_id, pid)
192 192 end
193 193
194 194 def tracker_id=(tid)
195 195 self.tracker = nil
196 196 result = write_attribute(:tracker_id, tid)
197 197 @custom_field_values = nil
198 198 result
199 199 end
200 200
201 201 # Overrides attributes= so that tracker_id gets assigned first
202 202 def attributes_with_tracker_first=(new_attributes, *args)
203 203 return if new_attributes.nil?
204 204 new_tracker_id = new_attributes['tracker_id'] || new_attributes[:tracker_id]
205 205 if new_tracker_id
206 206 self.tracker_id = new_tracker_id
207 207 end
208 208 send :attributes_without_tracker_first=, new_attributes, *args
209 209 end
210 210 # Do not redefine alias chain on reload (see #4838)
211 211 alias_method_chain(:attributes=, :tracker_first) unless method_defined?(:attributes_without_tracker_first=)
212 212
213 213 def estimated_hours=(h)
214 214 write_attribute :estimated_hours, (h.is_a?(String) ? h.to_hours : h)
215 215 end
216 216
217 217 SAFE_ATTRIBUTES = %w(
218 218 tracker_id
219 219 status_id
220 220 parent_issue_id
221 221 category_id
222 222 assigned_to_id
223 223 priority_id
224 224 fixed_version_id
225 225 subject
226 226 description
227 227 start_date
228 228 due_date
229 229 done_ratio
230 230 estimated_hours
231 231 custom_field_values
232 custom_fields
232 233 lock_version
233 234 ) unless const_defined?(:SAFE_ATTRIBUTES)
234 235
235 236 SAFE_ATTRIBUTES_ON_TRANSITION = %w(
236 237 status_id
237 238 assigned_to_id
238 239 fixed_version_id
239 240 done_ratio
240 241 ) unless const_defined?(:SAFE_ATTRIBUTES_ON_TRANSITION)
241 242
242 243 # Safely sets attributes
243 244 # Should be called from controllers instead of #attributes=
244 245 # attr_accessible is too rough because we still want things like
245 246 # Issue.new(:project => foo) to work
246 247 # TODO: move workflow/permission checks from controllers to here
247 248 def safe_attributes=(attrs, user=User.current)
248 249 return unless attrs.is_a?(Hash)
249 250
250 251 # User can change issue attributes only if he has :edit permission or if a workflow transition is allowed
251 252 if new_record? || user.allowed_to?(:edit_issues, project)
252 253 attrs = attrs.reject {|k,v| !SAFE_ATTRIBUTES.include?(k)}
253 254 elsif new_statuses_allowed_to(user).any?
254 255 attrs = attrs.reject {|k,v| !SAFE_ATTRIBUTES_ON_TRANSITION.include?(k)}
255 256 else
256 257 return
257 258 end
258 259
259 260 # Tracker must be set before since new_statuses_allowed_to depends on it.
260 261 if t = attrs.delete('tracker_id')
261 262 self.tracker_id = t
262 263 end
263 264
264 265 if attrs['status_id']
265 266 unless new_statuses_allowed_to(user).collect(&:id).include?(attrs['status_id'].to_i)
266 267 attrs.delete('status_id')
267 268 end
268 269 end
269 270
270 271 unless leaf?
271 272 attrs.reject! {|k,v| %w(priority_id done_ratio start_date due_date estimated_hours).include?(k)}
272 273 end
273 274
274 275 if attrs.has_key?('parent_issue_id')
275 276 if !user.allowed_to?(:manage_subtasks, project)
276 277 attrs.delete('parent_issue_id')
277 278 elsif !attrs['parent_issue_id'].blank?
278 279 attrs.delete('parent_issue_id') unless Issue.visible(user).exists?(attrs['parent_issue_id'].to_i)
279 280 end
280 281 end
281 282
282 283 self.attributes = attrs
283 284 end
284 285
285 286 def done_ratio
286 287 if Issue.use_status_for_done_ratio? && status && status.default_done_ratio
287 288 status.default_done_ratio
288 289 else
289 290 read_attribute(:done_ratio)
290 291 end
291 292 end
292 293
293 294 def self.use_status_for_done_ratio?
294 295 Setting.issue_done_ratio == 'issue_status'
295 296 end
296 297
297 298 def self.use_field_for_done_ratio?
298 299 Setting.issue_done_ratio == 'issue_field'
299 300 end
300 301
301 302 def validate
302 303 if self.due_date.nil? && @attributes['due_date'] && !@attributes['due_date'].empty?
303 304 errors.add :due_date, :not_a_date
304 305 end
305 306
306 307 if self.due_date and self.start_date and self.due_date < self.start_date
307 308 errors.add :due_date, :greater_than_start_date
308 309 end
309 310
310 311 if start_date && soonest_start && start_date < soonest_start
311 312 errors.add :start_date, :invalid
312 313 end
313 314
314 315 if fixed_version
315 316 if !assignable_versions.include?(fixed_version)
316 317 errors.add :fixed_version_id, :inclusion
317 318 elsif reopened? && fixed_version.closed?
318 319 errors.add_to_base I18n.t(:error_can_not_reopen_issue_on_closed_version)
319 320 end
320 321 end
321 322
322 323 # Checks that the issue can not be added/moved to a disabled tracker
323 324 if project && (tracker_id_changed? || project_id_changed?)
324 325 unless project.trackers.include?(tracker)
325 326 errors.add :tracker_id, :inclusion
326 327 end
327 328 end
328 329
329 330 # Checks parent issue assignment
330 331 if @parent_issue
331 332 if @parent_issue.project_id != project_id
332 333 errors.add :parent_issue_id, :not_same_project
333 334 elsif !new_record?
334 335 # moving an existing issue
335 336 if @parent_issue.root_id != root_id
336 337 # we can always move to another tree
337 338 elsif move_possible?(@parent_issue)
338 339 # move accepted inside tree
339 340 else
340 341 errors.add :parent_issue_id, :not_a_valid_parent
341 342 end
342 343 end
343 344 end
344 345 end
345 346
346 347 # Set the done_ratio using the status if that setting is set. This will keep the done_ratios
347 348 # even if the user turns off the setting later
348 349 def update_done_ratio_from_issue_status
349 350 if Issue.use_status_for_done_ratio? && status && status.default_done_ratio
350 351 self.done_ratio = status.default_done_ratio
351 352 end
352 353 end
353 354
354 355 def init_journal(user, notes = "")
355 356 @current_journal ||= Journal.new(:journalized => self, :user => user, :notes => notes)
356 357 @issue_before_change = self.clone
357 358 @issue_before_change.status = self.status
358 359 @custom_values_before_change = {}
359 360 self.custom_values.each {|c| @custom_values_before_change.store c.custom_field_id, c.value }
360 361 # Make sure updated_on is updated when adding a note.
361 362 updated_on_will_change!
362 363 @current_journal
363 364 end
364 365
365 366 # Return true if the issue is closed, otherwise false
366 367 def closed?
367 368 self.status.is_closed?
368 369 end
369 370
370 371 # Return true if the issue is being reopened
371 372 def reopened?
372 373 if !new_record? && status_id_changed?
373 374 status_was = IssueStatus.find_by_id(status_id_was)
374 375 status_new = IssueStatus.find_by_id(status_id)
375 376 if status_was && status_new && status_was.is_closed? && !status_new.is_closed?
376 377 return true
377 378 end
378 379 end
379 380 false
380 381 end
381 382
382 383 # Return true if the issue is being closed
383 384 def closing?
384 385 if !new_record? && status_id_changed?
385 386 status_was = IssueStatus.find_by_id(status_id_was)
386 387 status_new = IssueStatus.find_by_id(status_id)
387 388 if status_was && status_new && !status_was.is_closed? && status_new.is_closed?
388 389 return true
389 390 end
390 391 end
391 392 false
392 393 end
393 394
394 395 # Returns true if the issue is overdue
395 396 def overdue?
396 397 !due_date.nil? && (due_date < Date.today) && !status.is_closed?
397 398 end
398 399
399 400 # Is the amount of work done less than it should for the due date
400 401 def behind_schedule?
401 402 return false if start_date.nil? || due_date.nil?
402 403 done_date = start_date + ((due_date - start_date+1)* done_ratio/100).floor
403 404 return done_date <= Date.today
404 405 end
405 406
406 407 # Does this issue have children?
407 408 def children?
408 409 !leaf?
409 410 end
410 411
411 412 # Users the issue can be assigned to
412 413 def assignable_users
413 414 users = project.assignable_users
414 415 users << author if author
415 416 users.uniq.sort
416 417 end
417 418
418 419 # Versions that the issue can be assigned to
419 420 def assignable_versions
420 421 @assignable_versions ||= (project.shared_versions.open + [Version.find_by_id(fixed_version_id_was)]).compact.uniq.sort
421 422 end
422 423
423 424 # Returns true if this issue is blocked by another issue that is still open
424 425 def blocked?
425 426 !relations_to.detect {|ir| ir.relation_type == 'blocks' && !ir.issue_from.closed?}.nil?
426 427 end
427 428
428 429 # Returns an array of status that user is able to apply
429 430 def new_statuses_allowed_to(user, include_default=false)
430 431 statuses = status.find_new_statuses_allowed_to(user.roles_for_project(project), tracker)
431 432 statuses << status unless statuses.empty?
432 433 statuses << IssueStatus.default if include_default
433 434 statuses = statuses.uniq.sort
434 435 blocked? ? statuses.reject {|s| s.is_closed?} : statuses
435 436 end
436 437
437 438 # Returns the mail adresses of users that should be notified
438 439 def recipients
439 440 notified = project.notified_users
440 441 # Author and assignee are always notified unless they have been
441 442 # locked or don't want to be notified
442 443 notified << author if author && author.active? && author.notify_about?(self)
443 444 notified << assigned_to if assigned_to && assigned_to.active? && assigned_to.notify_about?(self)
444 445 notified.uniq!
445 446 # Remove users that can not view the issue
446 447 notified.reject! {|user| !visible?(user)}
447 448 notified.collect(&:mail)
448 449 end
449 450
450 451 # Returns the total number of hours spent on this issue and its descendants
451 452 #
452 453 # Example:
453 454 # spent_hours => 0.0
454 455 # spent_hours => 50.2
455 456 def spent_hours
456 457 @spent_hours ||= self_and_descendants.sum("#{TimeEntry.table_name}.hours", :include => :time_entries).to_f || 0.0
457 458 end
458 459
459 460 def relations
460 461 (relations_from + relations_to).sort
461 462 end
462 463
463 464 def all_dependent_issues
464 465 dependencies = []
465 466 relations_from.each do |relation|
466 467 dependencies << relation.issue_to
467 468 dependencies += relation.issue_to.all_dependent_issues
468 469 end
469 470 dependencies
470 471 end
471 472
472 473 # Returns an array of issues that duplicate this one
473 474 def duplicates
474 475 relations_to.select {|r| r.relation_type == IssueRelation::TYPE_DUPLICATES}.collect {|r| r.issue_from}
475 476 end
476 477
477 478 # Returns the due date or the target due date if any
478 479 # Used on gantt chart
479 480 def due_before
480 481 due_date || (fixed_version ? fixed_version.effective_date : nil)
481 482 end
482 483
483 484 # Returns the time scheduled for this issue.
484 485 #
485 486 # Example:
486 487 # Start Date: 2/26/09, End Date: 3/04/09
487 488 # duration => 6
488 489 def duration
489 490 (start_date && due_date) ? due_date - start_date : 0
490 491 end
491 492
492 493 def soonest_start
493 494 @soonest_start ||= (
494 495 relations_to.collect{|relation| relation.successor_soonest_start} +
495 496 ancestors.collect(&:soonest_start)
496 497 ).compact.max
497 498 end
498 499
499 500 def reschedule_after(date)
500 501 return if date.nil?
501 502 if leaf?
502 503 if start_date.nil? || start_date < date
503 504 self.start_date, self.due_date = date, date + duration
504 505 save
505 506 end
506 507 else
507 508 leaves.each do |leaf|
508 509 leaf.reschedule_after(date)
509 510 end
510 511 end
511 512 end
512 513
513 514 def <=>(issue)
514 515 if issue.nil?
515 516 -1
516 517 elsif root_id != issue.root_id
517 518 (root_id || 0) <=> (issue.root_id || 0)
518 519 else
519 520 (lft || 0) <=> (issue.lft || 0)
520 521 end
521 522 end
522 523
523 524 def to_s
524 525 "#{tracker} ##{id}: #{subject}"
525 526 end
526 527
527 528 # Returns a string of css classes that apply to the issue
528 529 def css_classes
529 530 s = "issue status-#{status.position} priority-#{priority.position}"
530 531 s << ' closed' if closed?
531 532 s << ' overdue' if overdue?
532 533 s << ' created-by-me' if User.current.logged? && author_id == User.current.id
533 534 s << ' assigned-to-me' if User.current.logged? && assigned_to_id == User.current.id
534 535 s
535 536 end
536 537
537 538 # Saves an issue, time_entry, attachments, and a journal from the parameters
538 539 # Returns false if save fails
539 540 def save_issue_with_child_records(params, existing_time_entry=nil)
540 541 Issue.transaction do
541 542 if params[:time_entry] && params[:time_entry][:hours].present? && User.current.allowed_to?(:log_time, project)
542 543 @time_entry = existing_time_entry || TimeEntry.new
543 544 @time_entry.project = project
544 545 @time_entry.issue = self
545 546 @time_entry.user = User.current
546 547 @time_entry.spent_on = Date.today
547 548 @time_entry.attributes = params[:time_entry]
548 549 self.time_entries << @time_entry
549 550 end
550 551
551 552 if valid?
552 553 attachments = Attachment.attach_files(self, params[:attachments])
553 554
554 555 attachments[:files].each {|a| @current_journal.details << JournalDetail.new(:property => 'attachment', :prop_key => a.id, :value => a.filename)}
555 556 # TODO: Rename hook
556 557 Redmine::Hook.call_hook(:controller_issues_edit_before_save, { :params => params, :issue => self, :time_entry => @time_entry, :journal => @current_journal})
557 558 begin
558 559 if save
559 560 # TODO: Rename hook
560 561 Redmine::Hook.call_hook(:controller_issues_edit_after_save, { :params => params, :issue => self, :time_entry => @time_entry, :journal => @current_journal})
561 562 else
562 563 raise ActiveRecord::Rollback
563 564 end
564 565 rescue ActiveRecord::StaleObjectError
565 566 attachments[:files].each(&:destroy)
566 567 errors.add_to_base l(:notice_locking_conflict)
567 568 raise ActiveRecord::Rollback
568 569 end
569 570 end
570 571 end
571 572 end
572 573
573 574 # Unassigns issues from +version+ if it's no longer shared with issue's project
574 575 def self.update_versions_from_sharing_change(version)
575 576 # Update issues assigned to the version
576 577 update_versions(["#{Issue.table_name}.fixed_version_id = ?", version.id])
577 578 end
578 579
579 580 # Unassigns issues from versions that are no longer shared
580 581 # after +project+ was moved
581 582 def self.update_versions_from_hierarchy_change(project)
582 583 moved_project_ids = project.self_and_descendants.reload.collect(&:id)
583 584 # Update issues of the moved projects and issues assigned to a version of a moved project
584 585 Issue.update_versions(["#{Version.table_name}.project_id IN (?) OR #{Issue.table_name}.project_id IN (?)", moved_project_ids, moved_project_ids])
585 586 end
586 587
587 588 def parent_issue_id=(arg)
588 589 parent_issue_id = arg.blank? ? nil : arg.to_i
589 590 if parent_issue_id && @parent_issue = Issue.find_by_id(parent_issue_id)
590 591 @parent_issue.id
591 592 else
592 593 @parent_issue = nil
593 594 nil
594 595 end
595 596 end
596 597
597 598 def parent_issue_id
598 599 if instance_variable_defined? :@parent_issue
599 600 @parent_issue.nil? ? nil : @parent_issue.id
600 601 else
601 602 parent_id
602 603 end
603 604 end
604 605
605 606 # Extracted from the ReportsController.
606 607 def self.by_tracker(project)
607 608 count_and_group_by(:project => project,
608 609 :field => 'tracker_id',
609 610 :joins => Tracker.table_name)
610 611 end
611 612
612 613 def self.by_version(project)
613 614 count_and_group_by(:project => project,
614 615 :field => 'fixed_version_id',
615 616 :joins => Version.table_name)
616 617 end
617 618
618 619 def self.by_priority(project)
619 620 count_and_group_by(:project => project,
620 621 :field => 'priority_id',
621 622 :joins => IssuePriority.table_name)
622 623 end
623 624
624 625 def self.by_category(project)
625 626 count_and_group_by(:project => project,
626 627 :field => 'category_id',
627 628 :joins => IssueCategory.table_name)
628 629 end
629 630
630 631 def self.by_assigned_to(project)
631 632 count_and_group_by(:project => project,
632 633 :field => 'assigned_to_id',
633 634 :joins => User.table_name)
634 635 end
635 636
636 637 def self.by_author(project)
637 638 count_and_group_by(:project => project,
638 639 :field => 'author_id',
639 640 :joins => User.table_name)
640 641 end
641 642
642 643 def self.by_subproject(project)
643 644 ActiveRecord::Base.connection.select_all("select s.id as status_id,
644 645 s.is_closed as closed,
645 646 i.project_id as project_id,
646 647 count(i.id) as total
647 648 from
648 649 #{Issue.table_name} i, #{IssueStatus.table_name} s
649 650 where
650 651 i.status_id=s.id
651 652 and i.project_id IN (#{project.descendants.active.collect{|p| p.id}.join(',')})
652 653 group by s.id, s.is_closed, i.project_id") if project.descendants.active.any?
653 654 end
654 655 # End ReportsController extraction
655 656
656 657 # Returns an array of projects that current user can move issues to
657 658 def self.allowed_target_projects_on_move
658 659 projects = []
659 660 if User.current.admin?
660 661 # admin is allowed to move issues to any active (visible) project
661 662 projects = Project.visible.all
662 663 elsif User.current.logged?
663 664 if Role.non_member.allowed_to?(:move_issues)
664 665 projects = Project.visible.all
665 666 else
666 667 User.current.memberships.each {|m| projects << m.project if m.roles.detect {|r| r.allowed_to?(:move_issues)}}
667 668 end
668 669 end
669 670 projects
670 671 end
671 672
672 673 private
673 674
674 675 def update_nested_set_attributes
675 676 if root_id.nil?
676 677 # issue was just created
677 678 self.root_id = (@parent_issue.nil? ? id : @parent_issue.root_id)
678 679 set_default_left_and_right
679 680 Issue.update_all("root_id = #{root_id}, lft = #{lft}, rgt = #{rgt}", ["id = ?", id])
680 681 if @parent_issue
681 682 move_to_child_of(@parent_issue)
682 683 end
683 684 reload
684 685 elsif parent_issue_id != parent_id
685 686 former_parent_id = parent_id
686 687 # moving an existing issue
687 688 if @parent_issue && @parent_issue.root_id == root_id
688 689 # inside the same tree
689 690 move_to_child_of(@parent_issue)
690 691 else
691 692 # to another tree
692 693 unless root?
693 694 move_to_right_of(root)
694 695 reload
695 696 end
696 697 old_root_id = root_id
697 698 self.root_id = (@parent_issue.nil? ? id : @parent_issue.root_id )
698 699 target_maxright = nested_set_scope.maximum(right_column_name) || 0
699 700 offset = target_maxright + 1 - lft
700 701 Issue.update_all("root_id = #{root_id}, lft = lft + #{offset}, rgt = rgt + #{offset}",
701 702 ["root_id = ? AND lft >= ? AND rgt <= ? ", old_root_id, lft, rgt])
702 703 self[left_column_name] = lft + offset
703 704 self[right_column_name] = rgt + offset
704 705 if @parent_issue
705 706 move_to_child_of(@parent_issue)
706 707 end
707 708 end
708 709 reload
709 710 # delete invalid relations of all descendants
710 711 self_and_descendants.each do |issue|
711 712 issue.relations.each do |relation|
712 713 relation.destroy unless relation.valid?
713 714 end
714 715 end
715 716 # update former parent
716 717 recalculate_attributes_for(former_parent_id) if former_parent_id
717 718 end
718 719 remove_instance_variable(:@parent_issue) if instance_variable_defined?(:@parent_issue)
719 720 end
720 721
721 722 def update_parent_attributes
722 723 recalculate_attributes_for(parent_id) if parent_id
723 724 end
724 725
725 726 def recalculate_attributes_for(issue_id)
726 727 if issue_id && p = Issue.find_by_id(issue_id)
727 728 # priority = highest priority of children
728 729 if priority_position = p.children.maximum("#{IssuePriority.table_name}.position", :include => :priority)
729 730 p.priority = IssuePriority.find_by_position(priority_position)
730 731 end
731 732
732 733 # start/due dates = lowest/highest dates of children
733 734 p.start_date = p.children.minimum(:start_date)
734 735 p.due_date = p.children.maximum(:due_date)
735 736 if p.start_date && p.due_date && p.due_date < p.start_date
736 737 p.start_date, p.due_date = p.due_date, p.start_date
737 738 end
738 739
739 740 # done ratio = weighted average ratio of leaves
740 741 unless Issue.use_status_for_done_ratio? && p.status && p.status.default_done_ratio
741 742 leaves_count = p.leaves.count
742 743 if leaves_count > 0
743 744 average = p.leaves.average(:estimated_hours).to_f
744 745 if average == 0
745 746 average = 1
746 747 end
747 748 done = p.leaves.sum("COALESCE(estimated_hours, #{average}) * (CASE WHEN is_closed = #{connection.quoted_true} THEN 100 ELSE COALESCE(done_ratio, 0) END)", :include => :status).to_f
748 749 progress = done / (average * leaves_count)
749 750 p.done_ratio = progress.round
750 751 end
751 752 end
752 753
753 754 # estimate = sum of leaves estimates
754 755 p.estimated_hours = p.leaves.sum(:estimated_hours).to_f
755 756 p.estimated_hours = nil if p.estimated_hours == 0.0
756 757
757 758 # ancestors will be recursively updated
758 759 p.save(false)
759 760 end
760 761 end
761 762
762 763 def destroy_children
763 764 unless leaf?
764 765 children.each do |child|
765 766 child.destroy
766 767 end
767 768 end
768 769 end
769 770
770 771 # Update issues so their versions are not pointing to a
771 772 # fixed_version that is not shared with the issue's project
772 773 def self.update_versions(conditions=nil)
773 774 # Only need to update issues with a fixed_version from
774 775 # a different project and that is not systemwide shared
775 776 Issue.all(:conditions => merge_conditions("#{Issue.table_name}.fixed_version_id IS NOT NULL" +
776 777 " AND #{Issue.table_name}.project_id <> #{Version.table_name}.project_id" +
777 778 " AND #{Version.table_name}.sharing <> 'system'",
778 779 conditions),
779 780 :include => [:project, :fixed_version]
780 781 ).each do |issue|
781 782 next if issue.project.nil? || issue.fixed_version.nil?
782 783 unless issue.project.shared_versions.include?(issue.fixed_version)
783 784 issue.init_journal(User.current)
784 785 issue.fixed_version = nil
785 786 issue.save
786 787 end
787 788 end
788 789 end
789 790
790 791 # Callback on attachment deletion
791 792 def attachment_removed(obj)
792 793 journal = init_journal(User.current)
793 794 journal.details << JournalDetail.new(:property => 'attachment',
794 795 :prop_key => obj.id,
795 796 :old_value => obj.filename)
796 797 journal.save
797 798 end
798 799
799 800 # Default assignment based on category
800 801 def default_assign
801 802 if assigned_to.nil? && category && category.assigned_to
802 803 self.assigned_to = category.assigned_to
803 804 end
804 805 end
805 806
806 807 # Updates start/due dates of following issues
807 808 def reschedule_following_issues
808 809 if start_date_changed? || due_date_changed?
809 810 relations_from.each do |relation|
810 811 relation.set_issue_to_dates
811 812 end
812 813 end
813 814 end
814 815
815 816 # Closes duplicates if the issue is being closed
816 817 def close_duplicates
817 818 if closing?
818 819 duplicates.each do |duplicate|
819 820 # Reload is need in case the duplicate was updated by a previous duplicate
820 821 duplicate.reload
821 822 # Don't re-close it if it's already closed
822 823 next if duplicate.closed?
823 824 # Same user and notes
824 825 if @current_journal
825 826 duplicate.init_journal(@current_journal.user, @current_journal.notes)
826 827 end
827 828 duplicate.update_attribute :status, self.status
828 829 end
829 830 end
830 831 end
831 832
832 833 # Saves the changes in a Journal
833 834 # Called after_save
834 835 def create_journal
835 836 if @current_journal
836 837 # attributes changes
837 838 (Issue.column_names - %w(id description root_id lft rgt lock_version created_on updated_on)).each {|c|
838 839 @current_journal.details << JournalDetail.new(:property => 'attr',
839 840 :prop_key => c,
840 841 :old_value => @issue_before_change.send(c),
841 842 :value => send(c)) unless send(c)==@issue_before_change.send(c)
842 843 }
843 844 # custom fields changes
844 845 custom_values.each {|c|
845 846 next if (@custom_values_before_change[c.custom_field_id]==c.value ||
846 847 (@custom_values_before_change[c.custom_field_id].blank? && c.value.blank?))
847 848 @current_journal.details << JournalDetail.new(:property => 'cf',
848 849 :prop_key => c.custom_field_id,
849 850 :old_value => @custom_values_before_change[c.custom_field_id],
850 851 :value => c.value)
851 852 }
852 853 @current_journal.save
853 854 # reset current journal
854 855 init_journal @current_journal.user, @current_journal.notes
855 856 end
856 857 end
857 858
858 859 # Query generator for selecting groups of issue counts for a project
859 860 # based on specific criteria
860 861 #
861 862 # Options
862 863 # * project - Project to search in.
863 864 # * field - String. Issue field to key off of in the grouping.
864 865 # * joins - String. The table name to join against.
865 866 def self.count_and_group_by(options)
866 867 project = options.delete(:project)
867 868 select_field = options.delete(:field)
868 869 joins = options.delete(:joins)
869 870
870 871 where = "i.#{select_field}=j.id"
871 872
872 873 ActiveRecord::Base.connection.select_all("select s.id as status_id,
873 874 s.is_closed as closed,
874 875 j.id as #{select_field},
875 876 count(i.id) as total
876 877 from
877 878 #{Issue.table_name} i, #{IssueStatus.table_name} s, #{joins} j
878 879 where
879 880 i.status_id=s.id
880 881 and #{where}
881 882 and i.project_id=#{project.id}
882 883 group by s.id, s.is_closed, j.id")
883 884 end
884 885
885 886
886 887 end
@@ -1,246 +1,246
1 1 ---
2 2 issues_001:
3 3 created_on: <%= 3.days.ago.to_date.to_s(:db) %>
4 4 project_id: 1
5 5 updated_on: <%= 1.day.ago.to_date.to_s(:db) %>
6 6 priority_id: 4
7 7 subject: Can't print recipes
8 8 id: 1
9 9 fixed_version_id:
10 10 category_id: 1
11 11 description: Unable to print recipes
12 12 tracker_id: 1
13 13 assigned_to_id:
14 14 author_id: 2
15 15 status_id: 1
16 16 start_date: <%= 1.day.ago.to_date.to_s(:db) %>
17 17 due_date: <%= 10.day.from_now.to_date.to_s(:db) %>
18 18 root_id: 1
19 19 lft: 1
20 20 rgt: 2
21 21 issues_002:
22 22 created_on: 2006-07-19 21:04:21 +02:00
23 23 project_id: 1
24 24 updated_on: 2006-07-19 21:09:50 +02:00
25 25 priority_id: 5
26 26 subject: Add ingredients categories
27 27 id: 2
28 28 fixed_version_id: 2
29 29 category_id:
30 30 description: Ingredients of the recipe should be classified by categories
31 31 tracker_id: 2
32 32 assigned_to_id: 3
33 33 author_id: 2
34 34 status_id: 2
35 35 start_date: <%= 2.day.ago.to_date.to_s(:db) %>
36 36 due_date:
37 37 root_id: 2
38 38 lft: 1
39 39 rgt: 2
40 40 lock_version: 3
41 41 done_ratio: 30
42 42 issues_003:
43 43 created_on: 2006-07-19 21:07:27 +02:00
44 44 project_id: 1
45 45 updated_on: 2006-07-19 21:07:27 +02:00
46 46 priority_id: 4
47 47 subject: Error 281 when updating a recipe
48 48 id: 3
49 49 fixed_version_id:
50 50 category_id:
51 51 description: Error 281 is encountered when saving a recipe
52 52 tracker_id: 1
53 53 assigned_to_id: 3
54 54 author_id: 2
55 55 status_id: 1
56 56 start_date: <%= 1.day.from_now.to_date.to_s(:db) %>
57 due_date: <%= 40.day.ago.to_date.to_s(:db) %>
57 due_date: <%= 40.day.from_now.to_date.to_s(:db) %>
58 58 root_id: 3
59 59 lft: 1
60 60 rgt: 2
61 61 issues_004:
62 62 created_on: <%= 5.days.ago.to_date.to_s(:db) %>
63 63 project_id: 2
64 64 updated_on: <%= 2.days.ago.to_date.to_s(:db) %>
65 65 priority_id: 4
66 66 subject: Issue on project 2
67 67 id: 4
68 68 fixed_version_id:
69 69 category_id:
70 70 description: Issue on project 2
71 71 tracker_id: 1
72 72 assigned_to_id: 2
73 73 author_id: 2
74 74 status_id: 1
75 75 root_id: 4
76 76 lft: 1
77 77 rgt: 2
78 78 issues_005:
79 79 created_on: <%= 5.days.ago.to_date.to_s(:db) %>
80 80 project_id: 3
81 81 updated_on: <%= 2.days.ago.to_date.to_s(:db) %>
82 82 priority_id: 4
83 83 subject: Subproject issue
84 84 id: 5
85 85 fixed_version_id:
86 86 category_id:
87 87 description: This is an issue on a cookbook subproject
88 88 tracker_id: 1
89 89 assigned_to_id:
90 90 author_id: 2
91 91 status_id: 1
92 92 root_id: 5
93 93 lft: 1
94 94 rgt: 2
95 95 issues_006:
96 96 created_on: <%= 1.minute.ago.to_date.to_s(:db) %>
97 97 project_id: 5
98 98 updated_on: <%= 1.minute.ago.to_date.to_s(:db) %>
99 99 priority_id: 4
100 100 subject: Issue of a private subproject
101 101 id: 6
102 102 fixed_version_id:
103 103 category_id:
104 104 description: This is an issue of a private subproject of cookbook
105 105 tracker_id: 1
106 106 assigned_to_id:
107 107 author_id: 2
108 108 status_id: 1
109 109 start_date: <%= Date.today.to_s(:db) %>
110 110 due_date: <%= 1.days.from_now.to_date.to_s(:db) %>
111 111 root_id: 6
112 112 lft: 1
113 113 rgt: 2
114 114 issues_007:
115 115 created_on: <%= 10.days.ago.to_date.to_s(:db) %>
116 116 project_id: 1
117 117 updated_on: <%= 10.days.ago.to_date.to_s(:db) %>
118 118 priority_id: 5
119 119 subject: Issue due today
120 120 id: 7
121 121 fixed_version_id:
122 122 category_id:
123 123 description: This is an issue that is due today
124 124 tracker_id: 1
125 125 assigned_to_id:
126 126 author_id: 2
127 127 status_id: 1
128 128 start_date: <%= 10.days.ago.to_s(:db) %>
129 129 due_date: <%= Date.today.to_s(:db) %>
130 130 lock_version: 0
131 131 root_id: 7
132 132 lft: 1
133 133 rgt: 2
134 134 issues_008:
135 135 created_on: <%= 10.days.ago.to_date.to_s(:db) %>
136 136 project_id: 1
137 137 updated_on: <%= 10.days.ago.to_date.to_s(:db) %>
138 138 priority_id: 5
139 139 subject: Closed issue
140 140 id: 8
141 141 fixed_version_id:
142 142 category_id:
143 143 description: This is a closed issue.
144 144 tracker_id: 1
145 145 assigned_to_id:
146 146 author_id: 2
147 147 status_id: 5
148 148 start_date:
149 149 due_date:
150 150 lock_version: 0
151 151 root_id: 8
152 152 lft: 1
153 153 rgt: 2
154 154 issues_009:
155 155 created_on: <%= 1.minute.ago.to_date.to_s(:db) %>
156 156 project_id: 5
157 157 updated_on: <%= 1.minute.ago.to_date.to_s(:db) %>
158 158 priority_id: 5
159 159 subject: Blocked Issue
160 160 id: 9
161 161 fixed_version_id:
162 162 category_id:
163 163 description: This is an issue that is blocked by issue #10
164 164 tracker_id: 1
165 165 assigned_to_id:
166 166 author_id: 2
167 167 status_id: 1
168 168 start_date: <%= Date.today.to_s(:db) %>
169 169 due_date: <%= 1.days.from_now.to_date.to_s(:db) %>
170 170 root_id: 9
171 171 lft: 1
172 172 rgt: 2
173 173 issues_010:
174 174 created_on: <%= 1.minute.ago.to_date.to_s(:db) %>
175 175 project_id: 5
176 176 updated_on: <%= 1.minute.ago.to_date.to_s(:db) %>
177 177 priority_id: 5
178 178 subject: Issue Doing the Blocking
179 179 id: 10
180 180 fixed_version_id:
181 181 category_id:
182 182 description: This is an issue that blocks issue #9
183 183 tracker_id: 1
184 184 assigned_to_id:
185 185 author_id: 2
186 186 status_id: 1
187 187 start_date: <%= Date.today.to_s(:db) %>
188 188 due_date: <%= 1.days.from_now.to_date.to_s(:db) %>
189 189 root_id: 10
190 190 lft: 1
191 191 rgt: 2
192 192 issues_011:
193 193 created_on: <%= 3.days.ago.to_date.to_s(:db) %>
194 194 project_id: 1
195 195 updated_on: <%= 1.day.ago.to_date.to_s(:db) %>
196 196 priority_id: 5
197 197 subject: Closed issue on a closed version
198 198 id: 11
199 199 fixed_version_id: 1
200 200 category_id: 1
201 201 description:
202 202 tracker_id: 1
203 203 assigned_to_id:
204 204 author_id: 2
205 205 status_id: 5
206 206 start_date: <%= 1.day.ago.to_date.to_s(:db) %>
207 207 due_date:
208 208 root_id: 11
209 209 lft: 1
210 210 rgt: 2
211 211 issues_012:
212 212 created_on: <%= 3.days.ago.to_date.to_s(:db) %>
213 213 project_id: 1
214 214 updated_on: <%= 1.day.ago.to_date.to_s(:db) %>
215 215 priority_id: 5
216 216 subject: Closed issue on a locked version
217 217 id: 12
218 218 fixed_version_id: 2
219 219 category_id: 1
220 220 description:
221 221 tracker_id: 1
222 222 assigned_to_id:
223 223 author_id: 3
224 224 status_id: 5
225 225 start_date: <%= 1.day.ago.to_date.to_s(:db) %>
226 226 due_date:
227 227 root_id: 12
228 228 lft: 1
229 229 rgt: 2
230 230 issues_013:
231 231 created_on: <%= 5.days.ago.to_date.to_s(:db) %>
232 232 project_id: 3
233 233 updated_on: <%= 2.days.ago.to_date.to_s(:db) %>
234 234 priority_id: 4
235 235 subject: Subproject issue two
236 236 id: 13
237 237 fixed_version_id:
238 238 category_id:
239 239 description: This is a second issue on a cookbook subproject
240 240 tracker_id: 1
241 241 assigned_to_id:
242 242 author_id: 2
243 243 status_id: 1
244 244 root_id: 13
245 245 lft: 1
246 246 rgt: 2
@@ -1,420 +1,437
1 1 # Redmine - project management software
2 2 # Copyright (C) 2006-2010 Jean-Philippe Lang
3 3 #
4 4 # This program is free software; you can redistribute it and/or
5 5 # modify it under the terms of the GNU General Public License
6 6 # as published by the Free Software Foundation; either version 2
7 7 # of the License, or (at your option) any later version.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU General Public License
15 15 # along with this program; if not, write to the Free Software
16 16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17 17
18 18 require "#{File.dirname(__FILE__)}/../../test_helper"
19 19
20 20 class ApiTest::IssuesTest < ActionController::IntegrationTest
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 :versions,
29 29 :trackers,
30 30 :projects_trackers,
31 31 :issue_categories,
32 32 :enabled_modules,
33 33 :enumerations,
34 34 :attachments,
35 35 :workflows,
36 36 :custom_fields,
37 37 :custom_values,
38 38 :custom_fields_projects,
39 39 :custom_fields_trackers,
40 40 :time_entries,
41 41 :journals,
42 42 :journal_details,
43 43 :queries
44 44
45 45 def setup
46 46 Setting.rest_api_enabled = '1'
47 47 end
48 48
49 49 # Use a private project to make sure auth is really working and not just
50 50 # only showing public issues.
51 51 context "/index.xml" do
52 52 should_allow_api_authentication(:get, "/projects/private-child/issues.xml")
53 53 end
54 54
55 55 context "/index.json" do
56 56 should_allow_api_authentication(:get, "/projects/private-child/issues.json")
57 57 end
58 58
59 59 context "/index.xml with filter" do
60 60 should_allow_api_authentication(:get, "/projects/private-child/issues.xml?status_id=5")
61 61
62 62 should "show only issues with the status_id" do
63 63 get '/issues.xml?status_id=5'
64 64 assert_tag :tag => 'issues',
65 65 :children => { :count => Issue.visible.count(:conditions => {:status_id => 5}),
66 66 :only => { :tag => 'issue' } }
67 67 end
68 68 end
69 69
70 70 context "/index.json with filter" do
71 71 should_allow_api_authentication(:get, "/projects/private-child/issues.json?status_id=5")
72 72
73 73 should "show only issues with the status_id" do
74 74 get '/issues.json?status_id=5'
75 75
76 76 json = ActiveSupport::JSON.decode(response.body)
77 77 status_ids_used = json['issues'].collect {|j| j['status']['id'] }
78 78 assert_equal 3, status_ids_used.length
79 79 assert status_ids_used.all? {|id| id == 5 }
80 80 end
81 81
82 82 end
83 83
84 84 # Issue 6 is on a private project
85 85 context "/issues/6.xml" do
86 86 should_allow_api_authentication(:get, "/issues/6.xml")
87 87 end
88 88
89 89 context "/issues/6.json" do
90 90 should_allow_api_authentication(:get, "/issues/6.json")
91 91 end
92 92
93 93 context "GET /issues/:id" do
94 94 context "with custom fields" do
95 95 context ".xml" do
96 96 should "display custom fields" do
97 97 get '/issues/3.xml'
98 98
99 99 assert_tag :tag => 'issue',
100 100 :child => {
101 101 :tag => 'custom_fields',
102 102 :attributes => { :type => 'array' },
103 103 :child => {
104 104 :tag => 'custom_field',
105 105 :attributes => { :id => '1'},
106 106 :child => {
107 107 :tag => 'value',
108 108 :content => 'MySQL'
109 109 }
110 110 }
111 111 }
112 112
113 113 assert_nothing_raised do
114 114 Hash.from_xml(response.body).to_xml
115 115 end
116 116 end
117 117 end
118 118 end
119 119
120 120 context "with subtasks" do
121 121 setup do
122 122 @c1 = Issue.generate!(:status_id => 1, :subject => "child c1", :tracker_id => 1, :project_id => 1, :parent_issue_id => 1)
123 123 @c2 = Issue.generate!(:status_id => 1, :subject => "child c2", :tracker_id => 1, :project_id => 1, :parent_issue_id => 1)
124 124 @c3 = Issue.generate!(:status_id => 1, :subject => "child c3", :tracker_id => 1, :project_id => 1, :parent_issue_id => @c1.id)
125 125 end
126 126
127 127 context ".xml" do
128 128 should "display children" do
129 129 get '/issues/1.xml'
130 130
131 131 assert_tag :tag => 'issue',
132 132 :child => {
133 133 :tag => 'children',
134 134 :children => {:count => 2},
135 135 :child => {
136 136 :tag => 'issue',
137 137 :attributes => {:id => @c1.id.to_s},
138 138 :child => {
139 139 :tag => 'subject',
140 140 :content => 'child c1',
141 141 :sibling => {
142 142 :tag => 'children',
143 143 :children => {:count => 1},
144 144 :child => {
145 145 :tag => 'issue',
146 146 :attributes => {:id => @c3.id.to_s}
147 147 }
148 148 }
149 149 }
150 150 }
151 151 }
152 152 end
153 153
154 154 context ".json" do
155 155 should "display children" do
156 156 get '/issues/1.json'
157 157
158 158 json = ActiveSupport::JSON.decode(response.body)
159 159 assert_equal([
160 160 {
161 161 'id' => @c1.id, 'subject' => 'child c1', 'tracker' => {'id' => 1, 'name' => 'Bug'},
162 162 'children' => [{ 'id' => @c3.id, 'subject' => 'child c3', 'tracker' => {'id' => 1, 'name' => 'Bug'} }]
163 163 },
164 164 { 'id' => @c2.id, 'subject' => 'child c2', 'tracker' => {'id' => 1, 'name' => 'Bug'} }
165 165 ],
166 166 json['issue']['children'])
167 167 end
168 168 end
169 169 end
170 170 end
171 171 end
172 172
173 173 context "POST /issues.xml" do
174 174 should_allow_api_authentication(:post,
175 175 '/issues.xml',
176 176 {:issue => {:project_id => 1, :subject => 'API test', :tracker_id => 2, :status_id => 3}},
177 177 {:success_code => :created})
178 178
179 179 should "create an issue with the attributes" do
180 180 assert_difference('Issue.count') do
181 181 post '/issues.xml', {:issue => {:project_id => 1, :subject => 'API test', :tracker_id => 2, :status_id => 3}}, :authorization => credentials('jsmith')
182 182 end
183 183
184 184 issue = Issue.first(:order => 'id DESC')
185 185 assert_equal 1, issue.project_id
186 186 assert_equal 2, issue.tracker_id
187 187 assert_equal 3, issue.status_id
188 188 assert_equal 'API test', issue.subject
189 189
190 190 assert_response :created
191 191 assert_equal 'application/xml', @response.content_type
192 192 assert_tag 'issue', :child => {:tag => 'id', :content => issue.id.to_s}
193 193 end
194 194 end
195 195
196 196 context "POST /issues.xml with failure" do
197 197 should_allow_api_authentication(:post,
198 198 '/issues.xml',
199 199 {:issue => {:project_id => 1}},
200 200 {:success_code => :unprocessable_entity})
201 201
202 202 should "have an errors tag" do
203 203 assert_no_difference('Issue.count') do
204 204 post '/issues.xml', {:issue => {:project_id => 1}}, :authorization => credentials('jsmith')
205 205 end
206 206
207 207 assert_tag :errors, :child => {:tag => 'error', :content => "Subject can't be blank"}
208 208 end
209 209 end
210 210
211 211 context "POST /issues.json" do
212 212 should_allow_api_authentication(:post,
213 213 '/issues.json',
214 214 {:issue => {:project_id => 1, :subject => 'API test', :tracker_id => 2, :status_id => 3}},
215 215 {:success_code => :created})
216 216
217 217 should "create an issue with the attributes" do
218 218 assert_difference('Issue.count') do
219 219 post '/issues.json', {:issue => {:project_id => 1, :subject => 'API test', :tracker_id => 2, :status_id => 3}}, :authorization => credentials('jsmith')
220 220 end
221 221
222 222 issue = Issue.first(:order => 'id DESC')
223 223 assert_equal 1, issue.project_id
224 224 assert_equal 2, issue.tracker_id
225 225 assert_equal 3, issue.status_id
226 226 assert_equal 'API test', issue.subject
227 227 end
228 228
229 229 end
230 230
231 231 context "POST /issues.json with failure" do
232 232 should_allow_api_authentication(:post,
233 233 '/issues.json',
234 234 {:issue => {:project_id => 1}},
235 235 {:success_code => :unprocessable_entity})
236 236
237 237 should "have an errors element" do
238 238 assert_no_difference('Issue.count') do
239 239 post '/issues.json', {:issue => {:project_id => 1}}, :authorization => credentials('jsmith')
240 240 end
241 241
242 242 json = ActiveSupport::JSON.decode(response.body)
243 243 assert json['errors'].include?(['subject', "can't be blank"])
244 244 end
245 245 end
246 246
247 247 # Issue 6 is on a private project
248 248 context "PUT /issues/6.xml" do
249 249 setup do
250 250 @parameters = {:issue => {:subject => 'API update', :notes => 'A new note'}}
251 251 @headers = { :authorization => credentials('jsmith') }
252 252 end
253 253
254 254 should_allow_api_authentication(:put,
255 255 '/issues/6.xml',
256 256 {:issue => {:subject => 'API update', :notes => 'A new note'}},
257 257 {:success_code => :ok})
258 258
259 259 should "not create a new issue" do
260 260 assert_no_difference('Issue.count') do
261 261 put '/issues/6.xml', @parameters, @headers
262 262 end
263 263 end
264 264
265 265 should "create a new journal" do
266 266 assert_difference('Journal.count') do
267 267 put '/issues/6.xml', @parameters, @headers
268 268 end
269 269 end
270 270
271 271 should "add the note to the journal" do
272 272 put '/issues/6.xml', @parameters, @headers
273 273
274 274 journal = Journal.last
275 275 assert_equal "A new note", journal.notes
276 276 end
277 277
278 278 should "update the issue" do
279 279 put '/issues/6.xml', @parameters, @headers
280 280
281 281 issue = Issue.find(6)
282 282 assert_equal "API update", issue.subject
283 283 end
284 284
285 285 end
286 286
287 context "PUT /issues/3.xml with custom fields" do
288 setup do
289 @parameters = {:issue => {:custom_fields => [{'id' => '1', 'value' => 'PostgreSQL' }, {'id' => '2', 'value' => '150'}]}}
290 @headers = { :authorization => credentials('jsmith') }
291 end
292
293 should "update custom fields" do
294 assert_no_difference('Issue.count') do
295 put '/issues/3.xml', @parameters, @headers
296 end
297
298 issue = Issue.find(3)
299 assert_equal '150', issue.custom_value_for(2).value
300 assert_equal 'PostgreSQL', issue.custom_value_for(1).value
301 end
302 end
303
287 304 context "PUT /issues/6.xml with failed update" do
288 305 setup do
289 306 @parameters = {:issue => {:subject => ''}}
290 307 @headers = { :authorization => credentials('jsmith') }
291 308 end
292 309
293 310 should_allow_api_authentication(:put,
294 311 '/issues/6.xml',
295 312 {:issue => {:subject => ''}}, # Missing subject should fail
296 313 {:success_code => :unprocessable_entity})
297 314
298 315 should "not create a new issue" do
299 316 assert_no_difference('Issue.count') do
300 317 put '/issues/6.xml', @parameters, @headers
301 318 end
302 319 end
303 320
304 321 should "not create a new journal" do
305 322 assert_no_difference('Journal.count') do
306 323 put '/issues/6.xml', @parameters, @headers
307 324 end
308 325 end
309 326
310 327 should "have an errors tag" do
311 328 put '/issues/6.xml', @parameters, @headers
312 329
313 330 assert_tag :errors, :child => {:tag => 'error', :content => "Subject can't be blank"}
314 331 end
315 332 end
316 333
317 334 context "PUT /issues/6.json" do
318 335 setup do
319 336 @parameters = {:issue => {:subject => 'API update', :notes => 'A new note'}}
320 337 @headers = { :authorization => credentials('jsmith') }
321 338 end
322 339
323 340 should_allow_api_authentication(:put,
324 341 '/issues/6.json',
325 342 {:issue => {:subject => 'API update', :notes => 'A new note'}},
326 343 {:success_code => :ok})
327 344
328 345 should "not create a new issue" do
329 346 assert_no_difference('Issue.count') do
330 347 put '/issues/6.json', @parameters, @headers
331 348 end
332 349 end
333 350
334 351 should "create a new journal" do
335 352 assert_difference('Journal.count') do
336 353 put '/issues/6.json', @parameters, @headers
337 354 end
338 355 end
339 356
340 357 should "add the note to the journal" do
341 358 put '/issues/6.json', @parameters, @headers
342 359
343 360 journal = Journal.last
344 361 assert_equal "A new note", journal.notes
345 362 end
346 363
347 364 should "update the issue" do
348 365 put '/issues/6.json', @parameters, @headers
349 366
350 367 issue = Issue.find(6)
351 368 assert_equal "API update", issue.subject
352 369 end
353 370
354 371 end
355 372
356 373 context "PUT /issues/6.json with failed update" do
357 374 setup do
358 375 @parameters = {:issue => {:subject => ''}}
359 376 @headers = { :authorization => credentials('jsmith') }
360 377 end
361 378
362 379 should_allow_api_authentication(:put,
363 380 '/issues/6.json',
364 381 {:issue => {:subject => ''}}, # Missing subject should fail
365 382 {:success_code => :unprocessable_entity})
366 383
367 384 should "not create a new issue" do
368 385 assert_no_difference('Issue.count') do
369 386 put '/issues/6.json', @parameters, @headers
370 387 end
371 388 end
372 389
373 390 should "not create a new journal" do
374 391 assert_no_difference('Journal.count') do
375 392 put '/issues/6.json', @parameters, @headers
376 393 end
377 394 end
378 395
379 396 should "have an errors attribute" do
380 397 put '/issues/6.json', @parameters, @headers
381 398
382 399 json = ActiveSupport::JSON.decode(response.body)
383 400 assert json['errors'].include?(['subject', "can't be blank"])
384 401 end
385 402 end
386 403
387 404 context "DELETE /issues/1.xml" do
388 405 should_allow_api_authentication(:delete,
389 406 '/issues/6.xml',
390 407 {},
391 408 {:success_code => :ok})
392 409
393 410 should "delete the issue" do
394 411 assert_difference('Issue.count',-1) do
395 412 delete '/issues/6.xml', {}, :authorization => credentials('jsmith')
396 413 end
397 414
398 415 assert_nil Issue.find_by_id(6)
399 416 end
400 417 end
401 418
402 419 context "DELETE /issues/1.json" do
403 420 should_allow_api_authentication(:delete,
404 421 '/issues/6.json',
405 422 {},
406 423 {:success_code => :ok})
407 424
408 425 should "delete the issue" do
409 426 assert_difference('Issue.count',-1) do
410 427 delete '/issues/6.json', {}, :authorization => credentials('jsmith')
411 428 end
412 429
413 430 assert_nil Issue.find_by_id(6)
414 431 end
415 432 end
416 433
417 434 def credentials(user, password=nil)
418 435 ActionController::HttpAuthentication::Basic.encode_credentials(user, password || user)
419 436 end
420 437 end
@@ -1,96 +1,111
1 1 # redMine - project management software
2 2 # Copyright (C) 2006-2008 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 module Redmine
19 19 module Acts
20 20 module Customizable
21 21 def self.included(base)
22 22 base.extend ClassMethods
23 23 end
24 24
25 25 module ClassMethods
26 26 def acts_as_customizable(options = {})
27 27 return if self.included_modules.include?(Redmine::Acts::Customizable::InstanceMethods)
28 28 cattr_accessor :customizable_options
29 29 self.customizable_options = options
30 30 has_many :custom_values, :as => :customized,
31 31 :include => :custom_field,
32 32 :order => "#{CustomField.table_name}.position",
33 33 :dependent => :delete_all
34 34 before_validation_on_create { |customized| customized.custom_field_values }
35 35 # Trigger validation only if custom values were changed
36 36 validates_associated :custom_values, :on => :update, :if => Proc.new { |customized| customized.custom_field_values_changed? }
37 37 send :include, Redmine::Acts::Customizable::InstanceMethods
38 38 # Save custom values when saving the customized object
39 39 after_save :save_custom_field_values
40 40 end
41 41 end
42 42
43 43 module InstanceMethods
44 44 def self.included(base)
45 45 base.extend ClassMethods
46 46 end
47 47
48 48 def available_custom_fields
49 49 CustomField.find(:all, :conditions => "type = '#{self.class.name}CustomField'",
50 50 :order => 'position')
51 51 end
52 52
53 # Sets the values of the object's custom fields
54 # values is an array like [{'id' => 1, 'value' => 'foo'}, {'id' => 2, 'value' => 'bar'}]
55 def custom_fields=(values)
56 values_to_hash = values.inject({}) do |hash, v|
57 v = v.stringify_keys
58 if v['id'] && v.has_key?('value')
59 hash[v['id']] = v['value']
60 end
61 hash
62 end
63 self.custom_field_values = values_to_hash
64 end
65
66 # Sets the values of the object's custom fields
67 # values is a hash like {'1' => 'foo', 2 => 'bar'}
53 68 def custom_field_values=(values)
54 69 @custom_field_values_changed = true
55 70 values = values.stringify_keys
56 71 custom_field_values.each do |custom_value|
57 72 custom_value.value = values[custom_value.custom_field_id.to_s] if values.has_key?(custom_value.custom_field_id.to_s)
58 73 end if values.is_a?(Hash)
59 74 end
60 75
61 76 def custom_field_values
62 77 @custom_field_values ||= available_custom_fields.collect { |x| custom_values.detect { |v| v.custom_field == x } || custom_values.build(:custom_field => x, :value => nil) }
63 78 end
64 79
65 80 def visible_custom_field_values
66 81 custom_field_values.select(&:visible?)
67 82 end
68 83
69 84 def custom_field_values_changed?
70 85 @custom_field_values_changed == true
71 86 end
72 87
73 88 def custom_value_for(c)
74 89 field_id = (c.is_a?(CustomField) ? c.id : c.to_i)
75 90 custom_values.detect {|v| v.custom_field_id == field_id }
76 91 end
77 92
78 93 def save_custom_field_values
79 94 custom_field_values.each(&:save)
80 95 @custom_field_values_changed = false
81 96 @custom_field_values = nil
82 97 end
83 98
84 99 def reset_custom_values!
85 100 @custom_field_values = nil
86 101 @custom_field_values_changed = true
87 102 values = custom_values.inject({}) {|h,v| h[v.custom_field_id] = v.value; h}
88 103 custom_values.each {|cv| cv.destroy unless custom_field_values.include?(cv)}
89 104 end
90 105
91 106 module ClassMethods
92 107 end
93 108 end
94 109 end
95 110 end
96 111 end
General Comments 0
You need to be logged in to leave comments. Login now