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