Class: Issue

Inherits:
ActiveRecord::Base show all
Defined in:
app/models/issue.rb

Overview

redMine - project management software Copyright (C) 2006-2007 Jean-Philippe Lang

This program is free software; you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation; either version 2 of the License, or (at your option) any later version.

This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details.

You should have received a copy of the GNU General Public License along with this program; if not, write to the Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.

Constant Summary

DONE_RATIO_OPTIONS =
%w(issue_field issue_status)
SAFE_ATTRIBUTES =
%w(
  tracker_id
  status_id
  parent_issue_id
  category_id
  assigned_to_id
  priority_id
  fixed_version_id
  subject
  description
  start_date
  due_date
  done_ratio
  estimated_hours
  custom_field_values
) unless const_defined?(:SAFE_ATTRIBUTES)

Instance Attribute Summary

Class Method Summary

Instance Method Summary

Methods inherited from ActiveRecord::Base

quoted_table_name

Instance Attribute Details

- (Object) current_journal (readonly)

Returns the value of attribute current_journal



52
53
54
# File 'app/models/issue.rb', line 52

def current_journal
  @current_journal
end

Class Method Details

+ (Object) by_assigned_to(project)



568
569
570
571
572
# File 'app/models/issue.rb', line 568

def self.by_assigned_to(project)
  count_and_group_by(:project => project,
                     :field => 'assigned_to_id',
                     :joins => User.table_name)
end

+ (Object) by_author(project)



574
575
576
577
578
# File 'app/models/issue.rb', line 574

def self.by_author(project)
  count_and_group_by(:project => project,
                     :field => 'author_id',
                     :joins => User.table_name)
end

+ (Object) by_category(project)



562
563
564
565
566
# File 'app/models/issue.rb', line 562

def self.by_category(project)
  count_and_group_by(:project => project,
                     :field => 'category_id',
                     :joins => IssueCategory.table_name)
end

+ (Object) by_priority(project)



556
557
558
559
560
# File 'app/models/issue.rb', line 556

def self.by_priority(project)
  count_and_group_by(:project => project,
                     :field => 'priority_id',
                     :joins => IssuePriority.table_name)
end

+ (Object) by_subproject(project)



580
581
582
583
584
585
586
587
588
589
590
591
# File 'app/models/issue.rb', line 580

def self.by_subproject(project)
  ActiveRecord::Base.connection.select_all("select    s.id as status_id, 
                                              s.is_closed as closed, 
                                              i.project_id as project_id,
                                              count(i.id) as total 
                                            from 
                                              #{Issue.table_name} i, #{IssueStatus.table_name} s
                                            where 
                                              i.status_id=s.id 
                                              and i.project_id IN (#{project.descendants.active.collect{|p| p.id}.join(',')})
                                            group by s.id, s.is_closed, i.project_id") if project.descendants.active.any?
end

+ (Object) by_tracker(project)

Extracted from the ReportsController.



544
545
546
547
548
# File 'app/models/issue.rb', line 544

def self.by_tracker(project)
  count_and_group_by(:project => project,
                     :field => 'tracker_id',
                     :joins => Tracker.table_name)
end

+ (Object) by_version(project)



550
551
552
553
554
# File 'app/models/issue.rb', line 550

def self.by_version(project)
  count_and_group_by(:project => project,
                     :field => 'fixed_version_id',
                     :joins => Version.table_name)
end

+ (Object) update_versions_from_hierarchy_change(project)

Unassigns issues from versions that are no longer shared after project was moved



519
520
521
522
523
# File 'app/models/issue.rb', line 519

def self.update_versions_from_hierarchy_change(project)
  moved_project_ids = project.self_and_descendants.reload.collect(&:id)
  # Update issues of the moved projects and issues assigned to a version of a moved project
  Issue.update_versions(["#{Version.table_name}.project_id IN (?) OR #{Issue.table_name}.project_id IN (?)", moved_project_ids, moved_project_ids])
end

+ (Object) update_versions_from_sharing_change(version)

Unassigns issues from version if it’s no longer shared with issue’s project



512
513
514
515
# File 'app/models/issue.rb', line 512

def self.update_versions_from_sharing_change(version)
  # Update issues assigned to the version
  update_versions(["#{Issue.table_name}.fixed_version_id = ?", version.id])
end

+ (Boolean) use_field_for_done_ratio?

Returns:

  • (Boolean)


258
259
260
# File 'app/models/issue.rb', line 258

def self.use_field_for_done_ratio?
  Setting.issue_done_ratio == 'issue_field'
end

+ (Boolean) use_status_for_done_ratio?

Returns:

  • (Boolean)


254
255
256
# File 'app/models/issue.rb', line 254

def self.use_status_for_done_ratio?
  Setting.issue_done_ratio == 'issue_status'
end

Instance Method Details

- (Object) <=>(issue)



459
460
461
462
463
464
465
466
467
# File 'app/models/issue.rb', line 459

def <=>(issue)
  if issue.nil?
    -1
  elsif root_id != issue.root_id
    (root_id || 0) <=> (issue.root_id || 0)
  else
    (lft || 0) <=> (issue.lft || 0)
  end
end

- (Object) after_initialize



81
82
83
84
85
86
87
# File 'app/models/issue.rb', line 81

def after_initialize
  if new_record?
    # set default values for new records only
    self.status ||= IssueStatus.default
    self.priority ||= IssuePriority.default
  end
end

- (Object) all_dependent_issues



409
410
411
412
413
414
415
416
# File 'app/models/issue.rb', line 409

def all_dependent_issues
  dependencies = []
  relations_from.each do |relation|
    dependencies << relation.issue_to
    dependencies += relation.issue_to.all_dependent_issues
  end
  dependencies
end

- (Object) assignable_users

Users the issue can be assigned to



361
362
363
# File 'app/models/issue.rb', line 361

def assignable_users
  project.assignable_users
end

- (Object) assignable_versions

Versions that the issue can be assigned to



366
367
368
# File 'app/models/issue.rb', line 366

def assignable_versions
  @assignable_versions ||= (project.shared_versions.open + [Version.find_by_id(fixed_version_id_was)]).compact.uniq.sort
end

- (Object) attributes_with_tracker_first=(new_attributes, *args)

Overrides attributes= so that tracker_id gets assigned first



185
186
187
188
189
190
191
192
# File 'app/models/issue.rb', line 185

def attributes_with_tracker_first=(new_attributes, *args)
  return if new_attributes.nil?
  new_tracker_id = new_attributes['tracker_id'] || new_attributes[:tracker_id]
  if new_tracker_id
    self.tracker_id = new_tracker_id
  end
  send :attributes_without_tracker_first=, new_attributes, *args
end

- (Object) available_custom_fields

Overrides Redmine::Acts::Customizable::InstanceMethods#available_custom_fields



90
91
92
# File 'app/models/issue.rb', line 90

def available_custom_fields
  (project && tracker) ? project.all_issue_custom_fields.select {|c| tracker.custom_fields.include? c } : []
end

- (Boolean) blocked?

Returns true if this issue is blocked by another issue that is still open

Returns:

  • (Boolean)


371
372
373
# File 'app/models/issue.rb', line 371

def blocked?
  !relations_to.detect {|ir| ir.relation_type == 'blocks' && !ir.issue_from.closed?}.nil?
end

- (Boolean) closed?

Return true if the issue is closed, otherwise false

Returns:

  • (Boolean)


327
328
329
# File 'app/models/issue.rb', line 327

def closed?
  self.status.is_closed?
end

- (Boolean) closing?

Return true if the issue is being closed

Returns:

  • (Boolean)


344
345
346
347
348
349
350
351
352
353
# File 'app/models/issue.rb', line 344

def closing?
  if !new_record? && status_id_changed?
    status_was = IssueStatus.find_by_id(status_id_was)
    status_new = IssueStatus.find_by_id(status_id)
    if status_was && status_new && !status_was.is_closed? && status_new.is_closed?
      return true
    end
  end
  false
end

- (Object) copy_from(arg)



94
95
96
97
98
99
100
# File 'app/models/issue.rb', line 94

def copy_from(arg)
  issue = arg.is_a?(Issue) ? arg : Issue.find(arg)
  self.attributes = issue.attributes.dup.except("id", "root_id", "parent_id", "lft", "rgt", "created_on", "updated_on")
  self.custom_field_values = issue.custom_field_values.inject({}) {|h,v| h[v.custom_field_id] = v.value; h}
  self.status = issue.status
  self
end

- (Object) css_classes

Returns a string of css classes that apply to the issue



474
475
476
477
478
479
480
481
# File 'app/models/issue.rb', line 474

def css_classes
  s = "issue status-#{status.position} priority-#{priority.position}"
  s << ' closed' if closed?
  s << ' overdue' if overdue?
  s << ' created-by-me' if User.current.logged? && author_id == User.current.id
  s << ' assigned-to-me' if User.current.logged? && assigned_to_id == User.current.id
  s
end

- (Object) done_ratio



246
247
248
249
250
251
252
# File 'app/models/issue.rb', line 246

def done_ratio
  if Issue.use_status_for_done_ratio? && status && status.default_done_ratio?
    status.default_done_ratio
  else
    read_attribute(:done_ratio)
  end
end

- (Object) due_before

Returns the due date or the target due date if any Used on gantt chart



425
426
427
# File 'app/models/issue.rb', line 425

def due_before
  due_date || (fixed_version ? fixed_version.effective_date : nil)
end

- (Object) duplicates

Returns an array of issues that duplicate this one



419
420
421
# File 'app/models/issue.rb', line 419

def duplicates
  relations_to.select {|r| r.relation_type == IssueRelation::TYPE_DUPLICATES}.collect {|r| r.issue_from}
end

- (Object) duration

Returns the time scheduled for this issue.

Example:

  Start Date: 2/26/09, End Date: 3/04/09
  duration => 6


434
435
436
# File 'app/models/issue.rb', line 434

def duration
  (start_date && due_date) ? due_date - start_date : 0
end

- (Object) estimated_hours=(h)



196
197
198
# File 'app/models/issue.rb', line 196

def estimated_hours=(h)
  write_attribute :estimated_hours, (h.is_a?(String) ? h.to_hours : h)
end

- (Object) init_journal(user, notes = "")



315
316
317
318
319
320
321
322
323
324
# File 'app/models/issue.rb', line 315

def init_journal(user, notes = "")
  @current_journal ||= Journal.new(:journalized => self, :user => user, :notes => notes)
  @issue_before_change = self.clone
  @issue_before_change.status = self.status
  @custom_values_before_change = {}
  self.custom_values.each {|c| @custom_values_before_change.store c.custom_field_id, c.value }
  # Make sure updated_on is updated when adding a note.
  updated_on_will_change!
  @current_journal
end

- (Object) move_to_project(*args)

Moves/copies an issue to a new project and tracker Returns the moved/copied issue on success, false on failure



104
105
106
107
108
# File 'app/models/issue.rb', line 104

def move_to_project(*args)
  ret = Issue.transaction do
    move_to_project_without_transaction(*args) || raise(ActiveRecord::Rollback)
  end || false
end

- (Object) move_to_project_without_transaction(new_project, new_tracker = nil, options = {})



110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
# File 'app/models/issue.rb', line 110

def move_to_project_without_transaction(new_project, new_tracker = nil, options = {})
  options ||= {}
  issue = options[:copy] ? self.class.new.copy_from(self) : self
  
  if new_project && issue.project_id != new_project.id
    # delete issue relations
    unless Setting.cross_project_issue_relations?
      issue.relations_from.clear
      issue.relations_to.clear
    end
    # issue is moved to another project
    # reassign to the category with same name if any
    new_category = issue.category.nil? ? nil : new_project.issue_categories.find_by_name(issue.category.name)
    issue.category = new_category
    # Keep the fixed_version if it's still valid in the new_project
    unless new_project.shared_versions.include?(issue.fixed_version)
      issue.fixed_version = nil
    end
    issue.project = new_project
    if issue.parent && issue.parent.project_id != issue.project_id
      issue.parent_issue_id = nil
    end
  end
  if new_tracker
    issue.tracker = new_tracker
    issue.reset_custom_values!
  end
  if options[:copy]
    issue.custom_field_values = self.custom_field_values.inject({}) {|h,v| h[v.custom_field_id] = v.value; h}
    issue.status = if options[:attributes] && options[:attributes][:status_id]
                     IssueStatus.find_by_id(options[:attributes][:status_id])
                   else
                     self.status
                   end
  end
  # Allow bulk setting of attributes on the issue
  if options[:attributes]
    issue.attributes = options[:attributes]
  end
  if issue.save
    unless options[:copy]
      # Manually update project_id on related time entries
      TimeEntry.update_all("project_id = #{new_project.id}", {:issue_id => id})
      
      issue.children.each do |child|
        unless child.move_to_project_without_transaction(new_project)
          # Move failed and transaction was rollback'd
          return false
        end
      end
    end
  else
    return false
  end
  issue
end

- (Object) new_statuses_allowed_to(user, include_default = false)

Returns an array of status that user is able to apply



376
377
378
379
380
381
382
# File 'app/models/issue.rb', line 376

def new_statuses_allowed_to(user, include_default=false)
  statuses = status.find_new_statuses_allowed_to(user.roles_for_project(project), tracker)
  statuses << status unless statuses.empty?
  statuses << IssueStatus.default if include_default
  statuses = statuses.uniq.sort
  blocked? ? statuses.reject {|s| s.is_closed?} : statuses
end

- (Boolean) overdue?

Returns true if the issue is overdue

Returns:

  • (Boolean)


356
357
358
# File 'app/models/issue.rb', line 356

def overdue?
  !due_date.nil? && (due_date < Date.today) && !status.is_closed?
end

- (Object) parent_issue_id



535
536
537
538
539
540
541
# File 'app/models/issue.rb', line 535

def parent_issue_id
  if instance_variable_defined? :@parent_issue
    @parent_issue.nil? ? nil : @parent_issue.id
  else
    parent_id
  end
end

- (Object) parent_issue_id=(arg)



525
526
527
528
529
530
531
532
533
# File 'app/models/issue.rb', line 525

def parent_issue_id=(arg)
  parent_issue_id = arg.blank? ? nil : arg.to_i
  if parent_issue_id && @parent_issue = Issue.find_by_id(parent_issue_id)
    @parent_issue.id
  else
    @parent_issue = nil
    nil
  end
end

- (Object) priority_id=(pid)



172
173
174
175
# File 'app/models/issue.rb', line 172

def priority_id=(pid)
  self.priority = nil
  write_attribute(:priority_id, pid)
end

- (Object) recipients

Returns the mail adresses of users that should be notified



385
386
387
388
389
390
391
392
393
394
# File 'app/models/issue.rb', line 385

def recipients
  notified = project.notified_users
  # Author and assignee are always notified unless they have been locked
  notified << author if author && author.active?
  notified << assigned_to if assigned_to && assigned_to.active?
  notified.uniq!
  # Remove users that can not view the issue
  notified.reject! {|user| !visible?(user)}
  notified.collect(&:mail)
end

- (Object) relations



405
406
407
# File 'app/models/issue.rb', line 405

def relations
  (relations_from + relations_to).sort
end

- (Boolean) reopened?

Return true if the issue is being reopened

Returns:

  • (Boolean)


332
333
334
335
336
337
338
339
340
341
# File 'app/models/issue.rb', line 332

def reopened?
  if !new_record? && status_id_changed?
    status_was = IssueStatus.find_by_id(status_id_was)
    status_new = IssueStatus.find_by_id(status_id)
    if status_was && status_new && status_was.is_closed? && !status_new.is_closed?
      return true
    end
  end
  false
end

- (Object) reschedule_after(date)



445
446
447
448
449
450
451
452
453
454
455
456
457
# File 'app/models/issue.rb', line 445

def reschedule_after(date)
  return if date.nil?
  if leaf?
    if start_date.nil? || start_date < date
      self.start_date, self.due_date = date, date + duration
      save
    end
  else
    leaves.each do |leaf|
      leaf.reschedule_after(date)
    end
  end
end

- (Object) safe_attributes=(attrs, user = User.current)

Safely sets attributes Should be called from controllers instead of #attributes= attr_accessible is too rough because we still want things like Issue.new(:project => foo) to work TODO: move workflow/permission checks from controllers to here



222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
# File 'app/models/issue.rb', line 222

def safe_attributes=(attrs, user=User.current)
  return if attrs.nil?
  attrs = attrs.reject {|k,v| !SAFE_ATTRIBUTES.include?(k)}
  if attrs['status_id']
    unless new_statuses_allowed_to(user).collect(&:id).include?(attrs['status_id'].to_i)
      attrs.delete('status_id')
    end
  end
  
  unless leaf?
    attrs.reject! {|k,v| %w(priority_id done_ratio start_date due_date estimated_hours).include?(k)}
  end
  
  if attrs.has_key?('parent_issue_id')
    if !user.allowed_to?(:manage_subtasks, project)
      attrs.delete('parent_issue_id')
    elsif !attrs['parent_issue_id'].blank?
      attrs.delete('parent_issue_id') unless Issue.visible(user).exists?(attrs['parent_issue_id'])
    end
  end
  
  self.attributes = attrs
end

- (Object) save_issue_with_child_records(params, existing_time_entry = nil)

Saves an issue, time_entry, attachments, and a journal from the parameters



484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
# File 'app/models/issue.rb', line 484

def save_issue_with_child_records(params, existing_time_entry=nil)
  if params[:time_entry] && params[:time_entry][:hours].present? && User.current.allowed_to?(:log_time, project)
    @time_entry = existing_time_entry || TimeEntry.new
    @time_entry.project = project
    @time_entry.issue = self
    @time_entry.user = User.current
    @time_entry.spent_on = Date.today
    @time_entry.attributes = params[:time_entry]
    self.time_entries << @time_entry
  end

  if valid?
    attachments = Attachment.attach_files(self, params[:attachments])

    attachments[:files].each {|a| @current_journal.details << JournalDetail.new(:property => 'attachment', :prop_key => a.id, :value => a.filename)}
    # TODO: Rename hook
    Redmine::Hook.call_hook(:controller_issues_edit_before_save, { :params => params, :issue => self, :time_entry => @time_entry, :journal => @current_journal})
    if save
      # TODO: Rename hook
      Redmine::Hook.call_hook(:controller_issues_edit_after_save, { :params => params, :issue => self, :time_entry => @time_entry, :journal => @current_journal})
      return true
    end
  end
  # failure, returns false

end

- (Object) soonest_start



438
439
440
441
442
443
# File 'app/models/issue.rb', line 438

def soonest_start
  @soonest_start ||= (
      relations_to.collect{|relation| relation.successor_soonest_start} +
      ancestors.collect(&:soonest_start)
    ).compact.max
end

- (Object) spent_hours

Returns the total number of hours spent on this issue and its descendants

Example:

  spent_hours => 0.0
  spent_hours => 50.2


401
402
403
# File 'app/models/issue.rb', line 401

def spent_hours
  @spent_hours ||= self_and_descendants.sum("#{TimeEntry.table_name}.hours", :include => :time_entries).to_f || 0.0
end

- (Object) status_id=(sid)



167
168
169
170
# File 'app/models/issue.rb', line 167

def status_id=(sid)
  self.status = nil
  write_attribute(:status_id, sid)
end

- (Object) to_s



469
470
471
# File 'app/models/issue.rb', line 469

def to_s
  "#{tracker} ##{id}: #{subject}"
end

- (Object) tracker_id=(tid)



177
178
179
180
181
182
# File 'app/models/issue.rb', line 177

def tracker_id=(tid)
  self.tracker = nil
  result = write_attribute(:tracker_id, tid)
  @custom_field_values = nil
  result
end

- (Object) update_done_ratio_from_issue_status

Set the done_ratio using the status if that setting is set. This will keep the done_ratios even if the user turns off the setting later



309
310
311
312
313
# File 'app/models/issue.rb', line 309

def update_done_ratio_from_issue_status
  if Issue.use_status_for_done_ratio? && status && status.default_done_ratio?
    self.done_ratio = status.default_done_ratio
  end
end

- (Object) validate



262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
# File 'app/models/issue.rb', line 262

def validate
  if self.due_date.nil? && @attributes['due_date'] && !@attributes['due_date'].empty?
    errors.add :due_date, :not_a_date
  end
  
  if self.due_date and self.start_date and self.due_date < self.start_date
    errors.add :due_date, :greater_than_start_date
  end
  
  if start_date && soonest_start && start_date < soonest_start
    errors.add :start_date, :invalid
  end
  
  if fixed_version
    if !assignable_versions.include?(fixed_version)
      errors.add :fixed_version_id, :inclusion
    elsif reopened? && fixed_version.closed?
      errors.add_to_base I18n.t(:error_can_not_reopen_issue_on_closed_version)
    end
  end
  
  # Checks that the issue can not be added/moved to a disabled tracker
  if project && (tracker_id_changed? || project_id_changed?)
    unless project.trackers.include?(tracker)
      errors.add :tracker_id, :inclusion
    end
  end
  
  # Checks parent issue assignment
  if @parent_issue
    if @parent_issue.project_id != project_id
      errors.add :parent_issue_id, :not_same_project
    elsif !new_record?
      # moving an existing issue
      if @parent_issue.root_id != root_id
        # we can always move to another tree
      elsif move_possible?(@parent_issue)
        # move accepted inside tree
      else
        errors.add :parent_issue_id, :not_a_valid_parent
      end
    end
  end
end

- (Boolean) visible?(usr = nil)

Returns true if usr or current user is allowed to view the issue

Returns:

  • (Boolean)


77
78
79
# File 'app/models/issue.rb', line 77

def visible?(usr=nil)
  (usr || User.current).allowed_to?(:view_issues, self.project)
end