##// END OF EJS Templates
Added some RDoc documentation for some models....
Eric Davis -
r2536:c2dfffd7f267
parent child
Show More
@@ -1,294 +1,304
1 # redMine - project management software
1 # redMine - project management software
2 # Copyright (C) 2006-2007 Jean-Philippe Lang
2 # Copyright (C) 2006-2007 Jean-Philippe Lang
3 #
3 #
4 # This program is free software; you can redistribute it and/or
4 # This program is free software; you can redistribute it and/or
5 # modify it under the terms of the GNU General Public License
5 # modify it under the terms of the GNU General Public License
6 # as published by the Free Software Foundation; either version 2
6 # as published by the Free Software Foundation; either version 2
7 # of the License, or (at your option) any later version.
7 # of the License, or (at your option) any later version.
8 #
8 #
9 # This program is distributed in the hope that it will be useful,
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 # GNU General Public License for more details.
12 # GNU General Public License for more details.
13 #
13 #
14 # You should have received a copy of the GNU General Public License
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
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.
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17
17
18 class Issue < ActiveRecord::Base
18 class Issue < ActiveRecord::Base
19 belongs_to :project
19 belongs_to :project
20 belongs_to :tracker
20 belongs_to :tracker
21 belongs_to :status, :class_name => 'IssueStatus', :foreign_key => 'status_id'
21 belongs_to :status, :class_name => 'IssueStatus', :foreign_key => 'status_id'
22 belongs_to :author, :class_name => 'User', :foreign_key => 'author_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'
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'
24 belongs_to :fixed_version, :class_name => 'Version', :foreign_key => 'fixed_version_id'
25 belongs_to :priority, :class_name => 'Enumeration', :foreign_key => 'priority_id'
25 belongs_to :priority, :class_name => 'Enumeration', :foreign_key => 'priority_id'
26 belongs_to :category, :class_name => 'IssueCategory', :foreign_key => 'category_id'
26 belongs_to :category, :class_name => 'IssueCategory', :foreign_key => 'category_id'
27
27
28 has_many :journals, :as => :journalized, :dependent => :destroy
28 has_many :journals, :as => :journalized, :dependent => :destroy
29 has_many :time_entries, :dependent => :delete_all
29 has_many :time_entries, :dependent => :delete_all
30 has_and_belongs_to_many :changesets, :order => "#{Changeset.table_name}.committed_on ASC, #{Changeset.table_name}.id ASC"
30 has_and_belongs_to_many :changesets, :order => "#{Changeset.table_name}.committed_on ASC, #{Changeset.table_name}.id ASC"
31
31
32 has_many :relations_from, :class_name => 'IssueRelation', :foreign_key => 'issue_from_id', :dependent => :delete_all
32 has_many :relations_from, :class_name => 'IssueRelation', :foreign_key => 'issue_from_id', :dependent => :delete_all
33 has_many :relations_to, :class_name => 'IssueRelation', :foreign_key => 'issue_to_id', :dependent => :delete_all
33 has_many :relations_to, :class_name => 'IssueRelation', :foreign_key => 'issue_to_id', :dependent => :delete_all
34
34
35 acts_as_attachable :after_remove => :attachment_removed
35 acts_as_attachable :after_remove => :attachment_removed
36 acts_as_customizable
36 acts_as_customizable
37 acts_as_watchable
37 acts_as_watchable
38 acts_as_searchable :columns => ['subject', "#{table_name}.description", "#{Journal.table_name}.notes"],
38 acts_as_searchable :columns => ['subject', "#{table_name}.description", "#{Journal.table_name}.notes"],
39 :include => [:project, :journals],
39 :include => [:project, :journals],
40 # sort by id so that limited eager loading doesn't break with postgresql
40 # sort by id so that limited eager loading doesn't break with postgresql
41 :order_column => "#{table_name}.id"
41 :order_column => "#{table_name}.id"
42 acts_as_event :title => Proc.new {|o| "#{o.tracker.name} ##{o.id}: #{o.subject}"},
42 acts_as_event :title => Proc.new {|o| "#{o.tracker.name} ##{o.id}: #{o.subject}"},
43 :url => Proc.new {|o| {:controller => 'issues', :action => 'show', :id => o.id}},
43 :url => Proc.new {|o| {:controller => 'issues', :action => 'show', :id => o.id}},
44 :type => Proc.new {|o| 'issue' + (o.closed? ? ' closed' : '') }
44 :type => Proc.new {|o| 'issue' + (o.closed? ? ' closed' : '') }
45
45
46 acts_as_activity_provider :find_options => {:include => [:project, :author, :tracker]},
46 acts_as_activity_provider :find_options => {:include => [:project, :author, :tracker]},
47 :author_key => :author_id
47 :author_key => :author_id
48
48
49 validates_presence_of :subject, :priority, :project, :tracker, :author, :status
49 validates_presence_of :subject, :priority, :project, :tracker, :author, :status
50 validates_length_of :subject, :maximum => 255
50 validates_length_of :subject, :maximum => 255
51 validates_inclusion_of :done_ratio, :in => 0..100
51 validates_inclusion_of :done_ratio, :in => 0..100
52 validates_numericality_of :estimated_hours, :allow_nil => true
52 validates_numericality_of :estimated_hours, :allow_nil => true
53
53
54 named_scope :visible, lambda {|*args| { :include => :project,
54 named_scope :visible, lambda {|*args| { :include => :project,
55 :conditions => Project.allowed_to_condition(args.first || User.current, :view_issues) } }
55 :conditions => Project.allowed_to_condition(args.first || User.current, :view_issues) } }
56
56
57 named_scope :open, :conditions => ["#{IssueStatus.table_name}.is_closed = ?", false], :include => :status
57 named_scope :open, :conditions => ["#{IssueStatus.table_name}.is_closed = ?", false], :include => :status
58
58
59 # Returns true if usr or current user is allowed to view the issue
59 # Returns true if usr or current user is allowed to view the issue
60 def visible?(usr=nil)
60 def visible?(usr=nil)
61 (usr || User.current).allowed_to?(:view_issues, self.project)
61 (usr || User.current).allowed_to?(:view_issues, self.project)
62 end
62 end
63
63
64 def after_initialize
64 def after_initialize
65 if new_record?
65 if new_record?
66 # set default values for new records only
66 # set default values for new records only
67 self.status ||= IssueStatus.default
67 self.status ||= IssueStatus.default
68 self.priority ||= Enumeration.priorities.default
68 self.priority ||= Enumeration.priorities.default
69 end
69 end
70 end
70 end
71
71
72 # Overrides Redmine::Acts::Customizable::InstanceMethods#available_custom_fields
72 # Overrides Redmine::Acts::Customizable::InstanceMethods#available_custom_fields
73 def available_custom_fields
73 def available_custom_fields
74 (project && tracker) ? project.all_issue_custom_fields.select {|c| tracker.custom_fields.include? c } : []
74 (project && tracker) ? project.all_issue_custom_fields.select {|c| tracker.custom_fields.include? c } : []
75 end
75 end
76
76
77 def copy_from(arg)
77 def copy_from(arg)
78 issue = arg.is_a?(Issue) ? arg : Issue.find(arg)
78 issue = arg.is_a?(Issue) ? arg : Issue.find(arg)
79 self.attributes = issue.attributes.dup
79 self.attributes = issue.attributes.dup
80 self.custom_values = issue.custom_values.collect {|v| v.clone}
80 self.custom_values = issue.custom_values.collect {|v| v.clone}
81 self
81 self
82 end
82 end
83
83
84 # Moves/copies an issue to a new project and tracker
84 # Moves/copies an issue to a new project and tracker
85 # Returns the moved/copied issue on success, false on failure
85 # Returns the moved/copied issue on success, false on failure
86 def move_to(new_project, new_tracker = nil, options = {})
86 def move_to(new_project, new_tracker = nil, options = {})
87 options ||= {}
87 options ||= {}
88 issue = options[:copy] ? self.clone : self
88 issue = options[:copy] ? self.clone : self
89 transaction do
89 transaction do
90 if new_project && issue.project_id != new_project.id
90 if new_project && issue.project_id != new_project.id
91 # delete issue relations
91 # delete issue relations
92 unless Setting.cross_project_issue_relations?
92 unless Setting.cross_project_issue_relations?
93 issue.relations_from.clear
93 issue.relations_from.clear
94 issue.relations_to.clear
94 issue.relations_to.clear
95 end
95 end
96 # issue is moved to another project
96 # issue is moved to another project
97 # reassign to the category with same name if any
97 # reassign to the category with same name if any
98 new_category = issue.category.nil? ? nil : new_project.issue_categories.find_by_name(issue.category.name)
98 new_category = issue.category.nil? ? nil : new_project.issue_categories.find_by_name(issue.category.name)
99 issue.category = new_category
99 issue.category = new_category
100 issue.fixed_version = nil
100 issue.fixed_version = nil
101 issue.project = new_project
101 issue.project = new_project
102 end
102 end
103 if new_tracker
103 if new_tracker
104 issue.tracker = new_tracker
104 issue.tracker = new_tracker
105 end
105 end
106 if options[:copy]
106 if options[:copy]
107 issue.custom_field_values = self.custom_field_values.inject({}) {|h,v| h[v.custom_field_id] = v.value; h}
107 issue.custom_field_values = self.custom_field_values.inject({}) {|h,v| h[v.custom_field_id] = v.value; h}
108 issue.status = self.status
108 issue.status = self.status
109 end
109 end
110 if issue.save
110 if issue.save
111 unless options[:copy]
111 unless options[:copy]
112 # Manually update project_id on related time entries
112 # Manually update project_id on related time entries
113 TimeEntry.update_all("project_id = #{new_project.id}", {:issue_id => id})
113 TimeEntry.update_all("project_id = #{new_project.id}", {:issue_id => id})
114 end
114 end
115 else
115 else
116 Issue.connection.rollback_db_transaction
116 Issue.connection.rollback_db_transaction
117 return false
117 return false
118 end
118 end
119 end
119 end
120 return issue
120 return issue
121 end
121 end
122
122
123 def priority_id=(pid)
123 def priority_id=(pid)
124 self.priority = nil
124 self.priority = nil
125 write_attribute(:priority_id, pid)
125 write_attribute(:priority_id, pid)
126 end
126 end
127
127
128 def estimated_hours=(h)
128 def estimated_hours=(h)
129 write_attribute :estimated_hours, (h.is_a?(String) ? h.to_hours : h)
129 write_attribute :estimated_hours, (h.is_a?(String) ? h.to_hours : h)
130 end
130 end
131
131
132 def validate
132 def validate
133 if self.due_date.nil? && @attributes['due_date'] && !@attributes['due_date'].empty?
133 if self.due_date.nil? && @attributes['due_date'] && !@attributes['due_date'].empty?
134 errors.add :due_date, :not_a_date
134 errors.add :due_date, :not_a_date
135 end
135 end
136
136
137 if self.due_date and self.start_date and self.due_date < self.start_date
137 if self.due_date and self.start_date and self.due_date < self.start_date
138 errors.add :due_date, :greater_than_start_date
138 errors.add :due_date, :greater_than_start_date
139 end
139 end
140
140
141 if start_date && soonest_start && start_date < soonest_start
141 if start_date && soonest_start && start_date < soonest_start
142 errors.add :start_date, :invalid
142 errors.add :start_date, :invalid
143 end
143 end
144 end
144 end
145
145
146 def validate_on_create
146 def validate_on_create
147 errors.add :tracker_id, :invalid unless project.trackers.include?(tracker)
147 errors.add :tracker_id, :invalid unless project.trackers.include?(tracker)
148 end
148 end
149
149
150 def before_create
150 def before_create
151 # default assignment based on category
151 # default assignment based on category
152 if assigned_to.nil? && category && category.assigned_to
152 if assigned_to.nil? && category && category.assigned_to
153 self.assigned_to = category.assigned_to
153 self.assigned_to = category.assigned_to
154 end
154 end
155 end
155 end
156
156
157 def before_save
157 def before_save
158 if @current_journal
158 if @current_journal
159 # attributes changes
159 # attributes changes
160 (Issue.column_names - %w(id description)).each {|c|
160 (Issue.column_names - %w(id description)).each {|c|
161 @current_journal.details << JournalDetail.new(:property => 'attr',
161 @current_journal.details << JournalDetail.new(:property => 'attr',
162 :prop_key => c,
162 :prop_key => c,
163 :old_value => @issue_before_change.send(c),
163 :old_value => @issue_before_change.send(c),
164 :value => send(c)) unless send(c)==@issue_before_change.send(c)
164 :value => send(c)) unless send(c)==@issue_before_change.send(c)
165 }
165 }
166 # custom fields changes
166 # custom fields changes
167 custom_values.each {|c|
167 custom_values.each {|c|
168 next if (@custom_values_before_change[c.custom_field_id]==c.value ||
168 next if (@custom_values_before_change[c.custom_field_id]==c.value ||
169 (@custom_values_before_change[c.custom_field_id].blank? && c.value.blank?))
169 (@custom_values_before_change[c.custom_field_id].blank? && c.value.blank?))
170 @current_journal.details << JournalDetail.new(:property => 'cf',
170 @current_journal.details << JournalDetail.new(:property => 'cf',
171 :prop_key => c.custom_field_id,
171 :prop_key => c.custom_field_id,
172 :old_value => @custom_values_before_change[c.custom_field_id],
172 :old_value => @custom_values_before_change[c.custom_field_id],
173 :value => c.value)
173 :value => c.value)
174 }
174 }
175 @current_journal.save
175 @current_journal.save
176 end
176 end
177 # Save the issue even if the journal is not saved (because empty)
177 # Save the issue even if the journal is not saved (because empty)
178 true
178 true
179 end
179 end
180
180
181 def after_save
181 def after_save
182 # Reload is needed in order to get the right status
182 # Reload is needed in order to get the right status
183 reload
183 reload
184
184
185 # Update start/due dates of following issues
185 # Update start/due dates of following issues
186 relations_from.each(&:set_issue_to_dates)
186 relations_from.each(&:set_issue_to_dates)
187
187
188 # Close duplicates if the issue was closed
188 # Close duplicates if the issue was closed
189 if @issue_before_change && !@issue_before_change.closed? && self.closed?
189 if @issue_before_change && !@issue_before_change.closed? && self.closed?
190 duplicates.each do |duplicate|
190 duplicates.each do |duplicate|
191 # Reload is need in case the duplicate was updated by a previous duplicate
191 # Reload is need in case the duplicate was updated by a previous duplicate
192 duplicate.reload
192 duplicate.reload
193 # Don't re-close it if it's already closed
193 # Don't re-close it if it's already closed
194 next if duplicate.closed?
194 next if duplicate.closed?
195 # Same user and notes
195 # Same user and notes
196 duplicate.init_journal(@current_journal.user, @current_journal.notes)
196 duplicate.init_journal(@current_journal.user, @current_journal.notes)
197 duplicate.update_attribute :status, self.status
197 duplicate.update_attribute :status, self.status
198 end
198 end
199 end
199 end
200 end
200 end
201
201
202 def init_journal(user, notes = "")
202 def init_journal(user, notes = "")
203 @current_journal ||= Journal.new(:journalized => self, :user => user, :notes => notes)
203 @current_journal ||= Journal.new(:journalized => self, :user => user, :notes => notes)
204 @issue_before_change = self.clone
204 @issue_before_change = self.clone
205 @issue_before_change.status = self.status
205 @issue_before_change.status = self.status
206 @custom_values_before_change = {}
206 @custom_values_before_change = {}
207 self.custom_values.each {|c| @custom_values_before_change.store c.custom_field_id, c.value }
207 self.custom_values.each {|c| @custom_values_before_change.store c.custom_field_id, c.value }
208 # Make sure updated_on is updated when adding a note.
208 # Make sure updated_on is updated when adding a note.
209 updated_on_will_change!
209 updated_on_will_change!
210 @current_journal
210 @current_journal
211 end
211 end
212
212
213 # Return true if the issue is closed, otherwise false
213 # Return true if the issue is closed, otherwise false
214 def closed?
214 def closed?
215 self.status.is_closed?
215 self.status.is_closed?
216 end
216 end
217
217
218 # Returns true if the issue is overdue
218 # Returns true if the issue is overdue
219 def overdue?
219 def overdue?
220 !due_date.nil? && (due_date < Date.today) && !status.is_closed?
220 !due_date.nil? && (due_date < Date.today) && !status.is_closed?
221 end
221 end
222
222
223 # Users the issue can be assigned to
223 # Users the issue can be assigned to
224 def assignable_users
224 def assignable_users
225 project.assignable_users
225 project.assignable_users
226 end
226 end
227
227
228 # Returns an array of status that user is able to apply
228 # Returns an array of status that user is able to apply
229 def new_statuses_allowed_to(user)
229 def new_statuses_allowed_to(user)
230 statuses = status.find_new_statuses_allowed_to(user.role_for_project(project), tracker)
230 statuses = status.find_new_statuses_allowed_to(user.role_for_project(project), tracker)
231 statuses << status unless statuses.empty?
231 statuses << status unless statuses.empty?
232 statuses.uniq.sort
232 statuses.uniq.sort
233 end
233 end
234
234
235 # Returns the mail adresses of users that should be notified for the issue
235 # Returns the mail adresses of users that should be notified for the issue
236 def recipients
236 def recipients
237 recipients = project.recipients
237 recipients = project.recipients
238 # Author and assignee are always notified unless they have been locked
238 # Author and assignee are always notified unless they have been locked
239 recipients << author.mail if author && author.active?
239 recipients << author.mail if author && author.active?
240 recipients << assigned_to.mail if assigned_to && assigned_to.active?
240 recipients << assigned_to.mail if assigned_to && assigned_to.active?
241 recipients.compact.uniq
241 recipients.compact.uniq
242 end
242 end
243
243
244 # Returns the total number of hours spent on this issue.
245 #
246 # Example:
247 # spent_hours => 0
248 # spent_hours => 50
244 def spent_hours
249 def spent_hours
245 @spent_hours ||= time_entries.sum(:hours) || 0
250 @spent_hours ||= time_entries.sum(:hours) || 0
246 end
251 end
247
252
248 def relations
253 def relations
249 (relations_from + relations_to).sort
254 (relations_from + relations_to).sort
250 end
255 end
251
256
252 def all_dependent_issues
257 def all_dependent_issues
253 dependencies = []
258 dependencies = []
254 relations_from.each do |relation|
259 relations_from.each do |relation|
255 dependencies << relation.issue_to
260 dependencies << relation.issue_to
256 dependencies += relation.issue_to.all_dependent_issues
261 dependencies += relation.issue_to.all_dependent_issues
257 end
262 end
258 dependencies
263 dependencies
259 end
264 end
260
265
261 # Returns an array of issues that duplicate this one
266 # Returns an array of issues that duplicate this one
262 def duplicates
267 def duplicates
263 relations_to.select {|r| r.relation_type == IssueRelation::TYPE_DUPLICATES}.collect {|r| r.issue_from}
268 relations_to.select {|r| r.relation_type == IssueRelation::TYPE_DUPLICATES}.collect {|r| r.issue_from}
264 end
269 end
265
270
266 # Returns the due date or the target due date if any
271 # Returns the due date or the target due date if any
267 # Used on gantt chart
272 # Used on gantt chart
268 def due_before
273 def due_before
269 due_date || (fixed_version ? fixed_version.effective_date : nil)
274 due_date || (fixed_version ? fixed_version.effective_date : nil)
270 end
275 end
271
276
277 # Returns the time scheduled for this issue.
278 #
279 # Example:
280 # Start Date: 2/26/09, End Date: 3/04/09
281 # duration => 6
272 def duration
282 def duration
273 (start_date && due_date) ? due_date - start_date : 0
283 (start_date && due_date) ? due_date - start_date : 0
274 end
284 end
275
285
276 def soonest_start
286 def soonest_start
277 @soonest_start ||= relations_to.collect{|relation| relation.successor_soonest_start}.compact.min
287 @soonest_start ||= relations_to.collect{|relation| relation.successor_soonest_start}.compact.min
278 end
288 end
279
289
280 def to_s
290 def to_s
281 "#{tracker} ##{id}: #{subject}"
291 "#{tracker} ##{id}: #{subject}"
282 end
292 end
283
293
284 private
294 private
285
295
286 # Callback on attachment deletion
296 # Callback on attachment deletion
287 def attachment_removed(obj)
297 def attachment_removed(obj)
288 journal = init_journal(User.current)
298 journal = init_journal(User.current)
289 journal.details << JournalDetail.new(:property => 'attachment',
299 journal.details << JournalDetail.new(:property => 'attachment',
290 :prop_key => obj.id,
300 :prop_key => obj.id,
291 :old_value => obj.filename)
301 :old_value => obj.filename)
292 journal.save
302 journal.save
293 end
303 end
294 end
304 end
@@ -1,308 +1,352
1 # redMine - project management software
1 # redMine - project management software
2 # Copyright (C) 2006-2007 Jean-Philippe Lang
2 # Copyright (C) 2006-2007 Jean-Philippe Lang
3 #
3 #
4 # This program is free software; you can redistribute it and/or
4 # This program is free software; you can redistribute it and/or
5 # modify it under the terms of the GNU General Public License
5 # modify it under the terms of the GNU General Public License
6 # as published by the Free Software Foundation; either version 2
6 # as published by the Free Software Foundation; either version 2
7 # of the License, or (at your option) any later version.
7 # of the License, or (at your option) any later version.
8 #
8 #
9 # This program is distributed in the hope that it will be useful,
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 # GNU General Public License for more details.
12 # GNU General Public License for more details.
13 #
13 #
14 # You should have received a copy of the GNU General Public License
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
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.
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17
17
18 class Mailer < ActionMailer::Base
18 class Mailer < ActionMailer::Base
19 helper :application
19 helper :application
20 helper :issues
20 helper :issues
21 helper :custom_fields
21 helper :custom_fields
22
22
23 include ActionController::UrlWriter
23 include ActionController::UrlWriter
24 include Redmine::I18n
24 include Redmine::I18n
25
25
26 def self.default_url_options
26 def self.default_url_options
27 h = Setting.host_name
27 h = Setting.host_name
28 h = h.to_s.gsub(%r{\/.*$}, '') unless Redmine::Utils.relative_url_root.blank?
28 h = h.to_s.gsub(%r{\/.*$}, '') unless Redmine::Utils.relative_url_root.blank?
29 { :host => h, :protocol => Setting.protocol }
29 { :host => h, :protocol => Setting.protocol }
30 end
30 end
31
31
32 # Builds a tmail object used to email recipients of the added issue.
33 #
34 # Example:
35 # issue_add(issue) => tmail object
36 # Mailer.deliver_issue_add(issue) => sends an email to issue recipients
32 def issue_add(issue)
37 def issue_add(issue)
33 redmine_headers 'Project' => issue.project.identifier,
38 redmine_headers 'Project' => issue.project.identifier,
34 'Issue-Id' => issue.id,
39 'Issue-Id' => issue.id,
35 'Issue-Author' => issue.author.login
40 'Issue-Author' => issue.author.login
36 redmine_headers 'Issue-Assignee' => issue.assigned_to.login if issue.assigned_to
41 redmine_headers 'Issue-Assignee' => issue.assigned_to.login if issue.assigned_to
37 message_id issue
42 message_id issue
38 recipients issue.recipients
43 recipients issue.recipients
39 cc(issue.watcher_recipients - @recipients)
44 cc(issue.watcher_recipients - @recipients)
40 subject "[#{issue.project.name} - #{issue.tracker.name} ##{issue.id}] (#{issue.status.name}) #{issue.subject}"
45 subject "[#{issue.project.name} - #{issue.tracker.name} ##{issue.id}] (#{issue.status.name}) #{issue.subject}"
41 body :issue => issue,
46 body :issue => issue,
42 :issue_url => url_for(:controller => 'issues', :action => 'show', :id => issue)
47 :issue_url => url_for(:controller => 'issues', :action => 'show', :id => issue)
43 end
48 end
44
49
50 # Builds a tmail object used to email recipients of the edited issue.
51 #
52 # Example:
53 # issue_edit(journal) => tmail object
54 # Mailer.deliver_issue_edit(journal) => sends an email to issue recipients
45 def issue_edit(journal)
55 def issue_edit(journal)
46 issue = journal.journalized
56 issue = journal.journalized
47 redmine_headers 'Project' => issue.project.identifier,
57 redmine_headers 'Project' => issue.project.identifier,
48 'Issue-Id' => issue.id,
58 'Issue-Id' => issue.id,
49 'Issue-Author' => issue.author.login
59 'Issue-Author' => issue.author.login
50 redmine_headers 'Issue-Assignee' => issue.assigned_to.login if issue.assigned_to
60 redmine_headers 'Issue-Assignee' => issue.assigned_to.login if issue.assigned_to
51 message_id journal
61 message_id journal
52 references issue
62 references issue
53 @author = journal.user
63 @author = journal.user
54 recipients issue.recipients
64 recipients issue.recipients
55 # Watchers in cc
65 # Watchers in cc
56 cc(issue.watcher_recipients - @recipients)
66 cc(issue.watcher_recipients - @recipients)
57 s = "[#{issue.project.name} - #{issue.tracker.name} ##{issue.id}] "
67 s = "[#{issue.project.name} - #{issue.tracker.name} ##{issue.id}] "
58 s << "(#{issue.status.name}) " if journal.new_value_for('status_id')
68 s << "(#{issue.status.name}) " if journal.new_value_for('status_id')
59 s << issue.subject
69 s << issue.subject
60 subject s
70 subject s
61 body :issue => issue,
71 body :issue => issue,
62 :journal => journal,
72 :journal => journal,
63 :issue_url => url_for(:controller => 'issues', :action => 'show', :id => issue)
73 :issue_url => url_for(:controller => 'issues', :action => 'show', :id => issue)
64 end
74 end
65
75
66 def reminder(user, issues, days)
76 def reminder(user, issues, days)
67 set_language_if_valid user.language
77 set_language_if_valid user.language
68 recipients user.mail
78 recipients user.mail
69 subject l(:mail_subject_reminder, issues.size)
79 subject l(:mail_subject_reminder, issues.size)
70 body :issues => issues,
80 body :issues => issues,
71 :days => days,
81 :days => days,
72 :issues_url => url_for(:controller => 'issues', :action => 'index', :set_filter => 1, :assigned_to_id => user.id, :sort_key => 'due_date', :sort_order => 'asc')
82 :issues_url => url_for(:controller => 'issues', :action => 'index', :set_filter => 1, :assigned_to_id => user.id, :sort_key => 'due_date', :sort_order => 'asc')
73 end
83 end
74
84
85 # Builds a tmail object used to email users belonging to the added document's project.
86 #
87 # Example:
88 # document_added(document) => tmail object
89 # Mailer.deliver_document_added(document) => sends an email to the document's project recipients
75 def document_added(document)
90 def document_added(document)
76 redmine_headers 'Project' => document.project.identifier
91 redmine_headers 'Project' => document.project.identifier
77 recipients document.project.recipients
92 recipients document.project.recipients
78 subject "[#{document.project.name}] #{l(:label_document_new)}: #{document.title}"
93 subject "[#{document.project.name}] #{l(:label_document_new)}: #{document.title}"
79 body :document => document,
94 body :document => document,
80 :document_url => url_for(:controller => 'documents', :action => 'show', :id => document)
95 :document_url => url_for(:controller => 'documents', :action => 'show', :id => document)
81 end
96 end
82
97
98 # Builds a tmail object used to email recipients of a project when an attachements are added.
99 #
100 # Example:
101 # attachments_added(attachments) => tmail object
102 # Mailer.deliver_attachments_added(attachments) => sends an email to the project's recipients
83 def attachments_added(attachments)
103 def attachments_added(attachments)
84 container = attachments.first.container
104 container = attachments.first.container
85 added_to = ''
105 added_to = ''
86 added_to_url = ''
106 added_to_url = ''
87 case container.class.name
107 case container.class.name
88 when 'Project'
108 when 'Project'
89 added_to_url = url_for(:controller => 'projects', :action => 'list_files', :id => container)
109 added_to_url = url_for(:controller => 'projects', :action => 'list_files', :id => container)
90 added_to = "#{l(:label_project)}: #{container}"
110 added_to = "#{l(:label_project)}: #{container}"
91 when 'Version'
111 when 'Version'
92 added_to_url = url_for(:controller => 'projects', :action => 'list_files', :id => container.project_id)
112 added_to_url = url_for(:controller => 'projects', :action => 'list_files', :id => container.project_id)
93 added_to = "#{l(:label_version)}: #{container.name}"
113 added_to = "#{l(:label_version)}: #{container.name}"
94 when 'Document'
114 when 'Document'
95 added_to_url = url_for(:controller => 'documents', :action => 'show', :id => container.id)
115 added_to_url = url_for(:controller => 'documents', :action => 'show', :id => container.id)
96 added_to = "#{l(:label_document)}: #{container.title}"
116 added_to = "#{l(:label_document)}: #{container.title}"
97 end
117 end
98 redmine_headers 'Project' => container.project.identifier
118 redmine_headers 'Project' => container.project.identifier
99 recipients container.project.recipients
119 recipients container.project.recipients
100 subject "[#{container.project.name}] #{l(:label_attachment_new)}"
120 subject "[#{container.project.name}] #{l(:label_attachment_new)}"
101 body :attachments => attachments,
121 body :attachments => attachments,
102 :added_to => added_to,
122 :added_to => added_to,
103 :added_to_url => added_to_url
123 :added_to_url => added_to_url
104 end
124 end
105
125
126 # Builds a tmail object used to email recipients of a news' project when a news item is added.
127 #
128 # Example:
129 # news_added(news) => tmail object
130 # Mailer.deliver_news_added(news) => sends an email to the news' project recipients
106 def news_added(news)
131 def news_added(news)
107 redmine_headers 'Project' => news.project.identifier
132 redmine_headers 'Project' => news.project.identifier
108 message_id news
133 message_id news
109 recipients news.project.recipients
134 recipients news.project.recipients
110 subject "[#{news.project.name}] #{l(:label_news)}: #{news.title}"
135 subject "[#{news.project.name}] #{l(:label_news)}: #{news.title}"
111 body :news => news,
136 body :news => news,
112 :news_url => url_for(:controller => 'news', :action => 'show', :id => news)
137 :news_url => url_for(:controller => 'news', :action => 'show', :id => news)
113 end
138 end
114
139
140 # Builds a tmail object used to email the specified recipients of the specified message that was posted.
141 #
142 # Example:
143 # message_posted(message, recipients) => tmail object
144 # Mailer.deliver_message_posted(message, recipients) => sends an email to the recipients
115 def message_posted(message, recipients)
145 def message_posted(message, recipients)
116 redmine_headers 'Project' => message.project.identifier,
146 redmine_headers 'Project' => message.project.identifier,
117 'Topic-Id' => (message.parent_id || message.id)
147 'Topic-Id' => (message.parent_id || message.id)
118 message_id message
148 message_id message
119 references message.parent unless message.parent.nil?
149 references message.parent unless message.parent.nil?
120 recipients(recipients)
150 recipients(recipients)
121 subject "[#{message.board.project.name} - #{message.board.name} - msg#{message.root.id}] #{message.subject}"
151 subject "[#{message.board.project.name} - #{message.board.name} - msg#{message.root.id}] #{message.subject}"
122 body :message => message,
152 body :message => message,
123 :message_url => url_for(:controller => 'messages', :action => 'show', :board_id => message.board_id, :id => message.root)
153 :message_url => url_for(:controller => 'messages', :action => 'show', :board_id => message.board_id, :id => message.root)
124 end
154 end
125
155
156 # Builds a tmail object used to email the specified user their account information.
157 #
158 # Example:
159 # account_information(user, password) => tmail object
160 # Mailer.deliver_account_information(user, password) => sends account information to the user
126 def account_information(user, password)
161 def account_information(user, password)
127 set_language_if_valid user.language
162 set_language_if_valid user.language
128 recipients user.mail
163 recipients user.mail
129 subject l(:mail_subject_register, Setting.app_title)
164 subject l(:mail_subject_register, Setting.app_title)
130 body :user => user,
165 body :user => user,
131 :password => password,
166 :password => password,
132 :login_url => url_for(:controller => 'account', :action => 'login')
167 :login_url => url_for(:controller => 'account', :action => 'login')
133 end
168 end
134
169
170 # Builds a tmail object used to email all active administrators of an account activation request.
171 #
172 # Example:
173 # account_activation_request(user) => tmail object
174 # Mailer.deliver_account_activation_request(user)=> sends an email to all active administrators
135 def account_activation_request(user)
175 def account_activation_request(user)
136 # Send the email to all active administrators
176 # Send the email to all active administrators
137 recipients User.active.find(:all, :conditions => {:admin => true}).collect { |u| u.mail }.compact
177 recipients User.active.find(:all, :conditions => {:admin => true}).collect { |u| u.mail }.compact
138 subject l(:mail_subject_account_activation_request, Setting.app_title)
178 subject l(:mail_subject_account_activation_request, Setting.app_title)
139 body :user => user,
179 body :user => user,
140 :url => url_for(:controller => 'users', :action => 'index', :status => User::STATUS_REGISTERED, :sort_key => 'created_on', :sort_order => 'desc')
180 :url => url_for(:controller => 'users', :action => 'index', :status => User::STATUS_REGISTERED, :sort_key => 'created_on', :sort_order => 'desc')
141 end
181 end
142
182
143 # A registered user's account was activated by an administrator
183 # Builds a tmail object used to email the specified user that their account was activated by an administrator.
184 #
185 # Example:
186 # account_activated(user) => tmail object
187 # Mailer.deliver_account_activated(user) => sends an email to the registered user
144 def account_activated(user)
188 def account_activated(user)
145 set_language_if_valid user.language
189 set_language_if_valid user.language
146 recipients user.mail
190 recipients user.mail
147 subject l(:mail_subject_register, Setting.app_title)
191 subject l(:mail_subject_register, Setting.app_title)
148 body :user => user,
192 body :user => user,
149 :login_url => url_for(:controller => 'account', :action => 'login')
193 :login_url => url_for(:controller => 'account', :action => 'login')
150 end
194 end
151
195
152 def lost_password(token)
196 def lost_password(token)
153 set_language_if_valid(token.user.language)
197 set_language_if_valid(token.user.language)
154 recipients token.user.mail
198 recipients token.user.mail
155 subject l(:mail_subject_lost_password, Setting.app_title)
199 subject l(:mail_subject_lost_password, Setting.app_title)
156 body :token => token,
200 body :token => token,
157 :url => url_for(:controller => 'account', :action => 'lost_password', :token => token.value)
201 :url => url_for(:controller => 'account', :action => 'lost_password', :token => token.value)
158 end
202 end
159
203
160 def register(token)
204 def register(token)
161 set_language_if_valid(token.user.language)
205 set_language_if_valid(token.user.language)
162 recipients token.user.mail
206 recipients token.user.mail
163 subject l(:mail_subject_register, Setting.app_title)
207 subject l(:mail_subject_register, Setting.app_title)
164 body :token => token,
208 body :token => token,
165 :url => url_for(:controller => 'account', :action => 'activate', :token => token.value)
209 :url => url_for(:controller => 'account', :action => 'activate', :token => token.value)
166 end
210 end
167
211
168 def test(user)
212 def test(user)
169 set_language_if_valid(user.language)
213 set_language_if_valid(user.language)
170 recipients user.mail
214 recipients user.mail
171 subject 'Redmine test'
215 subject 'Redmine test'
172 body :url => url_for(:controller => 'welcome')
216 body :url => url_for(:controller => 'welcome')
173 end
217 end
174
218
175 # Overrides default deliver! method to prevent from sending an email
219 # Overrides default deliver! method to prevent from sending an email
176 # with no recipient, cc or bcc
220 # with no recipient, cc or bcc
177 def deliver!(mail = @mail)
221 def deliver!(mail = @mail)
178 return false if (recipients.nil? || recipients.empty?) &&
222 return false if (recipients.nil? || recipients.empty?) &&
179 (cc.nil? || cc.empty?) &&
223 (cc.nil? || cc.empty?) &&
180 (bcc.nil? || bcc.empty?)
224 (bcc.nil? || bcc.empty?)
181
225
182 # Set Message-Id and References
226 # Set Message-Id and References
183 if @message_id_object
227 if @message_id_object
184 mail.message_id = self.class.message_id_for(@message_id_object)
228 mail.message_id = self.class.message_id_for(@message_id_object)
185 end
229 end
186 if @references_objects
230 if @references_objects
187 mail.references = @references_objects.collect {|o| self.class.message_id_for(o)}
231 mail.references = @references_objects.collect {|o| self.class.message_id_for(o)}
188 end
232 end
189 super(mail)
233 super(mail)
190 end
234 end
191
235
192 # Sends reminders to issue assignees
236 # Sends reminders to issue assignees
193 # Available options:
237 # Available options:
194 # * :days => how many days in the future to remind about (defaults to 7)
238 # * :days => how many days in the future to remind about (defaults to 7)
195 # * :tracker => id of tracker for filtering issues (defaults to all trackers)
239 # * :tracker => id of tracker for filtering issues (defaults to all trackers)
196 # * :project => id or identifier of project to process (defaults to all projects)
240 # * :project => id or identifier of project to process (defaults to all projects)
197 def self.reminders(options={})
241 def self.reminders(options={})
198 days = options[:days] || 7
242 days = options[:days] || 7
199 project = options[:project] ? Project.find(options[:project]) : nil
243 project = options[:project] ? Project.find(options[:project]) : nil
200 tracker = options[:tracker] ? Tracker.find(options[:tracker]) : nil
244 tracker = options[:tracker] ? Tracker.find(options[:tracker]) : nil
201
245
202 s = ARCondition.new ["#{IssueStatus.table_name}.is_closed = ? AND #{Issue.table_name}.due_date <= ?", false, days.day.from_now.to_date]
246 s = ARCondition.new ["#{IssueStatus.table_name}.is_closed = ? AND #{Issue.table_name}.due_date <= ?", false, days.day.from_now.to_date]
203 s << "#{Issue.table_name}.assigned_to_id IS NOT NULL"
247 s << "#{Issue.table_name}.assigned_to_id IS NOT NULL"
204 s << "#{Project.table_name}.status = #{Project::STATUS_ACTIVE}"
248 s << "#{Project.table_name}.status = #{Project::STATUS_ACTIVE}"
205 s << "#{Issue.table_name}.project_id = #{project.id}" if project
249 s << "#{Issue.table_name}.project_id = #{project.id}" if project
206 s << "#{Issue.table_name}.tracker_id = #{tracker.id}" if tracker
250 s << "#{Issue.table_name}.tracker_id = #{tracker.id}" if tracker
207
251
208 issues_by_assignee = Issue.find(:all, :include => [:status, :assigned_to, :project, :tracker],
252 issues_by_assignee = Issue.find(:all, :include => [:status, :assigned_to, :project, :tracker],
209 :conditions => s.conditions
253 :conditions => s.conditions
210 ).group_by(&:assigned_to)
254 ).group_by(&:assigned_to)
211 issues_by_assignee.each do |assignee, issues|
255 issues_by_assignee.each do |assignee, issues|
212 deliver_reminder(assignee, issues, days) unless assignee.nil?
256 deliver_reminder(assignee, issues, days) unless assignee.nil?
213 end
257 end
214 end
258 end
215
259
216 private
260 private
217 def initialize_defaults(method_name)
261 def initialize_defaults(method_name)
218 super
262 super
219 set_language_if_valid Setting.default_language
263 set_language_if_valid Setting.default_language
220 from Setting.mail_from
264 from Setting.mail_from
221
265
222 # Common headers
266 # Common headers
223 headers 'X-Mailer' => 'Redmine',
267 headers 'X-Mailer' => 'Redmine',
224 'X-Redmine-Host' => Setting.host_name,
268 'X-Redmine-Host' => Setting.host_name,
225 'X-Redmine-Site' => Setting.app_title,
269 'X-Redmine-Site' => Setting.app_title,
226 'List-Id' => "<#{Setting.mail_from.to_s.gsub('@', '.')}>"
270 'List-Id' => "<#{Setting.mail_from.to_s.gsub('@', '.')}>"
227 end
271 end
228
272
229 # Appends a Redmine header field (name is prepended with 'X-Redmine-')
273 # Appends a Redmine header field (name is prepended with 'X-Redmine-')
230 def redmine_headers(h)
274 def redmine_headers(h)
231 h.each { |k,v| headers["X-Redmine-#{k}"] = v }
275 h.each { |k,v| headers["X-Redmine-#{k}"] = v }
232 end
276 end
233
277
234 # Overrides the create_mail method
278 # Overrides the create_mail method
235 def create_mail
279 def create_mail
236 # Removes the current user from the recipients and cc
280 # Removes the current user from the recipients and cc
237 # if he doesn't want to receive notifications about what he does
281 # if he doesn't want to receive notifications about what he does
238 @author ||= User.current
282 @author ||= User.current
239 if @author.pref[:no_self_notified]
283 if @author.pref[:no_self_notified]
240 recipients.delete(@author.mail) if recipients
284 recipients.delete(@author.mail) if recipients
241 cc.delete(@author.mail) if cc
285 cc.delete(@author.mail) if cc
242 end
286 end
243 # Blind carbon copy recipients
287 # Blind carbon copy recipients
244 if Setting.bcc_recipients?
288 if Setting.bcc_recipients?
245 bcc([recipients, cc].flatten.compact.uniq)
289 bcc([recipients, cc].flatten.compact.uniq)
246 recipients []
290 recipients []
247 cc []
291 cc []
248 end
292 end
249 super
293 super
250 end
294 end
251
295
252 # Renders a message with the corresponding layout
296 # Renders a message with the corresponding layout
253 def render_message(method_name, body)
297 def render_message(method_name, body)
254 layout = method_name.to_s.match(%r{text\.html\.(rhtml|rxml)}) ? 'layout.text.html.rhtml' : 'layout.text.plain.rhtml'
298 layout = method_name.to_s.match(%r{text\.html\.(rhtml|rxml)}) ? 'layout.text.html.rhtml' : 'layout.text.plain.rhtml'
255 body[:content_for_layout] = render(:file => method_name, :body => body)
299 body[:content_for_layout] = render(:file => method_name, :body => body)
256 ActionView::Base.new(template_root, body, self).render(:file => "mailer/#{layout}", :use_full_path => true)
300 ActionView::Base.new(template_root, body, self).render(:file => "mailer/#{layout}", :use_full_path => true)
257 end
301 end
258
302
259 # for the case of plain text only
303 # for the case of plain text only
260 def body(*params)
304 def body(*params)
261 value = super(*params)
305 value = super(*params)
262 if Setting.plain_text_mail?
306 if Setting.plain_text_mail?
263 templates = Dir.glob("#{template_path}/#{@template}.text.plain.{rhtml,erb}")
307 templates = Dir.glob("#{template_path}/#{@template}.text.plain.{rhtml,erb}")
264 unless String === @body or templates.empty?
308 unless String === @body or templates.empty?
265 template = File.basename(templates.first)
309 template = File.basename(templates.first)
266 @body[:content_for_layout] = render(:file => template, :body => @body)
310 @body[:content_for_layout] = render(:file => template, :body => @body)
267 @body = ActionView::Base.new(template_root, @body, self).render(:file => "mailer/layout.text.plain.rhtml", :use_full_path => true)
311 @body = ActionView::Base.new(template_root, @body, self).render(:file => "mailer/layout.text.plain.rhtml", :use_full_path => true)
268 return @body
312 return @body
269 end
313 end
270 end
314 end
271 return value
315 return value
272 end
316 end
273
317
274 # Makes partial rendering work with Rails 1.2 (retro-compatibility)
318 # Makes partial rendering work with Rails 1.2 (retro-compatibility)
275 def self.controller_path
319 def self.controller_path
276 ''
320 ''
277 end unless respond_to?('controller_path')
321 end unless respond_to?('controller_path')
278
322
279 # Returns a predictable Message-Id for the given object
323 # Returns a predictable Message-Id for the given object
280 def self.message_id_for(object)
324 def self.message_id_for(object)
281 # id + timestamp should reduce the odds of a collision
325 # id + timestamp should reduce the odds of a collision
282 # as far as we don't send multiple emails for the same object
326 # as far as we don't send multiple emails for the same object
283 hash = "redmine.#{object.class.name.demodulize.underscore}-#{object.id}.#{object.created_on.strftime("%Y%m%d%H%M%S")}"
327 hash = "redmine.#{object.class.name.demodulize.underscore}-#{object.id}.#{object.created_on.strftime("%Y%m%d%H%M%S")}"
284 host = Setting.mail_from.to_s.gsub(%r{^.*@}, '')
328 host = Setting.mail_from.to_s.gsub(%r{^.*@}, '')
285 host = "#{::Socket.gethostname}.redmine" if host.empty?
329 host = "#{::Socket.gethostname}.redmine" if host.empty?
286 "<#{hash}@#{host}>"
330 "<#{hash}@#{host}>"
287 end
331 end
288
332
289 private
333 private
290
334
291 def message_id(object)
335 def message_id(object)
292 @message_id_object = object
336 @message_id_object = object
293 end
337 end
294
338
295 def references(object)
339 def references(object)
296 @references_objects ||= []
340 @references_objects ||= []
297 @references_objects << object
341 @references_objects << object
298 end
342 end
299 end
343 end
300
344
301 # Patch TMail so that message_id is not overwritten
345 # Patch TMail so that message_id is not overwritten
302 module TMail
346 module TMail
303 class Mail
347 class Mail
304 def add_message_id( fqdn = nil )
348 def add_message_id( fqdn = nil )
305 self.message_id ||= ::TMail::new_message_id(fqdn)
349 self.message_id ||= ::TMail::new_message_id(fqdn)
306 end
350 end
307 end
351 end
308 end
352 end
@@ -1,323 +1,337
1 # redMine - project management software
1 # redMine - project management software
2 # Copyright (C) 2006 Jean-Philippe Lang
2 # Copyright (C) 2006 Jean-Philippe Lang
3 #
3 #
4 # This program is free software; you can redistribute it and/or
4 # This program is free software; you can redistribute it and/or
5 # modify it under the terms of the GNU General Public License
5 # modify it under the terms of the GNU General Public License
6 # as published by the Free Software Foundation; either version 2
6 # as published by the Free Software Foundation; either version 2
7 # of the License, or (at your option) any later version.
7 # of the License, or (at your option) any later version.
8 #
8 #
9 # This program is distributed in the hope that it will be useful,
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 # GNU General Public License for more details.
12 # GNU General Public License for more details.
13 #
13 #
14 # You should have received a copy of the GNU General Public License
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
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.
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17
17
18 class Project < ActiveRecord::Base
18 class Project < ActiveRecord::Base
19 # Project statuses
19 # Project statuses
20 STATUS_ACTIVE = 1
20 STATUS_ACTIVE = 1
21 STATUS_ARCHIVED = 9
21 STATUS_ARCHIVED = 9
22
22
23 has_many :members, :include => :user, :conditions => "#{User.table_name}.status=#{User::STATUS_ACTIVE}"
23 has_many :members, :include => :user, :conditions => "#{User.table_name}.status=#{User::STATUS_ACTIVE}"
24 has_many :users, :through => :members
24 has_many :users, :through => :members
25 has_many :enabled_modules, :dependent => :delete_all
25 has_many :enabled_modules, :dependent => :delete_all
26 has_and_belongs_to_many :trackers, :order => "#{Tracker.table_name}.position"
26 has_and_belongs_to_many :trackers, :order => "#{Tracker.table_name}.position"
27 has_many :issues, :dependent => :destroy, :order => "#{Issue.table_name}.created_on DESC", :include => [:status, :tracker]
27 has_many :issues, :dependent => :destroy, :order => "#{Issue.table_name}.created_on DESC", :include => [:status, :tracker]
28 has_many :issue_changes, :through => :issues, :source => :journals
28 has_many :issue_changes, :through => :issues, :source => :journals
29 has_many :versions, :dependent => :destroy, :order => "#{Version.table_name}.effective_date DESC, #{Version.table_name}.name DESC"
29 has_many :versions, :dependent => :destroy, :order => "#{Version.table_name}.effective_date DESC, #{Version.table_name}.name DESC"
30 has_many :time_entries, :dependent => :delete_all
30 has_many :time_entries, :dependent => :delete_all
31 has_many :queries, :dependent => :delete_all
31 has_many :queries, :dependent => :delete_all
32 has_many :documents, :dependent => :destroy
32 has_many :documents, :dependent => :destroy
33 has_many :news, :dependent => :delete_all, :include => :author
33 has_many :news, :dependent => :delete_all, :include => :author
34 has_many :issue_categories, :dependent => :delete_all, :order => "#{IssueCategory.table_name}.name"
34 has_many :issue_categories, :dependent => :delete_all, :order => "#{IssueCategory.table_name}.name"
35 has_many :boards, :dependent => :destroy, :order => "position ASC"
35 has_many :boards, :dependent => :destroy, :order => "position ASC"
36 has_one :repository, :dependent => :destroy
36 has_one :repository, :dependent => :destroy
37 has_many :changesets, :through => :repository
37 has_many :changesets, :through => :repository
38 has_one :wiki, :dependent => :destroy
38 has_one :wiki, :dependent => :destroy
39 # Custom field for the project issues
39 # Custom field for the project issues
40 has_and_belongs_to_many :issue_custom_fields,
40 has_and_belongs_to_many :issue_custom_fields,
41 :class_name => 'IssueCustomField',
41 :class_name => 'IssueCustomField',
42 :order => "#{CustomField.table_name}.position",
42 :order => "#{CustomField.table_name}.position",
43 :join_table => "#{table_name_prefix}custom_fields_projects#{table_name_suffix}",
43 :join_table => "#{table_name_prefix}custom_fields_projects#{table_name_suffix}",
44 :association_foreign_key => 'custom_field_id'
44 :association_foreign_key => 'custom_field_id'
45
45
46 acts_as_nested_set :order => 'name', :dependent => :destroy
46 acts_as_nested_set :order => 'name', :dependent => :destroy
47 acts_as_attachable :view_permission => :view_files,
47 acts_as_attachable :view_permission => :view_files,
48 :delete_permission => :manage_files
48 :delete_permission => :manage_files
49
49
50 acts_as_customizable
50 acts_as_customizable
51 acts_as_searchable :columns => ['name', 'description'], :project_key => 'id', :permission => nil
51 acts_as_searchable :columns => ['name', 'description'], :project_key => 'id', :permission => nil
52 acts_as_event :title => Proc.new {|o| "#{l(:label_project)}: #{o.name}"},
52 acts_as_event :title => Proc.new {|o| "#{l(:label_project)}: #{o.name}"},
53 :url => Proc.new {|o| {:controller => 'projects', :action => 'show', :id => o.id}},
53 :url => Proc.new {|o| {:controller => 'projects', :action => 'show', :id => o.id}},
54 :author => nil
54 :author => nil
55
55
56 attr_protected :status, :enabled_module_names
56 attr_protected :status, :enabled_module_names
57
57
58 validates_presence_of :name, :identifier
58 validates_presence_of :name, :identifier
59 validates_uniqueness_of :name, :identifier
59 validates_uniqueness_of :name, :identifier
60 validates_associated :repository, :wiki
60 validates_associated :repository, :wiki
61 validates_length_of :name, :maximum => 30
61 validates_length_of :name, :maximum => 30
62 validates_length_of :homepage, :maximum => 255
62 validates_length_of :homepage, :maximum => 255
63 validates_length_of :identifier, :in => 2..20
63 validates_length_of :identifier, :in => 2..20
64 validates_format_of :identifier, :with => /^[a-z0-9\-]*$/
64 validates_format_of :identifier, :with => /^[a-z0-9\-]*$/
65
65
66 before_destroy :delete_all_members
66 before_destroy :delete_all_members
67
67
68 named_scope :has_module, lambda { |mod| { :conditions => ["#{Project.table_name}.id IN (SELECT em.project_id FROM #{EnabledModule.table_name} em WHERE em.name=?)", mod.to_s] } }
68 named_scope :has_module, lambda { |mod| { :conditions => ["#{Project.table_name}.id IN (SELECT em.project_id FROM #{EnabledModule.table_name} em WHERE em.name=?)", mod.to_s] } }
69 named_scope :active, { :conditions => "#{Project.table_name}.status = #{STATUS_ACTIVE}"}
69 named_scope :active, { :conditions => "#{Project.table_name}.status = #{STATUS_ACTIVE}"}
70 named_scope :public, { :conditions => { :is_public => true } }
70 named_scope :public, { :conditions => { :is_public => true } }
71 named_scope :visible, lambda { { :conditions => Project.visible_by(User.current) } }
71 named_scope :visible, lambda { { :conditions => Project.visible_by(User.current) } }
72
72
73 def identifier=(identifier)
73 def identifier=(identifier)
74 super unless identifier_frozen?
74 super unless identifier_frozen?
75 end
75 end
76
76
77 def identifier_frozen?
77 def identifier_frozen?
78 errors[:identifier].nil? && !(new_record? || identifier.blank?)
78 errors[:identifier].nil? && !(new_record? || identifier.blank?)
79 end
79 end
80
80
81 def issues_with_subprojects(include_subprojects=false)
81 def issues_with_subprojects(include_subprojects=false)
82 conditions = nil
82 conditions = nil
83 if include_subprojects
83 if include_subprojects
84 ids = [id] + descendants.collect(&:id)
84 ids = [id] + descendants.collect(&:id)
85 conditions = ["#{Project.table_name}.id IN (#{ids.join(',')}) AND #{Project.visible_by}"]
85 conditions = ["#{Project.table_name}.id IN (#{ids.join(',')}) AND #{Project.visible_by}"]
86 end
86 end
87 conditions ||= ["#{Project.table_name}.id = ?", id]
87 conditions ||= ["#{Project.table_name}.id = ?", id]
88 # Quick and dirty fix for Rails 2 compatibility
88 # Quick and dirty fix for Rails 2 compatibility
89 Issue.send(:with_scope, :find => { :conditions => conditions }) do
89 Issue.send(:with_scope, :find => { :conditions => conditions }) do
90 Version.send(:with_scope, :find => { :conditions => conditions }) do
90 Version.send(:with_scope, :find => { :conditions => conditions }) do
91 yield
91 yield
92 end
92 end
93 end
93 end
94 end
94 end
95
95
96 # returns latest created projects
96 # returns latest created projects
97 # non public projects will be returned only if user is a member of those
97 # non public projects will be returned only if user is a member of those
98 def self.latest(user=nil, count=5)
98 def self.latest(user=nil, count=5)
99 find(:all, :limit => count, :conditions => visible_by(user), :order => "created_on DESC")
99 find(:all, :limit => count, :conditions => visible_by(user), :order => "created_on DESC")
100 end
100 end
101
101
102 # Returns a SQL :conditions string used to find all active projects for the specified user.
103 #
104 # Examples:
105 # Projects.visible_by(admin) => "projects.status = 1"
106 # Projects.visible_by(normal_user) => "projects.status = 1 AND projects.is_public = 1"
102 def self.visible_by(user=nil)
107 def self.visible_by(user=nil)
103 user ||= User.current
108 user ||= User.current
104 if user && user.admin?
109 if user && user.admin?
105 return "#{Project.table_name}.status=#{Project::STATUS_ACTIVE}"
110 return "#{Project.table_name}.status=#{Project::STATUS_ACTIVE}"
106 elsif user && user.memberships.any?
111 elsif user && user.memberships.any?
107 return "#{Project.table_name}.status=#{Project::STATUS_ACTIVE} AND (#{Project.table_name}.is_public = #{connection.quoted_true} or #{Project.table_name}.id IN (#{user.memberships.collect{|m| m.project_id}.join(',')}))"
112 return "#{Project.table_name}.status=#{Project::STATUS_ACTIVE} AND (#{Project.table_name}.is_public = #{connection.quoted_true} or #{Project.table_name}.id IN (#{user.memberships.collect{|m| m.project_id}.join(',')}))"
108 else
113 else
109 return "#{Project.table_name}.status=#{Project::STATUS_ACTIVE} AND #{Project.table_name}.is_public = #{connection.quoted_true}"
114 return "#{Project.table_name}.status=#{Project::STATUS_ACTIVE} AND #{Project.table_name}.is_public = #{connection.quoted_true}"
110 end
115 end
111 end
116 end
112
117
113 def self.allowed_to_condition(user, permission, options={})
118 def self.allowed_to_condition(user, permission, options={})
114 statements = []
119 statements = []
115 base_statement = "#{Project.table_name}.status=#{Project::STATUS_ACTIVE}"
120 base_statement = "#{Project.table_name}.status=#{Project::STATUS_ACTIVE}"
116 if perm = Redmine::AccessControl.permission(permission)
121 if perm = Redmine::AccessControl.permission(permission)
117 unless perm.project_module.nil?
122 unless perm.project_module.nil?
118 # If the permission belongs to a project module, make sure the module is enabled
123 # If the permission belongs to a project module, make sure the module is enabled
119 base_statement << " AND EXISTS (SELECT em.id FROM #{EnabledModule.table_name} em WHERE em.name='#{perm.project_module}' AND em.project_id=#{Project.table_name}.id)"
124 base_statement << " AND EXISTS (SELECT em.id FROM #{EnabledModule.table_name} em WHERE em.name='#{perm.project_module}' AND em.project_id=#{Project.table_name}.id)"
120 end
125 end
121 end
126 end
122 if options[:project]
127 if options[:project]
123 project_statement = "#{Project.table_name}.id = #{options[:project].id}"
128 project_statement = "#{Project.table_name}.id = #{options[:project].id}"
124 project_statement << " OR (#{Project.table_name}.lft > #{options[:project].lft} AND #{Project.table_name}.rgt < #{options[:project].rgt})" if options[:with_subprojects]
129 project_statement << " OR (#{Project.table_name}.lft > #{options[:project].lft} AND #{Project.table_name}.rgt < #{options[:project].rgt})" if options[:with_subprojects]
125 base_statement = "(#{project_statement}) AND (#{base_statement})"
130 base_statement = "(#{project_statement}) AND (#{base_statement})"
126 end
131 end
127 if user.admin?
132 if user.admin?
128 # no restriction
133 # no restriction
129 else
134 else
130 statements << "1=0"
135 statements << "1=0"
131 if user.logged?
136 if user.logged?
132 statements << "#{Project.table_name}.is_public = #{connection.quoted_true}" if Role.non_member.allowed_to?(permission)
137 statements << "#{Project.table_name}.is_public = #{connection.quoted_true}" if Role.non_member.allowed_to?(permission)
133 allowed_project_ids = user.memberships.select {|m| m.role.allowed_to?(permission)}.collect {|m| m.project_id}
138 allowed_project_ids = user.memberships.select {|m| m.role.allowed_to?(permission)}.collect {|m| m.project_id}
134 statements << "#{Project.table_name}.id IN (#{allowed_project_ids.join(',')})" if allowed_project_ids.any?
139 statements << "#{Project.table_name}.id IN (#{allowed_project_ids.join(',')})" if allowed_project_ids.any?
135 elsif Role.anonymous.allowed_to?(permission)
140 elsif Role.anonymous.allowed_to?(permission)
136 # anonymous user allowed on public project
141 # anonymous user allowed on public project
137 statements << "#{Project.table_name}.is_public = #{connection.quoted_true}"
142 statements << "#{Project.table_name}.is_public = #{connection.quoted_true}"
138 else
143 else
139 # anonymous user is not authorized
144 # anonymous user is not authorized
140 end
145 end
141 end
146 end
142 statements.empty? ? base_statement : "((#{base_statement}) AND (#{statements.join(' OR ')}))"
147 statements.empty? ? base_statement : "((#{base_statement}) AND (#{statements.join(' OR ')}))"
143 end
148 end
144
149
150 # Returns a :conditions SQL string that can be used to find the issues associated with this project.
151 #
152 # Examples:
153 # project.project_condition(true) => "(projects.id = 1 OR (projects.lft > 1 AND projects.rgt < 10))"
154 # project.project_condition(false) => "projects.id = 1"
145 def project_condition(with_subprojects)
155 def project_condition(with_subprojects)
146 cond = "#{Project.table_name}.id = #{id}"
156 cond = "#{Project.table_name}.id = #{id}"
147 cond = "(#{cond} OR (#{Project.table_name}.lft > #{lft} AND #{Project.table_name}.rgt < #{rgt}))" if with_subprojects
157 cond = "(#{cond} OR (#{Project.table_name}.lft > #{lft} AND #{Project.table_name}.rgt < #{rgt}))" if with_subprojects
148 cond
158 cond
149 end
159 end
150
160
151 def self.find(*args)
161 def self.find(*args)
152 if args.first && args.first.is_a?(String) && !args.first.match(/^\d*$/)
162 if args.first && args.first.is_a?(String) && !args.first.match(/^\d*$/)
153 project = find_by_identifier(*args)
163 project = find_by_identifier(*args)
154 raise ActiveRecord::RecordNotFound, "Couldn't find Project with identifier=#{args.first}" if project.nil?
164 raise ActiveRecord::RecordNotFound, "Couldn't find Project with identifier=#{args.first}" if project.nil?
155 project
165 project
156 else
166 else
157 super
167 super
158 end
168 end
159 end
169 end
160
170
161 def to_param
171 def to_param
162 # id is used for projects with a numeric identifier (compatibility)
172 # id is used for projects with a numeric identifier (compatibility)
163 @to_param ||= (identifier.to_s =~ %r{^\d*$} ? id : identifier)
173 @to_param ||= (identifier.to_s =~ %r{^\d*$} ? id : identifier)
164 end
174 end
165
175
166 def active?
176 def active?
167 self.status == STATUS_ACTIVE
177 self.status == STATUS_ACTIVE
168 end
178 end
169
179
170 # Archives the project and its descendants recursively
180 # Archives the project and its descendants recursively
171 def archive
181 def archive
172 # Archive subprojects if any
182 # Archive subprojects if any
173 children.each do |subproject|
183 children.each do |subproject|
174 subproject.archive
184 subproject.archive
175 end
185 end
176 update_attribute :status, STATUS_ARCHIVED
186 update_attribute :status, STATUS_ARCHIVED
177 end
187 end
178
188
179 # Unarchives the project
189 # Unarchives the project
180 # All its ancestors must be active
190 # All its ancestors must be active
181 def unarchive
191 def unarchive
182 return false if ancestors.detect {|a| !a.active?}
192 return false if ancestors.detect {|a| !a.active?}
183 update_attribute :status, STATUS_ACTIVE
193 update_attribute :status, STATUS_ACTIVE
184 end
194 end
185
195
186 # Returns an array of projects the project can be moved to
196 # Returns an array of projects the project can be moved to
187 def possible_parents
197 def possible_parents
188 @possible_parents ||= (Project.active.find(:all) - self_and_descendants)
198 @possible_parents ||= (Project.active.find(:all) - self_and_descendants)
189 end
199 end
190
200
191 # Sets the parent of the project
201 # Sets the parent of the project
192 # Argument can be either a Project, a String, a Fixnum or nil
202 # Argument can be either a Project, a String, a Fixnum or nil
193 def set_parent!(p)
203 def set_parent!(p)
194 unless p.nil? || p.is_a?(Project)
204 unless p.nil? || p.is_a?(Project)
195 if p.to_s.blank?
205 if p.to_s.blank?
196 p = nil
206 p = nil
197 else
207 else
198 p = Project.find_by_id(p)
208 p = Project.find_by_id(p)
199 return false unless p
209 return false unless p
200 end
210 end
201 end
211 end
202 if p == parent && !p.nil?
212 if p == parent && !p.nil?
203 # Nothing to do
213 # Nothing to do
204 true
214 true
205 elsif p.nil? || (p.active? && move_possible?(p))
215 elsif p.nil? || (p.active? && move_possible?(p))
206 # Insert the project so that target's children or root projects stay alphabetically sorted
216 # Insert the project so that target's children or root projects stay alphabetically sorted
207 sibs = (p.nil? ? self.class.roots : p.children)
217 sibs = (p.nil? ? self.class.roots : p.children)
208 to_be_inserted_before = sibs.detect {|c| c.name.to_s.downcase > name.to_s.downcase }
218 to_be_inserted_before = sibs.detect {|c| c.name.to_s.downcase > name.to_s.downcase }
209 if to_be_inserted_before
219 if to_be_inserted_before
210 move_to_left_of(to_be_inserted_before)
220 move_to_left_of(to_be_inserted_before)
211 elsif p.nil?
221 elsif p.nil?
212 if sibs.empty?
222 if sibs.empty?
213 # move_to_root adds the project in first (ie. left) position
223 # move_to_root adds the project in first (ie. left) position
214 move_to_root
224 move_to_root
215 else
225 else
216 move_to_right_of(sibs.last) unless self == sibs.last
226 move_to_right_of(sibs.last) unless self == sibs.last
217 end
227 end
218 else
228 else
219 # move_to_child_of adds the project in last (ie.right) position
229 # move_to_child_of adds the project in last (ie.right) position
220 move_to_child_of(p)
230 move_to_child_of(p)
221 end
231 end
222 true
232 true
223 else
233 else
224 # Can not move to the given target
234 # Can not move to the given target
225 false
235 false
226 end
236 end
227 end
237 end
228
238
229 # Returns an array of the trackers used by the project and its active sub projects
239 # Returns an array of the trackers used by the project and its active sub projects
230 def rolled_up_trackers
240 def rolled_up_trackers
231 @rolled_up_trackers ||=
241 @rolled_up_trackers ||=
232 Tracker.find(:all, :include => :projects,
242 Tracker.find(:all, :include => :projects,
233 :select => "DISTINCT #{Tracker.table_name}.*",
243 :select => "DISTINCT #{Tracker.table_name}.*",
234 :conditions => ["#{Project.table_name}.lft >= ? AND #{Project.table_name}.rgt <= ? AND #{Project.table_name}.status = #{STATUS_ACTIVE}", lft, rgt],
244 :conditions => ["#{Project.table_name}.lft >= ? AND #{Project.table_name}.rgt <= ? AND #{Project.table_name}.status = #{STATUS_ACTIVE}", lft, rgt],
235 :order => "#{Tracker.table_name}.position")
245 :order => "#{Tracker.table_name}.position")
236 end
246 end
237
247
238 # Deletes all project's members
248 # Deletes all project's members
239 def delete_all_members
249 def delete_all_members
240 Member.delete_all(['project_id = ?', id])
250 Member.delete_all(['project_id = ?', id])
241 end
251 end
242
252
243 # Users issues can be assigned to
253 # Users issues can be assigned to
244 def assignable_users
254 def assignable_users
245 members.select {|m| m.role.assignable?}.collect {|m| m.user}.sort
255 members.select {|m| m.role.assignable?}.collect {|m| m.user}.sort
246 end
256 end
247
257
248 # Returns the mail adresses of users that should be always notified on project events
258 # Returns the mail adresses of users that should be always notified on project events
249 def recipients
259 def recipients
250 members.select {|m| m.mail_notification? || m.user.mail_notification?}.collect {|m| m.user.mail}
260 members.select {|m| m.mail_notification? || m.user.mail_notification?}.collect {|m| m.user.mail}
251 end
261 end
252
262
253 # Returns an array of all custom fields enabled for project issues
263 # Returns an array of all custom fields enabled for project issues
254 # (explictly associated custom fields and custom fields enabled for all projects)
264 # (explictly associated custom fields and custom fields enabled for all projects)
255 def all_issue_custom_fields
265 def all_issue_custom_fields
256 @all_issue_custom_fields ||= (IssueCustomField.for_all + issue_custom_fields).uniq.sort
266 @all_issue_custom_fields ||= (IssueCustomField.for_all + issue_custom_fields).uniq.sort
257 end
267 end
258
268
259 def project
269 def project
260 self
270 self
261 end
271 end
262
272
263 def <=>(project)
273 def <=>(project)
264 name.downcase <=> project.name.downcase
274 name.downcase <=> project.name.downcase
265 end
275 end
266
276
267 def to_s
277 def to_s
268 name
278 name
269 end
279 end
270
280
271 # Returns a short description of the projects (first lines)
281 # Returns a short description of the projects (first lines)
272 def short_description(length = 255)
282 def short_description(length = 255)
273 description.gsub(/^(.{#{length}}[^\n\r]*).*$/m, '\1...').strip if description
283 description.gsub(/^(.{#{length}}[^\n\r]*).*$/m, '\1...').strip if description
274 end
284 end
275
285
286 # Return true if this project is allowed to do the specified action.
287 # action can be:
288 # * a parameter-like Hash (eg. :controller => 'projects', :action => 'edit')
289 # * a permission Symbol (eg. :edit_project)
276 def allows_to?(action)
290 def allows_to?(action)
277 if action.is_a? Hash
291 if action.is_a? Hash
278 allowed_actions.include? "#{action[:controller]}/#{action[:action]}"
292 allowed_actions.include? "#{action[:controller]}/#{action[:action]}"
279 else
293 else
280 allowed_permissions.include? action
294 allowed_permissions.include? action
281 end
295 end
282 end
296 end
283
297
284 def module_enabled?(module_name)
298 def module_enabled?(module_name)
285 module_name = module_name.to_s
299 module_name = module_name.to_s
286 enabled_modules.detect {|m| m.name == module_name}
300 enabled_modules.detect {|m| m.name == module_name}
287 end
301 end
288
302
289 def enabled_module_names=(module_names)
303 def enabled_module_names=(module_names)
290 if module_names && module_names.is_a?(Array)
304 if module_names && module_names.is_a?(Array)
291 module_names = module_names.collect(&:to_s)
305 module_names = module_names.collect(&:to_s)
292 # remove disabled modules
306 # remove disabled modules
293 enabled_modules.each {|mod| mod.destroy unless module_names.include?(mod.name)}
307 enabled_modules.each {|mod| mod.destroy unless module_names.include?(mod.name)}
294 # add new modules
308 # add new modules
295 module_names.each {|name| enabled_modules << EnabledModule.new(:name => name)}
309 module_names.each {|name| enabled_modules << EnabledModule.new(:name => name)}
296 else
310 else
297 enabled_modules.clear
311 enabled_modules.clear
298 end
312 end
299 end
313 end
300
314
301 # Returns an auto-generated project identifier based on the last identifier used
315 # Returns an auto-generated project identifier based on the last identifier used
302 def self.next_identifier
316 def self.next_identifier
303 p = Project.find(:first, :order => 'created_on DESC')
317 p = Project.find(:first, :order => 'created_on DESC')
304 p.nil? ? nil : p.identifier.to_s.succ
318 p.nil? ? nil : p.identifier.to_s.succ
305 end
319 end
306
320
307 protected
321 protected
308 def validate
322 def validate
309 errors.add(:identifier, :invalid) if !identifier.blank? && identifier.match(/^\d*$/)
323 errors.add(:identifier, :invalid) if !identifier.blank? && identifier.match(/^\d*$/)
310 end
324 end
311
325
312 private
326 private
313 def allowed_permissions
327 def allowed_permissions
314 @allowed_permissions ||= begin
328 @allowed_permissions ||= begin
315 module_names = enabled_modules.collect {|m| m.name}
329 module_names = enabled_modules.collect {|m| m.name}
316 Redmine::AccessControl.modules_permissions(module_names).collect {|p| p.name}
330 Redmine::AccessControl.modules_permissions(module_names).collect {|p| p.name}
317 end
331 end
318 end
332 end
319
333
320 def allowed_actions
334 def allowed_actions
321 @actions_allowed ||= allowed_permissions.inject([]) { |actions, permission| actions += Redmine::AccessControl.allowed_actions(permission) }.flatten
335 @actions_allowed ||= allowed_permissions.inject([]) { |actions, permission| actions += Redmine::AccessControl.allowed_actions(permission) }.flatten
322 end
336 end
323 end
337 end
@@ -1,323 +1,325
1 # redMine - project management software
1 # redMine - project management software
2 # Copyright (C) 2006-2007 Jean-Philippe Lang
2 # Copyright (C) 2006-2007 Jean-Philippe Lang
3 #
3 #
4 # This program is free software; you can redistribute it and/or
4 # This program is free software; you can redistribute it and/or
5 # modify it under the terms of the GNU General Public License
5 # modify it under the terms of the GNU General Public License
6 # as published by the Free Software Foundation; either version 2
6 # as published by the Free Software Foundation; either version 2
7 # of the License, or (at your option) any later version.
7 # of the License, or (at your option) any later version.
8 #
8 #
9 # This program is distributed in the hope that it will be useful,
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 # GNU General Public License for more details.
12 # GNU General Public License for more details.
13 #
13 #
14 # You should have received a copy of the GNU General Public License
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
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.
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17
17
18 require "digest/sha1"
18 require "digest/sha1"
19
19
20 class User < ActiveRecord::Base
20 class User < ActiveRecord::Base
21
21
22 # Account statuses
22 # Account statuses
23 STATUS_ANONYMOUS = 0
23 STATUS_ANONYMOUS = 0
24 STATUS_ACTIVE = 1
24 STATUS_ACTIVE = 1
25 STATUS_REGISTERED = 2
25 STATUS_REGISTERED = 2
26 STATUS_LOCKED = 3
26 STATUS_LOCKED = 3
27
27
28 USER_FORMATS = {
28 USER_FORMATS = {
29 :firstname_lastname => '#{firstname} #{lastname}',
29 :firstname_lastname => '#{firstname} #{lastname}',
30 :firstname => '#{firstname}',
30 :firstname => '#{firstname}',
31 :lastname_firstname => '#{lastname} #{firstname}',
31 :lastname_firstname => '#{lastname} #{firstname}',
32 :lastname_coma_firstname => '#{lastname}, #{firstname}',
32 :lastname_coma_firstname => '#{lastname}, #{firstname}',
33 :username => '#{login}'
33 :username => '#{login}'
34 }
34 }
35
35
36 has_many :memberships, :class_name => 'Member', :include => [ :project, :role ], :conditions => "#{Project.table_name}.status=#{Project::STATUS_ACTIVE}", :order => "#{Project.table_name}.name"
36 has_many :memberships, :class_name => 'Member', :include => [ :project, :role ], :conditions => "#{Project.table_name}.status=#{Project::STATUS_ACTIVE}", :order => "#{Project.table_name}.name"
37 has_many :members, :dependent => :delete_all
37 has_many :members, :dependent => :delete_all
38 has_many :projects, :through => :memberships
38 has_many :projects, :through => :memberships
39 has_many :issue_categories, :foreign_key => 'assigned_to_id', :dependent => :nullify
39 has_many :issue_categories, :foreign_key => 'assigned_to_id', :dependent => :nullify
40 has_many :changesets, :dependent => :nullify
40 has_many :changesets, :dependent => :nullify
41 has_one :preference, :dependent => :destroy, :class_name => 'UserPreference'
41 has_one :preference, :dependent => :destroy, :class_name => 'UserPreference'
42 has_one :rss_token, :dependent => :destroy, :class_name => 'Token', :conditions => "action='feeds'"
42 has_one :rss_token, :dependent => :destroy, :class_name => 'Token', :conditions => "action='feeds'"
43 belongs_to :auth_source
43 belongs_to :auth_source
44
44
45 # Active non-anonymous users scope
45 # Active non-anonymous users scope
46 named_scope :active, :conditions => "#{User.table_name}.status = #{STATUS_ACTIVE}"
46 named_scope :active, :conditions => "#{User.table_name}.status = #{STATUS_ACTIVE}"
47
47
48 acts_as_customizable
48 acts_as_customizable
49
49
50 attr_accessor :password, :password_confirmation
50 attr_accessor :password, :password_confirmation
51 attr_accessor :last_before_login_on
51 attr_accessor :last_before_login_on
52 # Prevents unauthorized assignments
52 # Prevents unauthorized assignments
53 attr_protected :login, :admin, :password, :password_confirmation, :hashed_password
53 attr_protected :login, :admin, :password, :password_confirmation, :hashed_password
54
54
55 validates_presence_of :login, :firstname, :lastname, :mail, :if => Proc.new { |user| !user.is_a?(AnonymousUser) }
55 validates_presence_of :login, :firstname, :lastname, :mail, :if => Proc.new { |user| !user.is_a?(AnonymousUser) }
56 validates_uniqueness_of :login, :if => Proc.new { |user| !user.login.blank? }
56 validates_uniqueness_of :login, :if => Proc.new { |user| !user.login.blank? }
57 validates_uniqueness_of :mail, :if => Proc.new { |user| !user.mail.blank? }, :case_sensitive => false
57 validates_uniqueness_of :mail, :if => Proc.new { |user| !user.mail.blank? }, :case_sensitive => false
58 # Login must contain lettres, numbers, underscores only
58 # Login must contain lettres, numbers, underscores only
59 validates_format_of :login, :with => /^[a-z0-9_\-@\.]*$/i
59 validates_format_of :login, :with => /^[a-z0-9_\-@\.]*$/i
60 validates_length_of :login, :maximum => 30
60 validates_length_of :login, :maximum => 30
61 validates_format_of :firstname, :lastname, :with => /^[\w\s\'\-\.]*$/i
61 validates_format_of :firstname, :lastname, :with => /^[\w\s\'\-\.]*$/i
62 validates_length_of :firstname, :lastname, :maximum => 30
62 validates_length_of :firstname, :lastname, :maximum => 30
63 validates_format_of :mail, :with => /^([^@\s]+)@((?:[-a-z0-9]+\.)+[a-z]{2,})$/i, :allow_nil => true
63 validates_format_of :mail, :with => /^([^@\s]+)@((?:[-a-z0-9]+\.)+[a-z]{2,})$/i, :allow_nil => true
64 validates_length_of :mail, :maximum => 60, :allow_nil => true
64 validates_length_of :mail, :maximum => 60, :allow_nil => true
65 validates_length_of :password, :minimum => 4, :allow_nil => true
65 validates_length_of :password, :minimum => 4, :allow_nil => true
66 validates_confirmation_of :password, :allow_nil => true
66 validates_confirmation_of :password, :allow_nil => true
67
67
68 def before_create
68 def before_create
69 self.mail_notification = false
69 self.mail_notification = false
70 true
70 true
71 end
71 end
72
72
73 def before_save
73 def before_save
74 # update hashed_password if password was set
74 # update hashed_password if password was set
75 self.hashed_password = User.hash_password(self.password) if self.password
75 self.hashed_password = User.hash_password(self.password) if self.password
76 end
76 end
77
77
78 def reload(*args)
78 def reload(*args)
79 @name = nil
79 @name = nil
80 super
80 super
81 end
81 end
82
82
83 def identity_url=(url)
83 def identity_url=(url)
84 if url.blank?
84 if url.blank?
85 write_attribute(:identity_url, '')
85 write_attribute(:identity_url, '')
86 else
86 else
87 begin
87 begin
88 write_attribute(:identity_url, OpenIdAuthentication.normalize_identifier(url))
88 write_attribute(:identity_url, OpenIdAuthentication.normalize_identifier(url))
89 rescue OpenIdAuthentication::InvalidOpenId
89 rescue OpenIdAuthentication::InvalidOpenId
90 # Invlaid url, don't save
90 # Invlaid url, don't save
91 end
91 end
92 end
92 end
93 self.read_attribute(:identity_url)
93 self.read_attribute(:identity_url)
94 end
94 end
95
95
96 # Returns the user that matches provided login and password, or nil
96 # Returns the user that matches provided login and password, or nil
97 def self.try_to_login(login, password)
97 def self.try_to_login(login, password)
98 # Make sure no one can sign in with an empty password
98 # Make sure no one can sign in with an empty password
99 return nil if password.to_s.empty?
99 return nil if password.to_s.empty?
100 user = find(:first, :conditions => ["login=?", login])
100 user = find(:first, :conditions => ["login=?", login])
101 if user
101 if user
102 # user is already in local database
102 # user is already in local database
103 return nil if !user.active?
103 return nil if !user.active?
104 if user.auth_source
104 if user.auth_source
105 # user has an external authentication method
105 # user has an external authentication method
106 return nil unless user.auth_source.authenticate(login, password)
106 return nil unless user.auth_source.authenticate(login, password)
107 else
107 else
108 # authentication with local password
108 # authentication with local password
109 return nil unless User.hash_password(password) == user.hashed_password
109 return nil unless User.hash_password(password) == user.hashed_password
110 end
110 end
111 else
111 else
112 # user is not yet registered, try to authenticate with available sources
112 # user is not yet registered, try to authenticate with available sources
113 attrs = AuthSource.authenticate(login, password)
113 attrs = AuthSource.authenticate(login, password)
114 if attrs
114 if attrs
115 user = new(*attrs)
115 user = new(*attrs)
116 user.login = login
116 user.login = login
117 user.language = Setting.default_language
117 user.language = Setting.default_language
118 if user.save
118 if user.save
119 user.reload
119 user.reload
120 logger.info("User '#{user.login}' created from the LDAP") if logger
120 logger.info("User '#{user.login}' created from the LDAP") if logger
121 end
121 end
122 end
122 end
123 end
123 end
124 user.update_attribute(:last_login_on, Time.now) if user && !user.new_record?
124 user.update_attribute(:last_login_on, Time.now) if user && !user.new_record?
125 user
125 user
126 rescue => text
126 rescue => text
127 raise text
127 raise text
128 end
128 end
129
129
130 # Returns the user who matches the given autologin +key+ or nil
130 # Returns the user who matches the given autologin +key+ or nil
131 def self.try_to_autologin(key)
131 def self.try_to_autologin(key)
132 token = Token.find_by_action_and_value('autologin', key)
132 token = Token.find_by_action_and_value('autologin', key)
133 if token && (token.created_on > Setting.autologin.to_i.day.ago) && token.user && token.user.active?
133 if token && (token.created_on > Setting.autologin.to_i.day.ago) && token.user && token.user.active?
134 token.user.update_attribute(:last_login_on, Time.now)
134 token.user.update_attribute(:last_login_on, Time.now)
135 token.user
135 token.user
136 end
136 end
137 end
137 end
138
138
139 # Return user's full name for display
139 # Return user's full name for display
140 def name(formatter = nil)
140 def name(formatter = nil)
141 if formatter
141 if formatter
142 eval('"' + (USER_FORMATS[formatter] || USER_FORMATS[:firstname_lastname]) + '"')
142 eval('"' + (USER_FORMATS[formatter] || USER_FORMATS[:firstname_lastname]) + '"')
143 else
143 else
144 @name ||= eval('"' + (USER_FORMATS[Setting.user_format] || USER_FORMATS[:firstname_lastname]) + '"')
144 @name ||= eval('"' + (USER_FORMATS[Setting.user_format] || USER_FORMATS[:firstname_lastname]) + '"')
145 end
145 end
146 end
146 end
147
147
148 def active?
148 def active?
149 self.status == STATUS_ACTIVE
149 self.status == STATUS_ACTIVE
150 end
150 end
151
151
152 def registered?
152 def registered?
153 self.status == STATUS_REGISTERED
153 self.status == STATUS_REGISTERED
154 end
154 end
155
155
156 def locked?
156 def locked?
157 self.status == STATUS_LOCKED
157 self.status == STATUS_LOCKED
158 end
158 end
159
159
160 def check_password?(clear_password)
160 def check_password?(clear_password)
161 User.hash_password(clear_password) == self.hashed_password
161 User.hash_password(clear_password) == self.hashed_password
162 end
162 end
163
163
164 # Generate and set a random password. Useful for automated user creation
164 # Generate and set a random password. Useful for automated user creation
165 # Based on Token#generate_token_value
165 # Based on Token#generate_token_value
166 #
166 #
167 def random_password
167 def random_password
168 chars = ("a".."z").to_a + ("A".."Z").to_a + ("0".."9").to_a
168 chars = ("a".."z").to_a + ("A".."Z").to_a + ("0".."9").to_a
169 password = ''
169 password = ''
170 40.times { |i| password << chars[rand(chars.size-1)] }
170 40.times { |i| password << chars[rand(chars.size-1)] }
171 self.password = password
171 self.password = password
172 self.password_confirmation = password
172 self.password_confirmation = password
173 self
173 self
174 end
174 end
175
175
176 def pref
176 def pref
177 self.preference ||= UserPreference.new(:user => self)
177 self.preference ||= UserPreference.new(:user => self)
178 end
178 end
179
179
180 def time_zone
180 def time_zone
181 @time_zone ||= (self.pref.time_zone.blank? ? nil : ActiveSupport::TimeZone[self.pref.time_zone])
181 @time_zone ||= (self.pref.time_zone.blank? ? nil : ActiveSupport::TimeZone[self.pref.time_zone])
182 end
182 end
183
183
184 def wants_comments_in_reverse_order?
184 def wants_comments_in_reverse_order?
185 self.pref[:comments_sorting] == 'desc'
185 self.pref[:comments_sorting] == 'desc'
186 end
186 end
187
187
188 # Return user's RSS key (a 40 chars long string), used to access feeds
188 # Return user's RSS key (a 40 chars long string), used to access feeds
189 def rss_key
189 def rss_key
190 token = self.rss_token || Token.create(:user => self, :action => 'feeds')
190 token = self.rss_token || Token.create(:user => self, :action => 'feeds')
191 token.value
191 token.value
192 end
192 end
193
193
194 # Return an array of project ids for which the user has explicitly turned mail notifications on
194 # Return an array of project ids for which the user has explicitly turned mail notifications on
195 def notified_projects_ids
195 def notified_projects_ids
196 @notified_projects_ids ||= memberships.select {|m| m.mail_notification?}.collect(&:project_id)
196 @notified_projects_ids ||= memberships.select {|m| m.mail_notification?}.collect(&:project_id)
197 end
197 end
198
198
199 def notified_project_ids=(ids)
199 def notified_project_ids=(ids)
200 Member.update_all("mail_notification = #{connection.quoted_false}", ['user_id = ?', id])
200 Member.update_all("mail_notification = #{connection.quoted_false}", ['user_id = ?', id])
201 Member.update_all("mail_notification = #{connection.quoted_true}", ['user_id = ? AND project_id IN (?)', id, ids]) if ids && !ids.empty?
201 Member.update_all("mail_notification = #{connection.quoted_true}", ['user_id = ? AND project_id IN (?)', id, ids]) if ids && !ids.empty?
202 @notified_projects_ids = nil
202 @notified_projects_ids = nil
203 notified_projects_ids
203 notified_projects_ids
204 end
204 end
205
205
206 def self.find_by_rss_key(key)
206 def self.find_by_rss_key(key)
207 token = Token.find_by_value(key)
207 token = Token.find_by_value(key)
208 token && token.user.active? ? token.user : nil
208 token && token.user.active? ? token.user : nil
209 end
209 end
210
210
211 # Makes find_by_mail case-insensitive
211 # Makes find_by_mail case-insensitive
212 def self.find_by_mail(mail)
212 def self.find_by_mail(mail)
213 find(:first, :conditions => ["LOWER(mail) = ?", mail.to_s.downcase])
213 find(:first, :conditions => ["LOWER(mail) = ?", mail.to_s.downcase])
214 end
214 end
215
215
216 # Sort users by their display names
216 # Sort users by their display names
217 def <=>(user)
217 def <=>(user)
218 self.to_s.downcase <=> user.to_s.downcase
218 self.to_s.downcase <=> user.to_s.downcase
219 end
219 end
220
220
221 def to_s
221 def to_s
222 name
222 name
223 end
223 end
224
224
225 def logged?
225 def logged?
226 true
226 true
227 end
227 end
228
228
229 def anonymous?
229 def anonymous?
230 !logged?
230 !logged?
231 end
231 end
232
232
233 # Return user's role for project
233 # Return user's role for project
234 def role_for_project(project)
234 def role_for_project(project)
235 # No role on archived projects
235 # No role on archived projects
236 return nil unless project && project.active?
236 return nil unless project && project.active?
237 if logged?
237 if logged?
238 # Find project membership
238 # Find project membership
239 membership = memberships.detect {|m| m.project_id == project.id}
239 membership = memberships.detect {|m| m.project_id == project.id}
240 if membership
240 if membership
241 membership.role
241 membership.role
242 else
242 else
243 @role_non_member ||= Role.non_member
243 @role_non_member ||= Role.non_member
244 end
244 end
245 else
245 else
246 @role_anonymous ||= Role.anonymous
246 @role_anonymous ||= Role.anonymous
247 end
247 end
248 end
248 end
249
249
250 # Return true if the user is a member of project
250 # Return true if the user is a member of project
251 def member_of?(project)
251 def member_of?(project)
252 role_for_project(project).member?
252 role_for_project(project).member?
253 end
253 end
254
254
255 # Return true if the user is allowed to do the specified action on project
255 # Return true if the user is allowed to do the specified action on project
256 # action can be:
256 # action can be:
257 # * a parameter-like Hash (eg. :controller => 'projects', :action => 'edit')
257 # * a parameter-like Hash (eg. :controller => 'projects', :action => 'edit')
258 # * a permission Symbol (eg. :edit_project)
258 # * a permission Symbol (eg. :edit_project)
259 def allowed_to?(action, project, options={})
259 def allowed_to?(action, project, options={})
260 if project
260 if project
261 # No action allowed on archived projects
261 # No action allowed on archived projects
262 return false unless project.active?
262 return false unless project.active?
263 # No action allowed on disabled modules
263 # No action allowed on disabled modules
264 return false unless project.allows_to?(action)
264 return false unless project.allows_to?(action)
265 # Admin users are authorized for anything else
265 # Admin users are authorized for anything else
266 return true if admin?
266 return true if admin?
267
267
268 role = role_for_project(project)
268 role = role_for_project(project)
269 return false unless role
269 return false unless role
270 role.allowed_to?(action) && (project.is_public? || role.member?)
270 role.allowed_to?(action) && (project.is_public? || role.member?)
271
271
272 elsif options[:global]
272 elsif options[:global]
273 # authorize if user has at least one role that has this permission
273 # authorize if user has at least one role that has this permission
274 roles = memberships.collect {|m| m.role}.uniq
274 roles = memberships.collect {|m| m.role}.uniq
275 roles.detect {|r| r.allowed_to?(action)} || (self.logged? ? Role.non_member.allowed_to?(action) : Role.anonymous.allowed_to?(action))
275 roles.detect {|r| r.allowed_to?(action)} || (self.logged? ? Role.non_member.allowed_to?(action) : Role.anonymous.allowed_to?(action))
276 else
276 else
277 false
277 false
278 end
278 end
279 end
279 end
280
280
281 def self.current=(user)
281 def self.current=(user)
282 @current_user = user
282 @current_user = user
283 end
283 end
284
284
285 def self.current
285 def self.current
286 @current_user ||= User.anonymous
286 @current_user ||= User.anonymous
287 end
287 end
288
288
289 # Returns the anonymous user. If the anonymous user does not exist, it is created. There can be only
290 # one anonymous user per database.
289 def self.anonymous
291 def self.anonymous
290 anonymous_user = AnonymousUser.find(:first)
292 anonymous_user = AnonymousUser.find(:first)
291 if anonymous_user.nil?
293 if anonymous_user.nil?
292 anonymous_user = AnonymousUser.create(:lastname => 'Anonymous', :firstname => '', :mail => '', :login => '', :status => 0)
294 anonymous_user = AnonymousUser.create(:lastname => 'Anonymous', :firstname => '', :mail => '', :login => '', :status => 0)
293 raise 'Unable to create the anonymous user.' if anonymous_user.new_record?
295 raise 'Unable to create the anonymous user.' if anonymous_user.new_record?
294 end
296 end
295 anonymous_user
297 anonymous_user
296 end
298 end
297
299
298 private
300 private
299 # Return password digest
301 # Return password digest
300 def self.hash_password(clear_password)
302 def self.hash_password(clear_password)
301 Digest::SHA1.hexdigest(clear_password || "")
303 Digest::SHA1.hexdigest(clear_password || "")
302 end
304 end
303 end
305 end
304
306
305 class AnonymousUser < User
307 class AnonymousUser < User
306
308
307 def validate_on_create
309 def validate_on_create
308 # There should be only one AnonymousUser in the database
310 # There should be only one AnonymousUser in the database
309 errors.add_to_base 'An anonymous user already exists.' if AnonymousUser.find(:first)
311 errors.add_to_base 'An anonymous user already exists.' if AnonymousUser.find(:first)
310 end
312 end
311
313
312 def available_custom_fields
314 def available_custom_fields
313 []
315 []
314 end
316 end
315
317
316 # Overrides a few properties
318 # Overrides a few properties
317 def logged?; false end
319 def logged?; false end
318 def admin; false end
320 def admin; false end
319 def name; 'Anonymous' end
321 def name; 'Anonymous' end
320 def mail; nil end
322 def mail; nil end
321 def time_zone; nil end
323 def time_zone; nil end
322 def rss_key; nil end
324 def rss_key; nil end
323 end
325 end
@@ -1,143 +1,153
1 # redMine - project management software
1 # redMine - project management software
2 # Copyright (C) 2006 Jean-Philippe Lang
2 # Copyright (C) 2006 Jean-Philippe Lang
3 #
3 #
4 # This program is free software; you can redistribute it and/or
4 # This program is free software; you can redistribute it and/or
5 # modify it under the terms of the GNU General Public License
5 # modify it under the terms of the GNU General Public License
6 # as published by the Free Software Foundation; either version 2
6 # as published by the Free Software Foundation; either version 2
7 # of the License, or (at your option) any later version.
7 # of the License, or (at your option) any later version.
8 #
8 #
9 # This program is distributed in the hope that it will be useful,
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 # GNU General Public License for more details.
12 # GNU General Public License for more details.
13 #
13 #
14 # You should have received a copy of the GNU General Public License
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
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.
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17
17
18 class Version < ActiveRecord::Base
18 class Version < ActiveRecord::Base
19 before_destroy :check_integrity
19 before_destroy :check_integrity
20 belongs_to :project
20 belongs_to :project
21 has_many :fixed_issues, :class_name => 'Issue', :foreign_key => 'fixed_version_id'
21 has_many :fixed_issues, :class_name => 'Issue', :foreign_key => 'fixed_version_id'
22 acts_as_attachable :view_permission => :view_files,
22 acts_as_attachable :view_permission => :view_files,
23 :delete_permission => :manage_files
23 :delete_permission => :manage_files
24
24
25 validates_presence_of :name
25 validates_presence_of :name
26 validates_uniqueness_of :name, :scope => [:project_id]
26 validates_uniqueness_of :name, :scope => [:project_id]
27 validates_length_of :name, :maximum => 60
27 validates_length_of :name, :maximum => 60
28 validates_format_of :effective_date, :with => /^\d{4}-\d{2}-\d{2}$/, :message => :not_a_date, :allow_nil => true
28 validates_format_of :effective_date, :with => /^\d{4}-\d{2}-\d{2}$/, :message => :not_a_date, :allow_nil => true
29
29
30 def start_date
30 def start_date
31 effective_date
31 effective_date
32 end
32 end
33
33
34 def due_date
34 def due_date
35 effective_date
35 effective_date
36 end
36 end
37
37
38 # Returns the total estimated time for this version
38 # Returns the total estimated time for this version
39 def estimated_hours
39 def estimated_hours
40 @estimated_hours ||= fixed_issues.sum(:estimated_hours).to_f
40 @estimated_hours ||= fixed_issues.sum(:estimated_hours).to_f
41 end
41 end
42
42
43 # Returns the total reported time for this version
43 # Returns the total reported time for this version
44 def spent_hours
44 def spent_hours
45 @spent_hours ||= TimeEntry.sum(:hours, :include => :issue, :conditions => ["#{Issue.table_name}.fixed_version_id = ?", id]).to_f
45 @spent_hours ||= TimeEntry.sum(:hours, :include => :issue, :conditions => ["#{Issue.table_name}.fixed_version_id = ?", id]).to_f
46 end
46 end
47
47
48 # Returns true if the version is completed: due date reached and no open issues
48 # Returns true if the version is completed: due date reached and no open issues
49 def completed?
49 def completed?
50 effective_date && (effective_date <= Date.today) && (open_issues_count == 0)
50 effective_date && (effective_date <= Date.today) && (open_issues_count == 0)
51 end
51 end
52
52
53 # Returns the completion percentage of this version based on the amount of open/closed issues
54 # and the time spent on the open issues.
53 def completed_pourcent
55 def completed_pourcent
54 if issues_count == 0
56 if issues_count == 0
55 0
57 0
56 elsif open_issues_count == 0
58 elsif open_issues_count == 0
57 100
59 100
58 else
60 else
59 issues_progress(false) + issues_progress(true)
61 issues_progress(false) + issues_progress(true)
60 end
62 end
61 end
63 end
62
64
65 # Returns the percentage of issues that have been marked as 'closed'.
63 def closed_pourcent
66 def closed_pourcent
64 if issues_count == 0
67 if issues_count == 0
65 0
68 0
66 else
69 else
67 issues_progress(false)
70 issues_progress(false)
68 end
71 end
69 end
72 end
70
73
71 # Returns true if the version is overdue: due date reached and some open issues
74 # Returns true if the version is overdue: due date reached and some open issues
72 def overdue?
75 def overdue?
73 effective_date && (effective_date < Date.today) && (open_issues_count > 0)
76 effective_date && (effective_date < Date.today) && (open_issues_count > 0)
74 end
77 end
75
78
76 # Returns assigned issues count
79 # Returns assigned issues count
77 def issues_count
80 def issues_count
78 @issue_count ||= fixed_issues.count
81 @issue_count ||= fixed_issues.count
79 end
82 end
80
83
84 # Returns the total amount of open issues for this version.
81 def open_issues_count
85 def open_issues_count
82 @open_issues_count ||= Issue.count(:all, :conditions => ["fixed_version_id = ? AND is_closed = ?", self.id, false], :include => :status)
86 @open_issues_count ||= Issue.count(:all, :conditions => ["fixed_version_id = ? AND is_closed = ?", self.id, false], :include => :status)
83 end
87 end
84
88
89 # Returns the total amount of closed issues for this version.
85 def closed_issues_count
90 def closed_issues_count
86 @closed_issues_count ||= Issue.count(:all, :conditions => ["fixed_version_id = ? AND is_closed = ?", self.id, true], :include => :status)
91 @closed_issues_count ||= Issue.count(:all, :conditions => ["fixed_version_id = ? AND is_closed = ?", self.id, true], :include => :status)
87 end
92 end
88
93
89 def wiki_page
94 def wiki_page
90 if project.wiki && !wiki_page_title.blank?
95 if project.wiki && !wiki_page_title.blank?
91 @wiki_page ||= project.wiki.find_page(wiki_page_title)
96 @wiki_page ||= project.wiki.find_page(wiki_page_title)
92 end
97 end
93 @wiki_page
98 @wiki_page
94 end
99 end
95
100
96 def to_s; name end
101 def to_s; name end
97
102
98 # Versions are sorted by effective_date and name
103 # Versions are sorted by effective_date and name
99 # Those with no effective_date are at the end, sorted by name
104 # Those with no effective_date are at the end, sorted by name
100 def <=>(version)
105 def <=>(version)
101 if self.effective_date
106 if self.effective_date
102 version.effective_date ? (self.effective_date == version.effective_date ? self.name <=> version.name : self.effective_date <=> version.effective_date) : -1
107 version.effective_date ? (self.effective_date == version.effective_date ? self.name <=> version.name : self.effective_date <=> version.effective_date) : -1
103 else
108 else
104 version.effective_date ? 1 : (self.name <=> version.name)
109 version.effective_date ? 1 : (self.name <=> version.name)
105 end
110 end
106 end
111 end
107
112
108 private
113 private
109 def check_integrity
114 def check_integrity
110 raise "Can't delete version" if self.fixed_issues.find(:first)
115 raise "Can't delete version" if self.fixed_issues.find(:first)
111 end
116 end
112
117
113 # Returns the average estimated time of assigned issues
118 # Returns the average estimated time of assigned issues
114 # or 1 if no issue has an estimated time
119 # or 1 if no issue has an estimated time
115 # Used to weigth unestimated issues in progress calculation
120 # Used to weigth unestimated issues in progress calculation
116 def estimated_average
121 def estimated_average
117 if @estimated_average.nil?
122 if @estimated_average.nil?
118 average = fixed_issues.average(:estimated_hours).to_f
123 average = fixed_issues.average(:estimated_hours).to_f
119 if average == 0
124 if average == 0
120 average = 1
125 average = 1
121 end
126 end
122 @estimated_average = average
127 @estimated_average = average
123 end
128 end
124 @estimated_average
129 @estimated_average
125 end
130 end
126
131
127 # Returns the total progress of open or closed issues
132 # Returns the total progress of open or closed issues. The returned percentage takes into account
133 # the amount of estimated time set for this version.
134 #
135 # Examples:
136 # issues_progress(true) => returns the progress percentage for open issues.
137 # issues_progress(false) => returns the progress percentage for closed issues.
128 def issues_progress(open)
138 def issues_progress(open)
129 @issues_progress ||= {}
139 @issues_progress ||= {}
130 @issues_progress[open] ||= begin
140 @issues_progress[open] ||= begin
131 progress = 0
141 progress = 0
132 if issues_count > 0
142 if issues_count > 0
133 ratio = open ? 'done_ratio' : 100
143 ratio = open ? 'done_ratio' : 100
144
134 done = fixed_issues.sum("COALESCE(estimated_hours, #{estimated_average}) * #{ratio}",
145 done = fixed_issues.sum("COALESCE(estimated_hours, #{estimated_average}) * #{ratio}",
135 :include => :status,
146 :include => :status,
136 :conditions => ["is_closed = ?", !open]).to_f
147 :conditions => ["is_closed = ?", !open]).to_f
137
138 progress = done / (estimated_average * issues_count)
148 progress = done / (estimated_average * issues_count)
139 end
149 end
140 progress
150 progress
141 end
151 end
142 end
152 end
143 end
153 end
General Comments 0
You need to be logged in to leave comments. Login now