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