issue.rb

sunday walker, 2008-02-11 14:16

Download (10.9 KB)

 
1
# redMine - project management software
2
# Copyright (C) 2006-2007  Jean-Philippe Lang
3
#
4
# This program is free software; you can redistribute it and/or
5
# modify it under the terms of the GNU General Public License
6
# as published by the Free Software Foundation; either version 2
7
# of the License, or (at your option) any later version.
8
# 
9
# This program is distributed in the hope that it will be useful,
10
# but WITHOUT ANY WARRANTY; without even the implied warranty of
11
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
12
# GNU General Public License for more details.
13
# 
14
# You should have received a copy of the GNU General Public License
15
# along with this program; if not, write to the Free Software
16
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
17

    
18
class Issue < ActiveRecord::Base
19
  belongs_to :project
20
  belongs_to :tracker
21
  belongs_to :status, :class_name => 'IssueStatus', :foreign_key => 'status_id'
22
  belongs_to :author, :class_name => 'User', :foreign_key => 'author_id'
23
  belongs_to :assigned_to, :class_name => 'User', :foreign_key => 'assigned_to_id'
24
  belongs_to :fixed_version, :class_name => 'Version', :foreign_key => 'fixed_version_id'
25
  belongs_to :priority, :class_name => 'Enumeration', :foreign_key => 'priority_id'
26
  belongs_to :category, :class_name => 'IssueCategory', :foreign_key => 'category_id'
27

    
28
  has_many :journals, :as => :journalized, :dependent => :destroy
29
  has_many :attachments, :as => :container, :dependent => :destroy
30
  has_many :time_entries, :dependent => :nullify
31
  has_many :custom_values, :dependent => :delete_all, :as => :customized
32
  has_many :custom_fields, :through => :custom_values
33
  has_and_belongs_to_many :changesets, :order => "revision ASC"
34
  
35
  has_many :relations_from, :class_name => 'IssueRelation', :foreign_key => 'issue_from_id', :dependent => :delete_all
36
  has_many :relations_to, :class_name => 'IssueRelation', :foreign_key => 'issue_to_id', :dependent => :delete_all
37
  
38
  acts_as_watchable
39
  acts_as_searchable :columns => ['subject', 'description'], :with => {:journal => :issue}
40
  acts_as_event :title => Proc.new {|o| "#{o.tracker.name} ##{o.id}: #{o.subject}"},
41
                :url => Proc.new {|o| {:controller => 'issues', :action => 'show', :id => o.id}}                
42
  
43
  validates_presence_of :subject, :description, :priority, :project, :tracker, :author, :status
44
  validates_length_of :subject, :maximum => 255
45
  validates_inclusion_of :done_ratio, :in => 0..100
46
  validates_numericality_of :estimated_hours, :allow_nil => true
47
  validates_associated :custom_values, :on => :update
48

    
49
  def after_initialize
50
    if new_record?
51
      # set default values for new records only
52
      self.status ||= IssueStatus.default
53
      self.priority ||= Enumeration.default('IPRI')
54
    end
55
  end
56
  
57
  def copy_from(arg)
58
    issue = arg.is_a?(Issue) ? arg : Issue.find(arg)
59
    self.attributes = issue.attributes.dup
60
    self.custom_values = issue.custom_values.collect {|v| v.clone}
61
    self
62
  end
63
  
64
  # Move an issue to a new project and tracker
65
  def move_to(new_project, new_tracker = nil)
66
    transaction do
67
      if new_project && project_id != new_project.id
68
        # delete issue relations
69
        self.relations_from.clear
70
        self.relations_to.clear
71
        # issue is moved to another project
72
        self.category = nil 
73
        self.fixed_version = nil
74
        self.project = new_project
75
      end
76
      if new_tracker
77
        self.tracker = new_tracker
78
      end
79
      if save
80
        # Manually update project_id on related time entries
81
        TimeEntry.update_all("project_id = #{new_project.id}", {:issue_id => id})
82
      else
83
        rollback_db_transaction
84
        return false
85
      end
86
    end
87
    return true
88
  end
89
  
90
  def priority_id=(pid)
91
    self.priority = nil
92
    write_attribute(:priority_id, pid)
93
  end
94

    
95
  def validate
96
    if self.due_date.nil? && @attributes['due_date'] && !@attributes['due_date'].empty?
97
      errors.add :due_date, :activerecord_error_not_a_date
98
    end
99
    
100
    if self.due_date and self.start_date and self.due_date < self.start_date
101
      errors.add :due_date, :activerecord_error_greater_than_start_date
102
    end
103
    
104
    if start_date && soonest_start && start_date < soonest_start
105
      errors.add :start_date, :activerecord_error_invalid
106
    end
107
  end
108
  
109
  def validate_on_create
110
    errors.add :tracker_id, :activerecord_error_invalid unless project.trackers.include?(tracker)
111
  end
112
  
113
  def before_create
114
    # default assignment based on category
115
    if assigned_to.nil? && category && category.assigned_to
116
      self.assigned_to = category.assigned_to
117
    end
118
  end
119
  
120
  def before_save  
121
    if @current_journal
122
      # attributes changes
123
      (Issue.column_names - %w(id description)).each {|c|
124
        @current_journal.details << JournalDetail.new(:property => 'attr',
125
                                                      :prop_key => c,
126
                                                      :old_value => @issue_before_change.send(c),
127
                                                      :value => send(c)) unless send(c)==@issue_before_change.send(c)
128
      }
129
      # custom fields changes
130
      custom_values.each {|c|
131
        next if (@custom_values_before_change[c.custom_field_id]==c.value ||
132
                  (@custom_values_before_change[c.custom_field_id].blank? && c.value.blank?))
133
        @current_journal.details << JournalDetail.new(:property => 'cf', 
134
                                                      :prop_key => c.custom_field_id,
135
                                                      :old_value => @custom_values_before_change[c.custom_field_id],
136
                                                      :value => c.value)
137
      }      
138
      @current_journal.save
139
    end
140
    # Save the issue even if the journal is not saved (because empty)
141
    true
142
  end
143
  
144
  def after_save
145
    # Update start/due dates of following issues
146
    relations_from.each(&:set_issue_to_dates)
147
    
148
    # Close duplicates if the issue was closed
149
    if @issue_before_change && !@issue_before_change.closed? && self.closed?
150
      duplicates.each do |duplicate|
151
        # Don't re-close it if it's already closed
152
        next if duplicate.closed?
153
        # Same user and notes
154
        duplicate.init_journal(@current_journal.user, @current_journal.notes)
155
        duplicate.update_attribute :status, self.status
156
      end
157
    end
158
  end
159
  
160
  def custom_value_for(custom_field)
161
    self.custom_values.each {|v| return v if v.custom_field_id == custom_field.id }
162
    return nil
163
  end
164
  
165
  def init_journal(user, notes = "")
166
    @current_journal ||= Journal.new(:journalized => self, :user => user, :notes => notes)
167
    @issue_before_change = self.clone
168
    @custom_values_before_change = {}
169
    self.custom_values.each {|c| @custom_values_before_change.store c.custom_field_id, c.value }
170
    @current_journal
171
  end
172
  
173
  # Return true if the issue is closed, otherwise false
174
  def closed?
175
    self.status.is_closed?
176
  end
177
  
178
  # Users the issue can be assigned to
179
  def assignable_users
180
    project.assignable_users
181
  end
182
  
183
  # Returns the mail adresses of users that should be notified for the issue
184
  def recipients
185
    recipients = project.recipients
186
    # Author and assignee are always notified
187
    recipients << author.mail if author
188
    recipients << assigned_to.mail if assigned_to
189
    recipients.compact.uniq
190
  end
191
  
192
  def spent_hours
193
    @spent_hours ||= time_entries.sum(:hours) || 0
194
  end
195
  
196
  def relations
197
    (relations_from + relations_to).sort
198
  end
199
  
200
  def all_dependent_issues
201
    dependencies = []
202
    relations_from.each do |relation|
203
      dependencies << relation.issue_to
204
      dependencies += relation.issue_to.all_dependent_issues
205
    end
206
    dependencies
207
  end
208
  
209
  # Returns an array of the duplicate issues
210
  def duplicates
211
    relations.select {|r| r.relation_type == IssueRelation::TYPE_DUPLICATES}.collect {|r| r.other_issue(self)}
212
  end
213
  
214
  def duration
215
    (start_date && due_date) ? due_date - start_date : 0
216
  end
217
  
218
  def duration1
219
    (start_date && due_date) ? (due_date - start_date + 1) : 0
220
  end
221
  
222
  def soonest_start
223
    @soonest_start ||= relations_to.collect{|relation| relation.successor_soonest_start}.compact.min
224
  end
225
  
226
  def total(filed)
227
    if filed == 'done_ratio' || filed == 'planned_days' || filed == 'actual_days'
228
      @total_planned_days ||= self.duration1
229
      @total_actual_days ||= done_ratio ?  (@total_planned_days * done_ratio / 100).floor : 0
230
      @total_done_ratio ||= done_ratio
231
    else
232
      eval("@total_#{filed} ||= #{filed}")
233
    end
234
    if filed == 'estimated_hours' || filed == 'spent_hours'
235
      type = 'sum'
236
    elsif filed == 'start_date'
237
      type = 'min'
238
    elsif filed == 'due_date'
239
      type = 'max'
240
    elsif filed == 'priority' || filed == 'fixed_version'
241
      type = 'max_id'
242
    elsif filed == 'status'
243
      type = 'min_id'
244
    elsif filed == 'done_ratio' || filed == 'planned_days' || filed == 'actual_days'
245
      type = 'done_ratio'
246
    else
247
      return(eval("@total_#{filed}"))
248
    end
249
    if !eval("@total_#{filed}") || (eval("@total_#{filed}") == 0) || (type == 'max_id') || (type == 'min_id')
250
      relations_to.each do |relation|
251
        if relation.relation_type == IssueRelation::TYPE_PARENTS
252
          othertotal = relation.other_issue(self).total(filed)
253
          if ! eval("@total_#{filed}")
254
            eval("@total_#{filed} = othertotal")
255
          elsif type == 'done_ratio' || type == 'planned_days' || type == 'actual_days'
256
            planned_days = relation.other_issue(self).total('duration1')
257
            actual_days = relation.other_issue(self).total('done_ratio') ?  (planned_days * relation.other_issue(self).total('done_ratio') / 100).floor : 0
258
            @total_planned_days += planned_days
259
            @total_actual_days += actual_days
260
            @total_done_ratio = @total_planned_days != 0 ? (@total_actual_days * 100 / @total_planned_days).floor : 0
261
          elsif type == 'max_id'
262
            if othertotal && eval("@total_#{filed}.id") < othertotal.id
263
              eval("@total_#{filed} = othertotal")
264
            end
265
          elsif type == 'min_id'
266
            if othertotal && eval("@total_#{filed}.id") > othertotal.id
267
              eval("@total_#{filed} = othertotal")
268
            end
269
          elsif type == 'sum'
270
            if othertotal
271
              eval("@total_#{filed} += othertotal")
272
            end
273
          elsif type == "max"
274
            if othertotal && eval("@total_#{filed}") < othertotal
275
              eval("@total_#{filed} = othertotal")
276
            end
277
          elsif type == 'min'
278
            if othertotal && eval("@total_#{filed}") > othertotal
279
              eval("@total_#{filed} = othertotal")
280
            end
281
          end
282
        end
283
      end
284
    end
285
    eval("@total_#{filed}")
286
  end
287
end