##// END OF EJS Templates
Fixed: gantt displays issues by date of creation....
Jean-Philippe Lang -
r4307:77c6188ec272
parent child
Show More
@@ -1,887 +1,886
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 => 'IssuePriority', :foreign_key => 'priority_id'
25 belongs_to :priority, :class_name => 'IssuePriority', :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_nested_set :scope => 'root_id'
35 acts_as_nested_set :scope => 'root_id'
36 acts_as_attachable :after_remove => :attachment_removed
36 acts_as_attachable :after_remove => :attachment_removed
37 acts_as_customizable
37 acts_as_customizable
38 acts_as_watchable
38 acts_as_watchable
39 acts_as_searchable :columns => ['subject', "#{table_name}.description", "#{Journal.table_name}.notes"],
39 acts_as_searchable :columns => ['subject', "#{table_name}.description", "#{Journal.table_name}.notes"],
40 :include => [:project, :journals],
40 :include => [:project, :journals],
41 # sort by id so that limited eager loading doesn't break with postgresql
41 # sort by id so that limited eager loading doesn't break with postgresql
42 :order_column => "#{table_name}.id"
42 :order_column => "#{table_name}.id"
43 acts_as_event :title => Proc.new {|o| "#{o.tracker.name} ##{o.id} (#{o.status}): #{o.subject}"},
43 acts_as_event :title => Proc.new {|o| "#{o.tracker.name} ##{o.id} (#{o.status}): #{o.subject}"},
44 :url => Proc.new {|o| {:controller => 'issues', :action => 'show', :id => o.id}},
44 :url => Proc.new {|o| {:controller => 'issues', :action => 'show', :id => o.id}},
45 :type => Proc.new {|o| 'issue' + (o.closed? ? ' closed' : '') }
45 :type => Proc.new {|o| 'issue' + (o.closed? ? ' closed' : '') }
46
46
47 acts_as_activity_provider :find_options => {:include => [:project, :author, :tracker]},
47 acts_as_activity_provider :find_options => {:include => [:project, :author, :tracker]},
48 :author_key => :author_id
48 :author_key => :author_id
49
49
50 DONE_RATIO_OPTIONS = %w(issue_field issue_status)
50 DONE_RATIO_OPTIONS = %w(issue_field issue_status)
51
51
52 attr_reader :current_journal
52 attr_reader :current_journal
53
53
54 validates_presence_of :subject, :priority, :project, :tracker, :author, :status
54 validates_presence_of :subject, :priority, :project, :tracker, :author, :status
55
55
56 validates_length_of :subject, :maximum => 255
56 validates_length_of :subject, :maximum => 255
57 validates_inclusion_of :done_ratio, :in => 0..100
57 validates_inclusion_of :done_ratio, :in => 0..100
58 validates_numericality_of :estimated_hours, :allow_nil => true
58 validates_numericality_of :estimated_hours, :allow_nil => true
59
59
60 named_scope :visible, lambda {|*args| { :include => :project,
60 named_scope :visible, lambda {|*args| { :include => :project,
61 :conditions => Project.allowed_to_condition(args.first || User.current, :view_issues) } }
61 :conditions => Project.allowed_to_condition(args.first || User.current, :view_issues) } }
62
62
63 named_scope :open, :conditions => ["#{IssueStatus.table_name}.is_closed = ?", false], :include => :status
63 named_scope :open, :conditions => ["#{IssueStatus.table_name}.is_closed = ?", false], :include => :status
64
64
65 named_scope :recently_updated, :order => "#{Issue.table_name}.updated_on DESC"
65 named_scope :recently_updated, :order => "#{Issue.table_name}.updated_on DESC"
66 named_scope :with_limit, lambda { |limit| { :limit => limit} }
66 named_scope :with_limit, lambda { |limit| { :limit => limit} }
67 named_scope :on_active_project, :include => [:status, :project, :tracker],
67 named_scope :on_active_project, :include => [:status, :project, :tracker],
68 :conditions => ["#{Project.table_name}.status=#{Project::STATUS_ACTIVE}"]
68 :conditions => ["#{Project.table_name}.status=#{Project::STATUS_ACTIVE}"]
69 named_scope :for_gantt, lambda {
69 named_scope :for_gantt, lambda {
70 {
70 {
71 :include => [:tracker, :status, :assigned_to, :priority, :project, :fixed_version],
71 :include => [:tracker, :status, :assigned_to, :priority, :project, :fixed_version]
72 :order => "#{Issue.table_name}.due_date ASC, #{Issue.table_name}.start_date ASC, #{Issue.table_name}.id ASC"
73 }
72 }
74 }
73 }
75
74
76 named_scope :without_version, lambda {
75 named_scope :without_version, lambda {
77 {
76 {
78 :conditions => { :fixed_version_id => nil}
77 :conditions => { :fixed_version_id => nil}
79 }
78 }
80 }
79 }
81
80
82 named_scope :with_query, lambda {|query|
81 named_scope :with_query, lambda {|query|
83 {
82 {
84 :conditions => Query.merge_conditions(query.statement)
83 :conditions => Query.merge_conditions(query.statement)
85 }
84 }
86 }
85 }
87
86
88 before_create :default_assign
87 before_create :default_assign
89 before_save :close_duplicates, :update_done_ratio_from_issue_status
88 before_save :close_duplicates, :update_done_ratio_from_issue_status
90 after_save :reschedule_following_issues, :update_nested_set_attributes, :update_parent_attributes, :create_journal
89 after_save :reschedule_following_issues, :update_nested_set_attributes, :update_parent_attributes, :create_journal
91 after_destroy :destroy_children
90 after_destroy :destroy_children
92 after_destroy :update_parent_attributes
91 after_destroy :update_parent_attributes
93
92
94 # Returns true if usr or current user is allowed to view the issue
93 # Returns true if usr or current user is allowed to view the issue
95 def visible?(usr=nil)
94 def visible?(usr=nil)
96 (usr || User.current).allowed_to?(:view_issues, self.project)
95 (usr || User.current).allowed_to?(:view_issues, self.project)
97 end
96 end
98
97
99 def after_initialize
98 def after_initialize
100 if new_record?
99 if new_record?
101 # set default values for new records only
100 # set default values for new records only
102 self.status ||= IssueStatus.default
101 self.status ||= IssueStatus.default
103 self.priority ||= IssuePriority.default
102 self.priority ||= IssuePriority.default
104 end
103 end
105 end
104 end
106
105
107 # Overrides Redmine::Acts::Customizable::InstanceMethods#available_custom_fields
106 # Overrides Redmine::Acts::Customizable::InstanceMethods#available_custom_fields
108 def available_custom_fields
107 def available_custom_fields
109 (project && tracker) ? project.all_issue_custom_fields.select {|c| tracker.custom_fields.include? c } : []
108 (project && tracker) ? project.all_issue_custom_fields.select {|c| tracker.custom_fields.include? c } : []
110 end
109 end
111
110
112 def copy_from(arg)
111 def copy_from(arg)
113 issue = arg.is_a?(Issue) ? arg : Issue.visible.find(arg)
112 issue = arg.is_a?(Issue) ? arg : Issue.visible.find(arg)
114 self.attributes = issue.attributes.dup.except("id", "root_id", "parent_id", "lft", "rgt", "created_on", "updated_on")
113 self.attributes = issue.attributes.dup.except("id", "root_id", "parent_id", "lft", "rgt", "created_on", "updated_on")
115 self.custom_field_values = issue.custom_field_values.inject({}) {|h,v| h[v.custom_field_id] = v.value; h}
114 self.custom_field_values = issue.custom_field_values.inject({}) {|h,v| h[v.custom_field_id] = v.value; h}
116 self.status = issue.status
115 self.status = issue.status
117 self
116 self
118 end
117 end
119
118
120 # Moves/copies an issue to a new project and tracker
119 # Moves/copies an issue to a new project and tracker
121 # Returns the moved/copied issue on success, false on failure
120 # Returns the moved/copied issue on success, false on failure
122 def move_to_project(*args)
121 def move_to_project(*args)
123 ret = Issue.transaction do
122 ret = Issue.transaction do
124 move_to_project_without_transaction(*args) || raise(ActiveRecord::Rollback)
123 move_to_project_without_transaction(*args) || raise(ActiveRecord::Rollback)
125 end || false
124 end || false
126 end
125 end
127
126
128 def move_to_project_without_transaction(new_project, new_tracker = nil, options = {})
127 def move_to_project_without_transaction(new_project, new_tracker = nil, options = {})
129 options ||= {}
128 options ||= {}
130 issue = options[:copy] ? self.class.new.copy_from(self) : self
129 issue = options[:copy] ? self.class.new.copy_from(self) : self
131
130
132 if new_project && issue.project_id != new_project.id
131 if new_project && issue.project_id != new_project.id
133 # delete issue relations
132 # delete issue relations
134 unless Setting.cross_project_issue_relations?
133 unless Setting.cross_project_issue_relations?
135 issue.relations_from.clear
134 issue.relations_from.clear
136 issue.relations_to.clear
135 issue.relations_to.clear
137 end
136 end
138 # issue is moved to another project
137 # issue is moved to another project
139 # reassign to the category with same name if any
138 # reassign to the category with same name if any
140 new_category = issue.category.nil? ? nil : new_project.issue_categories.find_by_name(issue.category.name)
139 new_category = issue.category.nil? ? nil : new_project.issue_categories.find_by_name(issue.category.name)
141 issue.category = new_category
140 issue.category = new_category
142 # Keep the fixed_version if it's still valid in the new_project
141 # Keep the fixed_version if it's still valid in the new_project
143 unless new_project.shared_versions.include?(issue.fixed_version)
142 unless new_project.shared_versions.include?(issue.fixed_version)
144 issue.fixed_version = nil
143 issue.fixed_version = nil
145 end
144 end
146 issue.project = new_project
145 issue.project = new_project
147 if issue.parent && issue.parent.project_id != issue.project_id
146 if issue.parent && issue.parent.project_id != issue.project_id
148 issue.parent_issue_id = nil
147 issue.parent_issue_id = nil
149 end
148 end
150 end
149 end
151 if new_tracker
150 if new_tracker
152 issue.tracker = new_tracker
151 issue.tracker = new_tracker
153 issue.reset_custom_values!
152 issue.reset_custom_values!
154 end
153 end
155 if options[:copy]
154 if options[:copy]
156 issue.custom_field_values = self.custom_field_values.inject({}) {|h,v| h[v.custom_field_id] = v.value; h}
155 issue.custom_field_values = self.custom_field_values.inject({}) {|h,v| h[v.custom_field_id] = v.value; h}
157 issue.status = if options[:attributes] && options[:attributes][:status_id]
156 issue.status = if options[:attributes] && options[:attributes][:status_id]
158 IssueStatus.find_by_id(options[:attributes][:status_id])
157 IssueStatus.find_by_id(options[:attributes][:status_id])
159 else
158 else
160 self.status
159 self.status
161 end
160 end
162 end
161 end
163 # Allow bulk setting of attributes on the issue
162 # Allow bulk setting of attributes on the issue
164 if options[:attributes]
163 if options[:attributes]
165 issue.attributes = options[:attributes]
164 issue.attributes = options[:attributes]
166 end
165 end
167 if issue.save
166 if issue.save
168 unless options[:copy]
167 unless options[:copy]
169 # Manually update project_id on related time entries
168 # Manually update project_id on related time entries
170 TimeEntry.update_all("project_id = #{new_project.id}", {:issue_id => id})
169 TimeEntry.update_all("project_id = #{new_project.id}", {:issue_id => id})
171
170
172 issue.children.each do |child|
171 issue.children.each do |child|
173 unless child.move_to_project_without_transaction(new_project)
172 unless child.move_to_project_without_transaction(new_project)
174 # Move failed and transaction was rollback'd
173 # Move failed and transaction was rollback'd
175 return false
174 return false
176 end
175 end
177 end
176 end
178 end
177 end
179 else
178 else
180 return false
179 return false
181 end
180 end
182 issue
181 issue
183 end
182 end
184
183
185 def status_id=(sid)
184 def status_id=(sid)
186 self.status = nil
185 self.status = nil
187 write_attribute(:status_id, sid)
186 write_attribute(:status_id, sid)
188 end
187 end
189
188
190 def priority_id=(pid)
189 def priority_id=(pid)
191 self.priority = nil
190 self.priority = nil
192 write_attribute(:priority_id, pid)
191 write_attribute(:priority_id, pid)
193 end
192 end
194
193
195 def tracker_id=(tid)
194 def tracker_id=(tid)
196 self.tracker = nil
195 self.tracker = nil
197 result = write_attribute(:tracker_id, tid)
196 result = write_attribute(:tracker_id, tid)
198 @custom_field_values = nil
197 @custom_field_values = nil
199 result
198 result
200 end
199 end
201
200
202 # Overrides attributes= so that tracker_id gets assigned first
201 # Overrides attributes= so that tracker_id gets assigned first
203 def attributes_with_tracker_first=(new_attributes, *args)
202 def attributes_with_tracker_first=(new_attributes, *args)
204 return if new_attributes.nil?
203 return if new_attributes.nil?
205 new_tracker_id = new_attributes['tracker_id'] || new_attributes[:tracker_id]
204 new_tracker_id = new_attributes['tracker_id'] || new_attributes[:tracker_id]
206 if new_tracker_id
205 if new_tracker_id
207 self.tracker_id = new_tracker_id
206 self.tracker_id = new_tracker_id
208 end
207 end
209 send :attributes_without_tracker_first=, new_attributes, *args
208 send :attributes_without_tracker_first=, new_attributes, *args
210 end
209 end
211 # Do not redefine alias chain on reload (see #4838)
210 # Do not redefine alias chain on reload (see #4838)
212 alias_method_chain(:attributes=, :tracker_first) unless method_defined?(:attributes_without_tracker_first=)
211 alias_method_chain(:attributes=, :tracker_first) unless method_defined?(:attributes_without_tracker_first=)
213
212
214 def estimated_hours=(h)
213 def estimated_hours=(h)
215 write_attribute :estimated_hours, (h.is_a?(String) ? h.to_hours : h)
214 write_attribute :estimated_hours, (h.is_a?(String) ? h.to_hours : h)
216 end
215 end
217
216
218 SAFE_ATTRIBUTES = %w(
217 SAFE_ATTRIBUTES = %w(
219 tracker_id
218 tracker_id
220 status_id
219 status_id
221 parent_issue_id
220 parent_issue_id
222 category_id
221 category_id
223 assigned_to_id
222 assigned_to_id
224 priority_id
223 priority_id
225 fixed_version_id
224 fixed_version_id
226 subject
225 subject
227 description
226 description
228 start_date
227 start_date
229 due_date
228 due_date
230 done_ratio
229 done_ratio
231 estimated_hours
230 estimated_hours
232 custom_field_values
231 custom_field_values
233 lock_version
232 lock_version
234 ) unless const_defined?(:SAFE_ATTRIBUTES)
233 ) unless const_defined?(:SAFE_ATTRIBUTES)
235
234
236 SAFE_ATTRIBUTES_ON_TRANSITION = %w(
235 SAFE_ATTRIBUTES_ON_TRANSITION = %w(
237 status_id
236 status_id
238 assigned_to_id
237 assigned_to_id
239 fixed_version_id
238 fixed_version_id
240 done_ratio
239 done_ratio
241 ) unless const_defined?(:SAFE_ATTRIBUTES_ON_TRANSITION)
240 ) unless const_defined?(:SAFE_ATTRIBUTES_ON_TRANSITION)
242
241
243 # Safely sets attributes
242 # Safely sets attributes
244 # Should be called from controllers instead of #attributes=
243 # Should be called from controllers instead of #attributes=
245 # attr_accessible is too rough because we still want things like
244 # attr_accessible is too rough because we still want things like
246 # Issue.new(:project => foo) to work
245 # Issue.new(:project => foo) to work
247 # TODO: move workflow/permission checks from controllers to here
246 # TODO: move workflow/permission checks from controllers to here
248 def safe_attributes=(attrs, user=User.current)
247 def safe_attributes=(attrs, user=User.current)
249 return unless attrs.is_a?(Hash)
248 return unless attrs.is_a?(Hash)
250
249
251 # User can change issue attributes only if he has :edit permission or if a workflow transition is allowed
250 # User can change issue attributes only if he has :edit permission or if a workflow transition is allowed
252 if new_record? || user.allowed_to?(:edit_issues, project)
251 if new_record? || user.allowed_to?(:edit_issues, project)
253 attrs = attrs.reject {|k,v| !SAFE_ATTRIBUTES.include?(k)}
252 attrs = attrs.reject {|k,v| !SAFE_ATTRIBUTES.include?(k)}
254 elsif new_statuses_allowed_to(user).any?
253 elsif new_statuses_allowed_to(user).any?
255 attrs = attrs.reject {|k,v| !SAFE_ATTRIBUTES_ON_TRANSITION.include?(k)}
254 attrs = attrs.reject {|k,v| !SAFE_ATTRIBUTES_ON_TRANSITION.include?(k)}
256 else
255 else
257 return
256 return
258 end
257 end
259
258
260 # Tracker must be set before since new_statuses_allowed_to depends on it.
259 # Tracker must be set before since new_statuses_allowed_to depends on it.
261 if t = attrs.delete('tracker_id')
260 if t = attrs.delete('tracker_id')
262 self.tracker_id = t
261 self.tracker_id = t
263 end
262 end
264
263
265 if attrs['status_id']
264 if attrs['status_id']
266 unless new_statuses_allowed_to(user).collect(&:id).include?(attrs['status_id'].to_i)
265 unless new_statuses_allowed_to(user).collect(&:id).include?(attrs['status_id'].to_i)
267 attrs.delete('status_id')
266 attrs.delete('status_id')
268 end
267 end
269 end
268 end
270
269
271 unless leaf?
270 unless leaf?
272 attrs.reject! {|k,v| %w(priority_id done_ratio start_date due_date estimated_hours).include?(k)}
271 attrs.reject! {|k,v| %w(priority_id done_ratio start_date due_date estimated_hours).include?(k)}
273 end
272 end
274
273
275 if attrs.has_key?('parent_issue_id')
274 if attrs.has_key?('parent_issue_id')
276 if !user.allowed_to?(:manage_subtasks, project)
275 if !user.allowed_to?(:manage_subtasks, project)
277 attrs.delete('parent_issue_id')
276 attrs.delete('parent_issue_id')
278 elsif !attrs['parent_issue_id'].blank?
277 elsif !attrs['parent_issue_id'].blank?
279 attrs.delete('parent_issue_id') unless Issue.visible(user).exists?(attrs['parent_issue_id'].to_i)
278 attrs.delete('parent_issue_id') unless Issue.visible(user).exists?(attrs['parent_issue_id'].to_i)
280 end
279 end
281 end
280 end
282
281
283 self.attributes = attrs
282 self.attributes = attrs
284 end
283 end
285
284
286 def done_ratio
285 def done_ratio
287 if Issue.use_status_for_done_ratio? && status && status.default_done_ratio
286 if Issue.use_status_for_done_ratio? && status && status.default_done_ratio
288 status.default_done_ratio
287 status.default_done_ratio
289 else
288 else
290 read_attribute(:done_ratio)
289 read_attribute(:done_ratio)
291 end
290 end
292 end
291 end
293
292
294 def self.use_status_for_done_ratio?
293 def self.use_status_for_done_ratio?
295 Setting.issue_done_ratio == 'issue_status'
294 Setting.issue_done_ratio == 'issue_status'
296 end
295 end
297
296
298 def self.use_field_for_done_ratio?
297 def self.use_field_for_done_ratio?
299 Setting.issue_done_ratio == 'issue_field'
298 Setting.issue_done_ratio == 'issue_field'
300 end
299 end
301
300
302 def validate
301 def validate
303 if self.due_date.nil? && @attributes['due_date'] && !@attributes['due_date'].empty?
302 if self.due_date.nil? && @attributes['due_date'] && !@attributes['due_date'].empty?
304 errors.add :due_date, :not_a_date
303 errors.add :due_date, :not_a_date
305 end
304 end
306
305
307 if self.due_date and self.start_date and self.due_date < self.start_date
306 if self.due_date and self.start_date and self.due_date < self.start_date
308 errors.add :due_date, :greater_than_start_date
307 errors.add :due_date, :greater_than_start_date
309 end
308 end
310
309
311 if start_date && soonest_start && start_date < soonest_start
310 if start_date && soonest_start && start_date < soonest_start
312 errors.add :start_date, :invalid
311 errors.add :start_date, :invalid
313 end
312 end
314
313
315 if fixed_version
314 if fixed_version
316 if !assignable_versions.include?(fixed_version)
315 if !assignable_versions.include?(fixed_version)
317 errors.add :fixed_version_id, :inclusion
316 errors.add :fixed_version_id, :inclusion
318 elsif reopened? && fixed_version.closed?
317 elsif reopened? && fixed_version.closed?
319 errors.add_to_base I18n.t(:error_can_not_reopen_issue_on_closed_version)
318 errors.add_to_base I18n.t(:error_can_not_reopen_issue_on_closed_version)
320 end
319 end
321 end
320 end
322
321
323 # Checks that the issue can not be added/moved to a disabled tracker
322 # Checks that the issue can not be added/moved to a disabled tracker
324 if project && (tracker_id_changed? || project_id_changed?)
323 if project && (tracker_id_changed? || project_id_changed?)
325 unless project.trackers.include?(tracker)
324 unless project.trackers.include?(tracker)
326 errors.add :tracker_id, :inclusion
325 errors.add :tracker_id, :inclusion
327 end
326 end
328 end
327 end
329
328
330 # Checks parent issue assignment
329 # Checks parent issue assignment
331 if @parent_issue
330 if @parent_issue
332 if @parent_issue.project_id != project_id
331 if @parent_issue.project_id != project_id
333 errors.add :parent_issue_id, :not_same_project
332 errors.add :parent_issue_id, :not_same_project
334 elsif !new_record?
333 elsif !new_record?
335 # moving an existing issue
334 # moving an existing issue
336 if @parent_issue.root_id != root_id
335 if @parent_issue.root_id != root_id
337 # we can always move to another tree
336 # we can always move to another tree
338 elsif move_possible?(@parent_issue)
337 elsif move_possible?(@parent_issue)
339 # move accepted inside tree
338 # move accepted inside tree
340 else
339 else
341 errors.add :parent_issue_id, :not_a_valid_parent
340 errors.add :parent_issue_id, :not_a_valid_parent
342 end
341 end
343 end
342 end
344 end
343 end
345 end
344 end
346
345
347 # Set the done_ratio using the status if that setting is set. This will keep the done_ratios
346 # Set the done_ratio using the status if that setting is set. This will keep the done_ratios
348 # even if the user turns off the setting later
347 # even if the user turns off the setting later
349 def update_done_ratio_from_issue_status
348 def update_done_ratio_from_issue_status
350 if Issue.use_status_for_done_ratio? && status && status.default_done_ratio
349 if Issue.use_status_for_done_ratio? && status && status.default_done_ratio
351 self.done_ratio = status.default_done_ratio
350 self.done_ratio = status.default_done_ratio
352 end
351 end
353 end
352 end
354
353
355 def init_journal(user, notes = "")
354 def init_journal(user, notes = "")
356 @current_journal ||= Journal.new(:journalized => self, :user => user, :notes => notes)
355 @current_journal ||= Journal.new(:journalized => self, :user => user, :notes => notes)
357 @issue_before_change = self.clone
356 @issue_before_change = self.clone
358 @issue_before_change.status = self.status
357 @issue_before_change.status = self.status
359 @custom_values_before_change = {}
358 @custom_values_before_change = {}
360 self.custom_values.each {|c| @custom_values_before_change.store c.custom_field_id, c.value }
359 self.custom_values.each {|c| @custom_values_before_change.store c.custom_field_id, c.value }
361 # Make sure updated_on is updated when adding a note.
360 # Make sure updated_on is updated when adding a note.
362 updated_on_will_change!
361 updated_on_will_change!
363 @current_journal
362 @current_journal
364 end
363 end
365
364
366 # Return true if the issue is closed, otherwise false
365 # Return true if the issue is closed, otherwise false
367 def closed?
366 def closed?
368 self.status.is_closed?
367 self.status.is_closed?
369 end
368 end
370
369
371 # Return true if the issue is being reopened
370 # Return true if the issue is being reopened
372 def reopened?
371 def reopened?
373 if !new_record? && status_id_changed?
372 if !new_record? && status_id_changed?
374 status_was = IssueStatus.find_by_id(status_id_was)
373 status_was = IssueStatus.find_by_id(status_id_was)
375 status_new = IssueStatus.find_by_id(status_id)
374 status_new = IssueStatus.find_by_id(status_id)
376 if status_was && status_new && status_was.is_closed? && !status_new.is_closed?
375 if status_was && status_new && status_was.is_closed? && !status_new.is_closed?
377 return true
376 return true
378 end
377 end
379 end
378 end
380 false
379 false
381 end
380 end
382
381
383 # Return true if the issue is being closed
382 # Return true if the issue is being closed
384 def closing?
383 def closing?
385 if !new_record? && status_id_changed?
384 if !new_record? && status_id_changed?
386 status_was = IssueStatus.find_by_id(status_id_was)
385 status_was = IssueStatus.find_by_id(status_id_was)
387 status_new = IssueStatus.find_by_id(status_id)
386 status_new = IssueStatus.find_by_id(status_id)
388 if status_was && status_new && !status_was.is_closed? && status_new.is_closed?
387 if status_was && status_new && !status_was.is_closed? && status_new.is_closed?
389 return true
388 return true
390 end
389 end
391 end
390 end
392 false
391 false
393 end
392 end
394
393
395 # Returns true if the issue is overdue
394 # Returns true if the issue is overdue
396 def overdue?
395 def overdue?
397 !due_date.nil? && (due_date < Date.today) && !status.is_closed?
396 !due_date.nil? && (due_date < Date.today) && !status.is_closed?
398 end
397 end
399
398
400 # Is the amount of work done less than it should for the due date
399 # Is the amount of work done less than it should for the due date
401 def behind_schedule?
400 def behind_schedule?
402 return false if start_date.nil? || due_date.nil?
401 return false if start_date.nil? || due_date.nil?
403 done_date = start_date + ((due_date - start_date+1)* done_ratio/100).floor
402 done_date = start_date + ((due_date - start_date+1)* done_ratio/100).floor
404 return done_date <= Date.today
403 return done_date <= Date.today
405 end
404 end
406
405
407 # Does this issue have children?
406 # Does this issue have children?
408 def children?
407 def children?
409 !leaf?
408 !leaf?
410 end
409 end
411
410
412 # Users the issue can be assigned to
411 # Users the issue can be assigned to
413 def assignable_users
412 def assignable_users
414 users = project.assignable_users
413 users = project.assignable_users
415 users << author if author
414 users << author if author
416 users.uniq.sort
415 users.uniq.sort
417 end
416 end
418
417
419 # Versions that the issue can be assigned to
418 # Versions that the issue can be assigned to
420 def assignable_versions
419 def assignable_versions
421 @assignable_versions ||= (project.shared_versions.open + [Version.find_by_id(fixed_version_id_was)]).compact.uniq.sort
420 @assignable_versions ||= (project.shared_versions.open + [Version.find_by_id(fixed_version_id_was)]).compact.uniq.sort
422 end
421 end
423
422
424 # Returns true if this issue is blocked by another issue that is still open
423 # Returns true if this issue is blocked by another issue that is still open
425 def blocked?
424 def blocked?
426 !relations_to.detect {|ir| ir.relation_type == 'blocks' && !ir.issue_from.closed?}.nil?
425 !relations_to.detect {|ir| ir.relation_type == 'blocks' && !ir.issue_from.closed?}.nil?
427 end
426 end
428
427
429 # Returns an array of status that user is able to apply
428 # Returns an array of status that user is able to apply
430 def new_statuses_allowed_to(user, include_default=false)
429 def new_statuses_allowed_to(user, include_default=false)
431 statuses = status.find_new_statuses_allowed_to(user.roles_for_project(project), tracker)
430 statuses = status.find_new_statuses_allowed_to(user.roles_for_project(project), tracker)
432 statuses << status unless statuses.empty?
431 statuses << status unless statuses.empty?
433 statuses << IssueStatus.default if include_default
432 statuses << IssueStatus.default if include_default
434 statuses = statuses.uniq.sort
433 statuses = statuses.uniq.sort
435 blocked? ? statuses.reject {|s| s.is_closed?} : statuses
434 blocked? ? statuses.reject {|s| s.is_closed?} : statuses
436 end
435 end
437
436
438 # Returns the mail adresses of users that should be notified
437 # Returns the mail adresses of users that should be notified
439 def recipients
438 def recipients
440 notified = project.notified_users
439 notified = project.notified_users
441 # Author and assignee are always notified unless they have been
440 # Author and assignee are always notified unless they have been
442 # locked or don't want to be notified
441 # locked or don't want to be notified
443 notified << author if author && author.active? && author.notify_about?(self)
442 notified << author if author && author.active? && author.notify_about?(self)
444 notified << assigned_to if assigned_to && assigned_to.active? && assigned_to.notify_about?(self)
443 notified << assigned_to if assigned_to && assigned_to.active? && assigned_to.notify_about?(self)
445 notified.uniq!
444 notified.uniq!
446 # Remove users that can not view the issue
445 # Remove users that can not view the issue
447 notified.reject! {|user| !visible?(user)}
446 notified.reject! {|user| !visible?(user)}
448 notified.collect(&:mail)
447 notified.collect(&:mail)
449 end
448 end
450
449
451 # Returns the total number of hours spent on this issue and its descendants
450 # Returns the total number of hours spent on this issue and its descendants
452 #
451 #
453 # Example:
452 # Example:
454 # spent_hours => 0.0
453 # spent_hours => 0.0
455 # spent_hours => 50.2
454 # spent_hours => 50.2
456 def spent_hours
455 def spent_hours
457 @spent_hours ||= self_and_descendants.sum("#{TimeEntry.table_name}.hours", :include => :time_entries).to_f || 0.0
456 @spent_hours ||= self_and_descendants.sum("#{TimeEntry.table_name}.hours", :include => :time_entries).to_f || 0.0
458 end
457 end
459
458
460 def relations
459 def relations
461 (relations_from + relations_to).sort
460 (relations_from + relations_to).sort
462 end
461 end
463
462
464 def all_dependent_issues
463 def all_dependent_issues
465 dependencies = []
464 dependencies = []
466 relations_from.each do |relation|
465 relations_from.each do |relation|
467 dependencies << relation.issue_to
466 dependencies << relation.issue_to
468 dependencies += relation.issue_to.all_dependent_issues
467 dependencies += relation.issue_to.all_dependent_issues
469 end
468 end
470 dependencies
469 dependencies
471 end
470 end
472
471
473 # Returns an array of issues that duplicate this one
472 # Returns an array of issues that duplicate this one
474 def duplicates
473 def duplicates
475 relations_to.select {|r| r.relation_type == IssueRelation::TYPE_DUPLICATES}.collect {|r| r.issue_from}
474 relations_to.select {|r| r.relation_type == IssueRelation::TYPE_DUPLICATES}.collect {|r| r.issue_from}
476 end
475 end
477
476
478 # Returns the due date or the target due date if any
477 # Returns the due date or the target due date if any
479 # Used on gantt chart
478 # Used on gantt chart
480 def due_before
479 def due_before
481 due_date || (fixed_version ? fixed_version.effective_date : nil)
480 due_date || (fixed_version ? fixed_version.effective_date : nil)
482 end
481 end
483
482
484 # Returns the time scheduled for this issue.
483 # Returns the time scheduled for this issue.
485 #
484 #
486 # Example:
485 # Example:
487 # Start Date: 2/26/09, End Date: 3/04/09
486 # Start Date: 2/26/09, End Date: 3/04/09
488 # duration => 6
487 # duration => 6
489 def duration
488 def duration
490 (start_date && due_date) ? due_date - start_date : 0
489 (start_date && due_date) ? due_date - start_date : 0
491 end
490 end
492
491
493 def soonest_start
492 def soonest_start
494 @soonest_start ||= (
493 @soonest_start ||= (
495 relations_to.collect{|relation| relation.successor_soonest_start} +
494 relations_to.collect{|relation| relation.successor_soonest_start} +
496 ancestors.collect(&:soonest_start)
495 ancestors.collect(&:soonest_start)
497 ).compact.max
496 ).compact.max
498 end
497 end
499
498
500 def reschedule_after(date)
499 def reschedule_after(date)
501 return if date.nil?
500 return if date.nil?
502 if leaf?
501 if leaf?
503 if start_date.nil? || start_date < date
502 if start_date.nil? || start_date < date
504 self.start_date, self.due_date = date, date + duration
503 self.start_date, self.due_date = date, date + duration
505 save
504 save
506 end
505 end
507 else
506 else
508 leaves.each do |leaf|
507 leaves.each do |leaf|
509 leaf.reschedule_after(date)
508 leaf.reschedule_after(date)
510 end
509 end
511 end
510 end
512 end
511 end
513
512
514 def <=>(issue)
513 def <=>(issue)
515 if issue.nil?
514 if issue.nil?
516 -1
515 -1
517 elsif root_id != issue.root_id
516 elsif root_id != issue.root_id
518 (root_id || 0) <=> (issue.root_id || 0)
517 (root_id || 0) <=> (issue.root_id || 0)
519 else
518 else
520 (lft || 0) <=> (issue.lft || 0)
519 (lft || 0) <=> (issue.lft || 0)
521 end
520 end
522 end
521 end
523
522
524 def to_s
523 def to_s
525 "#{tracker} ##{id}: #{subject}"
524 "#{tracker} ##{id}: #{subject}"
526 end
525 end
527
526
528 # Returns a string of css classes that apply to the issue
527 # Returns a string of css classes that apply to the issue
529 def css_classes
528 def css_classes
530 s = "issue status-#{status.position} priority-#{priority.position}"
529 s = "issue status-#{status.position} priority-#{priority.position}"
531 s << ' closed' if closed?
530 s << ' closed' if closed?
532 s << ' overdue' if overdue?
531 s << ' overdue' if overdue?
533 s << ' created-by-me' if User.current.logged? && author_id == User.current.id
532 s << ' created-by-me' if User.current.logged? && author_id == User.current.id
534 s << ' assigned-to-me' if User.current.logged? && assigned_to_id == User.current.id
533 s << ' assigned-to-me' if User.current.logged? && assigned_to_id == User.current.id
535 s
534 s
536 end
535 end
537
536
538 # Saves an issue, time_entry, attachments, and a journal from the parameters
537 # Saves an issue, time_entry, attachments, and a journal from the parameters
539 # Returns false if save fails
538 # Returns false if save fails
540 def save_issue_with_child_records(params, existing_time_entry=nil)
539 def save_issue_with_child_records(params, existing_time_entry=nil)
541 Issue.transaction do
540 Issue.transaction do
542 if params[:time_entry] && params[:time_entry][:hours].present? && User.current.allowed_to?(:log_time, project)
541 if params[:time_entry] && params[:time_entry][:hours].present? && User.current.allowed_to?(:log_time, project)
543 @time_entry = existing_time_entry || TimeEntry.new
542 @time_entry = existing_time_entry || TimeEntry.new
544 @time_entry.project = project
543 @time_entry.project = project
545 @time_entry.issue = self
544 @time_entry.issue = self
546 @time_entry.user = User.current
545 @time_entry.user = User.current
547 @time_entry.spent_on = Date.today
546 @time_entry.spent_on = Date.today
548 @time_entry.attributes = params[:time_entry]
547 @time_entry.attributes = params[:time_entry]
549 self.time_entries << @time_entry
548 self.time_entries << @time_entry
550 end
549 end
551
550
552 if valid?
551 if valid?
553 attachments = Attachment.attach_files(self, params[:attachments])
552 attachments = Attachment.attach_files(self, params[:attachments])
554
553
555 attachments[:files].each {|a| @current_journal.details << JournalDetail.new(:property => 'attachment', :prop_key => a.id, :value => a.filename)}
554 attachments[:files].each {|a| @current_journal.details << JournalDetail.new(:property => 'attachment', :prop_key => a.id, :value => a.filename)}
556 # TODO: Rename hook
555 # TODO: Rename hook
557 Redmine::Hook.call_hook(:controller_issues_edit_before_save, { :params => params, :issue => self, :time_entry => @time_entry, :journal => @current_journal})
556 Redmine::Hook.call_hook(:controller_issues_edit_before_save, { :params => params, :issue => self, :time_entry => @time_entry, :journal => @current_journal})
558 begin
557 begin
559 if save
558 if save
560 # TODO: Rename hook
559 # TODO: Rename hook
561 Redmine::Hook.call_hook(:controller_issues_edit_after_save, { :params => params, :issue => self, :time_entry => @time_entry, :journal => @current_journal})
560 Redmine::Hook.call_hook(:controller_issues_edit_after_save, { :params => params, :issue => self, :time_entry => @time_entry, :journal => @current_journal})
562 else
561 else
563 raise ActiveRecord::Rollback
562 raise ActiveRecord::Rollback
564 end
563 end
565 rescue ActiveRecord::StaleObjectError
564 rescue ActiveRecord::StaleObjectError
566 attachments[:files].each(&:destroy)
565 attachments[:files].each(&:destroy)
567 errors.add_to_base l(:notice_locking_conflict)
566 errors.add_to_base l(:notice_locking_conflict)
568 raise ActiveRecord::Rollback
567 raise ActiveRecord::Rollback
569 end
568 end
570 end
569 end
571 end
570 end
572 end
571 end
573
572
574 # Unassigns issues from +version+ if it's no longer shared with issue's project
573 # Unassigns issues from +version+ if it's no longer shared with issue's project
575 def self.update_versions_from_sharing_change(version)
574 def self.update_versions_from_sharing_change(version)
576 # Update issues assigned to the version
575 # Update issues assigned to the version
577 update_versions(["#{Issue.table_name}.fixed_version_id = ?", version.id])
576 update_versions(["#{Issue.table_name}.fixed_version_id = ?", version.id])
578 end
577 end
579
578
580 # Unassigns issues from versions that are no longer shared
579 # Unassigns issues from versions that are no longer shared
581 # after +project+ was moved
580 # after +project+ was moved
582 def self.update_versions_from_hierarchy_change(project)
581 def self.update_versions_from_hierarchy_change(project)
583 moved_project_ids = project.self_and_descendants.reload.collect(&:id)
582 moved_project_ids = project.self_and_descendants.reload.collect(&:id)
584 # Update issues of the moved projects and issues assigned to a version of a moved project
583 # Update issues of the moved projects and issues assigned to a version of a moved project
585 Issue.update_versions(["#{Version.table_name}.project_id IN (?) OR #{Issue.table_name}.project_id IN (?)", moved_project_ids, moved_project_ids])
584 Issue.update_versions(["#{Version.table_name}.project_id IN (?) OR #{Issue.table_name}.project_id IN (?)", moved_project_ids, moved_project_ids])
586 end
585 end
587
586
588 def parent_issue_id=(arg)
587 def parent_issue_id=(arg)
589 parent_issue_id = arg.blank? ? nil : arg.to_i
588 parent_issue_id = arg.blank? ? nil : arg.to_i
590 if parent_issue_id && @parent_issue = Issue.find_by_id(parent_issue_id)
589 if parent_issue_id && @parent_issue = Issue.find_by_id(parent_issue_id)
591 @parent_issue.id
590 @parent_issue.id
592 else
591 else
593 @parent_issue = nil
592 @parent_issue = nil
594 nil
593 nil
595 end
594 end
596 end
595 end
597
596
598 def parent_issue_id
597 def parent_issue_id
599 if instance_variable_defined? :@parent_issue
598 if instance_variable_defined? :@parent_issue
600 @parent_issue.nil? ? nil : @parent_issue.id
599 @parent_issue.nil? ? nil : @parent_issue.id
601 else
600 else
602 parent_id
601 parent_id
603 end
602 end
604 end
603 end
605
604
606 # Extracted from the ReportsController.
605 # Extracted from the ReportsController.
607 def self.by_tracker(project)
606 def self.by_tracker(project)
608 count_and_group_by(:project => project,
607 count_and_group_by(:project => project,
609 :field => 'tracker_id',
608 :field => 'tracker_id',
610 :joins => Tracker.table_name)
609 :joins => Tracker.table_name)
611 end
610 end
612
611
613 def self.by_version(project)
612 def self.by_version(project)
614 count_and_group_by(:project => project,
613 count_and_group_by(:project => project,
615 :field => 'fixed_version_id',
614 :field => 'fixed_version_id',
616 :joins => Version.table_name)
615 :joins => Version.table_name)
617 end
616 end
618
617
619 def self.by_priority(project)
618 def self.by_priority(project)
620 count_and_group_by(:project => project,
619 count_and_group_by(:project => project,
621 :field => 'priority_id',
620 :field => 'priority_id',
622 :joins => IssuePriority.table_name)
621 :joins => IssuePriority.table_name)
623 end
622 end
624
623
625 def self.by_category(project)
624 def self.by_category(project)
626 count_and_group_by(:project => project,
625 count_and_group_by(:project => project,
627 :field => 'category_id',
626 :field => 'category_id',
628 :joins => IssueCategory.table_name)
627 :joins => IssueCategory.table_name)
629 end
628 end
630
629
631 def self.by_assigned_to(project)
630 def self.by_assigned_to(project)
632 count_and_group_by(:project => project,
631 count_and_group_by(:project => project,
633 :field => 'assigned_to_id',
632 :field => 'assigned_to_id',
634 :joins => User.table_name)
633 :joins => User.table_name)
635 end
634 end
636
635
637 def self.by_author(project)
636 def self.by_author(project)
638 count_and_group_by(:project => project,
637 count_and_group_by(:project => project,
639 :field => 'author_id',
638 :field => 'author_id',
640 :joins => User.table_name)
639 :joins => User.table_name)
641 end
640 end
642
641
643 def self.by_subproject(project)
642 def self.by_subproject(project)
644 ActiveRecord::Base.connection.select_all("select s.id as status_id,
643 ActiveRecord::Base.connection.select_all("select s.id as status_id,
645 s.is_closed as closed,
644 s.is_closed as closed,
646 i.project_id as project_id,
645 i.project_id as project_id,
647 count(i.id) as total
646 count(i.id) as total
648 from
647 from
649 #{Issue.table_name} i, #{IssueStatus.table_name} s
648 #{Issue.table_name} i, #{IssueStatus.table_name} s
650 where
649 where
651 i.status_id=s.id
650 i.status_id=s.id
652 and i.project_id IN (#{project.descendants.active.collect{|p| p.id}.join(',')})
651 and i.project_id IN (#{project.descendants.active.collect{|p| p.id}.join(',')})
653 group by s.id, s.is_closed, i.project_id") if project.descendants.active.any?
652 group by s.id, s.is_closed, i.project_id") if project.descendants.active.any?
654 end
653 end
655 # End ReportsController extraction
654 # End ReportsController extraction
656
655
657 # Returns an array of projects that current user can move issues to
656 # Returns an array of projects that current user can move issues to
658 def self.allowed_target_projects_on_move
657 def self.allowed_target_projects_on_move
659 projects = []
658 projects = []
660 if User.current.admin?
659 if User.current.admin?
661 # admin is allowed to move issues to any active (visible) project
660 # admin is allowed to move issues to any active (visible) project
662 projects = Project.visible.all
661 projects = Project.visible.all
663 elsif User.current.logged?
662 elsif User.current.logged?
664 if Role.non_member.allowed_to?(:move_issues)
663 if Role.non_member.allowed_to?(:move_issues)
665 projects = Project.visible.all
664 projects = Project.visible.all
666 else
665 else
667 User.current.memberships.each {|m| projects << m.project if m.roles.detect {|r| r.allowed_to?(:move_issues)}}
666 User.current.memberships.each {|m| projects << m.project if m.roles.detect {|r| r.allowed_to?(:move_issues)}}
668 end
667 end
669 end
668 end
670 projects
669 projects
671 end
670 end
672
671
673 private
672 private
674
673
675 def update_nested_set_attributes
674 def update_nested_set_attributes
676 if root_id.nil?
675 if root_id.nil?
677 # issue was just created
676 # issue was just created
678 self.root_id = (@parent_issue.nil? ? id : @parent_issue.root_id)
677 self.root_id = (@parent_issue.nil? ? id : @parent_issue.root_id)
679 set_default_left_and_right
678 set_default_left_and_right
680 Issue.update_all("root_id = #{root_id}, lft = #{lft}, rgt = #{rgt}", ["id = ?", id])
679 Issue.update_all("root_id = #{root_id}, lft = #{lft}, rgt = #{rgt}", ["id = ?", id])
681 if @parent_issue
680 if @parent_issue
682 move_to_child_of(@parent_issue)
681 move_to_child_of(@parent_issue)
683 end
682 end
684 reload
683 reload
685 elsif parent_issue_id != parent_id
684 elsif parent_issue_id != parent_id
686 former_parent_id = parent_id
685 former_parent_id = parent_id
687 # moving an existing issue
686 # moving an existing issue
688 if @parent_issue && @parent_issue.root_id == root_id
687 if @parent_issue && @parent_issue.root_id == root_id
689 # inside the same tree
688 # inside the same tree
690 move_to_child_of(@parent_issue)
689 move_to_child_of(@parent_issue)
691 else
690 else
692 # to another tree
691 # to another tree
693 unless root?
692 unless root?
694 move_to_right_of(root)
693 move_to_right_of(root)
695 reload
694 reload
696 end
695 end
697 old_root_id = root_id
696 old_root_id = root_id
698 self.root_id = (@parent_issue.nil? ? id : @parent_issue.root_id )
697 self.root_id = (@parent_issue.nil? ? id : @parent_issue.root_id )
699 target_maxright = nested_set_scope.maximum(right_column_name) || 0
698 target_maxright = nested_set_scope.maximum(right_column_name) || 0
700 offset = target_maxright + 1 - lft
699 offset = target_maxright + 1 - lft
701 Issue.update_all("root_id = #{root_id}, lft = lft + #{offset}, rgt = rgt + #{offset}",
700 Issue.update_all("root_id = #{root_id}, lft = lft + #{offset}, rgt = rgt + #{offset}",
702 ["root_id = ? AND lft >= ? AND rgt <= ? ", old_root_id, lft, rgt])
701 ["root_id = ? AND lft >= ? AND rgt <= ? ", old_root_id, lft, rgt])
703 self[left_column_name] = lft + offset
702 self[left_column_name] = lft + offset
704 self[right_column_name] = rgt + offset
703 self[right_column_name] = rgt + offset
705 if @parent_issue
704 if @parent_issue
706 move_to_child_of(@parent_issue)
705 move_to_child_of(@parent_issue)
707 end
706 end
708 end
707 end
709 reload
708 reload
710 # delete invalid relations of all descendants
709 # delete invalid relations of all descendants
711 self_and_descendants.each do |issue|
710 self_and_descendants.each do |issue|
712 issue.relations.each do |relation|
711 issue.relations.each do |relation|
713 relation.destroy unless relation.valid?
712 relation.destroy unless relation.valid?
714 end
713 end
715 end
714 end
716 # update former parent
715 # update former parent
717 recalculate_attributes_for(former_parent_id) if former_parent_id
716 recalculate_attributes_for(former_parent_id) if former_parent_id
718 end
717 end
719 remove_instance_variable(:@parent_issue) if instance_variable_defined?(:@parent_issue)
718 remove_instance_variable(:@parent_issue) if instance_variable_defined?(:@parent_issue)
720 end
719 end
721
720
722 def update_parent_attributes
721 def update_parent_attributes
723 recalculate_attributes_for(parent_id) if parent_id
722 recalculate_attributes_for(parent_id) if parent_id
724 end
723 end
725
724
726 def recalculate_attributes_for(issue_id)
725 def recalculate_attributes_for(issue_id)
727 if issue_id && p = Issue.find_by_id(issue_id)
726 if issue_id && p = Issue.find_by_id(issue_id)
728 # priority = highest priority of children
727 # priority = highest priority of children
729 if priority_position = p.children.maximum("#{IssuePriority.table_name}.position", :include => :priority)
728 if priority_position = p.children.maximum("#{IssuePriority.table_name}.position", :include => :priority)
730 p.priority = IssuePriority.find_by_position(priority_position)
729 p.priority = IssuePriority.find_by_position(priority_position)
731 end
730 end
732
731
733 # start/due dates = lowest/highest dates of children
732 # start/due dates = lowest/highest dates of children
734 p.start_date = p.children.minimum(:start_date)
733 p.start_date = p.children.minimum(:start_date)
735 p.due_date = p.children.maximum(:due_date)
734 p.due_date = p.children.maximum(:due_date)
736 if p.start_date && p.due_date && p.due_date < p.start_date
735 if p.start_date && p.due_date && p.due_date < p.start_date
737 p.start_date, p.due_date = p.due_date, p.start_date
736 p.start_date, p.due_date = p.due_date, p.start_date
738 end
737 end
739
738
740 # done ratio = weighted average ratio of leaves
739 # done ratio = weighted average ratio of leaves
741 unless Issue.use_status_for_done_ratio? && p.status && p.status.default_done_ratio
740 unless Issue.use_status_for_done_ratio? && p.status && p.status.default_done_ratio
742 leaves_count = p.leaves.count
741 leaves_count = p.leaves.count
743 if leaves_count > 0
742 if leaves_count > 0
744 average = p.leaves.average(:estimated_hours).to_f
743 average = p.leaves.average(:estimated_hours).to_f
745 if average == 0
744 if average == 0
746 average = 1
745 average = 1
747 end
746 end
748 done = p.leaves.sum("COALESCE(estimated_hours, #{average}) * (CASE WHEN is_closed = #{connection.quoted_true} THEN 100 ELSE COALESCE(done_ratio, 0) END)", :include => :status).to_f
747 done = p.leaves.sum("COALESCE(estimated_hours, #{average}) * (CASE WHEN is_closed = #{connection.quoted_true} THEN 100 ELSE COALESCE(done_ratio, 0) END)", :include => :status).to_f
749 progress = done / (average * leaves_count)
748 progress = done / (average * leaves_count)
750 p.done_ratio = progress.round
749 p.done_ratio = progress.round
751 end
750 end
752 end
751 end
753
752
754 # estimate = sum of leaves estimates
753 # estimate = sum of leaves estimates
755 p.estimated_hours = p.leaves.sum(:estimated_hours).to_f
754 p.estimated_hours = p.leaves.sum(:estimated_hours).to_f
756 p.estimated_hours = nil if p.estimated_hours == 0.0
755 p.estimated_hours = nil if p.estimated_hours == 0.0
757
756
758 # ancestors will be recursively updated
757 # ancestors will be recursively updated
759 p.save(false)
758 p.save(false)
760 end
759 end
761 end
760 end
762
761
763 def destroy_children
762 def destroy_children
764 unless leaf?
763 unless leaf?
765 children.each do |child|
764 children.each do |child|
766 child.destroy
765 child.destroy
767 end
766 end
768 end
767 end
769 end
768 end
770
769
771 # Update issues so their versions are not pointing to a
770 # Update issues so their versions are not pointing to a
772 # fixed_version that is not shared with the issue's project
771 # fixed_version that is not shared with the issue's project
773 def self.update_versions(conditions=nil)
772 def self.update_versions(conditions=nil)
774 # Only need to update issues with a fixed_version from
773 # Only need to update issues with a fixed_version from
775 # a different project and that is not systemwide shared
774 # a different project and that is not systemwide shared
776 Issue.all(:conditions => merge_conditions("#{Issue.table_name}.fixed_version_id IS NOT NULL" +
775 Issue.all(:conditions => merge_conditions("#{Issue.table_name}.fixed_version_id IS NOT NULL" +
777 " AND #{Issue.table_name}.project_id <> #{Version.table_name}.project_id" +
776 " AND #{Issue.table_name}.project_id <> #{Version.table_name}.project_id" +
778 " AND #{Version.table_name}.sharing <> 'system'",
777 " AND #{Version.table_name}.sharing <> 'system'",
779 conditions),
778 conditions),
780 :include => [:project, :fixed_version]
779 :include => [:project, :fixed_version]
781 ).each do |issue|
780 ).each do |issue|
782 next if issue.project.nil? || issue.fixed_version.nil?
781 next if issue.project.nil? || issue.fixed_version.nil?
783 unless issue.project.shared_versions.include?(issue.fixed_version)
782 unless issue.project.shared_versions.include?(issue.fixed_version)
784 issue.init_journal(User.current)
783 issue.init_journal(User.current)
785 issue.fixed_version = nil
784 issue.fixed_version = nil
786 issue.save
785 issue.save
787 end
786 end
788 end
787 end
789 end
788 end
790
789
791 # Callback on attachment deletion
790 # Callback on attachment deletion
792 def attachment_removed(obj)
791 def attachment_removed(obj)
793 journal = init_journal(User.current)
792 journal = init_journal(User.current)
794 journal.details << JournalDetail.new(:property => 'attachment',
793 journal.details << JournalDetail.new(:property => 'attachment',
795 :prop_key => obj.id,
794 :prop_key => obj.id,
796 :old_value => obj.filename)
795 :old_value => obj.filename)
797 journal.save
796 journal.save
798 end
797 end
799
798
800 # Default assignment based on category
799 # Default assignment based on category
801 def default_assign
800 def default_assign
802 if assigned_to.nil? && category && category.assigned_to
801 if assigned_to.nil? && category && category.assigned_to
803 self.assigned_to = category.assigned_to
802 self.assigned_to = category.assigned_to
804 end
803 end
805 end
804 end
806
805
807 # Updates start/due dates of following issues
806 # Updates start/due dates of following issues
808 def reschedule_following_issues
807 def reschedule_following_issues
809 if start_date_changed? || due_date_changed?
808 if start_date_changed? || due_date_changed?
810 relations_from.each do |relation|
809 relations_from.each do |relation|
811 relation.set_issue_to_dates
810 relation.set_issue_to_dates
812 end
811 end
813 end
812 end
814 end
813 end
815
814
816 # Closes duplicates if the issue is being closed
815 # Closes duplicates if the issue is being closed
817 def close_duplicates
816 def close_duplicates
818 if closing?
817 if closing?
819 duplicates.each do |duplicate|
818 duplicates.each do |duplicate|
820 # Reload is need in case the duplicate was updated by a previous duplicate
819 # Reload is need in case the duplicate was updated by a previous duplicate
821 duplicate.reload
820 duplicate.reload
822 # Don't re-close it if it's already closed
821 # Don't re-close it if it's already closed
823 next if duplicate.closed?
822 next if duplicate.closed?
824 # Same user and notes
823 # Same user and notes
825 if @current_journal
824 if @current_journal
826 duplicate.init_journal(@current_journal.user, @current_journal.notes)
825 duplicate.init_journal(@current_journal.user, @current_journal.notes)
827 end
826 end
828 duplicate.update_attribute :status, self.status
827 duplicate.update_attribute :status, self.status
829 end
828 end
830 end
829 end
831 end
830 end
832
831
833 # Saves the changes in a Journal
832 # Saves the changes in a Journal
834 # Called after_save
833 # Called after_save
835 def create_journal
834 def create_journal
836 if @current_journal
835 if @current_journal
837 # attributes changes
836 # attributes changes
838 (Issue.column_names - %w(id description root_id lft rgt lock_version created_on updated_on)).each {|c|
837 (Issue.column_names - %w(id description root_id lft rgt lock_version created_on updated_on)).each {|c|
839 @current_journal.details << JournalDetail.new(:property => 'attr',
838 @current_journal.details << JournalDetail.new(:property => 'attr',
840 :prop_key => c,
839 :prop_key => c,
841 :old_value => @issue_before_change.send(c),
840 :old_value => @issue_before_change.send(c),
842 :value => send(c)) unless send(c)==@issue_before_change.send(c)
841 :value => send(c)) unless send(c)==@issue_before_change.send(c)
843 }
842 }
844 # custom fields changes
843 # custom fields changes
845 custom_values.each {|c|
844 custom_values.each {|c|
846 next if (@custom_values_before_change[c.custom_field_id]==c.value ||
845 next if (@custom_values_before_change[c.custom_field_id]==c.value ||
847 (@custom_values_before_change[c.custom_field_id].blank? && c.value.blank?))
846 (@custom_values_before_change[c.custom_field_id].blank? && c.value.blank?))
848 @current_journal.details << JournalDetail.new(:property => 'cf',
847 @current_journal.details << JournalDetail.new(:property => 'cf',
849 :prop_key => c.custom_field_id,
848 :prop_key => c.custom_field_id,
850 :old_value => @custom_values_before_change[c.custom_field_id],
849 :old_value => @custom_values_before_change[c.custom_field_id],
851 :value => c.value)
850 :value => c.value)
852 }
851 }
853 @current_journal.save
852 @current_journal.save
854 # reset current journal
853 # reset current journal
855 init_journal @current_journal.user, @current_journal.notes
854 init_journal @current_journal.user, @current_journal.notes
856 end
855 end
857 end
856 end
858
857
859 # Query generator for selecting groups of issue counts for a project
858 # Query generator for selecting groups of issue counts for a project
860 # based on specific criteria
859 # based on specific criteria
861 #
860 #
862 # Options
861 # Options
863 # * project - Project to search in.
862 # * project - Project to search in.
864 # * field - String. Issue field to key off of in the grouping.
863 # * field - String. Issue field to key off of in the grouping.
865 # * joins - String. The table name to join against.
864 # * joins - String. The table name to join against.
866 def self.count_and_group_by(options)
865 def self.count_and_group_by(options)
867 project = options.delete(:project)
866 project = options.delete(:project)
868 select_field = options.delete(:field)
867 select_field = options.delete(:field)
869 joins = options.delete(:joins)
868 joins = options.delete(:joins)
870
869
871 where = "i.#{select_field}=j.id"
870 where = "i.#{select_field}=j.id"
872
871
873 ActiveRecord::Base.connection.select_all("select s.id as status_id,
872 ActiveRecord::Base.connection.select_all("select s.id as status_id,
874 s.is_closed as closed,
873 s.is_closed as closed,
875 j.id as #{select_field},
874 j.id as #{select_field},
876 count(i.id) as total
875 count(i.id) as total
877 from
876 from
878 #{Issue.table_name} i, #{IssueStatus.table_name} s, #{joins} j
877 #{Issue.table_name} i, #{IssueStatus.table_name} s, #{joins} j
879 where
878 where
880 i.status_id=s.id
879 i.status_id=s.id
881 and #{where}
880 and #{where}
882 and i.project_id=#{project.id}
881 and i.project_id=#{project.id}
883 group by s.id, s.is_closed, j.id")
882 group by s.id, s.is_closed, j.id")
884 end
883 end
885
884
886
885
887 end
886 end
@@ -1,974 +1,987
1 # Redmine - project management software
1 # Redmine - project management software
2 # Copyright (C) 2006-2008 Jean-Philippe Lang
2 # Copyright (C) 2006-2008 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 module Redmine
18 module Redmine
19 module Helpers
19 module Helpers
20 # Simple class to handle gantt chart data
20 # Simple class to handle gantt chart data
21 class Gantt
21 class Gantt
22 include ERB::Util
22 include ERB::Util
23 include Redmine::I18n
23 include Redmine::I18n
24
24
25 # :nodoc:
25 # :nodoc:
26 # Some utility methods for the PDF export
26 # Some utility methods for the PDF export
27 class PDF
27 class PDF
28 MaxCharactorsForSubject = 45
28 MaxCharactorsForSubject = 45
29 TotalWidth = 280
29 TotalWidth = 280
30 LeftPaneWidth = 100
30 LeftPaneWidth = 100
31
31
32 def self.right_pane_width
32 def self.right_pane_width
33 TotalWidth - LeftPaneWidth
33 TotalWidth - LeftPaneWidth
34 end
34 end
35 end
35 end
36
36
37 attr_reader :year_from, :month_from, :date_from, :date_to, :zoom, :months
37 attr_reader :year_from, :month_from, :date_from, :date_to, :zoom, :months
38 attr_accessor :query
38 attr_accessor :query
39 attr_accessor :project
39 attr_accessor :project
40 attr_accessor :view
40 attr_accessor :view
41
41
42 def initialize(options={})
42 def initialize(options={})
43 options = options.dup
43 options = options.dup
44
44
45 if options[:year] && options[:year].to_i >0
45 if options[:year] && options[:year].to_i >0
46 @year_from = options[:year].to_i
46 @year_from = options[:year].to_i
47 if options[:month] && options[:month].to_i >=1 && options[:month].to_i <= 12
47 if options[:month] && options[:month].to_i >=1 && options[:month].to_i <= 12
48 @month_from = options[:month].to_i
48 @month_from = options[:month].to_i
49 else
49 else
50 @month_from = 1
50 @month_from = 1
51 end
51 end
52 else
52 else
53 @month_from ||= Date.today.month
53 @month_from ||= Date.today.month
54 @year_from ||= Date.today.year
54 @year_from ||= Date.today.year
55 end
55 end
56
56
57 zoom = (options[:zoom] || User.current.pref[:gantt_zoom]).to_i
57 zoom = (options[:zoom] || User.current.pref[:gantt_zoom]).to_i
58 @zoom = (zoom > 0 && zoom < 5) ? zoom : 2
58 @zoom = (zoom > 0 && zoom < 5) ? zoom : 2
59 months = (options[:months] || User.current.pref[:gantt_months]).to_i
59 months = (options[:months] || User.current.pref[:gantt_months]).to_i
60 @months = (months > 0 && months < 25) ? months : 6
60 @months = (months > 0 && months < 25) ? months : 6
61
61
62 # Save gantt parameters as user preference (zoom and months count)
62 # Save gantt parameters as user preference (zoom and months count)
63 if (User.current.logged? && (@zoom != User.current.pref[:gantt_zoom] || @months != User.current.pref[:gantt_months]))
63 if (User.current.logged? && (@zoom != User.current.pref[:gantt_zoom] || @months != User.current.pref[:gantt_months]))
64 User.current.pref[:gantt_zoom], User.current.pref[:gantt_months] = @zoom, @months
64 User.current.pref[:gantt_zoom], User.current.pref[:gantt_months] = @zoom, @months
65 User.current.preference.save
65 User.current.preference.save
66 end
66 end
67
67
68 @date_from = Date.civil(@year_from, @month_from, 1)
68 @date_from = Date.civil(@year_from, @month_from, 1)
69 @date_to = (@date_from >> @months) - 1
69 @date_to = (@date_from >> @months) - 1
70 end
70 end
71
71
72 def common_params
72 def common_params
73 { :controller => 'gantts', :action => 'show', :project_id => @project }
73 { :controller => 'gantts', :action => 'show', :project_id => @project }
74 end
74 end
75
75
76 def params
76 def params
77 common_params.merge({ :zoom => zoom, :year => year_from, :month => month_from, :months => months })
77 common_params.merge({ :zoom => zoom, :year => year_from, :month => month_from, :months => months })
78 end
78 end
79
79
80 def params_previous
80 def params_previous
81 common_params.merge({:year => (date_from << months).year, :month => (date_from << months).month, :zoom => zoom, :months => months })
81 common_params.merge({:year => (date_from << months).year, :month => (date_from << months).month, :zoom => zoom, :months => months })
82 end
82 end
83
83
84 def params_next
84 def params_next
85 common_params.merge({:year => (date_from >> months).year, :month => (date_from >> months).month, :zoom => zoom, :months => months })
85 common_params.merge({:year => (date_from >> months).year, :month => (date_from >> months).month, :zoom => zoom, :months => months })
86 end
86 end
87
87
88 ### Extracted from the HTML view/helpers
88 ### Extracted from the HTML view/helpers
89 # Returns the number of rows that will be rendered on the Gantt chart
89 # Returns the number of rows that will be rendered on the Gantt chart
90 def number_of_rows
90 def number_of_rows
91 if @project
91 if @project
92 return number_of_rows_on_project(@project)
92 return number_of_rows_on_project(@project)
93 else
93 else
94 Project.roots.inject(0) do |total, project|
94 Project.roots.inject(0) do |total, project|
95 total += number_of_rows_on_project(project)
95 total += number_of_rows_on_project(project)
96 end
96 end
97 end
97 end
98 end
98 end
99
99
100 # Returns the number of rows that will be used to list a project on
100 # Returns the number of rows that will be used to list a project on
101 # the Gantt chart. This will recurse for each subproject.
101 # the Gantt chart. This will recurse for each subproject.
102 def number_of_rows_on_project(project)
102 def number_of_rows_on_project(project)
103 # Remove the project requirement for Versions because it will
103 # Remove the project requirement for Versions because it will
104 # restrict issues to only be on the current project. This
104 # restrict issues to only be on the current project. This
105 # ends up missing issues which are assigned to shared versions.
105 # ends up missing issues which are assigned to shared versions.
106 @query.project = nil if @query.project
106 @query.project = nil if @query.project
107
107
108 # One Root project
108 # One Root project
109 count = 1
109 count = 1
110 # Issues without a Version
110 # Issues without a Version
111 count += project.issues.for_gantt.without_version.with_query(@query).count
111 count += project.issues.for_gantt.without_version.with_query(@query).count
112
112
113 # Versions
113 # Versions
114 count += project.versions.count
114 count += project.versions.count
115
115
116 # Issues on the Versions
116 # Issues on the Versions
117 project.versions.each do |version|
117 project.versions.each do |version|
118 count += version.fixed_issues.for_gantt.with_query(@query).count
118 count += version.fixed_issues.for_gantt.with_query(@query).count
119 end
119 end
120
120
121 # Subprojects
121 # Subprojects
122 project.children.each do |subproject|
122 project.children.each do |subproject|
123 count += number_of_rows_on_project(subproject)
123 count += number_of_rows_on_project(subproject)
124 end
124 end
125
125
126 count
126 count
127 end
127 end
128
128
129 # Renders the subjects of the Gantt chart, the left side.
129 # Renders the subjects of the Gantt chart, the left side.
130 def subjects(options={})
130 def subjects(options={})
131 options = {:indent => 4, :render => :subject, :format => :html}.merge(options)
131 options = {:indent => 4, :render => :subject, :format => :html}.merge(options)
132
132
133 output = ''
133 output = ''
134 if @project
134 if @project
135 output << render_project(@project, options)
135 output << render_project(@project, options)
136 else
136 else
137 Project.roots.each do |project|
137 Project.roots.each do |project|
138 output << render_project(project, options)
138 output << render_project(project, options)
139 end
139 end
140 end
140 end
141
141
142 output
142 output
143 end
143 end
144
144
145 # Renders the lines of the Gantt chart, the right side
145 # Renders the lines of the Gantt chart, the right side
146 def lines(options={})
146 def lines(options={})
147 options = {:indent => 4, :render => :line, :format => :html}.merge(options)
147 options = {:indent => 4, :render => :line, :format => :html}.merge(options)
148 output = ''
148 output = ''
149
149
150 if @project
150 if @project
151 output << render_project(@project, options)
151 output << render_project(@project, options)
152 else
152 else
153 Project.roots.each do |project|
153 Project.roots.each do |project|
154 output << render_project(project, options)
154 output << render_project(project, options)
155 end
155 end
156 end
156 end
157
157
158 output
158 output
159 end
159 end
160
160
161 def render_project(project, options={})
161 def render_project(project, options={})
162 options[:top] = 0 unless options.key? :top
162 options[:top] = 0 unless options.key? :top
163 options[:indent_increment] = 20 unless options.key? :indent_increment
163 options[:indent_increment] = 20 unless options.key? :indent_increment
164 options[:top_increment] = 20 unless options.key? :top_increment
164 options[:top_increment] = 20 unless options.key? :top_increment
165
165
166 output = ''
166 output = ''
167 # Project Header
167 # Project Header
168 project_header = if options[:render] == :subject
168 project_header = if options[:render] == :subject
169 subject_for_project(project, options)
169 subject_for_project(project, options)
170 else
170 else
171 # :line
171 # :line
172 line_for_project(project, options)
172 line_for_project(project, options)
173 end
173 end
174 output << project_header if options[:format] == :html
174 output << project_header if options[:format] == :html
175
175
176 options[:top] += options[:top_increment]
176 options[:top] += options[:top_increment]
177 options[:indent] += options[:indent_increment]
177 options[:indent] += options[:indent_increment]
178
178
179 # Second, Issues without a version
179 # Second, Issues without a version
180 issues = project.issues.for_gantt.without_version.with_query(@query)
180 issues = project.issues.for_gantt.without_version.with_query(@query)
181 sort_issues!(issues)
181 if issues
182 if issues
182 issue_rendering = render_issues(issues, options)
183 issue_rendering = render_issues(issues, options)
183 output << issue_rendering if options[:format] == :html
184 output << issue_rendering if options[:format] == :html
184 end
185 end
185
186
186 # Third, Versions
187 # Third, Versions
187 project.versions.sort.each do |version|
188 project.versions.sort.each do |version|
188 version_rendering = render_version(version, options)
189 version_rendering = render_version(version, options)
189 output << version_rendering if options[:format] == :html
190 output << version_rendering if options[:format] == :html
190 end
191 end
191
192
192 # Fourth, subprojects
193 # Fourth, subprojects
193 project.children.each do |project|
194 project.children.each do |project|
194 subproject_rendering = render_project(project, options)
195 subproject_rendering = render_project(project, options)
195 output << subproject_rendering if options[:format] == :html
196 output << subproject_rendering if options[:format] == :html
196 end
197 end
197
198
198 # Remove indent to hit the next sibling
199 # Remove indent to hit the next sibling
199 options[:indent] -= options[:indent_increment]
200 options[:indent] -= options[:indent_increment]
200
201
201 output
202 output
202 end
203 end
203
204
204 def render_issues(issues, options={})
205 def render_issues(issues, options={})
205 output = ''
206 output = ''
206 issues.each do |i|
207 issues.each do |i|
207 issue_rendering = if options[:render] == :subject
208 issue_rendering = if options[:render] == :subject
208 subject_for_issue(i, options)
209 subject_for_issue(i, options)
209 else
210 else
210 # :line
211 # :line
211 line_for_issue(i, options)
212 line_for_issue(i, options)
212 end
213 end
213 output << issue_rendering if options[:format] == :html
214 output << issue_rendering if options[:format] == :html
214 options[:top] += options[:top_increment]
215 options[:top] += options[:top_increment]
215 end
216 end
216 output
217 output
217 end
218 end
218
219
219 def render_version(version, options={})
220 def render_version(version, options={})
220 output = ''
221 output = ''
221 # Version header
222 # Version header
222 version_rendering = if options[:render] == :subject
223 version_rendering = if options[:render] == :subject
223 subject_for_version(version, options)
224 subject_for_version(version, options)
224 else
225 else
225 # :line
226 # :line
226 line_for_version(version, options)
227 line_for_version(version, options)
227 end
228 end
228
229
229 output << version_rendering if options[:format] == :html
230 output << version_rendering if options[:format] == :html
230
231
231 options[:top] += options[:top_increment]
232 options[:top] += options[:top_increment]
232
233
233 # Remove the project requirement for Versions because it will
234 # Remove the project requirement for Versions because it will
234 # restrict issues to only be on the current project. This
235 # restrict issues to only be on the current project. This
235 # ends up missing issues which are assigned to shared versions.
236 # ends up missing issues which are assigned to shared versions.
236 @query.project = nil if @query.project
237 @query.project = nil if @query.project
237
238
238 issues = version.fixed_issues.for_gantt.with_query(@query)
239 issues = version.fixed_issues.for_gantt.with_query(@query)
239 if issues
240 if issues
241 sort_issues!(issues)
240 # Indent issues
242 # Indent issues
241 options[:indent] += options[:indent_increment]
243 options[:indent] += options[:indent_increment]
242 output << render_issues(issues, options)
244 output << render_issues(issues, options)
243 options[:indent] -= options[:indent_increment]
245 options[:indent] -= options[:indent_increment]
244 end
246 end
245
247
246 output
248 output
247 end
249 end
248
250
249 def subject_for_project(project, options)
251 def subject_for_project(project, options)
250 case options[:format]
252 case options[:format]
251 when :html
253 when :html
252 output = ''
254 output = ''
253
255
254 output << "<div class='project-name' style='position: absolute;line-height:1.2em;height:16px;top:#{options[:top]}px;left:#{options[:indent]}px;overflow:hidden;'><small> "
256 output << "<div class='project-name' style='position: absolute;line-height:1.2em;height:16px;top:#{options[:top]}px;left:#{options[:indent]}px;overflow:hidden;'><small> "
255 if project.is_a? Project
257 if project.is_a? Project
256 output << "<span class='icon icon-projects #{project.overdue? ? 'project-overdue' : ''}'>"
258 output << "<span class='icon icon-projects #{project.overdue? ? 'project-overdue' : ''}'>"
257 output << view.link_to_project(project)
259 output << view.link_to_project(project)
258 output << '</span>'
260 output << '</span>'
259 else
261 else
260 ActiveRecord::Base.logger.debug "Gantt#subject_for_project was not given a project"
262 ActiveRecord::Base.logger.debug "Gantt#subject_for_project was not given a project"
261 ''
263 ''
262 end
264 end
263 output << "</small></div>"
265 output << "</small></div>"
264
266
265 output
267 output
266 when :image
268 when :image
267
269
268 options[:image].fill('black')
270 options[:image].fill('black')
269 options[:image].stroke('transparent')
271 options[:image].stroke('transparent')
270 options[:image].stroke_width(1)
272 options[:image].stroke_width(1)
271 options[:image].text(options[:indent], options[:top] + 2, project.name)
273 options[:image].text(options[:indent], options[:top] + 2, project.name)
272 when :pdf
274 when :pdf
273 options[:pdf].SetY(options[:top])
275 options[:pdf].SetY(options[:top])
274 options[:pdf].SetX(15)
276 options[:pdf].SetX(15)
275
277
276 char_limit = PDF::MaxCharactorsForSubject - options[:indent]
278 char_limit = PDF::MaxCharactorsForSubject - options[:indent]
277 options[:pdf].Cell(options[:subject_width]-15, 5, (" " * options[:indent]) +"#{project.name}".sub(/^(.{#{char_limit}}[^\s]*\s).*$/, '\1 (...)'), "LR")
279 options[:pdf].Cell(options[:subject_width]-15, 5, (" " * options[:indent]) +"#{project.name}".sub(/^(.{#{char_limit}}[^\s]*\s).*$/, '\1 (...)'), "LR")
278
280
279 options[:pdf].SetY(options[:top])
281 options[:pdf].SetY(options[:top])
280 options[:pdf].SetX(options[:subject_width])
282 options[:pdf].SetX(options[:subject_width])
281 options[:pdf].Cell(options[:g_width], 5, "", "LR")
283 options[:pdf].Cell(options[:g_width], 5, "", "LR")
282 end
284 end
283 end
285 end
284
286
285 def line_for_project(project, options)
287 def line_for_project(project, options)
286 # Skip versions that don't have a start_date or due date
288 # Skip versions that don't have a start_date or due date
287 if project.is_a?(Project) && project.start_date && project.due_date
289 if project.is_a?(Project) && project.start_date && project.due_date
288 options[:zoom] ||= 1
290 options[:zoom] ||= 1
289 options[:g_width] ||= (self.date_to - self.date_from + 1) * options[:zoom]
291 options[:g_width] ||= (self.date_to - self.date_from + 1) * options[:zoom]
290
292
291
293
292 case options[:format]
294 case options[:format]
293 when :html
295 when :html
294 output = ''
296 output = ''
295 i_left = ((project.start_date - self.date_from)*options[:zoom]).floor
297 i_left = ((project.start_date - self.date_from)*options[:zoom]).floor
296
298
297 start_date = project.start_date
299 start_date = project.start_date
298 start_date ||= self.date_from
300 start_date ||= self.date_from
299 start_left = ((start_date - self.date_from)*options[:zoom]).floor
301 start_left = ((start_date - self.date_from)*options[:zoom]).floor
300
302
301 i_end_date = ((project.due_date <= self.date_to) ? project.due_date : self.date_to )
303 i_end_date = ((project.due_date <= self.date_to) ? project.due_date : self.date_to )
302 i_done_date = start_date + ((project.due_date - start_date+1)* project.completed_percent(:include_subprojects => true)/100).floor
304 i_done_date = start_date + ((project.due_date - start_date+1)* project.completed_percent(:include_subprojects => true)/100).floor
303 i_done_date = (i_done_date <= self.date_from ? self.date_from : i_done_date )
305 i_done_date = (i_done_date <= self.date_from ? self.date_from : i_done_date )
304 i_done_date = (i_done_date >= self.date_to ? self.date_to : i_done_date )
306 i_done_date = (i_done_date >= self.date_to ? self.date_to : i_done_date )
305
307
306 i_late_date = [i_end_date, Date.today].min if start_date < Date.today
308 i_late_date = [i_end_date, Date.today].min if start_date < Date.today
307 i_end = ((i_end_date - self.date_from) * options[:zoom]).floor
309 i_end = ((i_end_date - self.date_from) * options[:zoom]).floor
308
310
309 i_width = (i_end - i_left + 1).floor - 2 # total width of the issue (- 2 for left and right borders)
311 i_width = (i_end - i_left + 1).floor - 2 # total width of the issue (- 2 for left and right borders)
310 d_width = ((i_done_date - start_date)*options[:zoom]).floor - 2 # done width
312 d_width = ((i_done_date - start_date)*options[:zoom]).floor - 2 # done width
311 l_width = i_late_date ? ((i_late_date - start_date+1)*options[:zoom]).floor - 2 : 0 # delay width
313 l_width = i_late_date ? ((i_late_date - start_date+1)*options[:zoom]).floor - 2 : 0 # delay width
312
314
313 # Bar graphic
315 # Bar graphic
314
316
315 # Make sure that negative i_left and i_width don't
317 # Make sure that negative i_left and i_width don't
316 # overflow the subject
318 # overflow the subject
317 if i_end > 0 && i_left <= options[:g_width]
319 if i_end > 0 && i_left <= options[:g_width]
318 output << "<div style='top:#{ options[:top] }px;left:#{ start_left }px;width:#{ i_width }px;' class='task project_todo'>&nbsp;</div>"
320 output << "<div style='top:#{ options[:top] }px;left:#{ start_left }px;width:#{ i_width }px;' class='task project_todo'>&nbsp;</div>"
319 end
321 end
320
322
321 if l_width > 0 && i_left <= options[:g_width]
323 if l_width > 0 && i_left <= options[:g_width]
322 output << "<div style='top:#{ options[:top] }px;left:#{ start_left }px;width:#{ l_width }px;' class='task project_late'>&nbsp;</div>"
324 output << "<div style='top:#{ options[:top] }px;left:#{ start_left }px;width:#{ l_width }px;' class='task project_late'>&nbsp;</div>"
323 end
325 end
324 if d_width > 0 && i_left <= options[:g_width]
326 if d_width > 0 && i_left <= options[:g_width]
325 output<< "<div style='top:#{ options[:top] }px;left:#{ start_left }px;width:#{ d_width }px;' class='task project_done'>&nbsp;</div>"
327 output<< "<div style='top:#{ options[:top] }px;left:#{ start_left }px;width:#{ d_width }px;' class='task project_done'>&nbsp;</div>"
326 end
328 end
327
329
328
330
329 # Starting diamond
331 # Starting diamond
330 if start_left <= options[:g_width] && start_left > 0
332 if start_left <= options[:g_width] && start_left > 0
331 output << "<div style='top:#{ options[:top] }px;left:#{ start_left }px;width:15px;' class='task project-line starting'>&nbsp;</div>"
333 output << "<div style='top:#{ options[:top] }px;left:#{ start_left }px;width:15px;' class='task project-line starting'>&nbsp;</div>"
332 output << "<div style='top:#{ options[:top] }px;left:#{ start_left + 12 }px;' class='task label'>"
334 output << "<div style='top:#{ options[:top] }px;left:#{ start_left + 12 }px;' class='task label'>"
333 output << "</div>"
335 output << "</div>"
334 end
336 end
335
337
336 # Ending diamond
338 # Ending diamond
337 # Don't show items too far ahead
339 # Don't show items too far ahead
338 if i_end <= options[:g_width] && i_end > 0
340 if i_end <= options[:g_width] && i_end > 0
339 output << "<div style='top:#{ options[:top] }px;left:#{ i_end }px;width:15px;' class='task project-line ending'>&nbsp;</div>"
341 output << "<div style='top:#{ options[:top] }px;left:#{ i_end }px;width:15px;' class='task project-line ending'>&nbsp;</div>"
340 end
342 end
341
343
342 # DIsplay the Project name and %
344 # DIsplay the Project name and %
343 if i_end <= options[:g_width]
345 if i_end <= options[:g_width]
344 # Display the status even if it's floated off to the left
346 # Display the status even if it's floated off to the left
345 status_px = i_end + 12 # 12px for the diamond
347 status_px = i_end + 12 # 12px for the diamond
346 status_px = 0 if status_px <= 0
348 status_px = 0 if status_px <= 0
347
349
348 output << "<div style='top:#{ options[:top] }px;left:#{ status_px }px;' class='task label project-name'>"
350 output << "<div style='top:#{ options[:top] }px;left:#{ status_px }px;' class='task label project-name'>"
349 output << "<strong>#{h project } #{h project.completed_percent(:include_subprojects => true).to_i.to_s}%</strong>"
351 output << "<strong>#{h project } #{h project.completed_percent(:include_subprojects => true).to_i.to_s}%</strong>"
350 output << "</div>"
352 output << "</div>"
351 end
353 end
352
354
353 output
355 output
354 when :image
356 when :image
355 options[:image].stroke('transparent')
357 options[:image].stroke('transparent')
356 i_left = options[:subject_width] + ((project.due_date - self.date_from)*options[:zoom]).floor
358 i_left = options[:subject_width] + ((project.due_date - self.date_from)*options[:zoom]).floor
357
359
358 # Make sure negative i_left doesn't overflow the subject
360 # Make sure negative i_left doesn't overflow the subject
359 if i_left > options[:subject_width]
361 if i_left > options[:subject_width]
360 options[:image].fill('blue')
362 options[:image].fill('blue')
361 options[:image].rectangle(i_left, options[:top], i_left + 6, options[:top] - 6)
363 options[:image].rectangle(i_left, options[:top], i_left + 6, options[:top] - 6)
362 options[:image].fill('black')
364 options[:image].fill('black')
363 options[:image].text(i_left + 11, options[:top] + 1, project.name)
365 options[:image].text(i_left + 11, options[:top] + 1, project.name)
364 end
366 end
365 when :pdf
367 when :pdf
366 options[:pdf].SetY(options[:top]+1.5)
368 options[:pdf].SetY(options[:top]+1.5)
367 i_left = ((project.due_date - @date_from)*options[:zoom])
369 i_left = ((project.due_date - @date_from)*options[:zoom])
368
370
369 # Make sure negative i_left doesn't overflow the subject
371 # Make sure negative i_left doesn't overflow the subject
370 if i_left > 0
372 if i_left > 0
371 options[:pdf].SetX(options[:subject_width] + i_left)
373 options[:pdf].SetX(options[:subject_width] + i_left)
372 options[:pdf].SetFillColor(50,50,200)
374 options[:pdf].SetFillColor(50,50,200)
373 options[:pdf].Cell(2, 2, "", 0, 0, "", 1)
375 options[:pdf].Cell(2, 2, "", 0, 0, "", 1)
374
376
375 options[:pdf].SetY(options[:top]+1.5)
377 options[:pdf].SetY(options[:top]+1.5)
376 options[:pdf].SetX(options[:subject_width] + i_left + 3)
378 options[:pdf].SetX(options[:subject_width] + i_left + 3)
377 options[:pdf].Cell(30, 2, "#{project.name}")
379 options[:pdf].Cell(30, 2, "#{project.name}")
378 end
380 end
379 end
381 end
380 else
382 else
381 ActiveRecord::Base.logger.debug "Gantt#line_for_project was not given a project with a start_date"
383 ActiveRecord::Base.logger.debug "Gantt#line_for_project was not given a project with a start_date"
382 ''
384 ''
383 end
385 end
384 end
386 end
385
387
386 def subject_for_version(version, options)
388 def subject_for_version(version, options)
387 case options[:format]
389 case options[:format]
388 when :html
390 when :html
389 output = ''
391 output = ''
390 output << "<div class='version-name' style='position: absolute;line-height:1.2em;height:16px;top:#{options[:top]}px;left:#{options[:indent]}px;overflow:hidden;'><small> "
392 output << "<div class='version-name' style='position: absolute;line-height:1.2em;height:16px;top:#{options[:top]}px;left:#{options[:indent]}px;overflow:hidden;'><small> "
391 if version.is_a? Version
393 if version.is_a? Version
392 output << "<span class='icon icon-package #{version.behind_schedule? ? 'version-behind-schedule' : ''} #{version.overdue? ? 'version-overdue' : ''}'>"
394 output << "<span class='icon icon-package #{version.behind_schedule? ? 'version-behind-schedule' : ''} #{version.overdue? ? 'version-overdue' : ''}'>"
393 output << view.link_to_version(version)
395 output << view.link_to_version(version)
394 output << '</span>'
396 output << '</span>'
395 else
397 else
396 ActiveRecord::Base.logger.debug "Gantt#subject_for_version was not given a version"
398 ActiveRecord::Base.logger.debug "Gantt#subject_for_version was not given a version"
397 ''
399 ''
398 end
400 end
399 output << "</small></div>"
401 output << "</small></div>"
400
402
401 output
403 output
402 when :image
404 when :image
403 options[:image].fill('black')
405 options[:image].fill('black')
404 options[:image].stroke('transparent')
406 options[:image].stroke('transparent')
405 options[:image].stroke_width(1)
407 options[:image].stroke_width(1)
406 options[:image].text(options[:indent], options[:top] + 2, version.to_s_with_project)
408 options[:image].text(options[:indent], options[:top] + 2, version.to_s_with_project)
407 when :pdf
409 when :pdf
408 options[:pdf].SetY(options[:top])
410 options[:pdf].SetY(options[:top])
409 options[:pdf].SetX(15)
411 options[:pdf].SetX(15)
410
412
411 char_limit = PDF::MaxCharactorsForSubject - options[:indent]
413 char_limit = PDF::MaxCharactorsForSubject - options[:indent]
412 options[:pdf].Cell(options[:subject_width]-15, 5, (" " * options[:indent]) +"#{version.to_s_with_project}".sub(/^(.{#{char_limit}}[^\s]*\s).*$/, '\1 (...)'), "LR")
414 options[:pdf].Cell(options[:subject_width]-15, 5, (" " * options[:indent]) +"#{version.to_s_with_project}".sub(/^(.{#{char_limit}}[^\s]*\s).*$/, '\1 (...)'), "LR")
413
415
414 options[:pdf].SetY(options[:top])
416 options[:pdf].SetY(options[:top])
415 options[:pdf].SetX(options[:subject_width])
417 options[:pdf].SetX(options[:subject_width])
416 options[:pdf].Cell(options[:g_width], 5, "", "LR")
418 options[:pdf].Cell(options[:g_width], 5, "", "LR")
417 end
419 end
418 end
420 end
419
421
420 def line_for_version(version, options)
422 def line_for_version(version, options)
421 # Skip versions that don't have a start_date
423 # Skip versions that don't have a start_date
422 if version.is_a?(Version) && version.start_date && version.due_date
424 if version.is_a?(Version) && version.start_date && version.due_date
423 options[:zoom] ||= 1
425 options[:zoom] ||= 1
424 options[:g_width] ||= (self.date_to - self.date_from + 1) * options[:zoom]
426 options[:g_width] ||= (self.date_to - self.date_from + 1) * options[:zoom]
425
427
426 case options[:format]
428 case options[:format]
427 when :html
429 when :html
428 output = ''
430 output = ''
429 i_left = ((version.start_date - self.date_from)*options[:zoom]).floor
431 i_left = ((version.start_date - self.date_from)*options[:zoom]).floor
430 # TODO: or version.fixed_issues.collect(&:start_date).min
432 # TODO: or version.fixed_issues.collect(&:start_date).min
431 start_date = version.fixed_issues.minimum('start_date') if version.fixed_issues.present?
433 start_date = version.fixed_issues.minimum('start_date') if version.fixed_issues.present?
432 start_date ||= self.date_from
434 start_date ||= self.date_from
433 start_left = ((start_date - self.date_from)*options[:zoom]).floor
435 start_left = ((start_date - self.date_from)*options[:zoom]).floor
434
436
435 i_end_date = ((version.due_date <= self.date_to) ? version.due_date : self.date_to )
437 i_end_date = ((version.due_date <= self.date_to) ? version.due_date : self.date_to )
436 i_done_date = start_date + ((version.due_date - start_date+1)* version.completed_pourcent/100).floor
438 i_done_date = start_date + ((version.due_date - start_date+1)* version.completed_pourcent/100).floor
437 i_done_date = (i_done_date <= self.date_from ? self.date_from : i_done_date )
439 i_done_date = (i_done_date <= self.date_from ? self.date_from : i_done_date )
438 i_done_date = (i_done_date >= self.date_to ? self.date_to : i_done_date )
440 i_done_date = (i_done_date >= self.date_to ? self.date_to : i_done_date )
439
441
440 i_late_date = [i_end_date, Date.today].min if start_date < Date.today
442 i_late_date = [i_end_date, Date.today].min if start_date < Date.today
441
443
442 i_width = (i_left - start_left + 1).floor - 2 # total width of the issue (- 2 for left and right borders)
444 i_width = (i_left - start_left + 1).floor - 2 # total width of the issue (- 2 for left and right borders)
443 d_width = ((i_done_date - start_date)*options[:zoom]).floor - 2 # done width
445 d_width = ((i_done_date - start_date)*options[:zoom]).floor - 2 # done width
444 l_width = i_late_date ? ((i_late_date - start_date+1)*options[:zoom]).floor - 2 : 0 # delay width
446 l_width = i_late_date ? ((i_late_date - start_date+1)*options[:zoom]).floor - 2 : 0 # delay width
445
447
446 i_end = ((i_end_date - self.date_from) * options[:zoom]).floor # Ending pixel
448 i_end = ((i_end_date - self.date_from) * options[:zoom]).floor # Ending pixel
447
449
448 # Bar graphic
450 # Bar graphic
449
451
450 # Make sure that negative i_left and i_width don't
452 # Make sure that negative i_left and i_width don't
451 # overflow the subject
453 # overflow the subject
452 if i_width > 0 && i_left <= options[:g_width]
454 if i_width > 0 && i_left <= options[:g_width]
453 output << "<div style='top:#{ options[:top] }px;left:#{ start_left }px;width:#{ i_width }px;' class='task milestone_todo'>&nbsp;</div>"
455 output << "<div style='top:#{ options[:top] }px;left:#{ start_left }px;width:#{ i_width }px;' class='task milestone_todo'>&nbsp;</div>"
454 end
456 end
455 if l_width > 0 && i_left <= options[:g_width]
457 if l_width > 0 && i_left <= options[:g_width]
456 output << "<div style='top:#{ options[:top] }px;left:#{ start_left }px;width:#{ l_width }px;' class='task milestone_late'>&nbsp;</div>"
458 output << "<div style='top:#{ options[:top] }px;left:#{ start_left }px;width:#{ l_width }px;' class='task milestone_late'>&nbsp;</div>"
457 end
459 end
458 if d_width > 0 && i_left <= options[:g_width]
460 if d_width > 0 && i_left <= options[:g_width]
459 output<< "<div style='top:#{ options[:top] }px;left:#{ start_left }px;width:#{ d_width }px;' class='task milestone_done'>&nbsp;</div>"
461 output<< "<div style='top:#{ options[:top] }px;left:#{ start_left }px;width:#{ d_width }px;' class='task milestone_done'>&nbsp;</div>"
460 end
462 end
461
463
462
464
463 # Starting diamond
465 # Starting diamond
464 if start_left <= options[:g_width] && start_left > 0
466 if start_left <= options[:g_width] && start_left > 0
465 output << "<div style='top:#{ options[:top] }px;left:#{ start_left }px;width:15px;' class='task milestone starting'>&nbsp;</div>"
467 output << "<div style='top:#{ options[:top] }px;left:#{ start_left }px;width:15px;' class='task milestone starting'>&nbsp;</div>"
466 output << "<div style='top:#{ options[:top] }px;left:#{ start_left + 12 }px;background:#fff;' class='task'>"
468 output << "<div style='top:#{ options[:top] }px;left:#{ start_left + 12 }px;background:#fff;' class='task'>"
467 output << "</div>"
469 output << "</div>"
468 end
470 end
469
471
470 # Ending diamond
472 # Ending diamond
471 # Don't show items too far ahead
473 # Don't show items too far ahead
472 if i_left <= options[:g_width] && i_end > 0
474 if i_left <= options[:g_width] && i_end > 0
473 output << "<div style='top:#{ options[:top] }px;left:#{ i_end }px;width:15px;' class='task milestone ending'>&nbsp;</div>"
475 output << "<div style='top:#{ options[:top] }px;left:#{ i_end }px;width:15px;' class='task milestone ending'>&nbsp;</div>"
474 end
476 end
475
477
476 # Display the Version name and %
478 # Display the Version name and %
477 if i_end <= options[:g_width]
479 if i_end <= options[:g_width]
478 # Display the status even if it's floated off to the left
480 # Display the status even if it's floated off to the left
479 status_px = i_end + 12 # 12px for the diamond
481 status_px = i_end + 12 # 12px for the diamond
480 status_px = 0 if status_px <= 0
482 status_px = 0 if status_px <= 0
481
483
482 output << "<div style='top:#{ options[:top] }px;left:#{ status_px }px;' class='task label version-name'>"
484 output << "<div style='top:#{ options[:top] }px;left:#{ status_px }px;' class='task label version-name'>"
483 output << h("#{version.project} -") unless @project && @project == version.project
485 output << h("#{version.project} -") unless @project && @project == version.project
484 output << "<strong>#{h version } #{h version.completed_pourcent.to_i.to_s}%</strong>"
486 output << "<strong>#{h version } #{h version.completed_pourcent.to_i.to_s}%</strong>"
485 output << "</div>"
487 output << "</div>"
486 end
488 end
487
489
488 output
490 output
489 when :image
491 when :image
490 options[:image].stroke('transparent')
492 options[:image].stroke('transparent')
491 i_left = options[:subject_width] + ((version.start_date - @date_from)*options[:zoom]).floor
493 i_left = options[:subject_width] + ((version.start_date - @date_from)*options[:zoom]).floor
492
494
493 # Make sure negative i_left doesn't overflow the subject
495 # Make sure negative i_left doesn't overflow the subject
494 if i_left > options[:subject_width]
496 if i_left > options[:subject_width]
495 options[:image].fill('green')
497 options[:image].fill('green')
496 options[:image].rectangle(i_left, options[:top], i_left + 6, options[:top] - 6)
498 options[:image].rectangle(i_left, options[:top], i_left + 6, options[:top] - 6)
497 options[:image].fill('black')
499 options[:image].fill('black')
498 options[:image].text(i_left + 11, options[:top] + 1, version.name)
500 options[:image].text(i_left + 11, options[:top] + 1, version.name)
499 end
501 end
500 when :pdf
502 when :pdf
501 options[:pdf].SetY(options[:top]+1.5)
503 options[:pdf].SetY(options[:top]+1.5)
502 i_left = ((version.start_date - @date_from)*options[:zoom])
504 i_left = ((version.start_date - @date_from)*options[:zoom])
503
505
504 # Make sure negative i_left doesn't overflow the subject
506 # Make sure negative i_left doesn't overflow the subject
505 if i_left > 0
507 if i_left > 0
506 options[:pdf].SetX(options[:subject_width] + i_left)
508 options[:pdf].SetX(options[:subject_width] + i_left)
507 options[:pdf].SetFillColor(50,200,50)
509 options[:pdf].SetFillColor(50,200,50)
508 options[:pdf].Cell(2, 2, "", 0, 0, "", 1)
510 options[:pdf].Cell(2, 2, "", 0, 0, "", 1)
509
511
510 options[:pdf].SetY(options[:top]+1.5)
512 options[:pdf].SetY(options[:top]+1.5)
511 options[:pdf].SetX(options[:subject_width] + i_left + 3)
513 options[:pdf].SetX(options[:subject_width] + i_left + 3)
512 options[:pdf].Cell(30, 2, "#{version.name}")
514 options[:pdf].Cell(30, 2, "#{version.name}")
513 end
515 end
514 end
516 end
515 else
517 else
516 ActiveRecord::Base.logger.debug "Gantt#line_for_version was not given a version with a start_date"
518 ActiveRecord::Base.logger.debug "Gantt#line_for_version was not given a version with a start_date"
517 ''
519 ''
518 end
520 end
519 end
521 end
520
522
521 def subject_for_issue(issue, options)
523 def subject_for_issue(issue, options)
522 case options[:format]
524 case options[:format]
523 when :html
525 when :html
524 output = ''
526 output = ''
525 output << "<div class='tooltip'>"
527 output << "<div class='tooltip'>"
526 output << "<div class='issue-subject' style='position: absolute;line-height:1.2em;height:16px;top:#{options[:top]}px;left:#{options[:indent]}px;overflow:hidden;'><small> "
528 output << "<div class='issue-subject' style='position: absolute;line-height:1.2em;height:16px;top:#{options[:top]}px;left:#{options[:indent]}px;overflow:hidden;'><small> "
527 if issue.is_a? Issue
529 if issue.is_a? Issue
528 css_classes = []
530 css_classes = []
529 css_classes << 'issue-overdue' if issue.overdue?
531 css_classes << 'issue-overdue' if issue.overdue?
530 css_classes << 'issue-behind-schedule' if issue.behind_schedule?
532 css_classes << 'issue-behind-schedule' if issue.behind_schedule?
531 css_classes << 'icon icon-issue' unless Setting.gravatar_enabled? && issue.assigned_to
533 css_classes << 'icon icon-issue' unless Setting.gravatar_enabled? && issue.assigned_to
532
534
533 if issue.assigned_to.present?
535 if issue.assigned_to.present?
534 assigned_string = l(:field_assigned_to) + ": " + issue.assigned_to.name
536 assigned_string = l(:field_assigned_to) + ": " + issue.assigned_to.name
535 output << view.avatar(issue.assigned_to, :class => 'gravatar icon-gravatar', :size => 10, :title => assigned_string)
537 output << view.avatar(issue.assigned_to, :class => 'gravatar icon-gravatar', :size => 10, :title => assigned_string)
536 end
538 end
537 output << "<span class='#{css_classes.join(' ')}'>"
539 output << "<span class='#{css_classes.join(' ')}'>"
538 output << view.link_to_issue(issue)
540 output << view.link_to_issue(issue)
539 output << '</span>'
541 output << '</span>'
540 else
542 else
541 ActiveRecord::Base.logger.debug "Gantt#subject_for_issue was not given an issue"
543 ActiveRecord::Base.logger.debug "Gantt#subject_for_issue was not given an issue"
542 ''
544 ''
543 end
545 end
544 output << "</small></div>"
546 output << "</small></div>"
545
547
546 # Tooltip
548 # Tooltip
547 if issue.is_a? Issue
549 if issue.is_a? Issue
548 output << "<span class='tip' style='position: absolute;top:#{ options[:top].to_i + 16 }px;left:#{ options[:indent].to_i + 20 }px;'>"
550 output << "<span class='tip' style='position: absolute;top:#{ options[:top].to_i + 16 }px;left:#{ options[:indent].to_i + 20 }px;'>"
549 output << view.render_issue_tooltip(issue)
551 output << view.render_issue_tooltip(issue)
550 output << "</span>"
552 output << "</span>"
551 end
553 end
552
554
553 output << "</div>"
555 output << "</div>"
554 output
556 output
555 when :image
557 when :image
556 options[:image].fill('black')
558 options[:image].fill('black')
557 options[:image].stroke('transparent')
559 options[:image].stroke('transparent')
558 options[:image].stroke_width(1)
560 options[:image].stroke_width(1)
559 options[:image].text(options[:indent], options[:top] + 2, issue.subject)
561 options[:image].text(options[:indent], options[:top] + 2, issue.subject)
560 when :pdf
562 when :pdf
561 options[:pdf].SetY(options[:top])
563 options[:pdf].SetY(options[:top])
562 options[:pdf].SetX(15)
564 options[:pdf].SetX(15)
563
565
564 char_limit = PDF::MaxCharactorsForSubject - options[:indent]
566 char_limit = PDF::MaxCharactorsForSubject - options[:indent]
565 options[:pdf].Cell(options[:subject_width]-15, 5, (" " * options[:indent]) +"#{issue.tracker} #{issue.id}: #{issue.subject}".sub(/^(.{#{char_limit}}[^\s]*\s).*$/, '\1 (...)'), "LR")
567 options[:pdf].Cell(options[:subject_width]-15, 5, (" " * options[:indent]) +"#{issue.tracker} #{issue.id}: #{issue.subject}".sub(/^(.{#{char_limit}}[^\s]*\s).*$/, '\1 (...)'), "LR")
566
568
567 options[:pdf].SetY(options[:top])
569 options[:pdf].SetY(options[:top])
568 options[:pdf].SetX(options[:subject_width])
570 options[:pdf].SetX(options[:subject_width])
569 options[:pdf].Cell(options[:g_width], 5, "", "LR")
571 options[:pdf].Cell(options[:g_width], 5, "", "LR")
570 end
572 end
571 end
573 end
572
574
573 def line_for_issue(issue, options)
575 def line_for_issue(issue, options)
574 # Skip issues that don't have a due_before (due_date or version's due_date)
576 # Skip issues that don't have a due_before (due_date or version's due_date)
575 if issue.is_a?(Issue) && issue.due_before
577 if issue.is_a?(Issue) && issue.due_before
576 case options[:format]
578 case options[:format]
577 when :html
579 when :html
578 output = ''
580 output = ''
579 # Handle nil start_dates, rare but can happen.
581 # Handle nil start_dates, rare but can happen.
580 i_start_date = if issue.start_date && issue.start_date >= self.date_from
582 i_start_date = if issue.start_date && issue.start_date >= self.date_from
581 issue.start_date
583 issue.start_date
582 else
584 else
583 self.date_from
585 self.date_from
584 end
586 end
585
587
586 i_end_date = ((issue.due_before && issue.due_before <= self.date_to) ? issue.due_before : self.date_to )
588 i_end_date = ((issue.due_before && issue.due_before <= self.date_to) ? issue.due_before : self.date_to )
587 i_done_date = i_start_date + ((issue.due_before - i_start_date+1)*issue.done_ratio/100).floor
589 i_done_date = i_start_date + ((issue.due_before - i_start_date+1)*issue.done_ratio/100).floor
588 i_done_date = (i_done_date <= self.date_from ? self.date_from : i_done_date )
590 i_done_date = (i_done_date <= self.date_from ? self.date_from : i_done_date )
589 i_done_date = (i_done_date >= self.date_to ? self.date_to : i_done_date )
591 i_done_date = (i_done_date >= self.date_to ? self.date_to : i_done_date )
590
592
591 i_late_date = [i_end_date, Date.today].min if i_start_date < Date.today
593 i_late_date = [i_end_date, Date.today].min if i_start_date < Date.today
592
594
593 i_left = ((i_start_date - self.date_from)*options[:zoom]).floor
595 i_left = ((i_start_date - self.date_from)*options[:zoom]).floor
594 i_width = ((i_end_date - i_start_date + 1)*options[:zoom]).floor - 2 # total width of the issue (- 2 for left and right borders)
596 i_width = ((i_end_date - i_start_date + 1)*options[:zoom]).floor - 2 # total width of the issue (- 2 for left and right borders)
595 d_width = ((i_done_date - i_start_date)*options[:zoom]).floor - 2 # done width
597 d_width = ((i_done_date - i_start_date)*options[:zoom]).floor - 2 # done width
596 l_width = i_late_date ? ((i_late_date - i_start_date+1)*options[:zoom]).floor - 2 : 0 # delay width
598 l_width = i_late_date ? ((i_late_date - i_start_date+1)*options[:zoom]).floor - 2 : 0 # delay width
597 css = "task " + (issue.leaf? ? 'leaf' : 'parent')
599 css = "task " + (issue.leaf? ? 'leaf' : 'parent')
598
600
599 # Make sure that negative i_left and i_width don't
601 # Make sure that negative i_left and i_width don't
600 # overflow the subject
602 # overflow the subject
601 if i_width > 0
603 if i_width > 0
602 output << "<div style='top:#{ options[:top] }px;left:#{ i_left }px;width:#{ i_width }px;' class='#{css} task_todo'>&nbsp;</div>"
604 output << "<div style='top:#{ options[:top] }px;left:#{ i_left }px;width:#{ i_width }px;' class='#{css} task_todo'>&nbsp;</div>"
603 end
605 end
604 if l_width > 0
606 if l_width > 0
605 output << "<div style='top:#{ options[:top] }px;left:#{ i_left }px;width:#{ l_width }px;' class='#{css} task_late'>&nbsp;</div>"
607 output << "<div style='top:#{ options[:top] }px;left:#{ i_left }px;width:#{ l_width }px;' class='#{css} task_late'>&nbsp;</div>"
606 end
608 end
607 if d_width > 0
609 if d_width > 0
608 output<< "<div style='top:#{ options[:top] }px;left:#{ i_left }px;width:#{ d_width }px;' class='#{css} task_done'>&nbsp;</div>"
610 output<< "<div style='top:#{ options[:top] }px;left:#{ i_left }px;width:#{ d_width }px;' class='#{css} task_done'>&nbsp;</div>"
609 end
611 end
610
612
611 # Display the status even if it's floated off to the left
613 # Display the status even if it's floated off to the left
612 status_px = i_left + i_width + 5
614 status_px = i_left + i_width + 5
613 status_px = 5 if status_px <= 0
615 status_px = 5 if status_px <= 0
614
616
615 output << "<div style='top:#{ options[:top] }px;left:#{ status_px }px;' class='#{css} label issue-name'>"
617 output << "<div style='top:#{ options[:top] }px;left:#{ status_px }px;' class='#{css} label issue-name'>"
616 output << issue.status.name
618 output << issue.status.name
617 output << ' '
619 output << ' '
618 output << (issue.done_ratio).to_i.to_s
620 output << (issue.done_ratio).to_i.to_s
619 output << "%"
621 output << "%"
620 output << "</div>"
622 output << "</div>"
621
623
622 output << "<div class='tooltip' style='position: absolute;top:#{ options[:top] }px;left:#{ i_left }px;width:#{ i_width }px;height:12px;'>"
624 output << "<div class='tooltip' style='position: absolute;top:#{ options[:top] }px;left:#{ i_left }px;width:#{ i_width }px;height:12px;'>"
623 output << '<span class="tip">'
625 output << '<span class="tip">'
624 output << view.render_issue_tooltip(issue)
626 output << view.render_issue_tooltip(issue)
625 output << "</span></div>"
627 output << "</span></div>"
626 output
628 output
627
629
628 when :image
630 when :image
629 # Handle nil start_dates, rare but can happen.
631 # Handle nil start_dates, rare but can happen.
630 i_start_date = if issue.start_date && issue.start_date >= @date_from
632 i_start_date = if issue.start_date && issue.start_date >= @date_from
631 issue.start_date
633 issue.start_date
632 else
634 else
633 @date_from
635 @date_from
634 end
636 end
635
637
636 i_end_date = (issue.due_before <= date_to ? issue.due_before : date_to )
638 i_end_date = (issue.due_before <= date_to ? issue.due_before : date_to )
637 i_done_date = i_start_date + ((issue.due_before - i_start_date+1)*issue.done_ratio/100).floor
639 i_done_date = i_start_date + ((issue.due_before - i_start_date+1)*issue.done_ratio/100).floor
638 i_done_date = (i_done_date <= @date_from ? @date_from : i_done_date )
640 i_done_date = (i_done_date <= @date_from ? @date_from : i_done_date )
639 i_done_date = (i_done_date >= date_to ? date_to : i_done_date )
641 i_done_date = (i_done_date >= date_to ? date_to : i_done_date )
640 i_late_date = [i_end_date, Date.today].min if i_start_date < Date.today
642 i_late_date = [i_end_date, Date.today].min if i_start_date < Date.today
641
643
642 i_left = options[:subject_width] + ((i_start_date - @date_from)*options[:zoom]).floor
644 i_left = options[:subject_width] + ((i_start_date - @date_from)*options[:zoom]).floor
643 i_width = ((i_end_date - i_start_date + 1)*options[:zoom]).floor # total width of the issue
645 i_width = ((i_end_date - i_start_date + 1)*options[:zoom]).floor # total width of the issue
644 d_width = ((i_done_date - i_start_date)*options[:zoom]).floor # done width
646 d_width = ((i_done_date - i_start_date)*options[:zoom]).floor # done width
645 l_width = i_late_date ? ((i_late_date - i_start_date+1)*options[:zoom]).floor : 0 # delay width
647 l_width = i_late_date ? ((i_late_date - i_start_date+1)*options[:zoom]).floor : 0 # delay width
646
648
647
649
648 # Make sure that negative i_left and i_width don't
650 # Make sure that negative i_left and i_width don't
649 # overflow the subject
651 # overflow the subject
650 if i_width > 0
652 if i_width > 0
651 options[:image].fill('grey')
653 options[:image].fill('grey')
652 options[:image].rectangle(i_left, options[:top], i_left + i_width, options[:top] - 6)
654 options[:image].rectangle(i_left, options[:top], i_left + i_width, options[:top] - 6)
653 options[:image].fill('red')
655 options[:image].fill('red')
654 options[:image].rectangle(i_left, options[:top], i_left + l_width, options[:top] - 6) if l_width > 0
656 options[:image].rectangle(i_left, options[:top], i_left + l_width, options[:top] - 6) if l_width > 0
655 options[:image].fill('blue')
657 options[:image].fill('blue')
656 options[:image].rectangle(i_left, options[:top], i_left + d_width, options[:top] - 6) if d_width > 0
658 options[:image].rectangle(i_left, options[:top], i_left + d_width, options[:top] - 6) if d_width > 0
657 end
659 end
658
660
659 # Show the status and % done next to the subject if it overflows
661 # Show the status and % done next to the subject if it overflows
660 options[:image].fill('black')
662 options[:image].fill('black')
661 if i_width > 0
663 if i_width > 0
662 options[:image].text(i_left + i_width + 5,options[:top] + 1, "#{issue.status.name} #{issue.done_ratio}%")
664 options[:image].text(i_left + i_width + 5,options[:top] + 1, "#{issue.status.name} #{issue.done_ratio}%")
663 else
665 else
664 options[:image].text(options[:subject_width] + 5,options[:top] + 1, "#{issue.status.name} #{issue.done_ratio}%")
666 options[:image].text(options[:subject_width] + 5,options[:top] + 1, "#{issue.status.name} #{issue.done_ratio}%")
665 end
667 end
666
668
667 when :pdf
669 when :pdf
668 options[:pdf].SetY(options[:top]+1.5)
670 options[:pdf].SetY(options[:top]+1.5)
669 # Handle nil start_dates, rare but can happen.
671 # Handle nil start_dates, rare but can happen.
670 i_start_date = if issue.start_date && issue.start_date >= @date_from
672 i_start_date = if issue.start_date && issue.start_date >= @date_from
671 issue.start_date
673 issue.start_date
672 else
674 else
673 @date_from
675 @date_from
674 end
676 end
675
677
676 i_end_date = (issue.due_before <= @date_to ? issue.due_before : @date_to )
678 i_end_date = (issue.due_before <= @date_to ? issue.due_before : @date_to )
677
679
678 i_done_date = i_start_date + ((issue.due_before - i_start_date+1)*issue.done_ratio/100).floor
680 i_done_date = i_start_date + ((issue.due_before - i_start_date+1)*issue.done_ratio/100).floor
679 i_done_date = (i_done_date <= @date_from ? @date_from : i_done_date )
681 i_done_date = (i_done_date <= @date_from ? @date_from : i_done_date )
680 i_done_date = (i_done_date >= @date_to ? @date_to : i_done_date )
682 i_done_date = (i_done_date >= @date_to ? @date_to : i_done_date )
681
683
682 i_late_date = [i_end_date, Date.today].min if i_start_date < Date.today
684 i_late_date = [i_end_date, Date.today].min if i_start_date < Date.today
683
685
684 i_left = ((i_start_date - @date_from)*options[:zoom])
686 i_left = ((i_start_date - @date_from)*options[:zoom])
685 i_width = ((i_end_date - i_start_date + 1)*options[:zoom])
687 i_width = ((i_end_date - i_start_date + 1)*options[:zoom])
686 d_width = ((i_done_date - i_start_date)*options[:zoom])
688 d_width = ((i_done_date - i_start_date)*options[:zoom])
687 l_width = ((i_late_date - i_start_date+1)*options[:zoom]) if i_late_date
689 l_width = ((i_late_date - i_start_date+1)*options[:zoom]) if i_late_date
688 l_width ||= 0
690 l_width ||= 0
689
691
690 # Make sure that negative i_left and i_width don't
692 # Make sure that negative i_left and i_width don't
691 # overflow the subject
693 # overflow the subject
692 if i_width > 0
694 if i_width > 0
693 options[:pdf].SetX(options[:subject_width] + i_left)
695 options[:pdf].SetX(options[:subject_width] + i_left)
694 options[:pdf].SetFillColor(200,200,200)
696 options[:pdf].SetFillColor(200,200,200)
695 options[:pdf].Cell(i_width, 2, "", 0, 0, "", 1)
697 options[:pdf].Cell(i_width, 2, "", 0, 0, "", 1)
696 end
698 end
697
699
698 if l_width > 0
700 if l_width > 0
699 options[:pdf].SetY(options[:top]+1.5)
701 options[:pdf].SetY(options[:top]+1.5)
700 options[:pdf].SetX(options[:subject_width] + i_left)
702 options[:pdf].SetX(options[:subject_width] + i_left)
701 options[:pdf].SetFillColor(255,100,100)
703 options[:pdf].SetFillColor(255,100,100)
702 options[:pdf].Cell(l_width, 2, "", 0, 0, "", 1)
704 options[:pdf].Cell(l_width, 2, "", 0, 0, "", 1)
703 end
705 end
704 if d_width > 0
706 if d_width > 0
705 options[:pdf].SetY(options[:top]+1.5)
707 options[:pdf].SetY(options[:top]+1.5)
706 options[:pdf].SetX(options[:subject_width] + i_left)
708 options[:pdf].SetX(options[:subject_width] + i_left)
707 options[:pdf].SetFillColor(100,100,255)
709 options[:pdf].SetFillColor(100,100,255)
708 options[:pdf].Cell(d_width, 2, "", 0, 0, "", 1)
710 options[:pdf].Cell(d_width, 2, "", 0, 0, "", 1)
709 end
711 end
710
712
711 options[:pdf].SetY(options[:top]+1.5)
713 options[:pdf].SetY(options[:top]+1.5)
712
714
713 # Make sure that negative i_left and i_width don't
715 # Make sure that negative i_left and i_width don't
714 # overflow the subject
716 # overflow the subject
715 if (i_left + i_width) >= 0
717 if (i_left + i_width) >= 0
716 options[:pdf].SetX(options[:subject_width] + i_left + i_width)
718 options[:pdf].SetX(options[:subject_width] + i_left + i_width)
717 else
719 else
718 options[:pdf].SetX(options[:subject_width])
720 options[:pdf].SetX(options[:subject_width])
719 end
721 end
720 options[:pdf].Cell(30, 2, "#{issue.status} #{issue.done_ratio}%")
722 options[:pdf].Cell(30, 2, "#{issue.status} #{issue.done_ratio}%")
721 end
723 end
722 else
724 else
723 ActiveRecord::Base.logger.debug "GanttHelper#line_for_issue was not given an issue with a due_before"
725 ActiveRecord::Base.logger.debug "GanttHelper#line_for_issue was not given an issue with a due_before"
724 ''
726 ''
725 end
727 end
726 end
728 end
727
729
728 # Generates a gantt image
730 # Generates a gantt image
729 # Only defined if RMagick is avalaible
731 # Only defined if RMagick is avalaible
730 def to_image(format='PNG')
732 def to_image(format='PNG')
731 date_to = (@date_from >> @months)-1
733 date_to = (@date_from >> @months)-1
732 show_weeks = @zoom > 1
734 show_weeks = @zoom > 1
733 show_days = @zoom > 2
735 show_days = @zoom > 2
734
736
735 subject_width = 400
737 subject_width = 400
736 header_heigth = 18
738 header_heigth = 18
737 # width of one day in pixels
739 # width of one day in pixels
738 zoom = @zoom*2
740 zoom = @zoom*2
739 g_width = (@date_to - @date_from + 1)*zoom
741 g_width = (@date_to - @date_from + 1)*zoom
740 g_height = 20 * number_of_rows + 30
742 g_height = 20 * number_of_rows + 30
741 headers_heigth = (show_weeks ? 2*header_heigth : header_heigth)
743 headers_heigth = (show_weeks ? 2*header_heigth : header_heigth)
742 height = g_height + headers_heigth
744 height = g_height + headers_heigth
743
745
744 imgl = Magick::ImageList.new
746 imgl = Magick::ImageList.new
745 imgl.new_image(subject_width+g_width+1, height)
747 imgl.new_image(subject_width+g_width+1, height)
746 gc = Magick::Draw.new
748 gc = Magick::Draw.new
747
749
748 # Subjects
750 # Subjects
749 subjects(:image => gc, :top => (headers_heigth + 20), :indent => 4, :format => :image)
751 subjects(:image => gc, :top => (headers_heigth + 20), :indent => 4, :format => :image)
750
752
751 # Months headers
753 # Months headers
752 month_f = @date_from
754 month_f = @date_from
753 left = subject_width
755 left = subject_width
754 @months.times do
756 @months.times do
755 width = ((month_f >> 1) - month_f) * zoom
757 width = ((month_f >> 1) - month_f) * zoom
756 gc.fill('white')
758 gc.fill('white')
757 gc.stroke('grey')
759 gc.stroke('grey')
758 gc.stroke_width(1)
760 gc.stroke_width(1)
759 gc.rectangle(left, 0, left + width, height)
761 gc.rectangle(left, 0, left + width, height)
760 gc.fill('black')
762 gc.fill('black')
761 gc.stroke('transparent')
763 gc.stroke('transparent')
762 gc.stroke_width(1)
764 gc.stroke_width(1)
763 gc.text(left.round + 8, 14, "#{month_f.year}-#{month_f.month}")
765 gc.text(left.round + 8, 14, "#{month_f.year}-#{month_f.month}")
764 left = left + width
766 left = left + width
765 month_f = month_f >> 1
767 month_f = month_f >> 1
766 end
768 end
767
769
768 # Weeks headers
770 # Weeks headers
769 if show_weeks
771 if show_weeks
770 left = subject_width
772 left = subject_width
771 height = header_heigth
773 height = header_heigth
772 if @date_from.cwday == 1
774 if @date_from.cwday == 1
773 # date_from is monday
775 # date_from is monday
774 week_f = date_from
776 week_f = date_from
775 else
777 else
776 # find next monday after date_from
778 # find next monday after date_from
777 week_f = @date_from + (7 - @date_from.cwday + 1)
779 week_f = @date_from + (7 - @date_from.cwday + 1)
778 width = (7 - @date_from.cwday + 1) * zoom
780 width = (7 - @date_from.cwday + 1) * zoom
779 gc.fill('white')
781 gc.fill('white')
780 gc.stroke('grey')
782 gc.stroke('grey')
781 gc.stroke_width(1)
783 gc.stroke_width(1)
782 gc.rectangle(left, header_heigth, left + width, 2*header_heigth + g_height-1)
784 gc.rectangle(left, header_heigth, left + width, 2*header_heigth + g_height-1)
783 left = left + width
785 left = left + width
784 end
786 end
785 while week_f <= date_to
787 while week_f <= date_to
786 width = (week_f + 6 <= date_to) ? 7 * zoom : (date_to - week_f + 1) * zoom
788 width = (week_f + 6 <= date_to) ? 7 * zoom : (date_to - week_f + 1) * zoom
787 gc.fill('white')
789 gc.fill('white')
788 gc.stroke('grey')
790 gc.stroke('grey')
789 gc.stroke_width(1)
791 gc.stroke_width(1)
790 gc.rectangle(left.round, header_heigth, left.round + width, 2*header_heigth + g_height-1)
792 gc.rectangle(left.round, header_heigth, left.round + width, 2*header_heigth + g_height-1)
791 gc.fill('black')
793 gc.fill('black')
792 gc.stroke('transparent')
794 gc.stroke('transparent')
793 gc.stroke_width(1)
795 gc.stroke_width(1)
794 gc.text(left.round + 2, header_heigth + 14, week_f.cweek.to_s)
796 gc.text(left.round + 2, header_heigth + 14, week_f.cweek.to_s)
795 left = left + width
797 left = left + width
796 week_f = week_f+7
798 week_f = week_f+7
797 end
799 end
798 end
800 end
799
801
800 # Days details (week-end in grey)
802 # Days details (week-end in grey)
801 if show_days
803 if show_days
802 left = subject_width
804 left = subject_width
803 height = g_height + header_heigth - 1
805 height = g_height + header_heigth - 1
804 wday = @date_from.cwday
806 wday = @date_from.cwday
805 (date_to - @date_from + 1).to_i.times do
807 (date_to - @date_from + 1).to_i.times do
806 width = zoom
808 width = zoom
807 gc.fill(wday == 6 || wday == 7 ? '#eee' : 'white')
809 gc.fill(wday == 6 || wday == 7 ? '#eee' : 'white')
808 gc.stroke('grey')
810 gc.stroke('grey')
809 gc.stroke_width(1)
811 gc.stroke_width(1)
810 gc.rectangle(left, 2*header_heigth, left + width, 2*header_heigth + g_height-1)
812 gc.rectangle(left, 2*header_heigth, left + width, 2*header_heigth + g_height-1)
811 left = left + width
813 left = left + width
812 wday = wday + 1
814 wday = wday + 1
813 wday = 1 if wday > 7
815 wday = 1 if wday > 7
814 end
816 end
815 end
817 end
816
818
817 # border
819 # border
818 gc.fill('transparent')
820 gc.fill('transparent')
819 gc.stroke('grey')
821 gc.stroke('grey')
820 gc.stroke_width(1)
822 gc.stroke_width(1)
821 gc.rectangle(0, 0, subject_width+g_width, headers_heigth)
823 gc.rectangle(0, 0, subject_width+g_width, headers_heigth)
822 gc.stroke('black')
824 gc.stroke('black')
823 gc.rectangle(0, 0, subject_width+g_width, g_height+ headers_heigth-1)
825 gc.rectangle(0, 0, subject_width+g_width, g_height+ headers_heigth-1)
824
826
825 # content
827 # content
826 top = headers_heigth + 20
828 top = headers_heigth + 20
827
829
828 lines(:image => gc, :top => top, :zoom => zoom, :subject_width => subject_width, :format => :image)
830 lines(:image => gc, :top => top, :zoom => zoom, :subject_width => subject_width, :format => :image)
829
831
830 # today red line
832 # today red line
831 if Date.today >= @date_from and Date.today <= date_to
833 if Date.today >= @date_from and Date.today <= date_to
832 gc.stroke('red')
834 gc.stroke('red')
833 x = (Date.today-@date_from+1)*zoom + subject_width
835 x = (Date.today-@date_from+1)*zoom + subject_width
834 gc.line(x, headers_heigth, x, headers_heigth + g_height-1)
836 gc.line(x, headers_heigth, x, headers_heigth + g_height-1)
835 end
837 end
836
838
837 gc.draw(imgl)
839 gc.draw(imgl)
838 imgl.format = format
840 imgl.format = format
839 imgl.to_blob
841 imgl.to_blob
840 end if Object.const_defined?(:Magick)
842 end if Object.const_defined?(:Magick)
841
843
842 def to_pdf
844 def to_pdf
843 pdf = ::Redmine::Export::PDF::IFPDF.new(current_language)
845 pdf = ::Redmine::Export::PDF::IFPDF.new(current_language)
844 pdf.SetTitle("#{l(:label_gantt)} #{project}")
846 pdf.SetTitle("#{l(:label_gantt)} #{project}")
845 pdf.AliasNbPages
847 pdf.AliasNbPages
846 pdf.footer_date = format_date(Date.today)
848 pdf.footer_date = format_date(Date.today)
847 pdf.AddPage("L")
849 pdf.AddPage("L")
848 pdf.SetFontStyle('B',12)
850 pdf.SetFontStyle('B',12)
849 pdf.SetX(15)
851 pdf.SetX(15)
850 pdf.Cell(PDF::LeftPaneWidth, 20, project.to_s)
852 pdf.Cell(PDF::LeftPaneWidth, 20, project.to_s)
851 pdf.Ln
853 pdf.Ln
852 pdf.SetFontStyle('B',9)
854 pdf.SetFontStyle('B',9)
853
855
854 subject_width = PDF::LeftPaneWidth
856 subject_width = PDF::LeftPaneWidth
855 header_heigth = 5
857 header_heigth = 5
856
858
857 headers_heigth = header_heigth
859 headers_heigth = header_heigth
858 show_weeks = false
860 show_weeks = false
859 show_days = false
861 show_days = false
860
862
861 if self.months < 7
863 if self.months < 7
862 show_weeks = true
864 show_weeks = true
863 headers_heigth = 2*header_heigth
865 headers_heigth = 2*header_heigth
864 if self.months < 3
866 if self.months < 3
865 show_days = true
867 show_days = true
866 headers_heigth = 3*header_heigth
868 headers_heigth = 3*header_heigth
867 end
869 end
868 end
870 end
869
871
870 g_width = PDF.right_pane_width
872 g_width = PDF.right_pane_width
871 zoom = (g_width) / (self.date_to - self.date_from + 1)
873 zoom = (g_width) / (self.date_to - self.date_from + 1)
872 g_height = 120
874 g_height = 120
873 t_height = g_height + headers_heigth
875 t_height = g_height + headers_heigth
874
876
875 y_start = pdf.GetY
877 y_start = pdf.GetY
876
878
877 # Months headers
879 # Months headers
878 month_f = self.date_from
880 month_f = self.date_from
879 left = subject_width
881 left = subject_width
880 height = header_heigth
882 height = header_heigth
881 self.months.times do
883 self.months.times do
882 width = ((month_f >> 1) - month_f) * zoom
884 width = ((month_f >> 1) - month_f) * zoom
883 pdf.SetY(y_start)
885 pdf.SetY(y_start)
884 pdf.SetX(left)
886 pdf.SetX(left)
885 pdf.Cell(width, height, "#{month_f.year}-#{month_f.month}", "LTR", 0, "C")
887 pdf.Cell(width, height, "#{month_f.year}-#{month_f.month}", "LTR", 0, "C")
886 left = left + width
888 left = left + width
887 month_f = month_f >> 1
889 month_f = month_f >> 1
888 end
890 end
889
891
890 # Weeks headers
892 # Weeks headers
891 if show_weeks
893 if show_weeks
892 left = subject_width
894 left = subject_width
893 height = header_heigth
895 height = header_heigth
894 if self.date_from.cwday == 1
896 if self.date_from.cwday == 1
895 # self.date_from is monday
897 # self.date_from is monday
896 week_f = self.date_from
898 week_f = self.date_from
897 else
899 else
898 # find next monday after self.date_from
900 # find next monday after self.date_from
899 week_f = self.date_from + (7 - self.date_from.cwday + 1)
901 week_f = self.date_from + (7 - self.date_from.cwday + 1)
900 width = (7 - self.date_from.cwday + 1) * zoom-1
902 width = (7 - self.date_from.cwday + 1) * zoom-1
901 pdf.SetY(y_start + header_heigth)
903 pdf.SetY(y_start + header_heigth)
902 pdf.SetX(left)
904 pdf.SetX(left)
903 pdf.Cell(width + 1, height, "", "LTR")
905 pdf.Cell(width + 1, height, "", "LTR")
904 left = left + width+1
906 left = left + width+1
905 end
907 end
906 while week_f <= self.date_to
908 while week_f <= self.date_to
907 width = (week_f + 6 <= self.date_to) ? 7 * zoom : (self.date_to - week_f + 1) * zoom
909 width = (week_f + 6 <= self.date_to) ? 7 * zoom : (self.date_to - week_f + 1) * zoom
908 pdf.SetY(y_start + header_heigth)
910 pdf.SetY(y_start + header_heigth)
909 pdf.SetX(left)
911 pdf.SetX(left)
910 pdf.Cell(width, height, (width >= 5 ? week_f.cweek.to_s : ""), "LTR", 0, "C")
912 pdf.Cell(width, height, (width >= 5 ? week_f.cweek.to_s : ""), "LTR", 0, "C")
911 left = left + width
913 left = left + width
912 week_f = week_f+7
914 week_f = week_f+7
913 end
915 end
914 end
916 end
915
917
916 # Days headers
918 # Days headers
917 if show_days
919 if show_days
918 left = subject_width
920 left = subject_width
919 height = header_heigth
921 height = header_heigth
920 wday = self.date_from.cwday
922 wday = self.date_from.cwday
921 pdf.SetFontStyle('B',7)
923 pdf.SetFontStyle('B',7)
922 (self.date_to - self.date_from + 1).to_i.times do
924 (self.date_to - self.date_from + 1).to_i.times do
923 width = zoom
925 width = zoom
924 pdf.SetY(y_start + 2 * header_heigth)
926 pdf.SetY(y_start + 2 * header_heigth)
925 pdf.SetX(left)
927 pdf.SetX(left)
926 pdf.Cell(width, height, day_name(wday).first, "LTR", 0, "C")
928 pdf.Cell(width, height, day_name(wday).first, "LTR", 0, "C")
927 left = left + width
929 left = left + width
928 wday = wday + 1
930 wday = wday + 1
929 wday = 1 if wday > 7
931 wday = 1 if wday > 7
930 end
932 end
931 end
933 end
932
934
933 pdf.SetY(y_start)
935 pdf.SetY(y_start)
934 pdf.SetX(15)
936 pdf.SetX(15)
935 pdf.Cell(subject_width+g_width-15, headers_heigth, "", 1)
937 pdf.Cell(subject_width+g_width-15, headers_heigth, "", 1)
936
938
937 # Tasks
939 # Tasks
938 top = headers_heigth + y_start
940 top = headers_heigth + y_start
939 pdf_subjects_and_lines(pdf, {
941 pdf_subjects_and_lines(pdf, {
940 :top => top,
942 :top => top,
941 :zoom => zoom,
943 :zoom => zoom,
942 :subject_width => subject_width,
944 :subject_width => subject_width,
943 :g_width => g_width
945 :g_width => g_width
944 })
946 })
945
947
946
948
947 pdf.Line(15, top, subject_width+g_width, top)
949 pdf.Line(15, top, subject_width+g_width, top)
948 pdf.Output
950 pdf.Output
949
951
950
952
951 end
953 end
952
954
953 private
955 private
954
956
957 # Sorts a collection of issues by start_date, due_date, id for gantt rendering
958 def sort_issues!(issues)
959 issues.sort! do |a, b|
960 cmp = 0
961 cmp = (a.start_date <=> b.start_date) if a.start_date? && b.start_date?
962 cmp = (a.due_date <=> b.due_date) if cmp == 0 && a.due_date? && b.due_date?
963 cmp = (a.id <=> b.id) if cmp == 0
964 cmp
965 end
966 end
967
955 # Renders both the subjects and lines of the Gantt chart for the
968 # Renders both the subjects and lines of the Gantt chart for the
956 # PDF format
969 # PDF format
957 def pdf_subjects_and_lines(pdf, options = {})
970 def pdf_subjects_and_lines(pdf, options = {})
958 subject_options = {:indent => 0, :indent_increment => 5, :top_increment => 3, :render => :subject, :format => :pdf, :pdf => pdf}.merge(options)
971 subject_options = {:indent => 0, :indent_increment => 5, :top_increment => 3, :render => :subject, :format => :pdf, :pdf => pdf}.merge(options)
959 line_options = {:indent => 0, :indent_increment => 5, :top_increment => 3, :render => :line, :format => :pdf, :pdf => pdf}.merge(options)
972 line_options = {:indent => 0, :indent_increment => 5, :top_increment => 3, :render => :line, :format => :pdf, :pdf => pdf}.merge(options)
960
973
961 if @project
974 if @project
962 render_project(@project, subject_options)
975 render_project(@project, subject_options)
963 render_project(@project, line_options)
976 render_project(@project, line_options)
964 else
977 else
965 Project.roots.each do |project|
978 Project.roots.each do |project|
966 render_project(project, subject_options)
979 render_project(project, subject_options)
967 render_project(project, line_options)
980 render_project(project, line_options)
968 end
981 end
969 end
982 end
970 end
983 end
971
984
972 end
985 end
973 end
986 end
974 end
987 end
General Comments 0
You need to be logged in to leave comments. Login now