##// END OF EJS Templates
Makes Version#start_date return the minimum start_date of its issues....
Jean-Philippe Lang -
r4460:df9ea2413605
parent child
Show More
@@ -1,833 +1,833
1 1 # redMine - project management software
2 2 # Copyright (C) 2006 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 Project < ActiveRecord::Base
19 19 include Redmine::SafeAttributes
20 20
21 21 # Project statuses
22 22 STATUS_ACTIVE = 1
23 23 STATUS_ARCHIVED = 9
24 24
25 25 # Maximum length for project identifiers
26 26 IDENTIFIER_MAX_LENGTH = 100
27 27
28 28 # Specific overidden Activities
29 29 has_many :time_entry_activities
30 30 has_many :members, :include => [:user, :roles], :conditions => "#{User.table_name}.type='User' AND #{User.table_name}.status=#{User::STATUS_ACTIVE}"
31 31 has_many :memberships, :class_name => 'Member'
32 32 has_many :member_principals, :class_name => 'Member',
33 33 :include => :principal,
34 34 :conditions => "#{Principal.table_name}.type='Group' OR (#{Principal.table_name}.type='User' AND #{Principal.table_name}.status=#{User::STATUS_ACTIVE})"
35 35 has_many :users, :through => :members
36 36 has_many :principals, :through => :member_principals, :source => :principal
37 37
38 38 has_many :enabled_modules, :dependent => :delete_all
39 39 has_and_belongs_to_many :trackers, :order => "#{Tracker.table_name}.position"
40 40 has_many :issues, :dependent => :destroy, :order => "#{Issue.table_name}.created_on DESC", :include => [:status, :tracker]
41 41 has_many :issue_changes, :through => :issues, :source => :journals
42 42 has_many :versions, :dependent => :destroy, :order => "#{Version.table_name}.effective_date DESC, #{Version.table_name}.name DESC"
43 43 has_many :time_entries, :dependent => :delete_all
44 44 has_many :queries, :dependent => :delete_all
45 45 has_many :documents, :dependent => :destroy
46 46 has_many :news, :dependent => :delete_all, :include => :author
47 47 has_many :issue_categories, :dependent => :delete_all, :order => "#{IssueCategory.table_name}.name"
48 48 has_many :boards, :dependent => :destroy, :order => "position ASC"
49 49 has_one :repository, :dependent => :destroy
50 50 has_many :changesets, :through => :repository
51 51 has_one :wiki, :dependent => :destroy
52 52 # Custom field for the project issues
53 53 has_and_belongs_to_many :issue_custom_fields,
54 54 :class_name => 'IssueCustomField',
55 55 :order => "#{CustomField.table_name}.position",
56 56 :join_table => "#{table_name_prefix}custom_fields_projects#{table_name_suffix}",
57 57 :association_foreign_key => 'custom_field_id'
58 58
59 59 acts_as_nested_set :order => 'name'
60 60 acts_as_attachable :view_permission => :view_files,
61 61 :delete_permission => :manage_files
62 62
63 63 acts_as_customizable
64 64 acts_as_searchable :columns => ['name', 'identifier', 'description'], :project_key => 'id', :permission => nil
65 65 acts_as_event :title => Proc.new {|o| "#{l(:label_project)}: #{o.name}"},
66 66 :url => Proc.new {|o| {:controller => 'projects', :action => 'show', :id => o}},
67 67 :author => nil
68 68
69 69 attr_protected :status, :enabled_module_names
70 70
71 71 validates_presence_of :name, :identifier
72 72 validates_uniqueness_of :identifier
73 73 validates_associated :repository, :wiki
74 74 validates_length_of :name, :maximum => 255
75 75 validates_length_of :homepage, :maximum => 255
76 76 validates_length_of :identifier, :in => 1..IDENTIFIER_MAX_LENGTH
77 77 # donwcase letters, digits, dashes but not digits only
78 78 validates_format_of :identifier, :with => /^(?!\d+$)[a-z0-9\-]*$/, :if => Proc.new { |p| p.identifier_changed? }
79 79 # reserved words
80 80 validates_exclusion_of :identifier, :in => %w( new )
81 81
82 82 before_destroy :delete_all_members, :destroy_children
83 83
84 84 named_scope :has_module, lambda { |mod| { :conditions => ["#{Project.table_name}.id IN (SELECT em.project_id FROM #{EnabledModule.table_name} em WHERE em.name=?)", mod.to_s] } }
85 85 named_scope :active, { :conditions => "#{Project.table_name}.status = #{STATUS_ACTIVE}"}
86 86 named_scope :all_public, { :conditions => { :is_public => true } }
87 87 named_scope :visible, lambda { { :conditions => Project.visible_by(User.current) } }
88 88
89 89 def initialize(attributes = nil)
90 90 super
91 91
92 92 initialized = (attributes || {}).stringify_keys
93 93 if !initialized.key?('identifier') && Setting.sequential_project_identifiers?
94 94 self.identifier = Project.next_identifier
95 95 end
96 96 if !initialized.key?('is_public')
97 97 self.is_public = Setting.default_projects_public?
98 98 end
99 99 if !initialized.key?('enabled_module_names')
100 100 self.enabled_module_names = Setting.default_projects_modules
101 101 end
102 102 if !initialized.key?('trackers') && !initialized.key?('tracker_ids')
103 103 self.trackers = Tracker.all
104 104 end
105 105 end
106 106
107 107 def identifier=(identifier)
108 108 super unless identifier_frozen?
109 109 end
110 110
111 111 def identifier_frozen?
112 112 errors[:identifier].nil? && !(new_record? || identifier.blank?)
113 113 end
114 114
115 115 # returns latest created projects
116 116 # non public projects will be returned only if user is a member of those
117 117 def self.latest(user=nil, count=5)
118 118 find(:all, :limit => count, :conditions => visible_by(user), :order => "created_on DESC")
119 119 end
120 120
121 121 # Returns a SQL :conditions string used to find all active projects for the specified user.
122 122 #
123 123 # Examples:
124 124 # Projects.visible_by(admin) => "projects.status = 1"
125 125 # Projects.visible_by(normal_user) => "projects.status = 1 AND projects.is_public = 1"
126 126 def self.visible_by(user=nil)
127 127 user ||= User.current
128 128 if user && user.admin?
129 129 return "#{Project.table_name}.status=#{Project::STATUS_ACTIVE}"
130 130 elsif user && user.memberships.any?
131 131 return "#{Project.table_name}.status=#{Project::STATUS_ACTIVE} AND (#{Project.table_name}.is_public = #{connection.quoted_true} or #{Project.table_name}.id IN (#{user.memberships.collect{|m| m.project_id}.join(',')}))"
132 132 else
133 133 return "#{Project.table_name}.status=#{Project::STATUS_ACTIVE} AND #{Project.table_name}.is_public = #{connection.quoted_true}"
134 134 end
135 135 end
136 136
137 137 def self.allowed_to_condition(user, permission, options={})
138 138 statements = []
139 139 base_statement = "#{Project.table_name}.status=#{Project::STATUS_ACTIVE}"
140 140 if perm = Redmine::AccessControl.permission(permission)
141 141 unless perm.project_module.nil?
142 142 # If the permission belongs to a project module, make sure the module is enabled
143 143 base_statement << " AND #{Project.table_name}.id IN (SELECT em.project_id FROM #{EnabledModule.table_name} em WHERE em.name='#{perm.project_module}')"
144 144 end
145 145 end
146 146 if options[:project]
147 147 project_statement = "#{Project.table_name}.id = #{options[:project].id}"
148 148 project_statement << " OR (#{Project.table_name}.lft > #{options[:project].lft} AND #{Project.table_name}.rgt < #{options[:project].rgt})" if options[:with_subprojects]
149 149 base_statement = "(#{project_statement}) AND (#{base_statement})"
150 150 end
151 151 if user.admin?
152 152 # no restriction
153 153 else
154 154 statements << "1=0"
155 155 if user.logged?
156 156 if Role.non_member.allowed_to?(permission) && !options[:member]
157 157 statements << "#{Project.table_name}.is_public = #{connection.quoted_true}"
158 158 end
159 159 allowed_project_ids = user.memberships.select {|m| m.roles.detect {|role| role.allowed_to?(permission)}}.collect {|m| m.project_id}
160 160 statements << "#{Project.table_name}.id IN (#{allowed_project_ids.join(',')})" if allowed_project_ids.any?
161 161 else
162 162 if Role.anonymous.allowed_to?(permission) && !options[:member]
163 163 # anonymous user allowed on public project
164 164 statements << "#{Project.table_name}.is_public = #{connection.quoted_true}"
165 165 end
166 166 end
167 167 end
168 168 statements.empty? ? base_statement : "((#{base_statement}) AND (#{statements.join(' OR ')}))"
169 169 end
170 170
171 171 # Returns the Systemwide and project specific activities
172 172 def activities(include_inactive=false)
173 173 if include_inactive
174 174 return all_activities
175 175 else
176 176 return active_activities
177 177 end
178 178 end
179 179
180 180 # Will create a new Project specific Activity or update an existing one
181 181 #
182 182 # This will raise a ActiveRecord::Rollback if the TimeEntryActivity
183 183 # does not successfully save.
184 184 def update_or_create_time_entry_activity(id, activity_hash)
185 185 if activity_hash.respond_to?(:has_key?) && activity_hash.has_key?('parent_id')
186 186 self.create_time_entry_activity_if_needed(activity_hash)
187 187 else
188 188 activity = project.time_entry_activities.find_by_id(id.to_i)
189 189 activity.update_attributes(activity_hash) if activity
190 190 end
191 191 end
192 192
193 193 # Create a new TimeEntryActivity if it overrides a system TimeEntryActivity
194 194 #
195 195 # This will raise a ActiveRecord::Rollback if the TimeEntryActivity
196 196 # does not successfully save.
197 197 def create_time_entry_activity_if_needed(activity)
198 198 if activity['parent_id']
199 199
200 200 parent_activity = TimeEntryActivity.find(activity['parent_id'])
201 201 activity['name'] = parent_activity.name
202 202 activity['position'] = parent_activity.position
203 203
204 204 if Enumeration.overridding_change?(activity, parent_activity)
205 205 project_activity = self.time_entry_activities.create(activity)
206 206
207 207 if project_activity.new_record?
208 208 raise ActiveRecord::Rollback, "Overridding TimeEntryActivity was not successfully saved"
209 209 else
210 210 self.time_entries.update_all("activity_id = #{project_activity.id}", ["activity_id = ?", parent_activity.id])
211 211 end
212 212 end
213 213 end
214 214 end
215 215
216 216 # Returns a :conditions SQL string that can be used to find the issues associated with this project.
217 217 #
218 218 # Examples:
219 219 # project.project_condition(true) => "(projects.id = 1 OR (projects.lft > 1 AND projects.rgt < 10))"
220 220 # project.project_condition(false) => "projects.id = 1"
221 221 def project_condition(with_subprojects)
222 222 cond = "#{Project.table_name}.id = #{id}"
223 223 cond = "(#{cond} OR (#{Project.table_name}.lft > #{lft} AND #{Project.table_name}.rgt < #{rgt}))" if with_subprojects
224 224 cond
225 225 end
226 226
227 227 def self.find(*args)
228 228 if args.first && args.first.is_a?(String) && !args.first.match(/^\d*$/)
229 229 project = find_by_identifier(*args)
230 230 raise ActiveRecord::RecordNotFound, "Couldn't find Project with identifier=#{args.first}" if project.nil?
231 231 project
232 232 else
233 233 super
234 234 end
235 235 end
236 236
237 237 def to_param
238 238 # id is used for projects with a numeric identifier (compatibility)
239 239 @to_param ||= (identifier.to_s =~ %r{^\d*$} ? id : identifier)
240 240 end
241 241
242 242 def active?
243 243 self.status == STATUS_ACTIVE
244 244 end
245 245
246 246 def archived?
247 247 self.status == STATUS_ARCHIVED
248 248 end
249 249
250 250 # Archives the project and its descendants
251 251 def archive
252 252 # Check that there is no issue of a non descendant project that is assigned
253 253 # to one of the project or descendant versions
254 254 v_ids = self_and_descendants.collect {|p| p.version_ids}.flatten
255 255 if v_ids.any? && Issue.find(:first, :include => :project,
256 256 :conditions => ["(#{Project.table_name}.lft < ? OR #{Project.table_name}.rgt > ?)" +
257 257 " AND #{Issue.table_name}.fixed_version_id IN (?)", lft, rgt, v_ids])
258 258 return false
259 259 end
260 260 Project.transaction do
261 261 archive!
262 262 end
263 263 true
264 264 end
265 265
266 266 # Unarchives the project
267 267 # All its ancestors must be active
268 268 def unarchive
269 269 return false if ancestors.detect {|a| !a.active?}
270 270 update_attribute :status, STATUS_ACTIVE
271 271 end
272 272
273 273 # Returns an array of projects the project can be moved to
274 274 # by the current user
275 275 def allowed_parents
276 276 return @allowed_parents if @allowed_parents
277 277 @allowed_parents = Project.find(:all, :conditions => Project.allowed_to_condition(User.current, :add_subprojects))
278 278 @allowed_parents = @allowed_parents - self_and_descendants
279 279 if User.current.allowed_to?(:add_project, nil, :global => true) || (!new_record? && parent.nil?)
280 280 @allowed_parents << nil
281 281 end
282 282 unless parent.nil? || @allowed_parents.empty? || @allowed_parents.include?(parent)
283 283 @allowed_parents << parent
284 284 end
285 285 @allowed_parents
286 286 end
287 287
288 288 # Sets the parent of the project with authorization check
289 289 def set_allowed_parent!(p)
290 290 unless p.nil? || p.is_a?(Project)
291 291 if p.to_s.blank?
292 292 p = nil
293 293 else
294 294 p = Project.find_by_id(p)
295 295 return false unless p
296 296 end
297 297 end
298 298 if p.nil?
299 299 if !new_record? && allowed_parents.empty?
300 300 return false
301 301 end
302 302 elsif !allowed_parents.include?(p)
303 303 return false
304 304 end
305 305 set_parent!(p)
306 306 end
307 307
308 308 # Sets the parent of the project
309 309 # Argument can be either a Project, a String, a Fixnum or nil
310 310 def set_parent!(p)
311 311 unless p.nil? || p.is_a?(Project)
312 312 if p.to_s.blank?
313 313 p = nil
314 314 else
315 315 p = Project.find_by_id(p)
316 316 return false unless p
317 317 end
318 318 end
319 319 if p == parent && !p.nil?
320 320 # Nothing to do
321 321 true
322 322 elsif p.nil? || (p.active? && move_possible?(p))
323 323 # Insert the project so that target's children or root projects stay alphabetically sorted
324 324 sibs = (p.nil? ? self.class.roots : p.children)
325 325 to_be_inserted_before = sibs.detect {|c| c.name.to_s.downcase > name.to_s.downcase }
326 326 if to_be_inserted_before
327 327 move_to_left_of(to_be_inserted_before)
328 328 elsif p.nil?
329 329 if sibs.empty?
330 330 # move_to_root adds the project in first (ie. left) position
331 331 move_to_root
332 332 else
333 333 move_to_right_of(sibs.last) unless self == sibs.last
334 334 end
335 335 else
336 336 # move_to_child_of adds the project in last (ie.right) position
337 337 move_to_child_of(p)
338 338 end
339 339 Issue.update_versions_from_hierarchy_change(self)
340 340 true
341 341 else
342 342 # Can not move to the given target
343 343 false
344 344 end
345 345 end
346 346
347 347 # Returns an array of the trackers used by the project and its active sub projects
348 348 def rolled_up_trackers
349 349 @rolled_up_trackers ||=
350 350 Tracker.find(:all, :include => :projects,
351 351 :select => "DISTINCT #{Tracker.table_name}.*",
352 352 :conditions => ["#{Project.table_name}.lft >= ? AND #{Project.table_name}.rgt <= ? AND #{Project.table_name}.status = #{STATUS_ACTIVE}", lft, rgt],
353 353 :order => "#{Tracker.table_name}.position")
354 354 end
355 355
356 356 # Closes open and locked project versions that are completed
357 357 def close_completed_versions
358 358 Version.transaction do
359 359 versions.find(:all, :conditions => {:status => %w(open locked)}).each do |version|
360 360 if version.completed?
361 361 version.update_attribute(:status, 'closed')
362 362 end
363 363 end
364 364 end
365 365 end
366 366
367 367 # Returns a scope of the Versions on subprojects
368 368 def rolled_up_versions
369 369 @rolled_up_versions ||=
370 370 Version.scoped(:include => :project,
371 371 :conditions => ["#{Project.table_name}.lft >= ? AND #{Project.table_name}.rgt <= ? AND #{Project.table_name}.status = #{STATUS_ACTIVE}", lft, rgt])
372 372 end
373 373
374 374 # Returns a scope of the Versions used by the project
375 375 def shared_versions
376 376 @shared_versions ||=
377 377 Version.scoped(:include => :project,
378 378 :conditions => "#{Project.table_name}.id = #{id}" +
379 379 " OR (#{Project.table_name}.status = #{Project::STATUS_ACTIVE} AND (" +
380 380 " #{Version.table_name}.sharing = 'system'" +
381 381 " OR (#{Project.table_name}.lft >= #{root.lft} AND #{Project.table_name}.rgt <= #{root.rgt} AND #{Version.table_name}.sharing = 'tree')" +
382 382 " OR (#{Project.table_name}.lft < #{lft} AND #{Project.table_name}.rgt > #{rgt} AND #{Version.table_name}.sharing IN ('hierarchy', 'descendants'))" +
383 383 " OR (#{Project.table_name}.lft > #{lft} AND #{Project.table_name}.rgt < #{rgt} AND #{Version.table_name}.sharing = 'hierarchy')" +
384 384 "))")
385 385 end
386 386
387 387 # Returns a hash of project users grouped by role
388 388 def users_by_role
389 389 members.find(:all, :include => [:user, :roles]).inject({}) do |h, m|
390 390 m.roles.each do |r|
391 391 h[r] ||= []
392 392 h[r] << m.user
393 393 end
394 394 h
395 395 end
396 396 end
397 397
398 398 # Deletes all project's members
399 399 def delete_all_members
400 400 me, mr = Member.table_name, MemberRole.table_name
401 401 connection.delete("DELETE FROM #{mr} WHERE #{mr}.member_id IN (SELECT #{me}.id FROM #{me} WHERE #{me}.project_id = #{id})")
402 402 Member.delete_all(['project_id = ?', id])
403 403 end
404 404
405 405 # Users issues can be assigned to
406 406 def assignable_users
407 407 members.select {|m| m.roles.detect {|role| role.assignable?}}.collect {|m| m.user}.sort
408 408 end
409 409
410 410 # Returns the mail adresses of users that should be always notified on project events
411 411 def recipients
412 412 notified_users.collect {|user| user.mail}
413 413 end
414 414
415 415 # Returns the users that should be notified on project events
416 416 def notified_users
417 417 # TODO: User part should be extracted to User#notify_about?
418 418 members.select {|m| m.mail_notification? || m.user.mail_notification == 'all'}.collect {|m| m.user}
419 419 end
420 420
421 421 # Returns an array of all custom fields enabled for project issues
422 422 # (explictly associated custom fields and custom fields enabled for all projects)
423 423 def all_issue_custom_fields
424 424 @all_issue_custom_fields ||= (IssueCustomField.for_all + issue_custom_fields).uniq.sort
425 425 end
426 426
427 427 def project
428 428 self
429 429 end
430 430
431 431 def <=>(project)
432 432 name.downcase <=> project.name.downcase
433 433 end
434 434
435 435 def to_s
436 436 name
437 437 end
438 438
439 439 # Returns a short description of the projects (first lines)
440 440 def short_description(length = 255)
441 441 description.gsub(/^(.{#{length}}[^\n\r]*).*$/m, '\1...').strip if description
442 442 end
443 443
444 444 def css_classes
445 445 s = 'project'
446 446 s << ' root' if root?
447 447 s << ' child' if child?
448 448 s << (leaf? ? ' leaf' : ' parent')
449 449 s
450 450 end
451 451
452 452 # The earliest start date of a project, based on it's issues and versions
453 453 def start_date
454 454 [
455 455 issues.minimum('start_date'),
456 456 shared_versions.collect(&:effective_date),
457 shared_versions.collect {|v| v.fixed_issues.minimum('start_date')}
457 shared_versions.collect(&:start_date)
458 458 ].flatten.compact.min
459 459 end
460 460
461 461 # The latest due date of an issue or version
462 462 def due_date
463 463 [
464 464 issues.maximum('due_date'),
465 465 shared_versions.collect(&:effective_date),
466 466 shared_versions.collect {|v| v.fixed_issues.maximum('due_date')}
467 467 ].flatten.compact.max
468 468 end
469 469
470 470 def overdue?
471 471 active? && !due_date.nil? && (due_date < Date.today)
472 472 end
473 473
474 474 # Returns the percent completed for this project, based on the
475 475 # progress on it's versions.
476 476 def completed_percent(options={:include_subprojects => false})
477 477 if options.delete(:include_subprojects)
478 478 total = self_and_descendants.collect(&:completed_percent).sum
479 479
480 480 total / self_and_descendants.count
481 481 else
482 482 if versions.count > 0
483 483 total = versions.collect(&:completed_pourcent).sum
484 484
485 485 total / versions.count
486 486 else
487 487 100
488 488 end
489 489 end
490 490 end
491 491
492 492 # Return true if this project is allowed to do the specified action.
493 493 # action can be:
494 494 # * a parameter-like Hash (eg. :controller => 'projects', :action => 'edit')
495 495 # * a permission Symbol (eg. :edit_project)
496 496 def allows_to?(action)
497 497 if action.is_a? Hash
498 498 allowed_actions.include? "#{action[:controller]}/#{action[:action]}"
499 499 else
500 500 allowed_permissions.include? action
501 501 end
502 502 end
503 503
504 504 def module_enabled?(module_name)
505 505 module_name = module_name.to_s
506 506 enabled_modules.detect {|m| m.name == module_name}
507 507 end
508 508
509 509 def enabled_module_names=(module_names)
510 510 if module_names && module_names.is_a?(Array)
511 511 module_names = module_names.collect(&:to_s).reject(&:blank?)
512 512 # remove disabled modules
513 513 enabled_modules.each {|mod| mod.destroy unless module_names.include?(mod.name)}
514 514 # add new modules
515 515 module_names.reject {|name| module_enabled?(name)}.each {|name| enabled_modules << EnabledModule.new(:name => name)}
516 516 else
517 517 enabled_modules.clear
518 518 end
519 519 end
520 520
521 521 # Returns an array of the enabled modules names
522 522 def enabled_module_names
523 523 enabled_modules.collect(&:name)
524 524 end
525 525
526 526 safe_attributes 'name',
527 527 'description',
528 528 'homepage',
529 529 'is_public',
530 530 'identifier',
531 531 'custom_field_values',
532 532 'custom_fields',
533 533 'tracker_ids',
534 534 'issue_custom_field_ids'
535 535
536 536 # Returns an array of projects that are in this project's hierarchy
537 537 #
538 538 # Example: parents, children, siblings
539 539 def hierarchy
540 540 parents = project.self_and_ancestors || []
541 541 descendants = project.descendants || []
542 542 project_hierarchy = parents | descendants # Set union
543 543 end
544 544
545 545 # Returns an auto-generated project identifier based on the last identifier used
546 546 def self.next_identifier
547 547 p = Project.find(:first, :order => 'created_on DESC')
548 548 p.nil? ? nil : p.identifier.to_s.succ
549 549 end
550 550
551 551 # Copies and saves the Project instance based on the +project+.
552 552 # Duplicates the source project's:
553 553 # * Wiki
554 554 # * Versions
555 555 # * Categories
556 556 # * Issues
557 557 # * Members
558 558 # * Queries
559 559 #
560 560 # Accepts an +options+ argument to specify what to copy
561 561 #
562 562 # Examples:
563 563 # project.copy(1) # => copies everything
564 564 # project.copy(1, :only => 'members') # => copies members only
565 565 # project.copy(1, :only => ['members', 'versions']) # => copies members and versions
566 566 def copy(project, options={})
567 567 project = project.is_a?(Project) ? project : Project.find(project)
568 568
569 569 to_be_copied = %w(wiki versions issue_categories issues members queries boards)
570 570 to_be_copied = to_be_copied & options[:only].to_a unless options[:only].nil?
571 571
572 572 Project.transaction do
573 573 if save
574 574 reload
575 575 to_be_copied.each do |name|
576 576 send "copy_#{name}", project
577 577 end
578 578 Redmine::Hook.call_hook(:model_project_copy_before_save, :source_project => project, :destination_project => self)
579 579 save
580 580 end
581 581 end
582 582 end
583 583
584 584
585 585 # Copies +project+ and returns the new instance. This will not save
586 586 # the copy
587 587 def self.copy_from(project)
588 588 begin
589 589 project = project.is_a?(Project) ? project : Project.find(project)
590 590 if project
591 591 # clear unique attributes
592 592 attributes = project.attributes.dup.except('id', 'name', 'identifier', 'status', 'parent_id', 'lft', 'rgt')
593 593 copy = Project.new(attributes)
594 594 copy.enabled_modules = project.enabled_modules
595 595 copy.trackers = project.trackers
596 596 copy.custom_values = project.custom_values.collect {|v| v.clone}
597 597 copy.issue_custom_fields = project.issue_custom_fields
598 598 return copy
599 599 else
600 600 return nil
601 601 end
602 602 rescue ActiveRecord::RecordNotFound
603 603 return nil
604 604 end
605 605 end
606 606
607 607 # Yields the given block for each project with its level in the tree
608 608 def self.project_tree(projects, &block)
609 609 ancestors = []
610 610 projects.sort_by(&:lft).each do |project|
611 611 while (ancestors.any? && !project.is_descendant_of?(ancestors.last))
612 612 ancestors.pop
613 613 end
614 614 yield project, ancestors.size
615 615 ancestors << project
616 616 end
617 617 end
618 618
619 619 private
620 620
621 621 # Destroys children before destroying self
622 622 def destroy_children
623 623 children.each do |child|
624 624 child.destroy
625 625 end
626 626 end
627 627
628 628 # Copies wiki from +project+
629 629 def copy_wiki(project)
630 630 # Check that the source project has a wiki first
631 631 unless project.wiki.nil?
632 632 self.wiki ||= Wiki.new
633 633 wiki.attributes = project.wiki.attributes.dup.except("id", "project_id")
634 634 wiki_pages_map = {}
635 635 project.wiki.pages.each do |page|
636 636 # Skip pages without content
637 637 next if page.content.nil?
638 638 new_wiki_content = WikiContent.new(page.content.attributes.dup.except("id", "page_id", "updated_on"))
639 639 new_wiki_page = WikiPage.new(page.attributes.dup.except("id", "wiki_id", "created_on", "parent_id"))
640 640 new_wiki_page.content = new_wiki_content
641 641 wiki.pages << new_wiki_page
642 642 wiki_pages_map[page.id] = new_wiki_page
643 643 end
644 644 wiki.save
645 645 # Reproduce page hierarchy
646 646 project.wiki.pages.each do |page|
647 647 if page.parent_id && wiki_pages_map[page.id]
648 648 wiki_pages_map[page.id].parent = wiki_pages_map[page.parent_id]
649 649 wiki_pages_map[page.id].save
650 650 end
651 651 end
652 652 end
653 653 end
654 654
655 655 # Copies versions from +project+
656 656 def copy_versions(project)
657 657 project.versions.each do |version|
658 658 new_version = Version.new
659 659 new_version.attributes = version.attributes.dup.except("id", "project_id", "created_on", "updated_on")
660 660 self.versions << new_version
661 661 end
662 662 end
663 663
664 664 # Copies issue categories from +project+
665 665 def copy_issue_categories(project)
666 666 project.issue_categories.each do |issue_category|
667 667 new_issue_category = IssueCategory.new
668 668 new_issue_category.attributes = issue_category.attributes.dup.except("id", "project_id")
669 669 self.issue_categories << new_issue_category
670 670 end
671 671 end
672 672
673 673 # Copies issues from +project+
674 674 def copy_issues(project)
675 675 # Stores the source issue id as a key and the copied issues as the
676 676 # value. Used to map the two togeather for issue relations.
677 677 issues_map = {}
678 678
679 679 # Get issues sorted by root_id, lft so that parent issues
680 680 # get copied before their children
681 681 project.issues.find(:all, :order => 'root_id, lft').each do |issue|
682 682 new_issue = Issue.new
683 683 new_issue.copy_from(issue)
684 684 new_issue.project = self
685 685 # Reassign fixed_versions by name, since names are unique per
686 686 # project and the versions for self are not yet saved
687 687 if issue.fixed_version
688 688 new_issue.fixed_version = self.versions.select {|v| v.name == issue.fixed_version.name}.first
689 689 end
690 690 # Reassign the category by name, since names are unique per
691 691 # project and the categories for self are not yet saved
692 692 if issue.category
693 693 new_issue.category = self.issue_categories.select {|c| c.name == issue.category.name}.first
694 694 end
695 695 # Parent issue
696 696 if issue.parent_id
697 697 if copied_parent = issues_map[issue.parent_id]
698 698 new_issue.parent_issue_id = copied_parent.id
699 699 end
700 700 end
701 701
702 702 self.issues << new_issue
703 703 if new_issue.new_record?
704 704 logger.info "Project#copy_issues: issue ##{issue.id} could not be copied: #{new_issue.errors.full_messages}" if logger && logger.info
705 705 else
706 706 issues_map[issue.id] = new_issue unless new_issue.new_record?
707 707 end
708 708 end
709 709
710 710 # Relations after in case issues related each other
711 711 project.issues.each do |issue|
712 712 new_issue = issues_map[issue.id]
713 713 unless new_issue
714 714 # Issue was not copied
715 715 next
716 716 end
717 717
718 718 # Relations
719 719 issue.relations_from.each do |source_relation|
720 720 new_issue_relation = IssueRelation.new
721 721 new_issue_relation.attributes = source_relation.attributes.dup.except("id", "issue_from_id", "issue_to_id")
722 722 new_issue_relation.issue_to = issues_map[source_relation.issue_to_id]
723 723 if new_issue_relation.issue_to.nil? && Setting.cross_project_issue_relations?
724 724 new_issue_relation.issue_to = source_relation.issue_to
725 725 end
726 726 new_issue.relations_from << new_issue_relation
727 727 end
728 728
729 729 issue.relations_to.each do |source_relation|
730 730 new_issue_relation = IssueRelation.new
731 731 new_issue_relation.attributes = source_relation.attributes.dup.except("id", "issue_from_id", "issue_to_id")
732 732 new_issue_relation.issue_from = issues_map[source_relation.issue_from_id]
733 733 if new_issue_relation.issue_from.nil? && Setting.cross_project_issue_relations?
734 734 new_issue_relation.issue_from = source_relation.issue_from
735 735 end
736 736 new_issue.relations_to << new_issue_relation
737 737 end
738 738 end
739 739 end
740 740
741 741 # Copies members from +project+
742 742 def copy_members(project)
743 743 project.memberships.each do |member|
744 744 new_member = Member.new
745 745 new_member.attributes = member.attributes.dup.except("id", "project_id", "created_on")
746 746 # only copy non inherited roles
747 747 # inherited roles will be added when copying the group membership
748 748 role_ids = member.member_roles.reject(&:inherited?).collect(&:role_id)
749 749 next if role_ids.empty?
750 750 new_member.role_ids = role_ids
751 751 new_member.project = self
752 752 self.members << new_member
753 753 end
754 754 end
755 755
756 756 # Copies queries from +project+
757 757 def copy_queries(project)
758 758 project.queries.each do |query|
759 759 new_query = Query.new
760 760 new_query.attributes = query.attributes.dup.except("id", "project_id", "sort_criteria")
761 761 new_query.sort_criteria = query.sort_criteria if query.sort_criteria
762 762 new_query.project = self
763 763 self.queries << new_query
764 764 end
765 765 end
766 766
767 767 # Copies boards from +project+
768 768 def copy_boards(project)
769 769 project.boards.each do |board|
770 770 new_board = Board.new
771 771 new_board.attributes = board.attributes.dup.except("id", "project_id", "topics_count", "messages_count", "last_message_id")
772 772 new_board.project = self
773 773 self.boards << new_board
774 774 end
775 775 end
776 776
777 777 def allowed_permissions
778 778 @allowed_permissions ||= begin
779 779 module_names = enabled_modules.all(:select => :name).collect {|m| m.name}
780 780 Redmine::AccessControl.modules_permissions(module_names).collect {|p| p.name}
781 781 end
782 782 end
783 783
784 784 def allowed_actions
785 785 @actions_allowed ||= allowed_permissions.inject([]) { |actions, permission| actions += Redmine::AccessControl.allowed_actions(permission) }.flatten
786 786 end
787 787
788 788 # Returns all the active Systemwide and project specific activities
789 789 def active_activities
790 790 overridden_activity_ids = self.time_entry_activities.collect(&:parent_id)
791 791
792 792 if overridden_activity_ids.empty?
793 793 return TimeEntryActivity.shared.active
794 794 else
795 795 return system_activities_and_project_overrides
796 796 end
797 797 end
798 798
799 799 # Returns all the Systemwide and project specific activities
800 800 # (inactive and active)
801 801 def all_activities
802 802 overridden_activity_ids = self.time_entry_activities.collect(&:parent_id)
803 803
804 804 if overridden_activity_ids.empty?
805 805 return TimeEntryActivity.shared
806 806 else
807 807 return system_activities_and_project_overrides(true)
808 808 end
809 809 end
810 810
811 811 # Returns the systemwide active activities merged with the project specific overrides
812 812 def system_activities_and_project_overrides(include_inactive=false)
813 813 if include_inactive
814 814 return TimeEntryActivity.shared.
815 815 find(:all,
816 816 :conditions => ["id NOT IN (?)", self.time_entry_activities.collect(&:parent_id)]) +
817 817 self.time_entry_activities
818 818 else
819 819 return TimeEntryActivity.shared.active.
820 820 find(:all,
821 821 :conditions => ["id NOT IN (?)", self.time_entry_activities.collect(&:parent_id)]) +
822 822 self.time_entry_activities.active
823 823 end
824 824 end
825 825
826 826 # Archives subprojects recursively
827 827 def archive!
828 828 children.each do |subproject|
829 829 subproject.send :archive!
830 830 end
831 831 update_attribute :status, STATUS_ARCHIVED
832 832 end
833 833 end
@@ -1,234 +1,233
1 1 # Redmine - project management software
2 2 # Copyright (C) 2006-2010 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 Version < ActiveRecord::Base
19 19 after_update :update_issues_from_sharing_change
20 20 belongs_to :project
21 21 has_many :fixed_issues, :class_name => 'Issue', :foreign_key => 'fixed_version_id', :dependent => :nullify
22 22 acts_as_customizable
23 23 acts_as_attachable :view_permission => :view_files,
24 24 :delete_permission => :manage_files
25 25
26 26 VERSION_STATUSES = %w(open locked closed)
27 27 VERSION_SHARINGS = %w(none descendants hierarchy tree system)
28 28
29 29 validates_presence_of :name
30 30 validates_uniqueness_of :name, :scope => [:project_id]
31 31 validates_length_of :name, :maximum => 60
32 32 validates_format_of :effective_date, :with => /^\d{4}-\d{2}-\d{2}$/, :message => :not_a_date, :allow_nil => true
33 33 validates_inclusion_of :status, :in => VERSION_STATUSES
34 34 validates_inclusion_of :sharing, :in => VERSION_SHARINGS
35 35
36 36 named_scope :open, :conditions => {:status => 'open'}
37 37 named_scope :visible, lambda {|*args| { :include => :project,
38 38 :conditions => Project.allowed_to_condition(args.first || User.current, :view_issues) } }
39 39
40 40 # Returns true if +user+ or current user is allowed to view the version
41 41 def visible?(user=User.current)
42 42 user.allowed_to?(:view_issues, self.project)
43 43 end
44 44
45 45 def start_date
46 effective_date
46 @start_date ||= fixed_issues.minimum('start_date')
47 47 end
48 48
49 49 def due_date
50 50 effective_date
51 51 end
52 52
53 53 # Returns the total estimated time for this version
54 54 # (sum of leaves estimated_hours)
55 55 def estimated_hours
56 56 @estimated_hours ||= fixed_issues.leaves.sum(:estimated_hours).to_f
57 57 end
58 58
59 59 # Returns the total reported time for this version
60 60 def spent_hours
61 61 @spent_hours ||= TimeEntry.sum(:hours, :include => :issue, :conditions => ["#{Issue.table_name}.fixed_version_id = ?", id]).to_f
62 62 end
63 63
64 64 def closed?
65 65 status == 'closed'
66 66 end
67 67
68 68 def open?
69 69 status == 'open'
70 70 end
71 71
72 72 # Returns true if the version is completed: due date reached and no open issues
73 73 def completed?
74 74 effective_date && (effective_date <= Date.today) && (open_issues_count == 0)
75 75 end
76 76
77 77 def behind_schedule?
78 78 if completed_pourcent == 100
79 79 return false
80 elsif due_date && fixed_issues.present? && fixed_issues.minimum('start_date') # TODO: should use #start_date but that method is wrong...
81 start_date = fixed_issues.minimum('start_date')
80 elsif due_date && start_date
82 81 done_date = start_date + ((due_date - start_date+1)* completed_pourcent/100).floor
83 82 return done_date <= Date.today
84 83 else
85 84 false # No issues so it's not late
86 85 end
87 86 end
88 87
89 88 # Returns the completion percentage of this version based on the amount of open/closed issues
90 89 # and the time spent on the open issues.
91 90 def completed_pourcent
92 91 if issues_count == 0
93 92 0
94 93 elsif open_issues_count == 0
95 94 100
96 95 else
97 96 issues_progress(false) + issues_progress(true)
98 97 end
99 98 end
100 99
101 100 # Returns the percentage of issues that have been marked as 'closed'.
102 101 def closed_pourcent
103 102 if issues_count == 0
104 103 0
105 104 else
106 105 issues_progress(false)
107 106 end
108 107 end
109 108
110 109 # Returns true if the version is overdue: due date reached and some open issues
111 110 def overdue?
112 111 effective_date && (effective_date < Date.today) && (open_issues_count > 0)
113 112 end
114 113
115 114 # Returns assigned issues count
116 115 def issues_count
117 116 @issue_count ||= fixed_issues.count
118 117 end
119 118
120 119 # Returns the total amount of open issues for this version.
121 120 def open_issues_count
122 121 @open_issues_count ||= Issue.count(:all, :conditions => ["fixed_version_id = ? AND is_closed = ?", self.id, false], :include => :status)
123 122 end
124 123
125 124 # Returns the total amount of closed issues for this version.
126 125 def closed_issues_count
127 126 @closed_issues_count ||= Issue.count(:all, :conditions => ["fixed_version_id = ? AND is_closed = ?", self.id, true], :include => :status)
128 127 end
129 128
130 129 def wiki_page
131 130 if project.wiki && !wiki_page_title.blank?
132 131 @wiki_page ||= project.wiki.find_page(wiki_page_title)
133 132 end
134 133 @wiki_page
135 134 end
136 135
137 136 def to_s; name end
138 137
139 138 def to_s_with_project
140 139 "#{project} - #{name}"
141 140 end
142 141
143 142 # Versions are sorted by effective_date and "Project Name - Version name"
144 143 # Those with no effective_date are at the end, sorted by "Project Name - Version name"
145 144 def <=>(version)
146 145 if self.effective_date
147 146 if version.effective_date
148 147 if self.effective_date == version.effective_date
149 148 "#{self.project.name} - #{self.name}" <=> "#{version.project.name} - #{version.name}"
150 149 else
151 150 self.effective_date <=> version.effective_date
152 151 end
153 152 else
154 153 -1
155 154 end
156 155 else
157 156 if version.effective_date
158 157 1
159 158 else
160 159 "#{self.project.name} - #{self.name}" <=> "#{version.project.name} - #{version.name}"
161 160 end
162 161 end
163 162 end
164 163
165 164 # Returns the sharings that +user+ can set the version to
166 165 def allowed_sharings(user = User.current)
167 166 VERSION_SHARINGS.select do |s|
168 167 if sharing == s
169 168 true
170 169 else
171 170 case s
172 171 when 'system'
173 172 # Only admin users can set a systemwide sharing
174 173 user.admin?
175 174 when 'hierarchy', 'tree'
176 175 # Only users allowed to manage versions of the root project can
177 176 # set sharing to hierarchy or tree
178 177 project.nil? || user.allowed_to?(:manage_versions, project.root)
179 178 else
180 179 true
181 180 end
182 181 end
183 182 end
184 183 end
185 184
186 185 private
187 186
188 187 # Update the issue's fixed versions. Used if a version's sharing changes.
189 188 def update_issues_from_sharing_change
190 189 if sharing_changed?
191 190 if VERSION_SHARINGS.index(sharing_was).nil? ||
192 191 VERSION_SHARINGS.index(sharing).nil? ||
193 192 VERSION_SHARINGS.index(sharing_was) > VERSION_SHARINGS.index(sharing)
194 193 Issue.update_versions_from_sharing_change self
195 194 end
196 195 end
197 196 end
198 197
199 198 # Returns the average estimated time of assigned issues
200 199 # or 1 if no issue has an estimated time
201 200 # Used to weigth unestimated issues in progress calculation
202 201 def estimated_average
203 202 if @estimated_average.nil?
204 203 average = fixed_issues.average(:estimated_hours).to_f
205 204 if average == 0
206 205 average = 1
207 206 end
208 207 @estimated_average = average
209 208 end
210 209 @estimated_average
211 210 end
212 211
213 212 # Returns the total progress of open or closed issues. The returned percentage takes into account
214 213 # the amount of estimated time set for this version.
215 214 #
216 215 # Examples:
217 216 # issues_progress(true) => returns the progress percentage for open issues.
218 217 # issues_progress(false) => returns the progress percentage for closed issues.
219 218 def issues_progress(open)
220 219 @issues_progress ||= {}
221 220 @issues_progress[open] ||= begin
222 221 progress = 0
223 222 if issues_count > 0
224 223 ratio = open ? 'done_ratio' : 100
225 224
226 225 done = fixed_issues.sum("COALESCE(estimated_hours, #{estimated_average}) * #{ratio}",
227 226 :include => :status,
228 227 :conditions => ["is_closed = ?", !open]).to_f
229 228 progress = done / (estimated_average * issues_count)
230 229 end
231 230 progress
232 231 end
233 232 end
234 233 end
@@ -1,877 +1,877
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, :truncated, :max_rows
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
71 71 @subjects = ''
72 72 @lines = ''
73 73 @number_of_rows = nil
74 74
75 75 @issue_ancestors = []
76 76
77 77 @truncated = false
78 78 if options.has_key?(:max_rows)
79 79 @max_rows = options[:max_rows]
80 80 else
81 81 @max_rows = Setting.gantt_items_limit.blank? ? nil : Setting.gantt_items_limit.to_i
82 82 end
83 83 end
84 84
85 85 def common_params
86 86 { :controller => 'gantts', :action => 'show', :project_id => @project }
87 87 end
88 88
89 89 def params
90 90 common_params.merge({ :zoom => zoom, :year => year_from, :month => month_from, :months => months })
91 91 end
92 92
93 93 def params_previous
94 94 common_params.merge({:year => (date_from << months).year, :month => (date_from << months).month, :zoom => zoom, :months => months })
95 95 end
96 96
97 97 def params_next
98 98 common_params.merge({:year => (date_from >> months).year, :month => (date_from >> months).month, :zoom => zoom, :months => months })
99 99 end
100 100
101 101 ### Extracted from the HTML view/helpers
102 102 # Returns the number of rows that will be rendered on the Gantt chart
103 103 def number_of_rows
104 104 return @number_of_rows if @number_of_rows
105 105
106 106 rows = if @project
107 107 number_of_rows_on_project(@project)
108 108 else
109 109 Project.roots.visible.has_module('issue_tracking').inject(0) do |total, project|
110 110 total += number_of_rows_on_project(project)
111 111 end
112 112 end
113 113
114 114 rows > @max_rows ? @max_rows : rows
115 115 end
116 116
117 117 # Returns the number of rows that will be used to list a project on
118 118 # the Gantt chart. This will recurse for each subproject.
119 119 def number_of_rows_on_project(project)
120 120 # Remove the project requirement for Versions because it will
121 121 # restrict issues to only be on the current project. This
122 122 # ends up missing issues which are assigned to shared versions.
123 123 @query.project = nil if @query.project
124 124
125 125 # One Root project
126 126 count = 1
127 127 # Issues without a Version
128 128 count += project.issues.for_gantt.without_version.with_query(@query).count
129 129
130 130 # Versions
131 131 count += project.versions.count
132 132
133 133 # Issues on the Versions
134 134 project.versions.each do |version|
135 135 count += version.fixed_issues.for_gantt.with_query(@query).count
136 136 end
137 137
138 138 # Subprojects
139 139 project.children.visible.has_module('issue_tracking').each do |subproject|
140 140 count += number_of_rows_on_project(subproject)
141 141 end
142 142
143 143 count
144 144 end
145 145
146 146 # Renders the subjects of the Gantt chart, the left side.
147 147 def subjects(options={})
148 148 render(options.merge(:only => :subjects)) unless @subjects_rendered
149 149 @subjects
150 150 end
151 151
152 152 # Renders the lines of the Gantt chart, the right side
153 153 def lines(options={})
154 154 render(options.merge(:only => :lines)) unless @lines_rendered
155 155 @lines
156 156 end
157 157
158 158 def render(options={})
159 159 options = {:indent => 4, :render => :subject, :format => :html}.merge(options)
160 160
161 161 @subjects = '' unless options[:only] == :lines
162 162 @lines = '' unless options[:only] == :subjects
163 163 @number_of_rows = 0
164 164
165 165 if @project
166 166 render_project(@project, options)
167 167 else
168 168 Project.roots.visible.has_module('issue_tracking').each do |project|
169 169 render_project(project, options)
170 170 break if abort?
171 171 end
172 172 end
173 173
174 174 @subjects_rendered = true unless options[:only] == :lines
175 175 @lines_rendered = true unless options[:only] == :subjects
176 176
177 177 render_end(options)
178 178 end
179 179
180 180 def render_project(project, options={})
181 181 options[:top] = 0 unless options.key? :top
182 182 options[:indent_increment] = 20 unless options.key? :indent_increment
183 183 options[:top_increment] = 20 unless options.key? :top_increment
184 184
185 185 subject_for_project(project, options) unless options[:only] == :lines
186 186 line_for_project(project, options) unless options[:only] == :subjects
187 187
188 188 options[:top] += options[:top_increment]
189 189 options[:indent] += options[:indent_increment]
190 190 @number_of_rows += 1
191 191 return if abort?
192 192
193 193 # Second, Issues without a version
194 194 issues = project.issues.for_gantt.without_version.with_query(@query).all(:limit => current_limit)
195 195 sort_issues!(issues)
196 196 if issues
197 197 render_issues(issues, options)
198 198 return if abort?
199 199 end
200 200
201 201 # Third, Versions
202 202 project.versions.sort.each do |version|
203 203 render_version(version, options)
204 204 return if abort?
205 205 end
206 206
207 207 # Fourth, subprojects
208 208 project.children.visible.has_module('issue_tracking').each do |project|
209 209 render_project(project, options)
210 210 return if abort?
211 211 end unless project.leaf?
212 212
213 213 # Remove indent to hit the next sibling
214 214 options[:indent] -= options[:indent_increment]
215 215 end
216 216
217 217 def render_issues(issues, options={})
218 218 @issue_ancestors = []
219 219
220 220 issues.each do |i|
221 221 subject_for_issue(i, options) unless options[:only] == :lines
222 222 line_for_issue(i, options) unless options[:only] == :subjects
223 223
224 224 options[:top] += options[:top_increment]
225 225 @number_of_rows += 1
226 226 break if abort?
227 227 end
228 228
229 229 options[:indent] -= (options[:indent_increment] * @issue_ancestors.size)
230 230 end
231 231
232 232 def render_version(version, options={})
233 233 # Version header
234 234 subject_for_version(version, options) unless options[:only] == :lines
235 235 line_for_version(version, options) unless options[:only] == :subjects
236 236
237 237 options[:top] += options[:top_increment]
238 238 @number_of_rows += 1
239 239 return if abort?
240 240
241 241 # Remove the project requirement for Versions because it will
242 242 # restrict issues to only be on the current project. This
243 243 # ends up missing issues which are assigned to shared versions.
244 244 @query.project = nil if @query.project
245 245
246 246 issues = version.fixed_issues.for_gantt.with_query(@query).all(:limit => current_limit)
247 247 if issues
248 248 sort_issues!(issues)
249 249 # Indent issues
250 250 options[:indent] += options[:indent_increment]
251 251 render_issues(issues, options)
252 252 options[:indent] -= options[:indent_increment]
253 253 end
254 254 end
255 255
256 256 def render_end(options={})
257 257 case options[:format]
258 258 when :pdf
259 259 options[:pdf].Line(15, options[:top], PDF::TotalWidth, options[:top])
260 260 end
261 261 end
262 262
263 263 def subject_for_project(project, options)
264 264 case options[:format]
265 265 when :html
266 266 subject = "<span class='icon icon-projects #{project.overdue? ? 'project-overdue' : ''}'>"
267 267 subject << view.link_to_project(project)
268 268 subject << '</span>'
269 269 html_subject(options, subject, :css => "project-name")
270 270 when :image
271 271 image_subject(options, project.name)
272 272 when :pdf
273 273 pdf_new_page?(options)
274 274 pdf_subject(options, project.name)
275 275 end
276 276 end
277 277
278 278 def line_for_project(project, options)
279 279 # Skip versions that don't have a start_date or due date
280 280 if project.is_a?(Project) && project.start_date && project.due_date
281 281 options[:zoom] ||= 1
282 282 options[:g_width] ||= (self.date_to - self.date_from + 1) * options[:zoom]
283 283
284 284 coords = coordinates(project.start_date, project.due_date, project.completed_percent(:include_subprojects => true), options[:zoom])
285 285 label = "#{h project } #{h project.completed_percent(:include_subprojects => true).to_i.to_s}%"
286 286
287 287 case options[:format]
288 288 when :html
289 289 html_task(options, coords, :css => "project task", :label => label, :markers => true)
290 290 when :image
291 291 image_task(options, coords, :label => label, :markers => true, :height => 3)
292 292 when :pdf
293 293 pdf_task(options, coords, :label => label, :markers => true, :height => 0.8)
294 294 end
295 295 else
296 296 ActiveRecord::Base.logger.debug "Gantt#line_for_project was not given a project with a start_date"
297 297 ''
298 298 end
299 299 end
300 300
301 301 def subject_for_version(version, options)
302 302 case options[:format]
303 303 when :html
304 304 subject = "<span class='icon icon-package #{version.behind_schedule? ? 'version-behind-schedule' : ''} #{version.overdue? ? 'version-overdue' : ''}'>"
305 305 subject << view.link_to_version(version)
306 306 subject << '</span>'
307 307 html_subject(options, subject, :css => "version-name")
308 308 when :image
309 309 image_subject(options, version.to_s_with_project)
310 310 when :pdf
311 311 pdf_new_page?(options)
312 312 pdf_subject(options, version.to_s_with_project)
313 313 end
314 314 end
315 315
316 316 def line_for_version(version, options)
317 317 # Skip versions that don't have a start_date
318 318 if version.is_a?(Version) && version.start_date && version.due_date
319 319 options[:zoom] ||= 1
320 320 options[:g_width] ||= (self.date_to - self.date_from + 1) * options[:zoom]
321 321
322 coords = coordinates(version.fixed_issues.minimum('start_date'), version.due_date, version.completed_pourcent, options[:zoom])
322 coords = coordinates(version.start_date, version.due_date, version.completed_pourcent, options[:zoom])
323 323 label = "#{h version } #{h version.completed_pourcent.to_i.to_s}%"
324 324 label = h("#{version.project} -") + label unless @project && @project == version.project
325 325
326 326 case options[:format]
327 327 when :html
328 328 html_task(options, coords, :css => "version task", :label => label, :markers => true)
329 329 when :image
330 330 image_task(options, coords, :label => label, :markers => true, :height => 3)
331 331 when :pdf
332 332 pdf_task(options, coords, :label => label, :markers => true, :height => 0.8)
333 333 end
334 334 else
335 335 ActiveRecord::Base.logger.debug "Gantt#line_for_version was not given a version with a start_date"
336 336 ''
337 337 end
338 338 end
339 339
340 340 def subject_for_issue(issue, options)
341 341 while @issue_ancestors.any? && !issue.is_descendant_of?(@issue_ancestors.last)
342 342 @issue_ancestors.pop
343 343 options[:indent] -= options[:indent_increment]
344 344 end
345 345
346 346 output = case options[:format]
347 347 when :html
348 348 css_classes = ''
349 349 css_classes << ' issue-overdue' if issue.overdue?
350 350 css_classes << ' issue-behind-schedule' if issue.behind_schedule?
351 351 css_classes << ' icon icon-issue' unless Setting.gravatar_enabled? && issue.assigned_to
352 352
353 353 subject = "<span class='#{css_classes}'>"
354 354 if issue.assigned_to.present?
355 355 assigned_string = l(:field_assigned_to) + ": " + issue.assigned_to.name
356 356 subject << view.avatar(issue.assigned_to, :class => 'gravatar icon-gravatar', :size => 10, :title => assigned_string)
357 357 end
358 358 subject << view.link_to_issue(issue)
359 359 subject << '</span>'
360 360 html_subject(options, subject, :css => "issue-subject") + "\n"
361 361 when :image
362 362 image_subject(options, issue.subject)
363 363 when :pdf
364 364 pdf_new_page?(options)
365 365 pdf_subject(options, issue.subject)
366 366 end
367 367
368 368 unless issue.leaf?
369 369 @issue_ancestors << issue
370 370 options[:indent] += options[:indent_increment]
371 371 end
372 372
373 373 output
374 374 end
375 375
376 376 def line_for_issue(issue, options)
377 377 # Skip issues that don't have a due_before (due_date or version's due_date)
378 378 if issue.is_a?(Issue) && issue.due_before
379 379 coords = coordinates(issue.start_date, issue.due_before, issue.done_ratio, options[:zoom])
380 380 label = "#{ issue.status.name } #{ issue.done_ratio }%"
381 381
382 382 case options[:format]
383 383 when :html
384 384 html_task(options, coords, :css => "task " + (issue.leaf? ? 'leaf' : 'parent'), :label => label, :issue => issue, :markers => !issue.leaf?)
385 385 when :image
386 386 image_task(options, coords, :label => label)
387 387 when :pdf
388 388 pdf_task(options, coords, :label => label)
389 389 end
390 390 else
391 391 ActiveRecord::Base.logger.debug "GanttHelper#line_for_issue was not given an issue with a due_before"
392 392 ''
393 393 end
394 394 end
395 395
396 396 # Generates a gantt image
397 397 # Only defined if RMagick is avalaible
398 398 def to_image(format='PNG')
399 399 date_to = (@date_from >> @months)-1
400 400 show_weeks = @zoom > 1
401 401 show_days = @zoom > 2
402 402
403 403 subject_width = 400
404 404 header_heigth = 18
405 405 # width of one day in pixels
406 406 zoom = @zoom*2
407 407 g_width = (@date_to - @date_from + 1)*zoom
408 408 g_height = 20 * number_of_rows + 30
409 409 headers_heigth = (show_weeks ? 2*header_heigth : header_heigth)
410 410 height = g_height + headers_heigth
411 411
412 412 imgl = Magick::ImageList.new
413 413 imgl.new_image(subject_width+g_width+1, height)
414 414 gc = Magick::Draw.new
415 415
416 416 # Subjects
417 417 gc.stroke('transparent')
418 418 subjects(:image => gc, :top => (headers_heigth + 20), :indent => 4, :format => :image)
419 419
420 420 # Months headers
421 421 month_f = @date_from
422 422 left = subject_width
423 423 @months.times do
424 424 width = ((month_f >> 1) - month_f) * zoom
425 425 gc.fill('white')
426 426 gc.stroke('grey')
427 427 gc.stroke_width(1)
428 428 gc.rectangle(left, 0, left + width, height)
429 429 gc.fill('black')
430 430 gc.stroke('transparent')
431 431 gc.stroke_width(1)
432 432 gc.text(left.round + 8, 14, "#{month_f.year}-#{month_f.month}")
433 433 left = left + width
434 434 month_f = month_f >> 1
435 435 end
436 436
437 437 # Weeks headers
438 438 if show_weeks
439 439 left = subject_width
440 440 height = header_heigth
441 441 if @date_from.cwday == 1
442 442 # date_from is monday
443 443 week_f = date_from
444 444 else
445 445 # find next monday after date_from
446 446 week_f = @date_from + (7 - @date_from.cwday + 1)
447 447 width = (7 - @date_from.cwday + 1) * zoom
448 448 gc.fill('white')
449 449 gc.stroke('grey')
450 450 gc.stroke_width(1)
451 451 gc.rectangle(left, header_heigth, left + width, 2*header_heigth + g_height-1)
452 452 left = left + width
453 453 end
454 454 while week_f <= date_to
455 455 width = (week_f + 6 <= date_to) ? 7 * zoom : (date_to - week_f + 1) * zoom
456 456 gc.fill('white')
457 457 gc.stroke('grey')
458 458 gc.stroke_width(1)
459 459 gc.rectangle(left.round, header_heigth, left.round + width, 2*header_heigth + g_height-1)
460 460 gc.fill('black')
461 461 gc.stroke('transparent')
462 462 gc.stroke_width(1)
463 463 gc.text(left.round + 2, header_heigth + 14, week_f.cweek.to_s)
464 464 left = left + width
465 465 week_f = week_f+7
466 466 end
467 467 end
468 468
469 469 # Days details (week-end in grey)
470 470 if show_days
471 471 left = subject_width
472 472 height = g_height + header_heigth - 1
473 473 wday = @date_from.cwday
474 474 (date_to - @date_from + 1).to_i.times do
475 475 width = zoom
476 476 gc.fill(wday == 6 || wday == 7 ? '#eee' : 'white')
477 477 gc.stroke('grey')
478 478 gc.stroke_width(1)
479 479 gc.rectangle(left, 2*header_heigth, left + width, 2*header_heigth + g_height-1)
480 480 left = left + width
481 481 wday = wday + 1
482 482 wday = 1 if wday > 7
483 483 end
484 484 end
485 485
486 486 # border
487 487 gc.fill('transparent')
488 488 gc.stroke('grey')
489 489 gc.stroke_width(1)
490 490 gc.rectangle(0, 0, subject_width+g_width, headers_heigth)
491 491 gc.stroke('black')
492 492 gc.rectangle(0, 0, subject_width+g_width, g_height+ headers_heigth-1)
493 493
494 494 # content
495 495 top = headers_heigth + 20
496 496
497 497 gc.stroke('transparent')
498 498 lines(:image => gc, :top => top, :zoom => zoom, :subject_width => subject_width, :format => :image)
499 499
500 500 # today red line
501 501 if Date.today >= @date_from and Date.today <= date_to
502 502 gc.stroke('red')
503 503 x = (Date.today-@date_from+1)*zoom + subject_width
504 504 gc.line(x, headers_heigth, x, headers_heigth + g_height-1)
505 505 end
506 506
507 507 gc.draw(imgl)
508 508 imgl.format = format
509 509 imgl.to_blob
510 510 end if Object.const_defined?(:Magick)
511 511
512 512 def to_pdf
513 513 pdf = ::Redmine::Export::PDF::IFPDF.new(current_language)
514 514 pdf.SetTitle("#{l(:label_gantt)} #{project}")
515 515 pdf.AliasNbPages
516 516 pdf.footer_date = format_date(Date.today)
517 517 pdf.AddPage("L")
518 518 pdf.SetFontStyle('B',12)
519 519 pdf.SetX(15)
520 520 pdf.Cell(PDF::LeftPaneWidth, 20, project.to_s)
521 521 pdf.Ln
522 522 pdf.SetFontStyle('B',9)
523 523
524 524 subject_width = PDF::LeftPaneWidth
525 525 header_heigth = 5
526 526
527 527 headers_heigth = header_heigth
528 528 show_weeks = false
529 529 show_days = false
530 530
531 531 if self.months < 7
532 532 show_weeks = true
533 533 headers_heigth = 2*header_heigth
534 534 if self.months < 3
535 535 show_days = true
536 536 headers_heigth = 3*header_heigth
537 537 end
538 538 end
539 539
540 540 g_width = PDF.right_pane_width
541 541 zoom = (g_width) / (self.date_to - self.date_from + 1)
542 542 g_height = 120
543 543 t_height = g_height + headers_heigth
544 544
545 545 y_start = pdf.GetY
546 546
547 547 # Months headers
548 548 month_f = self.date_from
549 549 left = subject_width
550 550 height = header_heigth
551 551 self.months.times do
552 552 width = ((month_f >> 1) - month_f) * zoom
553 553 pdf.SetY(y_start)
554 554 pdf.SetX(left)
555 555 pdf.Cell(width, height, "#{month_f.year}-#{month_f.month}", "LTR", 0, "C")
556 556 left = left + width
557 557 month_f = month_f >> 1
558 558 end
559 559
560 560 # Weeks headers
561 561 if show_weeks
562 562 left = subject_width
563 563 height = header_heigth
564 564 if self.date_from.cwday == 1
565 565 # self.date_from is monday
566 566 week_f = self.date_from
567 567 else
568 568 # find next monday after self.date_from
569 569 week_f = self.date_from + (7 - self.date_from.cwday + 1)
570 570 width = (7 - self.date_from.cwday + 1) * zoom-1
571 571 pdf.SetY(y_start + header_heigth)
572 572 pdf.SetX(left)
573 573 pdf.Cell(width + 1, height, "", "LTR")
574 574 left = left + width+1
575 575 end
576 576 while week_f <= self.date_to
577 577 width = (week_f + 6 <= self.date_to) ? 7 * zoom : (self.date_to - week_f + 1) * zoom
578 578 pdf.SetY(y_start + header_heigth)
579 579 pdf.SetX(left)
580 580 pdf.Cell(width, height, (width >= 5 ? week_f.cweek.to_s : ""), "LTR", 0, "C")
581 581 left = left + width
582 582 week_f = week_f+7
583 583 end
584 584 end
585 585
586 586 # Days headers
587 587 if show_days
588 588 left = subject_width
589 589 height = header_heigth
590 590 wday = self.date_from.cwday
591 591 pdf.SetFontStyle('B',7)
592 592 (self.date_to - self.date_from + 1).to_i.times do
593 593 width = zoom
594 594 pdf.SetY(y_start + 2 * header_heigth)
595 595 pdf.SetX(left)
596 596 pdf.Cell(width, height, day_name(wday).first, "LTR", 0, "C")
597 597 left = left + width
598 598 wday = wday + 1
599 599 wday = 1 if wday > 7
600 600 end
601 601 end
602 602
603 603 pdf.SetY(y_start)
604 604 pdf.SetX(15)
605 605 pdf.Cell(subject_width+g_width-15, headers_heigth, "", 1)
606 606
607 607 # Tasks
608 608 top = headers_heigth + y_start
609 609 options = {
610 610 :top => top,
611 611 :zoom => zoom,
612 612 :subject_width => subject_width,
613 613 :g_width => g_width,
614 614 :indent => 0,
615 615 :indent_increment => 5,
616 616 :top_increment => 5,
617 617 :format => :pdf,
618 618 :pdf => pdf
619 619 }
620 620 render(options)
621 621 pdf.Output
622 622 end
623 623
624 624 private
625 625
626 626 def coordinates(start_date, end_date, progress, zoom=nil)
627 627 zoom ||= @zoom
628 628
629 629 coords = {}
630 630 if start_date && end_date && start_date < self.date_to && end_date > self.date_from
631 631 if start_date > self.date_from
632 632 coords[:start] = start_date - self.date_from
633 633 coords[:bar_start] = start_date - self.date_from
634 634 else
635 635 coords[:bar_start] = 0
636 636 end
637 637 if end_date < self.date_to
638 638 coords[:end] = end_date - self.date_from
639 639 coords[:bar_end] = end_date - self.date_from + 1
640 640 else
641 641 coords[:bar_end] = self.date_to - self.date_from + 1
642 642 end
643 643
644 644 if progress
645 645 progress_date = start_date + (end_date - start_date) * (progress / 100.0)
646 646 if progress_date > self.date_from && progress_date > start_date
647 647 if progress_date < self.date_to
648 648 coords[:bar_progress_end] = progress_date - self.date_from + 1
649 649 else
650 650 coords[:bar_progress_end] = self.date_to - self.date_from + 1
651 651 end
652 652 end
653 653
654 654 if progress_date < Date.today
655 655 late_date = [Date.today, end_date].min
656 656 if late_date > self.date_from && late_date > start_date
657 657 if late_date < self.date_to
658 658 coords[:bar_late_end] = late_date - self.date_from + 1
659 659 else
660 660 coords[:bar_late_end] = self.date_to - self.date_from + 1
661 661 end
662 662 end
663 663 end
664 664 end
665 665 end
666 666
667 667 # Transforms dates into pixels witdh
668 668 coords.keys.each do |key|
669 669 coords[key] = (coords[key] * zoom).floor
670 670 end
671 671 coords
672 672 end
673 673
674 674 # Sorts a collection of issues by start_date, due_date, id for gantt rendering
675 675 def sort_issues!(issues)
676 676 issues.sort! { |a, b| gantt_issue_compare(a, b, issues) }
677 677 end
678 678
679 679 def gantt_issue_compare(x, y, issues)
680 680 if x.parent_id == y.parent_id
681 681 gantt_start_compare(x, y)
682 682 elsif x.is_ancestor_of?(y)
683 683 -1
684 684 elsif y.is_ancestor_of?(x)
685 685 1
686 686 else
687 687 ax = issues.select {|i| i.is_a?(Issue) && i.is_ancestor_of?(x) && !i.is_ancestor_of?(y) }.sort_by(&:lft).first
688 688 ay = issues.select {|i| i.is_a?(Issue) && i.is_ancestor_of?(y) && !i.is_ancestor_of?(x) }.sort_by(&:lft).first
689 689 if ax.nil? && ay.nil?
690 690 gantt_start_compare(x, y)
691 691 else
692 692 gantt_issue_compare(ax || x, ay || y, issues)
693 693 end
694 694 end
695 695 end
696 696
697 697 def gantt_start_compare(x, y)
698 698 if x.start_date.nil?
699 699 -1
700 700 elsif y.start_date.nil?
701 701 1
702 702 else
703 703 x.start_date <=> y.start_date
704 704 end
705 705 end
706 706
707 707 def current_limit
708 708 if @max_rows
709 709 @max_rows - @number_of_rows
710 710 else
711 711 nil
712 712 end
713 713 end
714 714
715 715 def abort?
716 716 if @max_rows && @number_of_rows >= @max_rows
717 717 @truncated = true
718 718 end
719 719 end
720 720
721 721 def pdf_new_page?(options)
722 722 if options[:top] > 180
723 723 options[:pdf].Line(15, options[:top], PDF::TotalWidth, options[:top])
724 724 options[:pdf].AddPage("L")
725 725 options[:top] = 15
726 726 options[:pdf].Line(15, options[:top] - 0.1, PDF::TotalWidth, options[:top] - 0.1)
727 727 end
728 728 end
729 729
730 730 def html_subject(params, subject, options={})
731 731 output = "<div class=' #{options[:css] }' style='position: absolute;line-height:1.2em;height:16px;top:#{params[:top]}px;left:#{params[:indent]}px;overflow:hidden;'>"
732 732 output << subject
733 733 output << "</div>"
734 734 @subjects << output
735 735 output
736 736 end
737 737
738 738 def pdf_subject(params, subject, options={})
739 739 params[:pdf].SetY(params[:top])
740 740 params[:pdf].SetX(15)
741 741
742 742 char_limit = PDF::MaxCharactorsForSubject - params[:indent]
743 743 params[:pdf].Cell(params[:subject_width]-15, 5, (" " * params[:indent]) + subject.to_s.sub(/^(.{#{char_limit}}[^\s]*\s).*$/, '\1 (...)'), "LR")
744 744
745 745 params[:pdf].SetY(params[:top])
746 746 params[:pdf].SetX(params[:subject_width])
747 747 params[:pdf].Cell(params[:g_width], 5, "", "LR")
748 748 end
749 749
750 750 def image_subject(params, subject, options={})
751 751 params[:image].fill('black')
752 752 params[:image].stroke('transparent')
753 753 params[:image].stroke_width(1)
754 754 params[:image].text(params[:indent], params[:top] + 2, subject)
755 755 end
756 756
757 757 def html_task(params, coords, options={})
758 758 output = ''
759 759 # Renders the task bar, with progress and late
760 760 if coords[:bar_start] && coords[:bar_end]
761 761 output << "<div style='top:#{ params[:top] }px;left:#{ coords[:bar_start] }px;width:#{ coords[:bar_end] - coords[:bar_start] - 2}px;' class='#{options[:css]} task_todo'>&nbsp;</div>"
762 762
763 763 if coords[:bar_late_end]
764 764 output << "<div style='top:#{ params[:top] }px;left:#{ coords[:bar_start] }px;width:#{ coords[:bar_late_end] - coords[:bar_start] - 2}px;' class='#{options[:css]} task_late'>&nbsp;</div>"
765 765 end
766 766 if coords[:bar_progress_end]
767 767 output << "<div style='top:#{ params[:top] }px;left:#{ coords[:bar_start] }px;width:#{ coords[:bar_progress_end] - coords[:bar_start] - 2}px;' class='#{options[:css]} task_done'>&nbsp;</div>"
768 768 end
769 769 end
770 770 # Renders the markers
771 771 if options[:markers]
772 772 if coords[:start]
773 773 output << "<div style='top:#{ params[:top] }px;left:#{ coords[:start] }px;width:15px;' class='#{options[:css]} marker starting'>&nbsp;</div>"
774 774 end
775 775 if coords[:end]
776 776 output << "<div style='top:#{ params[:top] }px;left:#{ coords[:end] + params[:zoom] }px;width:15px;' class='#{options[:css]} marker ending'>&nbsp;</div>"
777 777 end
778 778 end
779 779 # Renders the label on the right
780 780 if options[:label]
781 781 output << "<div style='top:#{ params[:top] }px;left:#{ (coords[:bar_end] || 0) + 8 }px;' class='#{options[:css]} label'>"
782 782 output << options[:label]
783 783 output << "</div>"
784 784 end
785 785 # Renders the tooltip
786 786 if options[:issue] && coords[:bar_start] && coords[:bar_end]
787 787 output << "<div class='tooltip' style='position: absolute;top:#{ params[:top] }px;left:#{ coords[:bar_start] }px;width:#{ coords[:bar_end] - coords[:bar_start] }px;height:12px;'>"
788 788 output << '<span class="tip">'
789 789 output << view.render_issue_tooltip(options[:issue])
790 790 output << "</span></div>"
791 791 end
792 792 @lines << output
793 793 output
794 794 end
795 795
796 796 def pdf_task(params, coords, options={})
797 797 height = options[:height] || 2
798 798
799 799 # Renders the task bar, with progress and late
800 800 if coords[:bar_start] && coords[:bar_end]
801 801 params[:pdf].SetY(params[:top]+1.5)
802 802 params[:pdf].SetX(params[:subject_width] + coords[:bar_start])
803 803 params[:pdf].SetFillColor(200,200,200)
804 804 params[:pdf].Cell(coords[:bar_end] - coords[:bar_start], height, "", 0, 0, "", 1)
805 805
806 806 if coords[:bar_late_end]
807 807 params[:pdf].SetY(params[:top]+1.5)
808 808 params[:pdf].SetX(params[:subject_width] + coords[:bar_start])
809 809 params[:pdf].SetFillColor(255,100,100)
810 810 params[:pdf].Cell(coords[:bar_late_end] - coords[:bar_start], height, "", 0, 0, "", 1)
811 811 end
812 812 if coords[:bar_progress_end]
813 813 params[:pdf].SetY(params[:top]+1.5)
814 814 params[:pdf].SetX(params[:subject_width] + coords[:bar_start])
815 815 params[:pdf].SetFillColor(90,200,90)
816 816 params[:pdf].Cell(coords[:bar_progress_end] - coords[:bar_start], height, "", 0, 0, "", 1)
817 817 end
818 818 end
819 819 # Renders the markers
820 820 if options[:markers]
821 821 if coords[:start]
822 822 params[:pdf].SetY(params[:top] + 1)
823 823 params[:pdf].SetX(params[:subject_width] + coords[:start] - 1)
824 824 params[:pdf].SetFillColor(50,50,200)
825 825 params[:pdf].Cell(2, 2, "", 0, 0, "", 1)
826 826 end
827 827 if coords[:end]
828 828 params[:pdf].SetY(params[:top] + 1)
829 829 params[:pdf].SetX(params[:subject_width] + coords[:end] - 1)
830 830 params[:pdf].SetFillColor(50,50,200)
831 831 params[:pdf].Cell(2, 2, "", 0, 0, "", 1)
832 832 end
833 833 end
834 834 # Renders the label on the right
835 835 if options[:label]
836 836 params[:pdf].SetX(params[:subject_width] + (coords[:bar_end] || 0) + 5)
837 837 params[:pdf].Cell(30, 2, options[:label])
838 838 end
839 839 end
840 840
841 841 def image_task(params, coords, options={})
842 842 height = options[:height] || 6
843 843
844 844 # Renders the task bar, with progress and late
845 845 if coords[:bar_start] && coords[:bar_end]
846 846 params[:image].fill('grey')
847 847 params[:image].rectangle(params[:subject_width] + coords[:bar_start], params[:top], params[:subject_width] + coords[:bar_end], params[:top] - height)
848 848
849 849 if coords[:bar_late_end]
850 850 params[:image].fill('red')
851 851 params[:image].rectangle(params[:subject_width] + coords[:bar_start], params[:top], params[:subject_width] + coords[:bar_late_end], params[:top] - height)
852 852 end
853 853 if coords[:bar_progress_end]
854 854 params[:image].fill('green')
855 855 params[:image].rectangle(params[:subject_width] + coords[:bar_start], params[:top], params[:subject_width] + coords[:bar_progress_end], params[:top] - height)
856 856 end
857 857 end
858 858 # Renders the markers
859 859 if options[:markers]
860 860 if coords[:start]
861 861 params[:image].fill('blue')
862 862 params[:image].rectangle(params[:subject_width] + coords[:start], params[:top] + 1, params[:subject_width] + coords[:start] + 4, params[:top] - 4)
863 863 end
864 864 if coords[:end]
865 865 params[:image].fill('blue')
866 866 params[:image].rectangle(params[:subject_width] + coords[:end], params[:top] + 1, params[:subject_width] + coords[:end] + 4, params[:top] - 4)
867 867 end
868 868 end
869 869 # Renders the label on the right
870 870 if options[:label]
871 871 params[:image].fill('black')
872 872 params[:image].text(params[:subject_width] + (coords[:bar_end] || 0) + 5,params[:top] + 1, options[:label])
873 873 end
874 874 end
875 875 end
876 876 end
877 877 end
General Comments 0
You need to be logged in to leave comments. Login now