##// END OF EJS Templates
Select projects with issue_tracking module for gantt display and remove the nil start/due dates trick....
Jean-Philippe Lang -
r4363:b89820080366
parent child
Show More
@@ -1,817 +1,813
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 # Project statuses
20 20 STATUS_ACTIVE = 1
21 21 STATUS_ARCHIVED = 9
22 22
23 23 # Maximum length for project identifiers
24 24 IDENTIFIER_MAX_LENGTH = 100
25 25
26 26 # Specific overidden Activities
27 27 has_many :time_entry_activities
28 28 has_many :members, :include => [:user, :roles], :conditions => "#{User.table_name}.type='User' AND #{User.table_name}.status=#{User::STATUS_ACTIVE}"
29 29 has_many :memberships, :class_name => 'Member'
30 30 has_many :member_principals, :class_name => 'Member',
31 31 :include => :principal,
32 32 :conditions => "#{Principal.table_name}.type='Group' OR (#{Principal.table_name}.type='User' AND #{Principal.table_name}.status=#{User::STATUS_ACTIVE})"
33 33 has_many :users, :through => :members
34 34 has_many :principals, :through => :member_principals, :source => :principal
35 35
36 36 has_many :enabled_modules, :dependent => :delete_all
37 37 has_and_belongs_to_many :trackers, :order => "#{Tracker.table_name}.position"
38 38 has_many :issues, :dependent => :destroy, :order => "#{Issue.table_name}.created_on DESC", :include => [:status, :tracker]
39 39 has_many :issue_changes, :through => :issues, :source => :journals
40 40 has_many :versions, :dependent => :destroy, :order => "#{Version.table_name}.effective_date DESC, #{Version.table_name}.name DESC"
41 41 has_many :time_entries, :dependent => :delete_all
42 42 has_many :queries, :dependent => :delete_all
43 43 has_many :documents, :dependent => :destroy
44 44 has_many :news, :dependent => :delete_all, :include => :author
45 45 has_many :issue_categories, :dependent => :delete_all, :order => "#{IssueCategory.table_name}.name"
46 46 has_many :boards, :dependent => :destroy, :order => "position ASC"
47 47 has_one :repository, :dependent => :destroy
48 48 has_many :changesets, :through => :repository
49 49 has_one :wiki, :dependent => :destroy
50 50 # Custom field for the project issues
51 51 has_and_belongs_to_many :issue_custom_fields,
52 52 :class_name => 'IssueCustomField',
53 53 :order => "#{CustomField.table_name}.position",
54 54 :join_table => "#{table_name_prefix}custom_fields_projects#{table_name_suffix}",
55 55 :association_foreign_key => 'custom_field_id'
56 56
57 57 acts_as_nested_set :order => 'name'
58 58 acts_as_attachable :view_permission => :view_files,
59 59 :delete_permission => :manage_files
60 60
61 61 acts_as_customizable
62 62 acts_as_searchable :columns => ['name', 'identifier', 'description'], :project_key => 'id', :permission => nil
63 63 acts_as_event :title => Proc.new {|o| "#{l(:label_project)}: #{o.name}"},
64 64 :url => Proc.new {|o| {:controller => 'projects', :action => 'show', :id => o}},
65 65 :author => nil
66 66
67 67 attr_protected :status, :enabled_module_names
68 68
69 69 validates_presence_of :name, :identifier
70 70 validates_uniqueness_of :identifier
71 71 validates_associated :repository, :wiki
72 72 validates_length_of :name, :maximum => 255
73 73 validates_length_of :homepage, :maximum => 255
74 74 validates_length_of :identifier, :in => 1..IDENTIFIER_MAX_LENGTH
75 75 # donwcase letters, digits, dashes but not digits only
76 76 validates_format_of :identifier, :with => /^(?!\d+$)[a-z0-9\-]*$/, :if => Proc.new { |p| p.identifier_changed? }
77 77 # reserved words
78 78 validates_exclusion_of :identifier, :in => %w( new )
79 79
80 80 before_destroy :delete_all_members, :destroy_children
81 81
82 82 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] } }
83 83 named_scope :active, { :conditions => "#{Project.table_name}.status = #{STATUS_ACTIVE}"}
84 84 named_scope :all_public, { :conditions => { :is_public => true } }
85 85 named_scope :visible, lambda { { :conditions => Project.visible_by(User.current) } }
86 86
87 87 def initialize(attributes = nil)
88 88 super
89 89
90 90 initialized = (attributes || {}).stringify_keys
91 91 if !initialized.key?('identifier') && Setting.sequential_project_identifiers?
92 92 self.identifier = Project.next_identifier
93 93 end
94 94 if !initialized.key?('is_public')
95 95 self.is_public = Setting.default_projects_public?
96 96 end
97 97 if !initialized.key?('enabled_module_names')
98 98 self.enabled_module_names = Setting.default_projects_modules
99 99 end
100 100 if !initialized.key?('trackers') && !initialized.key?('tracker_ids')
101 101 self.trackers = Tracker.all
102 102 end
103 103 end
104 104
105 105 def identifier=(identifier)
106 106 super unless identifier_frozen?
107 107 end
108 108
109 109 def identifier_frozen?
110 110 errors[:identifier].nil? && !(new_record? || identifier.blank?)
111 111 end
112 112
113 113 # returns latest created projects
114 114 # non public projects will be returned only if user is a member of those
115 115 def self.latest(user=nil, count=5)
116 116 find(:all, :limit => count, :conditions => visible_by(user), :order => "created_on DESC")
117 117 end
118 118
119 119 # Returns a SQL :conditions string used to find all active projects for the specified user.
120 120 #
121 121 # Examples:
122 122 # Projects.visible_by(admin) => "projects.status = 1"
123 123 # Projects.visible_by(normal_user) => "projects.status = 1 AND projects.is_public = 1"
124 124 def self.visible_by(user=nil)
125 125 user ||= User.current
126 126 if user && user.admin?
127 127 return "#{Project.table_name}.status=#{Project::STATUS_ACTIVE}"
128 128 elsif user && user.memberships.any?
129 129 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(',')}))"
130 130 else
131 131 return "#{Project.table_name}.status=#{Project::STATUS_ACTIVE} AND #{Project.table_name}.is_public = #{connection.quoted_true}"
132 132 end
133 133 end
134 134
135 135 def self.allowed_to_condition(user, permission, options={})
136 136 statements = []
137 137 base_statement = "#{Project.table_name}.status=#{Project::STATUS_ACTIVE}"
138 138 if perm = Redmine::AccessControl.permission(permission)
139 139 unless perm.project_module.nil?
140 140 # If the permission belongs to a project module, make sure the module is enabled
141 141 base_statement << " AND #{Project.table_name}.id IN (SELECT em.project_id FROM #{EnabledModule.table_name} em WHERE em.name='#{perm.project_module}')"
142 142 end
143 143 end
144 144 if options[:project]
145 145 project_statement = "#{Project.table_name}.id = #{options[:project].id}"
146 146 project_statement << " OR (#{Project.table_name}.lft > #{options[:project].lft} AND #{Project.table_name}.rgt < #{options[:project].rgt})" if options[:with_subprojects]
147 147 base_statement = "(#{project_statement}) AND (#{base_statement})"
148 148 end
149 149 if user.admin?
150 150 # no restriction
151 151 else
152 152 statements << "1=0"
153 153 if user.logged?
154 154 if Role.non_member.allowed_to?(permission) && !options[:member]
155 155 statements << "#{Project.table_name}.is_public = #{connection.quoted_true}"
156 156 end
157 157 allowed_project_ids = user.memberships.select {|m| m.roles.detect {|role| role.allowed_to?(permission)}}.collect {|m| m.project_id}
158 158 statements << "#{Project.table_name}.id IN (#{allowed_project_ids.join(',')})" if allowed_project_ids.any?
159 159 else
160 160 if Role.anonymous.allowed_to?(permission) && !options[:member]
161 161 # anonymous user allowed on public project
162 162 statements << "#{Project.table_name}.is_public = #{connection.quoted_true}"
163 163 end
164 164 end
165 165 end
166 166 statements.empty? ? base_statement : "((#{base_statement}) AND (#{statements.join(' OR ')}))"
167 167 end
168 168
169 169 # Returns the Systemwide and project specific activities
170 170 def activities(include_inactive=false)
171 171 if include_inactive
172 172 return all_activities
173 173 else
174 174 return active_activities
175 175 end
176 176 end
177 177
178 178 # Will create a new Project specific Activity or update an existing one
179 179 #
180 180 # This will raise a ActiveRecord::Rollback if the TimeEntryActivity
181 181 # does not successfully save.
182 182 def update_or_create_time_entry_activity(id, activity_hash)
183 183 if activity_hash.respond_to?(:has_key?) && activity_hash.has_key?('parent_id')
184 184 self.create_time_entry_activity_if_needed(activity_hash)
185 185 else
186 186 activity = project.time_entry_activities.find_by_id(id.to_i)
187 187 activity.update_attributes(activity_hash) if activity
188 188 end
189 189 end
190 190
191 191 # Create a new TimeEntryActivity if it overrides a system TimeEntryActivity
192 192 #
193 193 # This will raise a ActiveRecord::Rollback if the TimeEntryActivity
194 194 # does not successfully save.
195 195 def create_time_entry_activity_if_needed(activity)
196 196 if activity['parent_id']
197 197
198 198 parent_activity = TimeEntryActivity.find(activity['parent_id'])
199 199 activity['name'] = parent_activity.name
200 200 activity['position'] = parent_activity.position
201 201
202 202 if Enumeration.overridding_change?(activity, parent_activity)
203 203 project_activity = self.time_entry_activities.create(activity)
204 204
205 205 if project_activity.new_record?
206 206 raise ActiveRecord::Rollback, "Overridding TimeEntryActivity was not successfully saved"
207 207 else
208 208 self.time_entries.update_all("activity_id = #{project_activity.id}", ["activity_id = ?", parent_activity.id])
209 209 end
210 210 end
211 211 end
212 212 end
213 213
214 214 # Returns a :conditions SQL string that can be used to find the issues associated with this project.
215 215 #
216 216 # Examples:
217 217 # project.project_condition(true) => "(projects.id = 1 OR (projects.lft > 1 AND projects.rgt < 10))"
218 218 # project.project_condition(false) => "projects.id = 1"
219 219 def project_condition(with_subprojects)
220 220 cond = "#{Project.table_name}.id = #{id}"
221 221 cond = "(#{cond} OR (#{Project.table_name}.lft > #{lft} AND #{Project.table_name}.rgt < #{rgt}))" if with_subprojects
222 222 cond
223 223 end
224 224
225 225 def self.find(*args)
226 226 if args.first && args.first.is_a?(String) && !args.first.match(/^\d*$/)
227 227 project = find_by_identifier(*args)
228 228 raise ActiveRecord::RecordNotFound, "Couldn't find Project with identifier=#{args.first}" if project.nil?
229 229 project
230 230 else
231 231 super
232 232 end
233 233 end
234 234
235 235 def to_param
236 236 # id is used for projects with a numeric identifier (compatibility)
237 237 @to_param ||= (identifier.to_s =~ %r{^\d*$} ? id : identifier)
238 238 end
239 239
240 240 def active?
241 241 self.status == STATUS_ACTIVE
242 242 end
243 243
244 244 def archived?
245 245 self.status == STATUS_ARCHIVED
246 246 end
247 247
248 248 # Archives the project and its descendants
249 249 def archive
250 250 # Check that there is no issue of a non descendant project that is assigned
251 251 # to one of the project or descendant versions
252 252 v_ids = self_and_descendants.collect {|p| p.version_ids}.flatten
253 253 if v_ids.any? && Issue.find(:first, :include => :project,
254 254 :conditions => ["(#{Project.table_name}.lft < ? OR #{Project.table_name}.rgt > ?)" +
255 255 " AND #{Issue.table_name}.fixed_version_id IN (?)", lft, rgt, v_ids])
256 256 return false
257 257 end
258 258 Project.transaction do
259 259 archive!
260 260 end
261 261 true
262 262 end
263 263
264 264 # Unarchives the project
265 265 # All its ancestors must be active
266 266 def unarchive
267 267 return false if ancestors.detect {|a| !a.active?}
268 268 update_attribute :status, STATUS_ACTIVE
269 269 end
270 270
271 271 # Returns an array of projects the project can be moved to
272 272 # by the current user
273 273 def allowed_parents
274 274 return @allowed_parents if @allowed_parents
275 275 @allowed_parents = Project.find(:all, :conditions => Project.allowed_to_condition(User.current, :add_subprojects))
276 276 @allowed_parents = @allowed_parents - self_and_descendants
277 277 if User.current.allowed_to?(:add_project, nil, :global => true) || (!new_record? && parent.nil?)
278 278 @allowed_parents << nil
279 279 end
280 280 unless parent.nil? || @allowed_parents.empty? || @allowed_parents.include?(parent)
281 281 @allowed_parents << parent
282 282 end
283 283 @allowed_parents
284 284 end
285 285
286 286 # Sets the parent of the project with authorization check
287 287 def set_allowed_parent!(p)
288 288 unless p.nil? || p.is_a?(Project)
289 289 if p.to_s.blank?
290 290 p = nil
291 291 else
292 292 p = Project.find_by_id(p)
293 293 return false unless p
294 294 end
295 295 end
296 296 if p.nil?
297 297 if !new_record? && allowed_parents.empty?
298 298 return false
299 299 end
300 300 elsif !allowed_parents.include?(p)
301 301 return false
302 302 end
303 303 set_parent!(p)
304 304 end
305 305
306 306 # Sets the parent of the project
307 307 # Argument can be either a Project, a String, a Fixnum or nil
308 308 def set_parent!(p)
309 309 unless p.nil? || p.is_a?(Project)
310 310 if p.to_s.blank?
311 311 p = nil
312 312 else
313 313 p = Project.find_by_id(p)
314 314 return false unless p
315 315 end
316 316 end
317 317 if p == parent && !p.nil?
318 318 # Nothing to do
319 319 true
320 320 elsif p.nil? || (p.active? && move_possible?(p))
321 321 # Insert the project so that target's children or root projects stay alphabetically sorted
322 322 sibs = (p.nil? ? self.class.roots : p.children)
323 323 to_be_inserted_before = sibs.detect {|c| c.name.to_s.downcase > name.to_s.downcase }
324 324 if to_be_inserted_before
325 325 move_to_left_of(to_be_inserted_before)
326 326 elsif p.nil?
327 327 if sibs.empty?
328 328 # move_to_root adds the project in first (ie. left) position
329 329 move_to_root
330 330 else
331 331 move_to_right_of(sibs.last) unless self == sibs.last
332 332 end
333 333 else
334 334 # move_to_child_of adds the project in last (ie.right) position
335 335 move_to_child_of(p)
336 336 end
337 337 Issue.update_versions_from_hierarchy_change(self)
338 338 true
339 339 else
340 340 # Can not move to the given target
341 341 false
342 342 end
343 343 end
344 344
345 345 # Returns an array of the trackers used by the project and its active sub projects
346 346 def rolled_up_trackers
347 347 @rolled_up_trackers ||=
348 348 Tracker.find(:all, :include => :projects,
349 349 :select => "DISTINCT #{Tracker.table_name}.*",
350 350 :conditions => ["#{Project.table_name}.lft >= ? AND #{Project.table_name}.rgt <= ? AND #{Project.table_name}.status = #{STATUS_ACTIVE}", lft, rgt],
351 351 :order => "#{Tracker.table_name}.position")
352 352 end
353 353
354 354 # Closes open and locked project versions that are completed
355 355 def close_completed_versions
356 356 Version.transaction do
357 357 versions.find(:all, :conditions => {:status => %w(open locked)}).each do |version|
358 358 if version.completed?
359 359 version.update_attribute(:status, 'closed')
360 360 end
361 361 end
362 362 end
363 363 end
364 364
365 365 # Returns a scope of the Versions on subprojects
366 366 def rolled_up_versions
367 367 @rolled_up_versions ||=
368 368 Version.scoped(:include => :project,
369 369 :conditions => ["#{Project.table_name}.lft >= ? AND #{Project.table_name}.rgt <= ? AND #{Project.table_name}.status = #{STATUS_ACTIVE}", lft, rgt])
370 370 end
371 371
372 372 # Returns a scope of the Versions used by the project
373 373 def shared_versions
374 374 @shared_versions ||=
375 375 Version.scoped(:include => :project,
376 376 :conditions => "#{Project.table_name}.id = #{id}" +
377 377 " OR (#{Project.table_name}.status = #{Project::STATUS_ACTIVE} AND (" +
378 378 " #{Version.table_name}.sharing = 'system'" +
379 379 " OR (#{Project.table_name}.lft >= #{root.lft} AND #{Project.table_name}.rgt <= #{root.rgt} AND #{Version.table_name}.sharing = 'tree')" +
380 380 " OR (#{Project.table_name}.lft < #{lft} AND #{Project.table_name}.rgt > #{rgt} AND #{Version.table_name}.sharing IN ('hierarchy', 'descendants'))" +
381 381 " OR (#{Project.table_name}.lft > #{lft} AND #{Project.table_name}.rgt < #{rgt} AND #{Version.table_name}.sharing = 'hierarchy')" +
382 382 "))")
383 383 end
384 384
385 385 # Returns a hash of project users grouped by role
386 386 def users_by_role
387 387 members.find(:all, :include => [:user, :roles]).inject({}) do |h, m|
388 388 m.roles.each do |r|
389 389 h[r] ||= []
390 390 h[r] << m.user
391 391 end
392 392 h
393 393 end
394 394 end
395 395
396 396 # Deletes all project's members
397 397 def delete_all_members
398 398 me, mr = Member.table_name, MemberRole.table_name
399 399 connection.delete("DELETE FROM #{mr} WHERE #{mr}.member_id IN (SELECT #{me}.id FROM #{me} WHERE #{me}.project_id = #{id})")
400 400 Member.delete_all(['project_id = ?', id])
401 401 end
402 402
403 403 # Users issues can be assigned to
404 404 def assignable_users
405 405 members.select {|m| m.roles.detect {|role| role.assignable?}}.collect {|m| m.user}.sort
406 406 end
407 407
408 408 # Returns the mail adresses of users that should be always notified on project events
409 409 def recipients
410 410 notified_users.collect {|user| user.mail}
411 411 end
412 412
413 413 # Returns the users that should be notified on project events
414 414 def notified_users
415 415 # TODO: User part should be extracted to User#notify_about?
416 416 members.select {|m| m.mail_notification? || m.user.mail_notification == 'all'}.collect {|m| m.user}
417 417 end
418 418
419 419 # Returns an array of all custom fields enabled for project issues
420 420 # (explictly associated custom fields and custom fields enabled for all projects)
421 421 def all_issue_custom_fields
422 422 @all_issue_custom_fields ||= (IssueCustomField.for_all + issue_custom_fields).uniq.sort
423 423 end
424 424
425 425 def project
426 426 self
427 427 end
428 428
429 429 def <=>(project)
430 430 name.downcase <=> project.name.downcase
431 431 end
432 432
433 433 def to_s
434 434 name
435 435 end
436 436
437 437 # Returns a short description of the projects (first lines)
438 438 def short_description(length = 255)
439 439 description.gsub(/^(.{#{length}}[^\n\r]*).*$/m, '\1...').strip if description
440 440 end
441 441
442 442 def css_classes
443 443 s = 'project'
444 444 s << ' root' if root?
445 445 s << ' child' if child?
446 446 s << (leaf? ? ' leaf' : ' parent')
447 447 s
448 448 end
449 449
450 450 # The earliest start date of a project, based on it's issues and versions
451 451 def start_date
452 if module_enabled?(:issue_tracking)
453 [
454 issues.minimum('start_date'),
455 shared_versions.collect(&:effective_date),
456 shared_versions.collect {|v| v.fixed_issues.minimum('start_date')}
457 ].flatten.compact.min
458 end
452 [
453 issues.minimum('start_date'),
454 shared_versions.collect(&:effective_date),
455 shared_versions.collect {|v| v.fixed_issues.minimum('start_date')}
456 ].flatten.compact.min
459 457 end
460 458
461 459 # The latest due date of an issue or version
462 460 def due_date
463 if module_enabled?(:issue_tracking)
464 [
465 issues.maximum('due_date'),
466 shared_versions.collect(&:effective_date),
467 shared_versions.collect {|v| v.fixed_issues.maximum('due_date')}
468 ].flatten.compact.max
469 end
461 [
462 issues.maximum('due_date'),
463 shared_versions.collect(&:effective_date),
464 shared_versions.collect {|v| v.fixed_issues.maximum('due_date')}
465 ].flatten.compact.max
470 466 end
471 467
472 468 def overdue?
473 469 active? && !due_date.nil? && (due_date < Date.today)
474 470 end
475 471
476 472 # Returns the percent completed for this project, based on the
477 473 # progress on it's versions.
478 474 def completed_percent(options={:include_subprojects => false})
479 475 if options.delete(:include_subprojects)
480 476 total = self_and_descendants.collect(&:completed_percent).sum
481 477
482 478 total / self_and_descendants.count
483 479 else
484 480 if versions.count > 0
485 481 total = versions.collect(&:completed_pourcent).sum
486 482
487 483 total / versions.count
488 484 else
489 485 100
490 486 end
491 487 end
492 488 end
493 489
494 490 # Return true if this project is allowed to do the specified action.
495 491 # action can be:
496 492 # * a parameter-like Hash (eg. :controller => 'projects', :action => 'edit')
497 493 # * a permission Symbol (eg. :edit_project)
498 494 def allows_to?(action)
499 495 if action.is_a? Hash
500 496 allowed_actions.include? "#{action[:controller]}/#{action[:action]}"
501 497 else
502 498 allowed_permissions.include? action
503 499 end
504 500 end
505 501
506 502 def module_enabled?(module_name)
507 503 module_name = module_name.to_s
508 504 enabled_modules.detect {|m| m.name == module_name}
509 505 end
510 506
511 507 def enabled_module_names=(module_names)
512 508 if module_names && module_names.is_a?(Array)
513 509 module_names = module_names.collect(&:to_s).reject(&:blank?)
514 510 # remove disabled modules
515 511 enabled_modules.each {|mod| mod.destroy unless module_names.include?(mod.name)}
516 512 # add new modules
517 513 module_names.reject {|name| module_enabled?(name)}.each {|name| enabled_modules << EnabledModule.new(:name => name)}
518 514 else
519 515 enabled_modules.clear
520 516 end
521 517 end
522 518
523 519 # Returns an array of the enabled modules names
524 520 def enabled_module_names
525 521 enabled_modules.collect(&:name)
526 522 end
527 523
528 524 # Returns an array of projects that are in this project's hierarchy
529 525 #
530 526 # Example: parents, children, siblings
531 527 def hierarchy
532 528 parents = project.self_and_ancestors || []
533 529 descendants = project.descendants || []
534 530 project_hierarchy = parents | descendants # Set union
535 531 end
536 532
537 533 # Returns an auto-generated project identifier based on the last identifier used
538 534 def self.next_identifier
539 535 p = Project.find(:first, :order => 'created_on DESC')
540 536 p.nil? ? nil : p.identifier.to_s.succ
541 537 end
542 538
543 539 # Copies and saves the Project instance based on the +project+.
544 540 # Duplicates the source project's:
545 541 # * Wiki
546 542 # * Versions
547 543 # * Categories
548 544 # * Issues
549 545 # * Members
550 546 # * Queries
551 547 #
552 548 # Accepts an +options+ argument to specify what to copy
553 549 #
554 550 # Examples:
555 551 # project.copy(1) # => copies everything
556 552 # project.copy(1, :only => 'members') # => copies members only
557 553 # project.copy(1, :only => ['members', 'versions']) # => copies members and versions
558 554 def copy(project, options={})
559 555 project = project.is_a?(Project) ? project : Project.find(project)
560 556
561 557 to_be_copied = %w(wiki versions issue_categories issues members queries boards)
562 558 to_be_copied = to_be_copied & options[:only].to_a unless options[:only].nil?
563 559
564 560 Project.transaction do
565 561 if save
566 562 reload
567 563 to_be_copied.each do |name|
568 564 send "copy_#{name}", project
569 565 end
570 566 Redmine::Hook.call_hook(:model_project_copy_before_save, :source_project => project, :destination_project => self)
571 567 save
572 568 end
573 569 end
574 570 end
575 571
576 572
577 573 # Copies +project+ and returns the new instance. This will not save
578 574 # the copy
579 575 def self.copy_from(project)
580 576 begin
581 577 project = project.is_a?(Project) ? project : Project.find(project)
582 578 if project
583 579 # clear unique attributes
584 580 attributes = project.attributes.dup.except('id', 'name', 'identifier', 'status', 'parent_id', 'lft', 'rgt')
585 581 copy = Project.new(attributes)
586 582 copy.enabled_modules = project.enabled_modules
587 583 copy.trackers = project.trackers
588 584 copy.custom_values = project.custom_values.collect {|v| v.clone}
589 585 copy.issue_custom_fields = project.issue_custom_fields
590 586 return copy
591 587 else
592 588 return nil
593 589 end
594 590 rescue ActiveRecord::RecordNotFound
595 591 return nil
596 592 end
597 593 end
598 594
599 595 # Yields the given block for each project with its level in the tree
600 596 def self.project_tree(projects, &block)
601 597 ancestors = []
602 598 projects.sort_by(&:lft).each do |project|
603 599 while (ancestors.any? && !project.is_descendant_of?(ancestors.last))
604 600 ancestors.pop
605 601 end
606 602 yield project, ancestors.size
607 603 ancestors << project
608 604 end
609 605 end
610 606
611 607 private
612 608
613 609 # Destroys children before destroying self
614 610 def destroy_children
615 611 children.each do |child|
616 612 child.destroy
617 613 end
618 614 end
619 615
620 616 # Copies wiki from +project+
621 617 def copy_wiki(project)
622 618 # Check that the source project has a wiki first
623 619 unless project.wiki.nil?
624 620 self.wiki ||= Wiki.new
625 621 wiki.attributes = project.wiki.attributes.dup.except("id", "project_id")
626 622 wiki_pages_map = {}
627 623 project.wiki.pages.each do |page|
628 624 # Skip pages without content
629 625 next if page.content.nil?
630 626 new_wiki_content = WikiContent.new(page.content.attributes.dup.except("id", "page_id", "updated_on"))
631 627 new_wiki_page = WikiPage.new(page.attributes.dup.except("id", "wiki_id", "created_on", "parent_id"))
632 628 new_wiki_page.content = new_wiki_content
633 629 wiki.pages << new_wiki_page
634 630 wiki_pages_map[page.id] = new_wiki_page
635 631 end
636 632 wiki.save
637 633 # Reproduce page hierarchy
638 634 project.wiki.pages.each do |page|
639 635 if page.parent_id && wiki_pages_map[page.id]
640 636 wiki_pages_map[page.id].parent = wiki_pages_map[page.parent_id]
641 637 wiki_pages_map[page.id].save
642 638 end
643 639 end
644 640 end
645 641 end
646 642
647 643 # Copies versions from +project+
648 644 def copy_versions(project)
649 645 project.versions.each do |version|
650 646 new_version = Version.new
651 647 new_version.attributes = version.attributes.dup.except("id", "project_id", "created_on", "updated_on")
652 648 self.versions << new_version
653 649 end
654 650 end
655 651
656 652 # Copies issue categories from +project+
657 653 def copy_issue_categories(project)
658 654 project.issue_categories.each do |issue_category|
659 655 new_issue_category = IssueCategory.new
660 656 new_issue_category.attributes = issue_category.attributes.dup.except("id", "project_id")
661 657 self.issue_categories << new_issue_category
662 658 end
663 659 end
664 660
665 661 # Copies issues from +project+
666 662 def copy_issues(project)
667 663 # Stores the source issue id as a key and the copied issues as the
668 664 # value. Used to map the two togeather for issue relations.
669 665 issues_map = {}
670 666
671 667 # Get issues sorted by root_id, lft so that parent issues
672 668 # get copied before their children
673 669 project.issues.find(:all, :order => 'root_id, lft').each do |issue|
674 670 new_issue = Issue.new
675 671 new_issue.copy_from(issue)
676 672 new_issue.project = self
677 673 # Reassign fixed_versions by name, since names are unique per
678 674 # project and the versions for self are not yet saved
679 675 if issue.fixed_version
680 676 new_issue.fixed_version = self.versions.select {|v| v.name == issue.fixed_version.name}.first
681 677 end
682 678 # Reassign the category by name, since names are unique per
683 679 # project and the categories for self are not yet saved
684 680 if issue.category
685 681 new_issue.category = self.issue_categories.select {|c| c.name == issue.category.name}.first
686 682 end
687 683 # Parent issue
688 684 if issue.parent_id
689 685 if copied_parent = issues_map[issue.parent_id]
690 686 new_issue.parent_issue_id = copied_parent.id
691 687 end
692 688 end
693 689
694 690 self.issues << new_issue
695 691 issues_map[issue.id] = new_issue
696 692 end
697 693
698 694 # Relations after in case issues related each other
699 695 project.issues.each do |issue|
700 696 new_issue = issues_map[issue.id]
701 697
702 698 # Relations
703 699 issue.relations_from.each do |source_relation|
704 700 new_issue_relation = IssueRelation.new
705 701 new_issue_relation.attributes = source_relation.attributes.dup.except("id", "issue_from_id", "issue_to_id")
706 702 new_issue_relation.issue_to = issues_map[source_relation.issue_to_id]
707 703 if new_issue_relation.issue_to.nil? && Setting.cross_project_issue_relations?
708 704 new_issue_relation.issue_to = source_relation.issue_to
709 705 end
710 706 new_issue.relations_from << new_issue_relation
711 707 end
712 708
713 709 issue.relations_to.each do |source_relation|
714 710 new_issue_relation = IssueRelation.new
715 711 new_issue_relation.attributes = source_relation.attributes.dup.except("id", "issue_from_id", "issue_to_id")
716 712 new_issue_relation.issue_from = issues_map[source_relation.issue_from_id]
717 713 if new_issue_relation.issue_from.nil? && Setting.cross_project_issue_relations?
718 714 new_issue_relation.issue_from = source_relation.issue_from
719 715 end
720 716 new_issue.relations_to << new_issue_relation
721 717 end
722 718 end
723 719 end
724 720
725 721 # Copies members from +project+
726 722 def copy_members(project)
727 723 project.memberships.each do |member|
728 724 new_member = Member.new
729 725 new_member.attributes = member.attributes.dup.except("id", "project_id", "created_on")
730 726 # only copy non inherited roles
731 727 # inherited roles will be added when copying the group membership
732 728 role_ids = member.member_roles.reject(&:inherited?).collect(&:role_id)
733 729 next if role_ids.empty?
734 730 new_member.role_ids = role_ids
735 731 new_member.project = self
736 732 self.members << new_member
737 733 end
738 734 end
739 735
740 736 # Copies queries from +project+
741 737 def copy_queries(project)
742 738 project.queries.each do |query|
743 739 new_query = Query.new
744 740 new_query.attributes = query.attributes.dup.except("id", "project_id", "sort_criteria")
745 741 new_query.sort_criteria = query.sort_criteria if query.sort_criteria
746 742 new_query.project = self
747 743 self.queries << new_query
748 744 end
749 745 end
750 746
751 747 # Copies boards from +project+
752 748 def copy_boards(project)
753 749 project.boards.each do |board|
754 750 new_board = Board.new
755 751 new_board.attributes = board.attributes.dup.except("id", "project_id", "topics_count", "messages_count", "last_message_id")
756 752 new_board.project = self
757 753 self.boards << new_board
758 754 end
759 755 end
760 756
761 757 def allowed_permissions
762 758 @allowed_permissions ||= begin
763 759 module_names = enabled_modules.all(:select => :name).collect {|m| m.name}
764 760 Redmine::AccessControl.modules_permissions(module_names).collect {|p| p.name}
765 761 end
766 762 end
767 763
768 764 def allowed_actions
769 765 @actions_allowed ||= allowed_permissions.inject([]) { |actions, permission| actions += Redmine::AccessControl.allowed_actions(permission) }.flatten
770 766 end
771 767
772 768 # Returns all the active Systemwide and project specific activities
773 769 def active_activities
774 770 overridden_activity_ids = self.time_entry_activities.collect(&:parent_id)
775 771
776 772 if overridden_activity_ids.empty?
777 773 return TimeEntryActivity.shared.active
778 774 else
779 775 return system_activities_and_project_overrides
780 776 end
781 777 end
782 778
783 779 # Returns all the Systemwide and project specific activities
784 780 # (inactive and active)
785 781 def all_activities
786 782 overridden_activity_ids = self.time_entry_activities.collect(&:parent_id)
787 783
788 784 if overridden_activity_ids.empty?
789 785 return TimeEntryActivity.shared
790 786 else
791 787 return system_activities_and_project_overrides(true)
792 788 end
793 789 end
794 790
795 791 # Returns the systemwide active activities merged with the project specific overrides
796 792 def system_activities_and_project_overrides(include_inactive=false)
797 793 if include_inactive
798 794 return TimeEntryActivity.shared.
799 795 find(:all,
800 796 :conditions => ["id NOT IN (?)", self.time_entry_activities.collect(&:parent_id)]) +
801 797 self.time_entry_activities
802 798 else
803 799 return TimeEntryActivity.shared.active.
804 800 find(:all,
805 801 :conditions => ["id NOT IN (?)", self.time_entry_activities.collect(&:parent_id)]) +
806 802 self.time_entry_activities.active
807 803 end
808 804 end
809 805
810 806 # Archives subprojects recursively
811 807 def archive!
812 808 children.each do |subproject|
813 809 subproject.send :archive!
814 810 end
815 811 update_attribute :status, STATUS_ARCHIVED
816 812 end
817 813 end
@@ -1,974 +1,974
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
71 71 @subjects = ''
72 72 @lines = ''
73 73 @number_of_rows = nil
74 74 end
75 75
76 76 def common_params
77 77 { :controller => 'gantts', :action => 'show', :project_id => @project }
78 78 end
79 79
80 80 def params
81 81 common_params.merge({ :zoom => zoom, :year => year_from, :month => month_from, :months => months })
82 82 end
83 83
84 84 def params_previous
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 def params_next
89 89 common_params.merge({:year => (date_from >> months).year, :month => (date_from >> months).month, :zoom => zoom, :months => months })
90 90 end
91 91
92 92 ### Extracted from the HTML view/helpers
93 93 # Returns the number of rows that will be rendered on the Gantt chart
94 94 def number_of_rows
95 95 return @number_of_rows if @number_of_rows
96 96
97 97 if @project
98 98 return number_of_rows_on_project(@project)
99 99 else
100 Project.roots.visible.inject(0) do |total, project|
100 Project.roots.visible.has_module('issue_tracking').inject(0) do |total, project|
101 101 total += number_of_rows_on_project(project)
102 102 end
103 103 end
104 104 end
105 105
106 106 # Returns the number of rows that will be used to list a project on
107 107 # the Gantt chart. This will recurse for each subproject.
108 108 def number_of_rows_on_project(project)
109 109 # Remove the project requirement for Versions because it will
110 110 # restrict issues to only be on the current project. This
111 111 # ends up missing issues which are assigned to shared versions.
112 112 @query.project = nil if @query.project
113 113
114 114 # One Root project
115 115 count = 1
116 116 # Issues without a Version
117 117 count += project.issues.for_gantt.without_version.with_query(@query).count
118 118
119 119 # Versions
120 120 count += project.versions.count
121 121
122 122 # Issues on the Versions
123 123 project.versions.each do |version|
124 124 count += version.fixed_issues.for_gantt.with_query(@query).count
125 125 end
126 126
127 127 # Subprojects
128 project.children.visible.each do |subproject|
128 project.children.visible.has_module('issue_tracking').each do |subproject|
129 129 count += number_of_rows_on_project(subproject)
130 130 end
131 131
132 132 count
133 133 end
134 134
135 135 # Renders the subjects of the Gantt chart, the left side.
136 136 def subjects(options={})
137 137 render(options.merge(:only => :subjects)) unless @subjects_rendered
138 138 @subjects
139 139 end
140 140
141 141 # Renders the lines of the Gantt chart, the right side
142 142 def lines(options={})
143 143 render(options.merge(:only => :lines)) unless @lines_rendered
144 144 @lines
145 145 end
146 146
147 147 def render(options={})
148 148 options = {:indent => 4, :render => :subject, :format => :html}.merge(options)
149 149
150 150 @subjects = '' unless options[:only] == :lines
151 151 @lines = '' unless options[:only] == :subjects
152 152 @number_of_rows = 0
153 153
154 154 if @project
155 155 render_project(@project, options)
156 156 else
157 Project.roots.visible.each do |project|
157 Project.roots.visible.has_module('issue_tracking').each do |project|
158 158 render_project(project, options)
159 159 end
160 160 end
161 161
162 162 @subjects_rendered = true unless options[:only] == :lines
163 163 @lines_rendered = true unless options[:only] == :subjects
164 164
165 165 render_end(options)
166 166 end
167 167
168 168 def render_project(project, options={})
169 169 options[:top] = 0 unless options.key? :top
170 170 options[:indent_increment] = 20 unless options.key? :indent_increment
171 171 options[:top_increment] = 20 unless options.key? :top_increment
172 172
173 173 subject_for_project(project, options) unless options[:only] == :lines
174 174 line_for_project(project, options) unless options[:only] == :subjects
175 175
176 176 options[:top] += options[:top_increment]
177 177 options[:indent] += options[:indent_increment]
178 178 @number_of_rows += 1
179 179
180 180 # Second, Issues without a version
181 181 issues = project.issues.for_gantt.without_version.with_query(@query)
182 182 sort_issues!(issues)
183 183 if issues
184 184 render_issues(issues, options)
185 185 end
186 186
187 187 # Third, Versions
188 188 project.versions.sort.each do |version|
189 189 render_version(version, options)
190 190 end
191 191
192 192 # Fourth, subprojects
193 project.children.visible.each do |project|
193 project.children.visible.has_module('issue_tracking').each do |project|
194 194 render_project(project, options)
195 195 end
196 196
197 197 # Remove indent to hit the next sibling
198 198 options[:indent] -= options[:indent_increment]
199 199 end
200 200
201 201 def render_issues(issues, options={})
202 202 issues.each do |i|
203 203 subject_for_issue(i, options) unless options[:only] == :lines
204 204 line_for_issue(i, options) unless options[:only] == :subjects
205 205
206 206 options[:top] += options[:top_increment]
207 207 @number_of_rows += 1
208 208 end
209 209 end
210 210
211 211 def render_version(version, options={})
212 212 # Version header
213 213 subject_for_version(version, options) unless options[:only] == :lines
214 214 line_for_version(version, options) unless options[:only] == :subjects
215 215
216 216 options[:top] += options[:top_increment]
217 217 @number_of_rows += 1
218 218
219 219 # Remove the project requirement for Versions because it will
220 220 # restrict issues to only be on the current project. This
221 221 # ends up missing issues which are assigned to shared versions.
222 222 @query.project = nil if @query.project
223 223
224 224 issues = version.fixed_issues.for_gantt.with_query(@query)
225 225 if issues
226 226 sort_issues!(issues)
227 227 # Indent issues
228 228 options[:indent] += options[:indent_increment]
229 229 render_issues(issues, options)
230 230 options[:indent] -= options[:indent_increment]
231 231 end
232 232 end
233 233
234 234 def render_end(options={})
235 235 case options[:format]
236 236 when :pdf
237 237 options[:pdf].Line(15, options[:top], PDF::TotalWidth, options[:top])
238 238 end
239 239 end
240 240
241 241 def subject_for_project(project, options)
242 242 case options[:format]
243 243 when :html
244 244 output = ''
245 245
246 246 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> "
247 247 if project.is_a? Project
248 248 output << "<span class='icon icon-projects #{project.overdue? ? 'project-overdue' : ''}'>"
249 249 output << view.link_to_project(project)
250 250 output << '</span>'
251 251 else
252 252 ActiveRecord::Base.logger.debug "Gantt#subject_for_project was not given a project"
253 253 ''
254 254 end
255 255 output << "</small></div>"
256 256 @subjects << output
257 257 output
258 258 when :image
259 259
260 260 options[:image].fill('black')
261 261 options[:image].stroke('transparent')
262 262 options[:image].stroke_width(1)
263 263 options[:image].text(options[:indent], options[:top] + 2, project.name)
264 264 when :pdf
265 265 pdf_new_page?(options)
266 266 options[:pdf].SetY(options[:top])
267 267 options[:pdf].SetX(15)
268 268
269 269 char_limit = PDF::MaxCharactorsForSubject - options[:indent]
270 270 options[:pdf].Cell(options[:subject_width]-15, 5, (" " * options[:indent]) +"#{project.name}".sub(/^(.{#{char_limit}}[^\s]*\s).*$/, '\1 (...)'), "LR")
271 271
272 272 options[:pdf].SetY(options[:top])
273 273 options[:pdf].SetX(options[:subject_width])
274 274 options[:pdf].Cell(options[:g_width], 5, "", "LR")
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
285 285 case options[:format]
286 286 when :html
287 287 output = ''
288 288 i_left = ((project.start_date - self.date_from)*options[:zoom]).floor
289 289
290 290 start_date = project.start_date
291 291 start_date ||= self.date_from
292 292 start_left = ((start_date - self.date_from)*options[:zoom]).floor
293 293
294 294 i_end_date = ((project.due_date <= self.date_to) ? project.due_date : self.date_to )
295 295 i_done_date = start_date + ((project.due_date - start_date+1)* project.completed_percent(:include_subprojects => true)/100).floor
296 296 i_done_date = (i_done_date <= self.date_from ? self.date_from : i_done_date )
297 297 i_done_date = (i_done_date >= self.date_to ? self.date_to : i_done_date )
298 298
299 299 i_late_date = [i_end_date, Date.today].min if start_date < Date.today
300 300 i_end = ((i_end_date - self.date_from) * options[:zoom]).floor
301 301
302 302 i_width = (i_end - i_left + 1).floor - 2 # total width of the issue (- 2 for left and right borders)
303 303 d_width = ((i_done_date - start_date)*options[:zoom]).floor - 2 # done width
304 304 l_width = i_late_date ? ((i_late_date - start_date+1)*options[:zoom]).floor - 2 : 0 # delay width
305 305
306 306 # Bar graphic
307 307
308 308 # Make sure that negative i_left and i_width don't
309 309 # overflow the subject
310 310 if i_end > 0 && i_left <= options[:g_width]
311 311 output << "<div style='top:#{ options[:top] }px;left:#{ start_left }px;width:#{ i_width }px;' class='task project_todo'>&nbsp;</div>"
312 312 end
313 313
314 314 if l_width > 0 && i_left <= options[:g_width]
315 315 output << "<div style='top:#{ options[:top] }px;left:#{ start_left }px;width:#{ l_width }px;' class='task project_late'>&nbsp;</div>"
316 316 end
317 317 if d_width > 0 && i_left <= options[:g_width]
318 318 output<< "<div style='top:#{ options[:top] }px;left:#{ start_left }px;width:#{ d_width }px;' class='task project_done'>&nbsp;</div>"
319 319 end
320 320
321 321
322 322 # Starting diamond
323 323 if start_left <= options[:g_width] && start_left > 0
324 324 output << "<div style='top:#{ options[:top] }px;left:#{ start_left }px;width:15px;' class='task project-line starting'>&nbsp;</div>"
325 325 output << "<div style='top:#{ options[:top] }px;left:#{ start_left + 12 }px;' class='task label'>"
326 326 output << "</div>"
327 327 end
328 328
329 329 # Ending diamond
330 330 # Don't show items too far ahead
331 331 if i_end <= options[:g_width] && i_end > 0
332 332 output << "<div style='top:#{ options[:top] }px;left:#{ i_end }px;width:15px;' class='task project-line ending'>&nbsp;</div>"
333 333 end
334 334
335 335 # DIsplay the Project name and %
336 336 if i_end <= options[:g_width]
337 337 # Display the status even if it's floated off to the left
338 338 status_px = i_end + 12 # 12px for the diamond
339 339 status_px = 0 if status_px <= 0
340 340
341 341 output << "<div style='top:#{ options[:top] }px;left:#{ status_px }px;' class='task label project-name'>"
342 342 output << "<strong>#{h project } #{h project.completed_percent(:include_subprojects => true).to_i.to_s}%</strong>"
343 343 output << "</div>"
344 344 end
345 345 @lines << output
346 346 output
347 347 when :image
348 348 options[:image].stroke('transparent')
349 349 i_left = options[:subject_width] + ((project.due_date - self.date_from)*options[:zoom]).floor
350 350
351 351 # Make sure negative i_left doesn't overflow the subject
352 352 if i_left > options[:subject_width]
353 353 options[:image].fill('blue')
354 354 options[:image].rectangle(i_left, options[:top], i_left + 6, options[:top] - 6)
355 355 options[:image].fill('black')
356 356 options[:image].text(i_left + 11, options[:top] + 1, project.name)
357 357 end
358 358 when :pdf
359 359 options[:pdf].SetY(options[:top]+1.5)
360 360 i_left = ((project.due_date - @date_from)*options[:zoom])
361 361
362 362 # Make sure negative i_left doesn't overflow the subject
363 363 if i_left > 0
364 364 options[:pdf].SetX(options[:subject_width] + i_left)
365 365 options[:pdf].SetFillColor(50,50,200)
366 366 options[:pdf].Cell(2, 2, "", 0, 0, "", 1)
367 367
368 368 options[:pdf].SetY(options[:top]+1.5)
369 369 options[:pdf].SetX(options[:subject_width] + i_left + 3)
370 370 options[:pdf].Cell(30, 2, "#{project.name}")
371 371 end
372 372 end
373 373 else
374 374 ActiveRecord::Base.logger.debug "Gantt#line_for_project was not given a project with a start_date"
375 375 ''
376 376 end
377 377 end
378 378
379 379 def subject_for_version(version, options)
380 380 case options[:format]
381 381 when :html
382 382 output = ''
383 383 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> "
384 384 if version.is_a? Version
385 385 output << "<span class='icon icon-package #{version.behind_schedule? ? 'version-behind-schedule' : ''} #{version.overdue? ? 'version-overdue' : ''}'>"
386 386 output << view.link_to_version(version)
387 387 output << '</span>'
388 388 else
389 389 ActiveRecord::Base.logger.debug "Gantt#subject_for_version was not given a version"
390 390 ''
391 391 end
392 392 output << "</small></div>"
393 393 @subjects << output
394 394 output
395 395 when :image
396 396 options[:image].fill('black')
397 397 options[:image].stroke('transparent')
398 398 options[:image].stroke_width(1)
399 399 options[:image].text(options[:indent], options[:top] + 2, version.to_s_with_project)
400 400 when :pdf
401 401 pdf_new_page?(options)
402 402 options[:pdf].SetY(options[:top])
403 403 options[:pdf].SetX(15)
404 404
405 405 char_limit = PDF::MaxCharactorsForSubject - options[:indent]
406 406 options[:pdf].Cell(options[:subject_width]-15, 5, (" " * options[:indent]) +"#{version.to_s_with_project}".sub(/^(.{#{char_limit}}[^\s]*\s).*$/, '\1 (...)'), "LR")
407 407
408 408 options[:pdf].SetY(options[:top])
409 409 options[:pdf].SetX(options[:subject_width])
410 410 options[:pdf].Cell(options[:g_width], 5, "", "LR")
411 411 end
412 412 end
413 413
414 414 def line_for_version(version, options)
415 415 # Skip versions that don't have a start_date
416 416 if version.is_a?(Version) && version.start_date && version.due_date
417 417 options[:zoom] ||= 1
418 418 options[:g_width] ||= (self.date_to - self.date_from + 1) * options[:zoom]
419 419
420 420 case options[:format]
421 421 when :html
422 422 output = ''
423 423 i_left = ((version.start_date - self.date_from)*options[:zoom]).floor
424 424 # TODO: or version.fixed_issues.collect(&:start_date).min
425 425 start_date = version.fixed_issues.minimum('start_date') if version.fixed_issues.present?
426 426 start_date ||= self.date_from
427 427 start_left = ((start_date - self.date_from)*options[:zoom]).floor
428 428
429 429 i_end_date = ((version.due_date <= self.date_to) ? version.due_date : self.date_to )
430 430 i_done_date = start_date + ((version.due_date - start_date+1)* version.completed_pourcent/100).floor
431 431 i_done_date = (i_done_date <= self.date_from ? self.date_from : i_done_date )
432 432 i_done_date = (i_done_date >= self.date_to ? self.date_to : i_done_date )
433 433
434 434 i_late_date = [i_end_date, Date.today].min if start_date < Date.today
435 435
436 436 i_width = (i_left - start_left + 1).floor - 2 # total width of the issue (- 2 for left and right borders)
437 437 d_width = ((i_done_date - start_date)*options[:zoom]).floor - 2 # done width
438 438 l_width = i_late_date ? ((i_late_date - start_date+1)*options[:zoom]).floor - 2 : 0 # delay width
439 439
440 440 i_end = ((i_end_date - self.date_from) * options[:zoom]).floor # Ending pixel
441 441
442 442 # Bar graphic
443 443
444 444 # Make sure that negative i_left and i_width don't
445 445 # overflow the subject
446 446 if i_width > 0 && i_left <= options[:g_width]
447 447 output << "<div style='top:#{ options[:top] }px;left:#{ start_left }px;width:#{ i_width }px;' class='task milestone_todo'>&nbsp;</div>"
448 448 end
449 449 if l_width > 0 && i_left <= options[:g_width]
450 450 output << "<div style='top:#{ options[:top] }px;left:#{ start_left }px;width:#{ l_width }px;' class='task milestone_late'>&nbsp;</div>"
451 451 end
452 452 if d_width > 0 && i_left <= options[:g_width]
453 453 output<< "<div style='top:#{ options[:top] }px;left:#{ start_left }px;width:#{ d_width }px;' class='task milestone_done'>&nbsp;</div>"
454 454 end
455 455
456 456
457 457 # Starting diamond
458 458 if start_left <= options[:g_width] && start_left > 0
459 459 output << "<div style='top:#{ options[:top] }px;left:#{ start_left }px;width:15px;' class='task milestone starting'>&nbsp;</div>"
460 460 output << "<div style='top:#{ options[:top] }px;left:#{ start_left + 12 }px;background:#fff;' class='task'>"
461 461 output << "</div>"
462 462 end
463 463
464 464 # Ending diamond
465 465 # Don't show items too far ahead
466 466 if i_left <= options[:g_width] && i_end > 0
467 467 output << "<div style='top:#{ options[:top] }px;left:#{ i_end }px;width:15px;' class='task milestone ending'>&nbsp;</div>"
468 468 end
469 469
470 470 # Display the Version name and %
471 471 if i_end <= options[:g_width]
472 472 # Display the status even if it's floated off to the left
473 473 status_px = i_end + 12 # 12px for the diamond
474 474 status_px = 0 if status_px <= 0
475 475
476 476 output << "<div style='top:#{ options[:top] }px;left:#{ status_px }px;' class='task label version-name'>"
477 477 output << h("#{version.project} -") unless @project && @project == version.project
478 478 output << "<strong>#{h version } #{h version.completed_pourcent.to_i.to_s}%</strong>"
479 479 output << "</div>"
480 480 end
481 481 @lines << output
482 482 output
483 483 when :image
484 484 options[:image].stroke('transparent')
485 485 i_left = options[:subject_width] + ((version.start_date - @date_from)*options[:zoom]).floor
486 486
487 487 # Make sure negative i_left doesn't overflow the subject
488 488 if i_left > options[:subject_width]
489 489 options[:image].fill('green')
490 490 options[:image].rectangle(i_left, options[:top], i_left + 6, options[:top] - 6)
491 491 options[:image].fill('black')
492 492 options[:image].text(i_left + 11, options[:top] + 1, version.name)
493 493 end
494 494 when :pdf
495 495 options[:pdf].SetY(options[:top]+1.5)
496 496 i_left = ((version.start_date - @date_from)*options[:zoom])
497 497
498 498 # Make sure negative i_left doesn't overflow the subject
499 499 if i_left > 0
500 500 options[:pdf].SetX(options[:subject_width] + i_left)
501 501 options[:pdf].SetFillColor(50,200,50)
502 502 options[:pdf].Cell(2, 2, "", 0, 0, "", 1)
503 503
504 504 options[:pdf].SetY(options[:top]+1.5)
505 505 options[:pdf].SetX(options[:subject_width] + i_left + 3)
506 506 options[:pdf].Cell(30, 2, "#{version.name}")
507 507 end
508 508 end
509 509 else
510 510 ActiveRecord::Base.logger.debug "Gantt#line_for_version was not given a version with a start_date"
511 511 ''
512 512 end
513 513 end
514 514
515 515 def subject_for_issue(issue, options)
516 516 case options[:format]
517 517 when :html
518 518 output = ''
519 519 output << "<div class='tooltip'>"
520 520 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> "
521 521 if issue.is_a? Issue
522 522 css_classes = []
523 523 css_classes << 'issue-overdue' if issue.overdue?
524 524 css_classes << 'issue-behind-schedule' if issue.behind_schedule?
525 525 css_classes << 'icon icon-issue' unless Setting.gravatar_enabled? && issue.assigned_to
526 526
527 527 if issue.assigned_to.present?
528 528 assigned_string = l(:field_assigned_to) + ": " + issue.assigned_to.name
529 529 output << view.avatar(issue.assigned_to, :class => 'gravatar icon-gravatar', :size => 10, :title => assigned_string)
530 530 end
531 531 output << "<span class='#{css_classes.join(' ')}'>"
532 532 output << view.link_to_issue(issue)
533 533 output << '</span>'
534 534 else
535 535 ActiveRecord::Base.logger.debug "Gantt#subject_for_issue was not given an issue"
536 536 ''
537 537 end
538 538 output << "</small></div>"
539 539
540 540 # Tooltip
541 541 if issue.is_a? Issue
542 542 output << "<span class='tip' style='position: absolute;top:#{ options[:top].to_i + 16 }px;left:#{ options[:indent].to_i + 20 }px;'>"
543 543 output << view.render_issue_tooltip(issue)
544 544 output << "</span>"
545 545 end
546 546
547 547 output << "</div>"
548 548 @subjects << output
549 549 output
550 550 when :image
551 551 options[:image].fill('black')
552 552 options[:image].stroke('transparent')
553 553 options[:image].stroke_width(1)
554 554 options[:image].text(options[:indent], options[:top] + 2, issue.subject)
555 555 when :pdf
556 556 pdf_new_page?(options)
557 557 options[:pdf].SetY(options[:top])
558 558 options[:pdf].SetX(15)
559 559
560 560 char_limit = PDF::MaxCharactorsForSubject - options[:indent]
561 561 options[:pdf].Cell(options[:subject_width]-15, 5, (" " * options[:indent]) +"#{issue.tracker} #{issue.id}: #{issue.subject}".sub(/^(.{#{char_limit}}[^\s]*\s).*$/, '\1 (...)'), "LR")
562 562
563 563 options[:pdf].SetY(options[:top])
564 564 options[:pdf].SetX(options[:subject_width])
565 565 options[:pdf].Cell(options[:g_width], 5, "", "LR")
566 566 end
567 567 end
568 568
569 569 def line_for_issue(issue, options)
570 570 # Skip issues that don't have a due_before (due_date or version's due_date)
571 571 if issue.is_a?(Issue) && issue.due_before
572 572 case options[:format]
573 573 when :html
574 574 output = ''
575 575 # Handle nil start_dates, rare but can happen.
576 576 i_start_date = if issue.start_date && issue.start_date >= self.date_from
577 577 issue.start_date
578 578 else
579 579 self.date_from
580 580 end
581 581
582 582 i_end_date = ((issue.due_before && issue.due_before <= self.date_to) ? issue.due_before : self.date_to )
583 583 i_done_date = i_start_date + ((issue.due_before - i_start_date+1)*issue.done_ratio/100).floor
584 584 i_done_date = (i_done_date <= self.date_from ? self.date_from : i_done_date )
585 585 i_done_date = (i_done_date >= self.date_to ? self.date_to : i_done_date )
586 586
587 587 i_late_date = [i_end_date, Date.today].min if i_start_date < Date.today
588 588
589 589 i_left = ((i_start_date - self.date_from)*options[:zoom]).floor
590 590 i_width = ((i_end_date - i_start_date + 1)*options[:zoom]).floor - 2 # total width of the issue (- 2 for left and right borders)
591 591 d_width = ((i_done_date - i_start_date)*options[:zoom]).floor - 2 # done width
592 592 l_width = i_late_date ? ((i_late_date - i_start_date+1)*options[:zoom]).floor - 2 : 0 # delay width
593 593 css = "task " + (issue.leaf? ? 'leaf' : 'parent')
594 594
595 595 # Make sure that negative i_left and i_width don't
596 596 # overflow the subject
597 597 if i_width > 0
598 598 output << "<div style='top:#{ options[:top] }px;left:#{ i_left }px;width:#{ i_width }px;' class='#{css} task_todo'>&nbsp;</div>"
599 599 end
600 600 if l_width > 0
601 601 output << "<div style='top:#{ options[:top] }px;left:#{ i_left }px;width:#{ l_width }px;' class='#{css} task_late'>&nbsp;</div>"
602 602 end
603 603 if d_width > 0
604 604 output<< "<div style='top:#{ options[:top] }px;left:#{ i_left }px;width:#{ d_width }px;' class='#{css} task_done'>&nbsp;</div>"
605 605 end
606 606
607 607 # Display the status even if it's floated off to the left
608 608 status_px = i_left + i_width + 5
609 609 status_px = 5 if status_px <= 0
610 610
611 611 output << "<div style='top:#{ options[:top] }px;left:#{ status_px }px;' class='#{css} label issue-name'>"
612 612 output << issue.status.name
613 613 output << ' '
614 614 output << (issue.done_ratio).to_i.to_s
615 615 output << "%"
616 616 output << "</div>"
617 617
618 618 output << "<div class='tooltip' style='position: absolute;top:#{ options[:top] }px;left:#{ i_left }px;width:#{ i_width }px;height:12px;'>"
619 619 output << '<span class="tip">'
620 620 output << view.render_issue_tooltip(issue)
621 621 output << "</span></div>"
622 622 @lines << output
623 623 output
624 624
625 625 when :image
626 626 # Handle nil start_dates, rare but can happen.
627 627 i_start_date = if issue.start_date && issue.start_date >= @date_from
628 628 issue.start_date
629 629 else
630 630 @date_from
631 631 end
632 632
633 633 i_end_date = (issue.due_before <= date_to ? issue.due_before : date_to )
634 634 i_done_date = i_start_date + ((issue.due_before - i_start_date+1)*issue.done_ratio/100).floor
635 635 i_done_date = (i_done_date <= @date_from ? @date_from : i_done_date )
636 636 i_done_date = (i_done_date >= date_to ? date_to : i_done_date )
637 637 i_late_date = [i_end_date, Date.today].min if i_start_date < Date.today
638 638
639 639 i_left = options[:subject_width] + ((i_start_date - @date_from)*options[:zoom]).floor
640 640 i_width = ((i_end_date - i_start_date + 1)*options[:zoom]).floor # total width of the issue
641 641 d_width = ((i_done_date - i_start_date)*options[:zoom]).floor # done width
642 642 l_width = i_late_date ? ((i_late_date - i_start_date+1)*options[:zoom]).floor : 0 # delay width
643 643
644 644
645 645 # Make sure that negative i_left and i_width don't
646 646 # overflow the subject
647 647 if i_width > 0
648 648 options[:image].fill('grey')
649 649 options[:image].rectangle(i_left, options[:top], i_left + i_width, options[:top] - 6)
650 650 options[:image].fill('red')
651 651 options[:image].rectangle(i_left, options[:top], i_left + l_width, options[:top] - 6) if l_width > 0
652 652 options[:image].fill('blue')
653 653 options[:image].rectangle(i_left, options[:top], i_left + d_width, options[:top] - 6) if d_width > 0
654 654 end
655 655
656 656 # Show the status and % done next to the subject if it overflows
657 657 options[:image].fill('black')
658 658 if i_width > 0
659 659 options[:image].text(i_left + i_width + 5,options[:top] + 1, "#{issue.status.name} #{issue.done_ratio}%")
660 660 else
661 661 options[:image].text(options[:subject_width] + 5,options[:top] + 1, "#{issue.status.name} #{issue.done_ratio}%")
662 662 end
663 663
664 664 when :pdf
665 665 options[:pdf].SetY(options[:top]+1.5)
666 666 # Handle nil start_dates, rare but can happen.
667 667 i_start_date = if issue.start_date && issue.start_date >= @date_from
668 668 issue.start_date
669 669 else
670 670 @date_from
671 671 end
672 672
673 673 i_end_date = (issue.due_before <= @date_to ? issue.due_before : @date_to )
674 674
675 675 i_done_date = i_start_date + ((issue.due_before - i_start_date+1)*issue.done_ratio/100).floor
676 676 i_done_date = (i_done_date <= @date_from ? @date_from : i_done_date )
677 677 i_done_date = (i_done_date >= @date_to ? @date_to : i_done_date )
678 678
679 679 i_late_date = [i_end_date, Date.today].min if i_start_date < Date.today
680 680
681 681 i_left = ((i_start_date - @date_from)*options[:zoom])
682 682 i_width = ((i_end_date - i_start_date + 1)*options[:zoom])
683 683 d_width = ((i_done_date - i_start_date)*options[:zoom])
684 684 l_width = ((i_late_date - i_start_date+1)*options[:zoom]) if i_late_date
685 685 l_width ||= 0
686 686
687 687 # Make sure that negative i_left and i_width don't
688 688 # overflow the subject
689 689 if i_width > 0
690 690 options[:pdf].SetX(options[:subject_width] + i_left)
691 691 options[:pdf].SetFillColor(200,200,200)
692 692 options[:pdf].Cell(i_width, 2, "", 0, 0, "", 1)
693 693 end
694 694
695 695 if l_width > 0
696 696 options[:pdf].SetY(options[:top]+1.5)
697 697 options[:pdf].SetX(options[:subject_width] + i_left)
698 698 options[:pdf].SetFillColor(255,100,100)
699 699 options[:pdf].Cell(l_width, 2, "", 0, 0, "", 1)
700 700 end
701 701 if d_width > 0
702 702 options[:pdf].SetY(options[:top]+1.5)
703 703 options[:pdf].SetX(options[:subject_width] + i_left)
704 704 options[:pdf].SetFillColor(100,100,255)
705 705 options[:pdf].Cell(d_width, 2, "", 0, 0, "", 1)
706 706 end
707 707
708 708 options[:pdf].SetY(options[:top]+1.5)
709 709
710 710 # Make sure that negative i_left and i_width don't
711 711 # overflow the subject
712 712 if (i_left + i_width) >= 0
713 713 options[:pdf].SetX(options[:subject_width] + i_left + i_width)
714 714 else
715 715 options[:pdf].SetX(options[:subject_width])
716 716 end
717 717 options[:pdf].Cell(30, 2, "#{issue.status} #{issue.done_ratio}%")
718 718 end
719 719 else
720 720 ActiveRecord::Base.logger.debug "GanttHelper#line_for_issue was not given an issue with a due_before"
721 721 ''
722 722 end
723 723 end
724 724
725 725 # Generates a gantt image
726 726 # Only defined if RMagick is avalaible
727 727 def to_image(format='PNG')
728 728 date_to = (@date_from >> @months)-1
729 729 show_weeks = @zoom > 1
730 730 show_days = @zoom > 2
731 731
732 732 subject_width = 400
733 733 header_heigth = 18
734 734 # width of one day in pixels
735 735 zoom = @zoom*2
736 736 g_width = (@date_to - @date_from + 1)*zoom
737 737 g_height = 20 * number_of_rows + 30
738 738 headers_heigth = (show_weeks ? 2*header_heigth : header_heigth)
739 739 height = g_height + headers_heigth
740 740
741 741 imgl = Magick::ImageList.new
742 742 imgl.new_image(subject_width+g_width+1, height)
743 743 gc = Magick::Draw.new
744 744
745 745 # Subjects
746 746 subjects(:image => gc, :top => (headers_heigth + 20), :indent => 4, :format => :image)
747 747
748 748 # Months headers
749 749 month_f = @date_from
750 750 left = subject_width
751 751 @months.times do
752 752 width = ((month_f >> 1) - month_f) * zoom
753 753 gc.fill('white')
754 754 gc.stroke('grey')
755 755 gc.stroke_width(1)
756 756 gc.rectangle(left, 0, left + width, height)
757 757 gc.fill('black')
758 758 gc.stroke('transparent')
759 759 gc.stroke_width(1)
760 760 gc.text(left.round + 8, 14, "#{month_f.year}-#{month_f.month}")
761 761 left = left + width
762 762 month_f = month_f >> 1
763 763 end
764 764
765 765 # Weeks headers
766 766 if show_weeks
767 767 left = subject_width
768 768 height = header_heigth
769 769 if @date_from.cwday == 1
770 770 # date_from is monday
771 771 week_f = date_from
772 772 else
773 773 # find next monday after date_from
774 774 week_f = @date_from + (7 - @date_from.cwday + 1)
775 775 width = (7 - @date_from.cwday + 1) * zoom
776 776 gc.fill('white')
777 777 gc.stroke('grey')
778 778 gc.stroke_width(1)
779 779 gc.rectangle(left, header_heigth, left + width, 2*header_heigth + g_height-1)
780 780 left = left + width
781 781 end
782 782 while week_f <= date_to
783 783 width = (week_f + 6 <= date_to) ? 7 * zoom : (date_to - week_f + 1) * zoom
784 784 gc.fill('white')
785 785 gc.stroke('grey')
786 786 gc.stroke_width(1)
787 787 gc.rectangle(left.round, header_heigth, left.round + width, 2*header_heigth + g_height-1)
788 788 gc.fill('black')
789 789 gc.stroke('transparent')
790 790 gc.stroke_width(1)
791 791 gc.text(left.round + 2, header_heigth + 14, week_f.cweek.to_s)
792 792 left = left + width
793 793 week_f = week_f+7
794 794 end
795 795 end
796 796
797 797 # Days details (week-end in grey)
798 798 if show_days
799 799 left = subject_width
800 800 height = g_height + header_heigth - 1
801 801 wday = @date_from.cwday
802 802 (date_to - @date_from + 1).to_i.times do
803 803 width = zoom
804 804 gc.fill(wday == 6 || wday == 7 ? '#eee' : 'white')
805 805 gc.stroke('grey')
806 806 gc.stroke_width(1)
807 807 gc.rectangle(left, 2*header_heigth, left + width, 2*header_heigth + g_height-1)
808 808 left = left + width
809 809 wday = wday + 1
810 810 wday = 1 if wday > 7
811 811 end
812 812 end
813 813
814 814 # border
815 815 gc.fill('transparent')
816 816 gc.stroke('grey')
817 817 gc.stroke_width(1)
818 818 gc.rectangle(0, 0, subject_width+g_width, headers_heigth)
819 819 gc.stroke('black')
820 820 gc.rectangle(0, 0, subject_width+g_width, g_height+ headers_heigth-1)
821 821
822 822 # content
823 823 top = headers_heigth + 20
824 824
825 825 lines(:image => gc, :top => top, :zoom => zoom, :subject_width => subject_width, :format => :image)
826 826
827 827 # today red line
828 828 if Date.today >= @date_from and Date.today <= date_to
829 829 gc.stroke('red')
830 830 x = (Date.today-@date_from+1)*zoom + subject_width
831 831 gc.line(x, headers_heigth, x, headers_heigth + g_height-1)
832 832 end
833 833
834 834 gc.draw(imgl)
835 835 imgl.format = format
836 836 imgl.to_blob
837 837 end if Object.const_defined?(:Magick)
838 838
839 839 def to_pdf
840 840 pdf = ::Redmine::Export::PDF::IFPDF.new(current_language)
841 841 pdf.SetTitle("#{l(:label_gantt)} #{project}")
842 842 pdf.AliasNbPages
843 843 pdf.footer_date = format_date(Date.today)
844 844 pdf.AddPage("L")
845 845 pdf.SetFontStyle('B',12)
846 846 pdf.SetX(15)
847 847 pdf.Cell(PDF::LeftPaneWidth, 20, project.to_s)
848 848 pdf.Ln
849 849 pdf.SetFontStyle('B',9)
850 850
851 851 subject_width = PDF::LeftPaneWidth
852 852 header_heigth = 5
853 853
854 854 headers_heigth = header_heigth
855 855 show_weeks = false
856 856 show_days = false
857 857
858 858 if self.months < 7
859 859 show_weeks = true
860 860 headers_heigth = 2*header_heigth
861 861 if self.months < 3
862 862 show_days = true
863 863 headers_heigth = 3*header_heigth
864 864 end
865 865 end
866 866
867 867 g_width = PDF.right_pane_width
868 868 zoom = (g_width) / (self.date_to - self.date_from + 1)
869 869 g_height = 120
870 870 t_height = g_height + headers_heigth
871 871
872 872 y_start = pdf.GetY
873 873
874 874 # Months headers
875 875 month_f = self.date_from
876 876 left = subject_width
877 877 height = header_heigth
878 878 self.months.times do
879 879 width = ((month_f >> 1) - month_f) * zoom
880 880 pdf.SetY(y_start)
881 881 pdf.SetX(left)
882 882 pdf.Cell(width, height, "#{month_f.year}-#{month_f.month}", "LTR", 0, "C")
883 883 left = left + width
884 884 month_f = month_f >> 1
885 885 end
886 886
887 887 # Weeks headers
888 888 if show_weeks
889 889 left = subject_width
890 890 height = header_heigth
891 891 if self.date_from.cwday == 1
892 892 # self.date_from is monday
893 893 week_f = self.date_from
894 894 else
895 895 # find next monday after self.date_from
896 896 week_f = self.date_from + (7 - self.date_from.cwday + 1)
897 897 width = (7 - self.date_from.cwday + 1) * zoom-1
898 898 pdf.SetY(y_start + header_heigth)
899 899 pdf.SetX(left)
900 900 pdf.Cell(width + 1, height, "", "LTR")
901 901 left = left + width+1
902 902 end
903 903 while week_f <= self.date_to
904 904 width = (week_f + 6 <= self.date_to) ? 7 * zoom : (self.date_to - week_f + 1) * zoom
905 905 pdf.SetY(y_start + header_heigth)
906 906 pdf.SetX(left)
907 907 pdf.Cell(width, height, (width >= 5 ? week_f.cweek.to_s : ""), "LTR", 0, "C")
908 908 left = left + width
909 909 week_f = week_f+7
910 910 end
911 911 end
912 912
913 913 # Days headers
914 914 if show_days
915 915 left = subject_width
916 916 height = header_heigth
917 917 wday = self.date_from.cwday
918 918 pdf.SetFontStyle('B',7)
919 919 (self.date_to - self.date_from + 1).to_i.times do
920 920 width = zoom
921 921 pdf.SetY(y_start + 2 * header_heigth)
922 922 pdf.SetX(left)
923 923 pdf.Cell(width, height, day_name(wday).first, "LTR", 0, "C")
924 924 left = left + width
925 925 wday = wday + 1
926 926 wday = 1 if wday > 7
927 927 end
928 928 end
929 929
930 930 pdf.SetY(y_start)
931 931 pdf.SetX(15)
932 932 pdf.Cell(subject_width+g_width-15, headers_heigth, "", 1)
933 933
934 934 # Tasks
935 935 top = headers_heigth + y_start
936 936 options = {
937 937 :top => top,
938 938 :zoom => zoom,
939 939 :subject_width => subject_width,
940 940 :g_width => g_width,
941 941 :indent => 0,
942 942 :indent_increment => 5,
943 943 :top_increment => 5,
944 944 :format => :pdf,
945 945 :pdf => pdf
946 946 }
947 947 render(options)
948 948 pdf.Output
949 949 end
950 950
951 951 private
952 952
953 953 # Sorts a collection of issues by start_date, due_date, id for gantt rendering
954 954 def sort_issues!(issues)
955 955 issues.sort! do |a, b|
956 956 cmp = 0
957 957 cmp = (a.start_date <=> b.start_date) if a.start_date? && b.start_date?
958 958 cmp = (a.due_date <=> b.due_date) if cmp == 0 && a.due_date? && b.due_date?
959 959 cmp = (a.id <=> b.id) if cmp == 0
960 960 cmp
961 961 end
962 962 end
963 963
964 964 def pdf_new_page?(options)
965 965 if options[:top] > 180
966 966 options[:pdf].Line(15, options[:top], PDF::TotalWidth, options[:top])
967 967 options[:pdf].AddPage("L")
968 968 options[:top] = 15
969 969 options[:pdf].Line(15, options[:top] - 0.1, PDF::TotalWidth, options[:top] - 0.1)
970 970 end
971 971 end
972 972 end
973 973 end
974 974 end
@@ -1,1047 +1,1031
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 require File.dirname(__FILE__) + '/../test_helper'
19 19
20 20 class ProjectTest < ActiveSupport::TestCase
21 21 fixtures :all
22 22
23 23 def setup
24 24 @ecookbook = Project.find(1)
25 25 @ecookbook_sub1 = Project.find(3)
26 26 User.current = nil
27 27 end
28 28
29 29 should_validate_presence_of :name
30 30 should_validate_presence_of :identifier
31 31
32 32 should_validate_uniqueness_of :identifier
33 33
34 34 context "associations" do
35 35 should_have_many :members
36 36 should_have_many :users, :through => :members
37 37 should_have_many :member_principals
38 38 should_have_many :principals, :through => :member_principals
39 39 should_have_many :enabled_modules
40 40 should_have_many :issues
41 41 should_have_many :issue_changes, :through => :issues
42 42 should_have_many :versions
43 43 should_have_many :time_entries
44 44 should_have_many :queries
45 45 should_have_many :documents
46 46 should_have_many :news
47 47 should_have_many :issue_categories
48 48 should_have_many :boards
49 49 should_have_many :changesets, :through => :repository
50 50
51 51 should_have_one :repository
52 52 should_have_one :wiki
53 53
54 54 should_have_and_belong_to_many :trackers
55 55 should_have_and_belong_to_many :issue_custom_fields
56 56 end
57 57
58 58 def test_truth
59 59 assert_kind_of Project, @ecookbook
60 60 assert_equal "eCookbook", @ecookbook.name
61 61 end
62 62
63 63 def test_default_attributes
64 64 with_settings :default_projects_public => '1' do
65 65 assert_equal true, Project.new.is_public
66 66 assert_equal false, Project.new(:is_public => false).is_public
67 67 end
68 68
69 69 with_settings :default_projects_public => '0' do
70 70 assert_equal false, Project.new.is_public
71 71 assert_equal true, Project.new(:is_public => true).is_public
72 72 end
73 73
74 74 with_settings :sequential_project_identifiers => '1' do
75 75 assert !Project.new.identifier.blank?
76 76 assert Project.new(:identifier => '').identifier.blank?
77 77 end
78 78
79 79 with_settings :sequential_project_identifiers => '0' do
80 80 assert Project.new.identifier.blank?
81 81 assert !Project.new(:identifier => 'test').blank?
82 82 end
83 83
84 84 with_settings :default_projects_modules => ['issue_tracking', 'repository'] do
85 85 assert_equal ['issue_tracking', 'repository'], Project.new.enabled_module_names
86 86 end
87 87
88 88 assert_equal Tracker.all, Project.new.trackers
89 89 assert_equal Tracker.find(1, 3), Project.new(:tracker_ids => [1, 3]).trackers
90 90 end
91 91
92 92 def test_update
93 93 assert_equal "eCookbook", @ecookbook.name
94 94 @ecookbook.name = "eCook"
95 95 assert @ecookbook.save, @ecookbook.errors.full_messages.join("; ")
96 96 @ecookbook.reload
97 97 assert_equal "eCook", @ecookbook.name
98 98 end
99 99
100 100 def test_validate_identifier
101 101 to_test = {"abc" => true,
102 102 "ab12" => true,
103 103 "ab-12" => true,
104 104 "12" => false,
105 105 "new" => false}
106 106
107 107 to_test.each do |identifier, valid|
108 108 p = Project.new
109 109 p.identifier = identifier
110 110 p.valid?
111 111 assert_equal valid, p.errors.on('identifier').nil?
112 112 end
113 113 end
114 114
115 115 def test_members_should_be_active_users
116 116 Project.all.each do |project|
117 117 assert_nil project.members.detect {|m| !(m.user.is_a?(User) && m.user.active?) }
118 118 end
119 119 end
120 120
121 121 def test_users_should_be_active_users
122 122 Project.all.each do |project|
123 123 assert_nil project.users.detect {|u| !(u.is_a?(User) && u.active?) }
124 124 end
125 125 end
126 126
127 127 def test_archive
128 128 user = @ecookbook.members.first.user
129 129 @ecookbook.archive
130 130 @ecookbook.reload
131 131
132 132 assert !@ecookbook.active?
133 133 assert @ecookbook.archived?
134 134 assert !user.projects.include?(@ecookbook)
135 135 # Subproject are also archived
136 136 assert !@ecookbook.children.empty?
137 137 assert @ecookbook.descendants.active.empty?
138 138 end
139 139
140 140 def test_archive_should_fail_if_versions_are_used_by_non_descendant_projects
141 141 # Assign an issue of a project to a version of a child project
142 142 Issue.find(4).update_attribute :fixed_version_id, 4
143 143
144 144 assert_no_difference "Project.count(:all, :conditions => 'status = #{Project::STATUS_ARCHIVED}')" do
145 145 assert_equal false, @ecookbook.archive
146 146 end
147 147 @ecookbook.reload
148 148 assert @ecookbook.active?
149 149 end
150 150
151 151 def test_unarchive
152 152 user = @ecookbook.members.first.user
153 153 @ecookbook.archive
154 154 # A subproject of an archived project can not be unarchived
155 155 assert !@ecookbook_sub1.unarchive
156 156
157 157 # Unarchive project
158 158 assert @ecookbook.unarchive
159 159 @ecookbook.reload
160 160 assert @ecookbook.active?
161 161 assert !@ecookbook.archived?
162 162 assert user.projects.include?(@ecookbook)
163 163 # Subproject can now be unarchived
164 164 @ecookbook_sub1.reload
165 165 assert @ecookbook_sub1.unarchive
166 166 end
167 167
168 168 def test_destroy
169 169 # 2 active members
170 170 assert_equal 2, @ecookbook.members.size
171 171 # and 1 is locked
172 172 assert_equal 3, Member.find(:all, :conditions => ['project_id = ?', @ecookbook.id]).size
173 173 # some boards
174 174 assert @ecookbook.boards.any?
175 175
176 176 @ecookbook.destroy
177 177 # make sure that the project non longer exists
178 178 assert_raise(ActiveRecord::RecordNotFound) { Project.find(@ecookbook.id) }
179 179 # make sure related data was removed
180 180 assert_nil Member.first(:conditions => {:project_id => @ecookbook.id})
181 181 assert_nil Board.first(:conditions => {:project_id => @ecookbook.id})
182 182 assert_nil Issue.first(:conditions => {:project_id => @ecookbook.id})
183 183 end
184 184
185 185 def test_move_an_orphan_project_to_a_root_project
186 186 sub = Project.find(2)
187 187 sub.set_parent! @ecookbook
188 188 assert_equal @ecookbook.id, sub.parent.id
189 189 @ecookbook.reload
190 190 assert_equal 4, @ecookbook.children.size
191 191 end
192 192
193 193 def test_move_an_orphan_project_to_a_subproject
194 194 sub = Project.find(2)
195 195 assert sub.set_parent!(@ecookbook_sub1)
196 196 end
197 197
198 198 def test_move_a_root_project_to_a_project
199 199 sub = @ecookbook
200 200 assert sub.set_parent!(Project.find(2))
201 201 end
202 202
203 203 def test_should_not_move_a_project_to_its_children
204 204 sub = @ecookbook
205 205 assert !(sub.set_parent!(Project.find(3)))
206 206 end
207 207
208 208 def test_set_parent_should_add_roots_in_alphabetical_order
209 209 ProjectCustomField.delete_all
210 210 Project.delete_all
211 211 Project.create!(:name => 'Project C', :identifier => 'project-c').set_parent!(nil)
212 212 Project.create!(:name => 'Project B', :identifier => 'project-b').set_parent!(nil)
213 213 Project.create!(:name => 'Project D', :identifier => 'project-d').set_parent!(nil)
214 214 Project.create!(:name => 'Project A', :identifier => 'project-a').set_parent!(nil)
215 215
216 216 assert_equal 4, Project.count
217 217 assert_equal Project.all.sort_by(&:name), Project.all.sort_by(&:lft)
218 218 end
219 219
220 220 def test_set_parent_should_add_children_in_alphabetical_order
221 221 ProjectCustomField.delete_all
222 222 parent = Project.create!(:name => 'Parent', :identifier => 'parent')
223 223 Project.create!(:name => 'Project C', :identifier => 'project-c').set_parent!(parent)
224 224 Project.create!(:name => 'Project B', :identifier => 'project-b').set_parent!(parent)
225 225 Project.create!(:name => 'Project D', :identifier => 'project-d').set_parent!(parent)
226 226 Project.create!(:name => 'Project A', :identifier => 'project-a').set_parent!(parent)
227 227
228 228 parent.reload
229 229 assert_equal 4, parent.children.size
230 230 assert_equal parent.children.sort_by(&:name), parent.children
231 231 end
232 232
233 233 def test_rebuild_should_sort_children_alphabetically
234 234 ProjectCustomField.delete_all
235 235 parent = Project.create!(:name => 'Parent', :identifier => 'parent')
236 236 Project.create!(:name => 'Project C', :identifier => 'project-c').move_to_child_of(parent)
237 237 Project.create!(:name => 'Project B', :identifier => 'project-b').move_to_child_of(parent)
238 238 Project.create!(:name => 'Project D', :identifier => 'project-d').move_to_child_of(parent)
239 239 Project.create!(:name => 'Project A', :identifier => 'project-a').move_to_child_of(parent)
240 240
241 241 Project.update_all("lft = NULL, rgt = NULL")
242 242 Project.rebuild!
243 243
244 244 parent.reload
245 245 assert_equal 4, parent.children.size
246 246 assert_equal parent.children.sort_by(&:name), parent.children
247 247 end
248 248
249 249
250 250 def test_set_parent_should_update_issue_fixed_version_associations_when_a_fixed_version_is_moved_out_of_the_hierarchy
251 251 # Parent issue with a hierarchy project's fixed version
252 252 parent_issue = Issue.find(1)
253 253 parent_issue.update_attribute(:fixed_version_id, 4)
254 254 parent_issue.reload
255 255 assert_equal 4, parent_issue.fixed_version_id
256 256
257 257 # Should keep fixed versions for the issues
258 258 issue_with_local_fixed_version = Issue.find(5)
259 259 issue_with_local_fixed_version.update_attribute(:fixed_version_id, 4)
260 260 issue_with_local_fixed_version.reload
261 261 assert_equal 4, issue_with_local_fixed_version.fixed_version_id
262 262
263 263 # Local issue with hierarchy fixed_version
264 264 issue_with_hierarchy_fixed_version = Issue.find(13)
265 265 issue_with_hierarchy_fixed_version.update_attribute(:fixed_version_id, 6)
266 266 issue_with_hierarchy_fixed_version.reload
267 267 assert_equal 6, issue_with_hierarchy_fixed_version.fixed_version_id
268 268
269 269 # Move project out of the issue's hierarchy
270 270 moved_project = Project.find(3)
271 271 moved_project.set_parent!(Project.find(2))
272 272 parent_issue.reload
273 273 issue_with_local_fixed_version.reload
274 274 issue_with_hierarchy_fixed_version.reload
275 275
276 276 assert_equal 4, issue_with_local_fixed_version.fixed_version_id, "Fixed version was not keep on an issue local to the moved project"
277 277 assert_equal nil, issue_with_hierarchy_fixed_version.fixed_version_id, "Fixed version is still set after moving the Project out of the hierarchy where the version is defined in"
278 278 assert_equal nil, parent_issue.fixed_version_id, "Fixed version is still set after moving the Version out of the hierarchy for the issue."
279 279 end
280 280
281 281 def test_parent
282 282 p = Project.find(6).parent
283 283 assert p.is_a?(Project)
284 284 assert_equal 5, p.id
285 285 end
286 286
287 287 def test_ancestors
288 288 a = Project.find(6).ancestors
289 289 assert a.first.is_a?(Project)
290 290 assert_equal [1, 5], a.collect(&:id)
291 291 end
292 292
293 293 def test_root
294 294 r = Project.find(6).root
295 295 assert r.is_a?(Project)
296 296 assert_equal 1, r.id
297 297 end
298 298
299 299 def test_children
300 300 c = Project.find(1).children
301 301 assert c.first.is_a?(Project)
302 302 assert_equal [5, 3, 4], c.collect(&:id)
303 303 end
304 304
305 305 def test_descendants
306 306 d = Project.find(1).descendants
307 307 assert d.first.is_a?(Project)
308 308 assert_equal [5, 6, 3, 4], d.collect(&:id)
309 309 end
310 310
311 311 def test_allowed_parents_should_be_empty_for_non_member_user
312 312 Role.non_member.add_permission!(:add_project)
313 313 user = User.find(9)
314 314 assert user.memberships.empty?
315 315 User.current = user
316 316 assert Project.new.allowed_parents.compact.empty?
317 317 end
318 318
319 319 def test_allowed_parents_with_add_subprojects_permission
320 320 Role.find(1).remove_permission!(:add_project)
321 321 Role.find(1).add_permission!(:add_subprojects)
322 322 User.current = User.find(2)
323 323 # new project
324 324 assert !Project.new.allowed_parents.include?(nil)
325 325 assert Project.new.allowed_parents.include?(Project.find(1))
326 326 # existing root project
327 327 assert Project.find(1).allowed_parents.include?(nil)
328 328 # existing child
329 329 assert Project.find(3).allowed_parents.include?(Project.find(1))
330 330 assert !Project.find(3).allowed_parents.include?(nil)
331 331 end
332 332
333 333 def test_allowed_parents_with_add_project_permission
334 334 Role.find(1).add_permission!(:add_project)
335 335 Role.find(1).remove_permission!(:add_subprojects)
336 336 User.current = User.find(2)
337 337 # new project
338 338 assert Project.new.allowed_parents.include?(nil)
339 339 assert !Project.new.allowed_parents.include?(Project.find(1))
340 340 # existing root project
341 341 assert Project.find(1).allowed_parents.include?(nil)
342 342 # existing child
343 343 assert Project.find(3).allowed_parents.include?(Project.find(1))
344 344 assert Project.find(3).allowed_parents.include?(nil)
345 345 end
346 346
347 347 def test_allowed_parents_with_add_project_and_subprojects_permission
348 348 Role.find(1).add_permission!(:add_project)
349 349 Role.find(1).add_permission!(:add_subprojects)
350 350 User.current = User.find(2)
351 351 # new project
352 352 assert Project.new.allowed_parents.include?(nil)
353 353 assert Project.new.allowed_parents.include?(Project.find(1))
354 354 # existing root project
355 355 assert Project.find(1).allowed_parents.include?(nil)
356 356 # existing child
357 357 assert Project.find(3).allowed_parents.include?(Project.find(1))
358 358 assert Project.find(3).allowed_parents.include?(nil)
359 359 end
360 360
361 361 def test_users_by_role
362 362 users_by_role = Project.find(1).users_by_role
363 363 assert_kind_of Hash, users_by_role
364 364 role = Role.find(1)
365 365 assert_kind_of Array, users_by_role[role]
366 366 assert users_by_role[role].include?(User.find(2))
367 367 end
368 368
369 369 def test_rolled_up_trackers
370 370 parent = Project.find(1)
371 371 parent.trackers = Tracker.find([1,2])
372 372 child = parent.children.find(3)
373 373
374 374 assert_equal [1, 2], parent.tracker_ids
375 375 assert_equal [2, 3], child.trackers.collect(&:id)
376 376
377 377 assert_kind_of Tracker, parent.rolled_up_trackers.first
378 378 assert_equal Tracker.find(1), parent.rolled_up_trackers.first
379 379
380 380 assert_equal [1, 2, 3], parent.rolled_up_trackers.collect(&:id)
381 381 assert_equal [2, 3], child.rolled_up_trackers.collect(&:id)
382 382 end
383 383
384 384 def test_rolled_up_trackers_should_ignore_archived_subprojects
385 385 parent = Project.find(1)
386 386 parent.trackers = Tracker.find([1,2])
387 387 child = parent.children.find(3)
388 388 child.trackers = Tracker.find([1,3])
389 389 parent.children.each(&:archive)
390 390
391 391 assert_equal [1,2], parent.rolled_up_trackers.collect(&:id)
392 392 end
393 393
394 394 context "#rolled_up_versions" do
395 395 setup do
396 396 @project = Project.generate!
397 397 @parent_version_1 = Version.generate!(:project => @project)
398 398 @parent_version_2 = Version.generate!(:project => @project)
399 399 end
400 400
401 401 should "include the versions for the current project" do
402 402 assert_same_elements [@parent_version_1, @parent_version_2], @project.rolled_up_versions
403 403 end
404 404
405 405 should "include versions for a subproject" do
406 406 @subproject = Project.generate!
407 407 @subproject.set_parent!(@project)
408 408 @subproject_version = Version.generate!(:project => @subproject)
409 409
410 410 assert_same_elements [
411 411 @parent_version_1,
412 412 @parent_version_2,
413 413 @subproject_version
414 414 ], @project.rolled_up_versions
415 415 end
416 416
417 417 should "include versions for a sub-subproject" do
418 418 @subproject = Project.generate!
419 419 @subproject.set_parent!(@project)
420 420 @sub_subproject = Project.generate!
421 421 @sub_subproject.set_parent!(@subproject)
422 422 @sub_subproject_version = Version.generate!(:project => @sub_subproject)
423 423
424 424 @project.reload
425 425
426 426 assert_same_elements [
427 427 @parent_version_1,
428 428 @parent_version_2,
429 429 @sub_subproject_version
430 430 ], @project.rolled_up_versions
431 431 end
432 432
433 433
434 434 should "only check active projects" do
435 435 @subproject = Project.generate!
436 436 @subproject.set_parent!(@project)
437 437 @subproject_version = Version.generate!(:project => @subproject)
438 438 assert @subproject.archive
439 439
440 440 @project.reload
441 441
442 442 assert !@subproject.active?
443 443 assert_same_elements [@parent_version_1, @parent_version_2], @project.rolled_up_versions
444 444 end
445 445 end
446 446
447 447 def test_shared_versions_none_sharing
448 448 p = Project.find(5)
449 449 v = Version.create!(:name => 'none_sharing', :project => p, :sharing => 'none')
450 450 assert p.shared_versions.include?(v)
451 451 assert !p.children.first.shared_versions.include?(v)
452 452 assert !p.root.shared_versions.include?(v)
453 453 assert !p.siblings.first.shared_versions.include?(v)
454 454 assert !p.root.siblings.first.shared_versions.include?(v)
455 455 end
456 456
457 457 def test_shared_versions_descendants_sharing
458 458 p = Project.find(5)
459 459 v = Version.create!(:name => 'descendants_sharing', :project => p, :sharing => 'descendants')
460 460 assert p.shared_versions.include?(v)
461 461 assert p.children.first.shared_versions.include?(v)
462 462 assert !p.root.shared_versions.include?(v)
463 463 assert !p.siblings.first.shared_versions.include?(v)
464 464 assert !p.root.siblings.first.shared_versions.include?(v)
465 465 end
466 466
467 467 def test_shared_versions_hierarchy_sharing
468 468 p = Project.find(5)
469 469 v = Version.create!(:name => 'hierarchy_sharing', :project => p, :sharing => 'hierarchy')
470 470 assert p.shared_versions.include?(v)
471 471 assert p.children.first.shared_versions.include?(v)
472 472 assert p.root.shared_versions.include?(v)
473 473 assert !p.siblings.first.shared_versions.include?(v)
474 474 assert !p.root.siblings.first.shared_versions.include?(v)
475 475 end
476 476
477 477 def test_shared_versions_tree_sharing
478 478 p = Project.find(5)
479 479 v = Version.create!(:name => 'tree_sharing', :project => p, :sharing => 'tree')
480 480 assert p.shared_versions.include?(v)
481 481 assert p.children.first.shared_versions.include?(v)
482 482 assert p.root.shared_versions.include?(v)
483 483 assert p.siblings.first.shared_versions.include?(v)
484 484 assert !p.root.siblings.first.shared_versions.include?(v)
485 485 end
486 486
487 487 def test_shared_versions_system_sharing
488 488 p = Project.find(5)
489 489 v = Version.create!(:name => 'system_sharing', :project => p, :sharing => 'system')
490 490 assert p.shared_versions.include?(v)
491 491 assert p.children.first.shared_versions.include?(v)
492 492 assert p.root.shared_versions.include?(v)
493 493 assert p.siblings.first.shared_versions.include?(v)
494 494 assert p.root.siblings.first.shared_versions.include?(v)
495 495 end
496 496
497 497 def test_shared_versions
498 498 parent = Project.find(1)
499 499 child = parent.children.find(3)
500 500 private_child = parent.children.find(5)
501 501
502 502 assert_equal [1,2,3], parent.version_ids.sort
503 503 assert_equal [4], child.version_ids
504 504 assert_equal [6], private_child.version_ids
505 505 assert_equal [7], Version.find_all_by_sharing('system').collect(&:id)
506 506
507 507 assert_equal 6, parent.shared_versions.size
508 508 parent.shared_versions.each do |version|
509 509 assert_kind_of Version, version
510 510 end
511 511
512 512 assert_equal [1,2,3,4,6,7], parent.shared_versions.collect(&:id).sort
513 513 end
514 514
515 515 def test_shared_versions_should_ignore_archived_subprojects
516 516 parent = Project.find(1)
517 517 child = parent.children.find(3)
518 518 child.archive
519 519 parent.reload
520 520
521 521 assert_equal [1,2,3], parent.version_ids.sort
522 522 assert_equal [4], child.version_ids
523 523 assert !parent.shared_versions.collect(&:id).include?(4)
524 524 end
525 525
526 526 def test_shared_versions_visible_to_user
527 527 user = User.find(3)
528 528 parent = Project.find(1)
529 529 child = parent.children.find(5)
530 530
531 531 assert_equal [1,2,3], parent.version_ids.sort
532 532 assert_equal [6], child.version_ids
533 533
534 534 versions = parent.shared_versions.visible(user)
535 535
536 536 assert_equal 4, versions.size
537 537 versions.each do |version|
538 538 assert_kind_of Version, version
539 539 end
540 540
541 541 assert !versions.collect(&:id).include?(6)
542 542 end
543 543
544 544
545 545 def test_next_identifier
546 546 ProjectCustomField.delete_all
547 547 Project.create!(:name => 'last', :identifier => 'p2008040')
548 548 assert_equal 'p2008041', Project.next_identifier
549 549 end
550 550
551 551 def test_next_identifier_first_project
552 552 Project.delete_all
553 553 assert_nil Project.next_identifier
554 554 end
555 555
556 556
557 557 def test_enabled_module_names_should_not_recreate_enabled_modules
558 558 project = Project.find(1)
559 559 # Remove one module
560 560 modules = project.enabled_modules.slice(0..-2)
561 561 assert modules.any?
562 562 assert_difference 'EnabledModule.count', -1 do
563 563 project.enabled_module_names = modules.collect(&:name)
564 564 end
565 565 project.reload
566 566 # Ids should be preserved
567 567 assert_equal project.enabled_module_ids.sort, modules.collect(&:id).sort
568 568 end
569 569
570 570 def test_copy_from_existing_project
571 571 source_project = Project.find(1)
572 572 copied_project = Project.copy_from(1)
573 573
574 574 assert copied_project
575 575 # Cleared attributes
576 576 assert copied_project.id.blank?
577 577 assert copied_project.name.blank?
578 578 assert copied_project.identifier.blank?
579 579
580 580 # Duplicated attributes
581 581 assert_equal source_project.description, copied_project.description
582 582 assert_equal source_project.enabled_modules, copied_project.enabled_modules
583 583 assert_equal source_project.trackers, copied_project.trackers
584 584
585 585 # Default attributes
586 586 assert_equal 1, copied_project.status
587 587 end
588 588
589 589 def test_activities_should_use_the_system_activities
590 590 project = Project.find(1)
591 591 assert_equal project.activities, TimeEntryActivity.find(:all, :conditions => {:active => true} )
592 592 end
593 593
594 594
595 595 def test_activities_should_use_the_project_specific_activities
596 596 project = Project.find(1)
597 597 overridden_activity = TimeEntryActivity.new({:name => "Project", :project => project})
598 598 assert overridden_activity.save!
599 599
600 600 assert project.activities.include?(overridden_activity), "Project specific Activity not found"
601 601 end
602 602
603 603 def test_activities_should_not_include_the_inactive_project_specific_activities
604 604 project = Project.find(1)
605 605 overridden_activity = TimeEntryActivity.new({:name => "Project", :project => project, :parent => TimeEntryActivity.find(:first), :active => false})
606 606 assert overridden_activity.save!
607 607
608 608 assert !project.activities.include?(overridden_activity), "Inactive Project specific Activity found"
609 609 end
610 610
611 611 def test_activities_should_not_include_project_specific_activities_from_other_projects
612 612 project = Project.find(1)
613 613 overridden_activity = TimeEntryActivity.new({:name => "Project", :project => Project.find(2)})
614 614 assert overridden_activity.save!
615 615
616 616 assert !project.activities.include?(overridden_activity), "Project specific Activity found on a different project"
617 617 end
618 618
619 619 def test_activities_should_handle_nils
620 620 overridden_activity = TimeEntryActivity.new({:name => "Project", :project => Project.find(1), :parent => TimeEntryActivity.find(:first)})
621 621 TimeEntryActivity.delete_all
622 622
623 623 # No activities
624 624 project = Project.find(1)
625 625 assert project.activities.empty?
626 626
627 627 # No system, one overridden
628 628 assert overridden_activity.save!
629 629 project.reload
630 630 assert_equal [overridden_activity], project.activities
631 631 end
632 632
633 633 def test_activities_should_override_system_activities_with_project_activities
634 634 project = Project.find(1)
635 635 parent_activity = TimeEntryActivity.find(:first)
636 636 overridden_activity = TimeEntryActivity.new({:name => "Project", :project => project, :parent => parent_activity})
637 637 assert overridden_activity.save!
638 638
639 639 assert project.activities.include?(overridden_activity), "Project specific Activity not found"
640 640 assert !project.activities.include?(parent_activity), "System Activity found when it should have been overridden"
641 641 end
642 642
643 643 def test_activities_should_include_inactive_activities_if_specified
644 644 project = Project.find(1)
645 645 overridden_activity = TimeEntryActivity.new({:name => "Project", :project => project, :parent => TimeEntryActivity.find(:first), :active => false})
646 646 assert overridden_activity.save!
647 647
648 648 assert project.activities(true).include?(overridden_activity), "Inactive Project specific Activity not found"
649 649 end
650 650
651 651 test 'activities should not include active System activities if the project has an override that is inactive' do
652 652 project = Project.find(1)
653 653 system_activity = TimeEntryActivity.find_by_name('Design')
654 654 assert system_activity.active?
655 655 overridden_activity = TimeEntryActivity.generate!(:project => project, :parent => system_activity, :active => false)
656 656 assert overridden_activity.save!
657 657
658 658 assert !project.activities.include?(overridden_activity), "Inactive Project specific Activity not found"
659 659 assert !project.activities.include?(system_activity), "System activity found when the project has an inactive override"
660 660 end
661 661
662 662 def test_close_completed_versions
663 663 Version.update_all("status = 'open'")
664 664 project = Project.find(1)
665 665 assert_not_nil project.versions.detect {|v| v.completed? && v.status == 'open'}
666 666 assert_not_nil project.versions.detect {|v| !v.completed? && v.status == 'open'}
667 667 project.close_completed_versions
668 668 project.reload
669 669 assert_nil project.versions.detect {|v| v.completed? && v.status != 'closed'}
670 670 assert_not_nil project.versions.detect {|v| !v.completed? && v.status == 'open'}
671 671 end
672 672
673 673 context "Project#copy" do
674 674 setup do
675 675 ProjectCustomField.destroy_all # Custom values are a mess to isolate in tests
676 676 Project.destroy_all :identifier => "copy-test"
677 677 @source_project = Project.find(2)
678 678 @project = Project.new(:name => 'Copy Test', :identifier => 'copy-test')
679 679 @project.trackers = @source_project.trackers
680 680 @project.enabled_module_names = @source_project.enabled_modules.collect(&:name)
681 681 end
682 682
683 683 should "copy issues" do
684 684 @source_project.issues << Issue.generate!(:status => IssueStatus.find_by_name('Closed'),
685 685 :subject => "copy issue status",
686 686 :tracker_id => 1,
687 687 :assigned_to_id => 2,
688 688 :project_id => @source_project.id)
689 689 assert @project.valid?
690 690 assert @project.issues.empty?
691 691 assert @project.copy(@source_project)
692 692
693 693 assert_equal @source_project.issues.size, @project.issues.size
694 694 @project.issues.each do |issue|
695 695 assert issue.valid?
696 696 assert ! issue.assigned_to.blank?
697 697 assert_equal @project, issue.project
698 698 end
699 699
700 700 copied_issue = @project.issues.first(:conditions => {:subject => "copy issue status"})
701 701 assert copied_issue
702 702 assert copied_issue.status
703 703 assert_equal "Closed", copied_issue.status.name
704 704 end
705 705
706 706 should "change the new issues to use the copied version" do
707 707 User.current = User.find(1)
708 708 assigned_version = Version.generate!(:name => "Assigned Issues", :status => 'open')
709 709 @source_project.versions << assigned_version
710 710 assert_equal 3, @source_project.versions.size
711 711 Issue.generate_for_project!(@source_project,
712 712 :fixed_version_id => assigned_version.id,
713 713 :subject => "change the new issues to use the copied version",
714 714 :tracker_id => 1,
715 715 :project_id => @source_project.id)
716 716
717 717 assert @project.copy(@source_project)
718 718 @project.reload
719 719 copied_issue = @project.issues.first(:conditions => {:subject => "change the new issues to use the copied version"})
720 720
721 721 assert copied_issue
722 722 assert copied_issue.fixed_version
723 723 assert_equal "Assigned Issues", copied_issue.fixed_version.name # Same name
724 724 assert_not_equal assigned_version.id, copied_issue.fixed_version.id # Different record
725 725 end
726 726
727 727 should "copy issue relations" do
728 728 Setting.cross_project_issue_relations = '1'
729 729
730 730 second_issue = Issue.generate!(:status_id => 5,
731 731 :subject => "copy issue relation",
732 732 :tracker_id => 1,
733 733 :assigned_to_id => 2,
734 734 :project_id => @source_project.id)
735 735 source_relation = IssueRelation.generate!(:issue_from => Issue.find(4),
736 736 :issue_to => second_issue,
737 737 :relation_type => "relates")
738 738 source_relation_cross_project = IssueRelation.generate!(:issue_from => Issue.find(1),
739 739 :issue_to => second_issue,
740 740 :relation_type => "duplicates")
741 741
742 742 assert @project.copy(@source_project)
743 743 assert_equal @source_project.issues.count, @project.issues.count
744 744 copied_issue = @project.issues.find_by_subject("Issue on project 2") # Was #4
745 745 copied_second_issue = @project.issues.find_by_subject("copy issue relation")
746 746
747 747 # First issue with a relation on project
748 748 assert_equal 1, copied_issue.relations.size, "Relation not copied"
749 749 copied_relation = copied_issue.relations.first
750 750 assert_equal "relates", copied_relation.relation_type
751 751 assert_equal copied_second_issue.id, copied_relation.issue_to_id
752 752 assert_not_equal source_relation.id, copied_relation.id
753 753
754 754 # Second issue with a cross project relation
755 755 assert_equal 2, copied_second_issue.relations.size, "Relation not copied"
756 756 copied_relation = copied_second_issue.relations.select {|r| r.relation_type == 'duplicates'}.first
757 757 assert_equal "duplicates", copied_relation.relation_type
758 758 assert_equal 1, copied_relation.issue_from_id, "Cross project relation not kept"
759 759 assert_not_equal source_relation_cross_project.id, copied_relation.id
760 760 end
761 761
762 762 should "copy memberships" do
763 763 assert @project.valid?
764 764 assert @project.members.empty?
765 765 assert @project.copy(@source_project)
766 766
767 767 assert_equal @source_project.memberships.size, @project.memberships.size
768 768 @project.memberships.each do |membership|
769 769 assert membership
770 770 assert_equal @project, membership.project
771 771 end
772 772 end
773 773
774 774 should "copy project specific queries" do
775 775 assert @project.valid?
776 776 assert @project.queries.empty?
777 777 assert @project.copy(@source_project)
778 778
779 779 assert_equal @source_project.queries.size, @project.queries.size
780 780 @project.queries.each do |query|
781 781 assert query
782 782 assert_equal @project, query.project
783 783 end
784 784 end
785 785
786 786 should "copy versions" do
787 787 @source_project.versions << Version.generate!
788 788 @source_project.versions << Version.generate!
789 789
790 790 assert @project.versions.empty?
791 791 assert @project.copy(@source_project)
792 792
793 793 assert_equal @source_project.versions.size, @project.versions.size
794 794 @project.versions.each do |version|
795 795 assert version
796 796 assert_equal @project, version.project
797 797 end
798 798 end
799 799
800 800 should "copy wiki" do
801 801 assert_difference 'Wiki.count' do
802 802 assert @project.copy(@source_project)
803 803 end
804 804
805 805 assert @project.wiki
806 806 assert_not_equal @source_project.wiki, @project.wiki
807 807 assert_equal "Start page", @project.wiki.start_page
808 808 end
809 809
810 810 should "copy wiki pages and content with hierarchy" do
811 811 assert_difference 'WikiPage.count', @source_project.wiki.pages.size do
812 812 assert @project.copy(@source_project)
813 813 end
814 814
815 815 assert @project.wiki
816 816 assert_equal @source_project.wiki.pages.size, @project.wiki.pages.size
817 817
818 818 @project.wiki.pages.each do |wiki_page|
819 819 assert wiki_page.content
820 820 assert !@source_project.wiki.pages.include?(wiki_page)
821 821 end
822 822
823 823 parent = @project.wiki.find_page('Parent_page')
824 824 child1 = @project.wiki.find_page('Child_page_1')
825 825 child2 = @project.wiki.find_page('Child_page_2')
826 826 assert_equal parent, child1.parent
827 827 assert_equal parent, child2.parent
828 828 end
829 829
830 830 should "copy issue categories" do
831 831 assert @project.copy(@source_project)
832 832
833 833 assert_equal 2, @project.issue_categories.size
834 834 @project.issue_categories.each do |issue_category|
835 835 assert !@source_project.issue_categories.include?(issue_category)
836 836 end
837 837 end
838 838
839 839 should "copy boards" do
840 840 assert @project.copy(@source_project)
841 841
842 842 assert_equal 1, @project.boards.size
843 843 @project.boards.each do |board|
844 844 assert !@source_project.boards.include?(board)
845 845 end
846 846 end
847 847
848 848 should "change the new issues to use the copied issue categories" do
849 849 issue = Issue.find(4)
850 850 issue.update_attribute(:category_id, 3)
851 851
852 852 assert @project.copy(@source_project)
853 853
854 854 @project.issues.each do |issue|
855 855 assert issue.category
856 856 assert_equal "Stock management", issue.category.name # Same name
857 857 assert_not_equal IssueCategory.find(3), issue.category # Different record
858 858 end
859 859 end
860 860
861 861 should "limit copy with :only option" do
862 862 assert @project.members.empty?
863 863 assert @project.issue_categories.empty?
864 864 assert @source_project.issues.any?
865 865
866 866 assert @project.copy(@source_project, :only => ['members', 'issue_categories'])
867 867
868 868 assert @project.members.any?
869 869 assert @project.issue_categories.any?
870 870 assert @project.issues.empty?
871 871 end
872 872
873 873 end
874 874
875 875 context "#start_date" do
876 876 setup do
877 877 ProjectCustomField.destroy_all # Custom values are a mess to isolate in tests
878 878 @project = Project.generate!(:identifier => 'test0')
879 879 @project.trackers << Tracker.generate!
880 880 end
881 881
882 882 should "be nil if there are no issues on the project" do
883 883 assert_nil @project.start_date
884 884 end
885
886 should "be nil if issue tracking is disabled" do
887 Issue.generate_for_project!(@project, :start_date => Date.today)
888 @project.enabled_modules.find_all_by_name('issue_tracking').each {|m| m.destroy}
889 @project.reload
890
891 assert_nil @project.start_date
892 end
893 885
894 886 should "be tested when issues have no start date"
895 887
896 888 should "be the earliest start date of it's issues" do
897 889 early = 7.days.ago.to_date
898 890 Issue.generate_for_project!(@project, :start_date => Date.today)
899 891 Issue.generate_for_project!(@project, :start_date => early)
900 892
901 893 assert_equal early, @project.start_date
902 894 end
903 895
904 896 end
905 897
906 898 context "#due_date" do
907 899 setup do
908 900 ProjectCustomField.destroy_all # Custom values are a mess to isolate in tests
909 901 @project = Project.generate!(:identifier => 'test0')
910 902 @project.trackers << Tracker.generate!
911 903 end
912 904
913 905 should "be nil if there are no issues on the project" do
914 906 assert_nil @project.due_date
915 907 end
916
917 should "be nil if issue tracking is disabled" do
918 Issue.generate_for_project!(@project, :due_date => Date.today)
919 @project.enabled_modules.find_all_by_name('issue_tracking').each {|m| m.destroy}
920 @project.reload
921
922 assert_nil @project.due_date
923 end
924 908
925 909 should "be tested when issues have no due date"
926 910
927 911 should "be the latest due date of it's issues" do
928 912 future = 7.days.from_now.to_date
929 913 Issue.generate_for_project!(@project, :due_date => future)
930 914 Issue.generate_for_project!(@project, :due_date => Date.today)
931 915
932 916 assert_equal future, @project.due_date
933 917 end
934 918
935 919 should "be the latest due date of it's versions" do
936 920 future = 7.days.from_now.to_date
937 921 @project.versions << Version.generate!(:effective_date => future)
938 922 @project.versions << Version.generate!(:effective_date => Date.today)
939 923
940 924
941 925 assert_equal future, @project.due_date
942 926
943 927 end
944 928
945 929 should "pick the latest date from it's issues and versions" do
946 930 future = 7.days.from_now.to_date
947 931 far_future = 14.days.from_now.to_date
948 932 Issue.generate_for_project!(@project, :due_date => far_future)
949 933 @project.versions << Version.generate!(:effective_date => future)
950 934
951 935 assert_equal far_future, @project.due_date
952 936 end
953 937
954 938 end
955 939
956 940 context "Project#completed_percent" do
957 941 setup do
958 942 ProjectCustomField.destroy_all # Custom values are a mess to isolate in tests
959 943 @project = Project.generate!(:identifier => 'test0')
960 944 @project.trackers << Tracker.generate!
961 945 end
962 946
963 947 context "no versions" do
964 948 should "be 100" do
965 949 assert_equal 100, @project.completed_percent
966 950 end
967 951 end
968 952
969 953 context "with versions" do
970 954 should "return 0 if the versions have no issues" do
971 955 Version.generate!(:project => @project)
972 956 Version.generate!(:project => @project)
973 957
974 958 assert_equal 0, @project.completed_percent
975 959 end
976 960
977 961 should "return 100 if the version has only closed issues" do
978 962 v1 = Version.generate!(:project => @project)
979 963 Issue.generate_for_project!(@project, :status => IssueStatus.find_by_name('Closed'), :fixed_version => v1)
980 964 v2 = Version.generate!(:project => @project)
981 965 Issue.generate_for_project!(@project, :status => IssueStatus.find_by_name('Closed'), :fixed_version => v2)
982 966
983 967 assert_equal 100, @project.completed_percent
984 968 end
985 969
986 970 should "return the averaged completed percent of the versions (not weighted)" do
987 971 v1 = Version.generate!(:project => @project)
988 972 Issue.generate_for_project!(@project, :status => IssueStatus.find_by_name('New'), :estimated_hours => 10, :done_ratio => 50, :fixed_version => v1)
989 973 v2 = Version.generate!(:project => @project)
990 974 Issue.generate_for_project!(@project, :status => IssueStatus.find_by_name('New'), :estimated_hours => 10, :done_ratio => 50, :fixed_version => v2)
991 975
992 976 assert_equal 50, @project.completed_percent
993 977 end
994 978
995 979 end
996 980 end
997 981
998 982 context "#notified_users" do
999 983 setup do
1000 984 @project = Project.generate!
1001 985 @role = Role.generate!
1002 986
1003 987 @user_with_membership_notification = User.generate!(:mail_notification => 'selected')
1004 988 Member.generate!(:project => @project, :roles => [@role], :principal => @user_with_membership_notification, :mail_notification => true)
1005 989
1006 990 @all_events_user = User.generate!(:mail_notification => 'all')
1007 991 Member.generate!(:project => @project, :roles => [@role], :principal => @all_events_user)
1008 992
1009 993 @no_events_user = User.generate!(:mail_notification => 'none')
1010 994 Member.generate!(:project => @project, :roles => [@role], :principal => @no_events_user)
1011 995
1012 996 @only_my_events_user = User.generate!(:mail_notification => 'only_my_events')
1013 997 Member.generate!(:project => @project, :roles => [@role], :principal => @only_my_events_user)
1014 998
1015 999 @only_assigned_user = User.generate!(:mail_notification => 'only_assigned')
1016 1000 Member.generate!(:project => @project, :roles => [@role], :principal => @only_assigned_user)
1017 1001
1018 1002 @only_owned_user = User.generate!(:mail_notification => 'only_owner')
1019 1003 Member.generate!(:project => @project, :roles => [@role], :principal => @only_owned_user)
1020 1004 end
1021 1005
1022 1006 should "include members with a mail notification" do
1023 1007 assert @project.notified_users.include?(@user_with_membership_notification)
1024 1008 end
1025 1009
1026 1010 should "include users with the 'all' notification option" do
1027 1011 assert @project.notified_users.include?(@all_events_user)
1028 1012 end
1029 1013
1030 1014 should "not include users with the 'none' notification option" do
1031 1015 assert !@project.notified_users.include?(@no_events_user)
1032 1016 end
1033 1017
1034 1018 should "not include users with the 'only_my_events' notification option" do
1035 1019 assert !@project.notified_users.include?(@only_my_events_user)
1036 1020 end
1037 1021
1038 1022 should "not include users with the 'only_assigned' notification option" do
1039 1023 assert !@project.notified_users.include?(@only_assigned_user)
1040 1024 end
1041 1025
1042 1026 should "not include users with the 'only_owner' notification option" do
1043 1027 assert !@project.notified_users.include?(@only_owned_user)
1044 1028 end
1045 1029 end
1046 1030
1047 1031 end
General Comments 0
You need to be logged in to leave comments. Login now