##// END OF EJS Templates
Change the TimelogController's to/from dates based on the project time entries...
Eric Davis -
r3973:cdfc57d5442f
parent child
Show More
@@ -1,324 +1,324
1 1 # redMine - project management software
2 2 # Copyright (C) 2006-2007 Jean-Philippe Lang
3 3 #
4 4 # This program is free software; you can redistribute it and/or
5 5 # modify it under the terms of the GNU General Public License
6 6 # as published by the Free Software Foundation; either version 2
7 7 # of the License, or (at your option) any later version.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU General Public License
15 15 # along with this program; if not, write to the Free Software
16 16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17 17
18 18 class TimelogController < ApplicationController
19 19 menu_item :issues
20 20 before_filter :find_project, :authorize, :only => [:edit, :destroy]
21 21 before_filter :find_optional_project, :only => [:report, :details]
22 22 before_filter :load_available_criterias, :only => [:report]
23 23
24 24 verify :method => :post, :only => :destroy, :redirect_to => { :action => :details }
25 25
26 26 helper :sort
27 27 include SortHelper
28 28 helper :issues
29 29 include TimelogHelper
30 30 helper :custom_fields
31 31 include CustomFieldsHelper
32 32
33 33 def report
34 34 @criterias = params[:criterias] || []
35 35 @criterias = @criterias.select{|criteria| @available_criterias.has_key? criteria}
36 36 @criterias.uniq!
37 37 @criterias = @criterias[0,3]
38 38
39 39 @columns = (params[:columns] && %w(year month week day).include?(params[:columns])) ? params[:columns] : 'month'
40 40
41 41 retrieve_date_range
42 42
43 43 unless @criterias.empty?
44 44 sql_select = @criterias.collect{|criteria| @available_criterias[criteria][:sql] + " AS " + criteria}.join(', ')
45 45 sql_group_by = @criterias.collect{|criteria| @available_criterias[criteria][:sql]}.join(', ')
46 46 sql_condition = ''
47 47
48 48 if @project.nil?
49 49 sql_condition = Project.allowed_to_condition(User.current, :view_time_entries)
50 50 elsif @issue.nil?
51 51 sql_condition = @project.project_condition(Setting.display_subprojects_issues?)
52 52 else
53 53 sql_condition = "#{Issue.table_name}.root_id = #{@issue.root_id} AND #{Issue.table_name}.lft >= #{@issue.lft} AND #{Issue.table_name}.rgt <= #{@issue.rgt}"
54 54 end
55 55
56 56 sql = "SELECT #{sql_select}, tyear, tmonth, tweek, spent_on, SUM(hours) AS hours"
57 57 sql << " FROM #{TimeEntry.table_name}"
58 58 sql << time_report_joins
59 59 sql << " WHERE"
60 60 sql << " (%s) AND" % sql_condition
61 61 sql << " (spent_on BETWEEN '%s' AND '%s')" % [ActiveRecord::Base.connection.quoted_date(@from), ActiveRecord::Base.connection.quoted_date(@to)]
62 62 sql << " GROUP BY #{sql_group_by}, tyear, tmonth, tweek, spent_on"
63 63
64 64 @hours = ActiveRecord::Base.connection.select_all(sql)
65 65
66 66 @hours.each do |row|
67 67 case @columns
68 68 when 'year'
69 69 row['year'] = row['tyear']
70 70 when 'month'
71 71 row['month'] = "#{row['tyear']}-#{row['tmonth']}"
72 72 when 'week'
73 73 row['week'] = "#{row['tyear']}-#{row['tweek']}"
74 74 when 'day'
75 75 row['day'] = "#{row['spent_on']}"
76 76 end
77 77 end
78 78
79 79 @total_hours = @hours.inject(0) {|s,k| s = s + k['hours'].to_f}
80 80
81 81 @periods = []
82 82 # Date#at_beginning_of_ not supported in Rails 1.2.x
83 83 date_from = @from.to_time
84 84 # 100 columns max
85 85 while date_from <= @to.to_time && @periods.length < 100
86 86 case @columns
87 87 when 'year'
88 88 @periods << "#{date_from.year}"
89 89 date_from = (date_from + 1.year).at_beginning_of_year
90 90 when 'month'
91 91 @periods << "#{date_from.year}-#{date_from.month}"
92 92 date_from = (date_from + 1.month).at_beginning_of_month
93 93 when 'week'
94 94 @periods << "#{date_from.year}-#{date_from.to_date.cweek}"
95 95 date_from = (date_from + 7.day).at_beginning_of_week
96 96 when 'day'
97 97 @periods << "#{date_from.to_date}"
98 98 date_from = date_from + 1.day
99 99 end
100 100 end
101 101 end
102 102
103 103 respond_to do |format|
104 104 format.html { render :layout => !request.xhr? }
105 105 format.csv { send_data(report_to_csv(@criterias, @periods, @hours), :type => 'text/csv; header=present', :filename => 'timelog.csv') }
106 106 end
107 107 end
108 108
109 109 def details
110 110 sort_init 'spent_on', 'desc'
111 111 sort_update 'spent_on' => 'spent_on',
112 112 'user' => 'user_id',
113 113 'activity' => 'activity_id',
114 114 'project' => "#{Project.table_name}.name",
115 115 'issue' => 'issue_id',
116 116 'hours' => 'hours'
117 117
118 118 cond = ARCondition.new
119 119 if @project.nil?
120 120 cond << Project.allowed_to_condition(User.current, :view_time_entries)
121 121 elsif @issue.nil?
122 122 cond << @project.project_condition(Setting.display_subprojects_issues?)
123 123 else
124 124 cond << "#{Issue.table_name}.root_id = #{@issue.root_id} AND #{Issue.table_name}.lft >= #{@issue.lft} AND #{Issue.table_name}.rgt <= #{@issue.rgt}"
125 125 end
126 126
127 127 retrieve_date_range
128 128 cond << ['spent_on BETWEEN ? AND ?', @from, @to]
129 129
130 130 TimeEntry.visible_by(User.current) do
131 131 respond_to do |format|
132 132 format.html {
133 133 # Paginate results
134 134 @entry_count = TimeEntry.count(:include => [:project, :issue], :conditions => cond.conditions)
135 135 @entry_pages = Paginator.new self, @entry_count, per_page_option, params['page']
136 136 @entries = TimeEntry.find(:all,
137 137 :include => [:project, :activity, :user, {:issue => :tracker}],
138 138 :conditions => cond.conditions,
139 139 :order => sort_clause,
140 140 :limit => @entry_pages.items_per_page,
141 141 :offset => @entry_pages.current.offset)
142 142 @total_hours = TimeEntry.sum(:hours, :include => [:project, :issue], :conditions => cond.conditions).to_f
143 143
144 144 render :layout => !request.xhr?
145 145 }
146 146 format.atom {
147 147 entries = TimeEntry.find(:all,
148 148 :include => [:project, :activity, :user, {:issue => :tracker}],
149 149 :conditions => cond.conditions,
150 150 :order => "#{TimeEntry.table_name}.created_on DESC",
151 151 :limit => Setting.feeds_limit.to_i)
152 152 render_feed(entries, :title => l(:label_spent_time))
153 153 }
154 154 format.csv {
155 155 # Export all entries
156 156 @entries = TimeEntry.find(:all,
157 157 :include => [:project, :activity, :user, {:issue => [:tracker, :assigned_to, :priority]}],
158 158 :conditions => cond.conditions,
159 159 :order => sort_clause)
160 160 send_data(entries_to_csv(@entries), :type => 'text/csv; header=present', :filename => 'timelog.csv')
161 161 }
162 162 end
163 163 end
164 164 end
165 165
166 166 def edit
167 167 (render_403; return) if @time_entry && !@time_entry.editable_by?(User.current)
168 168 @time_entry ||= TimeEntry.new(:project => @project, :issue => @issue, :user => User.current, :spent_on => User.current.today)
169 169 @time_entry.attributes = params[:time_entry]
170 170
171 171 call_hook(:controller_timelog_edit_before_save, { :params => params, :time_entry => @time_entry })
172 172
173 173 if request.post? and @time_entry.save
174 174 flash[:notice] = l(:notice_successful_update)
175 175 redirect_back_or_default :action => 'details', :project_id => @time_entry.project
176 176 return
177 177 end
178 178 end
179 179
180 180 def destroy
181 181 (render_404; return) unless @time_entry
182 182 (render_403; return) unless @time_entry.editable_by?(User.current)
183 183 if @time_entry.destroy && @time_entry.destroyed?
184 184 flash[:notice] = l(:notice_successful_delete)
185 185 else
186 186 flash[:error] = l(:notice_unable_delete_time_entry)
187 187 end
188 188 redirect_to :back
189 189 rescue ::ActionController::RedirectBackError
190 190 redirect_to :action => 'details', :project_id => @time_entry.project
191 191 end
192 192
193 193 private
194 194 def find_project
195 195 if params[:id]
196 196 @time_entry = TimeEntry.find(params[:id])
197 197 @project = @time_entry.project
198 198 elsif params[:issue_id]
199 199 @issue = Issue.find(params[:issue_id])
200 200 @project = @issue.project
201 201 elsif params[:project_id]
202 202 @project = Project.find(params[:project_id])
203 203 else
204 204 render_404
205 205 return false
206 206 end
207 207 rescue ActiveRecord::RecordNotFound
208 208 render_404
209 209 end
210 210
211 211 def find_optional_project
212 212 if !params[:issue_id].blank?
213 213 @issue = Issue.find(params[:issue_id])
214 214 @project = @issue.project
215 215 elsif !params[:project_id].blank?
216 216 @project = Project.find(params[:project_id])
217 217 end
218 218 deny_access unless User.current.allowed_to?(:view_time_entries, @project, :global => true)
219 219 end
220 220
221 221 # Retrieves the date range based on predefined ranges or specific from/to param dates
222 222 def retrieve_date_range
223 223 @free_period = false
224 224 @from, @to = nil, nil
225 225
226 226 if params[:period_type] == '1' || (params[:period_type].nil? && !params[:period].nil?)
227 227 case params[:period].to_s
228 228 when 'today'
229 229 @from = @to = Date.today
230 230 when 'yesterday'
231 231 @from = @to = Date.today - 1
232 232 when 'current_week'
233 233 @from = Date.today - (Date.today.cwday - 1)%7
234 234 @to = @from + 6
235 235 when 'last_week'
236 236 @from = Date.today - 7 - (Date.today.cwday - 1)%7
237 237 @to = @from + 6
238 238 when '7_days'
239 239 @from = Date.today - 7
240 240 @to = Date.today
241 241 when 'current_month'
242 242 @from = Date.civil(Date.today.year, Date.today.month, 1)
243 243 @to = (@from >> 1) - 1
244 244 when 'last_month'
245 245 @from = Date.civil(Date.today.year, Date.today.month, 1) << 1
246 246 @to = (@from >> 1) - 1
247 247 when '30_days'
248 248 @from = Date.today - 30
249 249 @to = Date.today
250 250 when 'current_year'
251 251 @from = Date.civil(Date.today.year, 1, 1)
252 252 @to = Date.civil(Date.today.year, 12, 31)
253 253 end
254 254 elsif params[:period_type] == '2' || (params[:period_type].nil? && (!params[:from].nil? || !params[:to].nil?))
255 255 begin; @from = params[:from].to_s.to_date unless params[:from].blank?; rescue; end
256 256 begin; @to = params[:to].to_s.to_date unless params[:to].blank?; rescue; end
257 257 @free_period = true
258 258 else
259 259 # default
260 260 end
261 261
262 262 @from, @to = @to, @from if @from && @to && @from > @to
263 @from ||= (TimeEntry.earilest_date_for_project || Date.today) - 1
264 @to ||= (TimeEntry.latest_date_for_project || Date.today)
263 @from ||= (TimeEntry.earilest_date_for_project(@project) || Date.today)
264 @to ||= (TimeEntry.latest_date_for_project(@project) || Date.today)
265 265 end
266 266
267 267 def load_available_criterias
268 268 @available_criterias = { 'project' => {:sql => "#{TimeEntry.table_name}.project_id",
269 269 :klass => Project,
270 270 :label => :label_project},
271 271 'version' => {:sql => "#{Issue.table_name}.fixed_version_id",
272 272 :klass => Version,
273 273 :label => :label_version},
274 274 'category' => {:sql => "#{Issue.table_name}.category_id",
275 275 :klass => IssueCategory,
276 276 :label => :field_category},
277 277 'member' => {:sql => "#{TimeEntry.table_name}.user_id",
278 278 :klass => User,
279 279 :label => :label_member},
280 280 'tracker' => {:sql => "#{Issue.table_name}.tracker_id",
281 281 :klass => Tracker,
282 282 :label => :label_tracker},
283 283 'activity' => {:sql => "#{TimeEntry.table_name}.activity_id",
284 284 :klass => TimeEntryActivity,
285 285 :label => :label_activity},
286 286 'issue' => {:sql => "#{TimeEntry.table_name}.issue_id",
287 287 :klass => Issue,
288 288 :label => :label_issue}
289 289 }
290 290
291 291 # Add list and boolean custom fields as available criterias
292 292 custom_fields = (@project.nil? ? IssueCustomField.for_all : @project.all_issue_custom_fields)
293 293 custom_fields.select {|cf| %w(list bool).include? cf.field_format }.each do |cf|
294 294 @available_criterias["cf_#{cf.id}"] = {:sql => "(SELECT c.value FROM #{CustomValue.table_name} c WHERE c.custom_field_id = #{cf.id} AND c.customized_type = 'Issue' AND c.customized_id = #{Issue.table_name}.id)",
295 295 :format => cf.field_format,
296 296 :label => cf.name}
297 297 end if @project
298 298
299 299 # Add list and boolean time entry custom fields
300 300 TimeEntryCustomField.find(:all).select {|cf| %w(list bool).include? cf.field_format }.each do |cf|
301 301 @available_criterias["cf_#{cf.id}"] = {:sql => "(SELECT c.value FROM #{CustomValue.table_name} c WHERE c.custom_field_id = #{cf.id} AND c.customized_type = 'TimeEntry' AND c.customized_id = #{TimeEntry.table_name}.id)",
302 302 :format => cf.field_format,
303 303 :label => cf.name}
304 304 end
305 305
306 306 # Add list and boolean time entry activity custom fields
307 307 TimeEntryActivityCustomField.find(:all).select {|cf| %w(list bool).include? cf.field_format }.each do |cf|
308 308 @available_criterias["cf_#{cf.id}"] = {:sql => "(SELECT c.value FROM #{CustomValue.table_name} c WHERE c.custom_field_id = #{cf.id} AND c.customized_type = 'Enumeration' AND c.customized_id = #{TimeEntry.table_name}.activity_id)",
309 309 :format => cf.field_format,
310 310 :label => cf.name}
311 311 end
312 312
313 313 call_hook(:controller_timelog_available_criterias, { :available_criterias => @available_criterias, :project => @project })
314 314 @available_criterias
315 315 end
316 316
317 317 def time_report_joins
318 318 sql = ''
319 319 sql << " LEFT JOIN #{Issue.table_name} ON #{TimeEntry.table_name}.issue_id = #{Issue.table_name}.id"
320 320 sql << " LEFT JOIN #{Project.table_name} ON #{TimeEntry.table_name}.project_id = #{Project.table_name}.id"
321 321 call_hook(:controller_timelog_time_report_joins, {:sql => sql} )
322 322 sql
323 323 end
324 324 end
@@ -1,765 +1,774
1 1 # redMine - project management software
2 2 # Copyright (C) 2006 Jean-Philippe Lang
3 3 #
4 4 # This program is free software; you can redistribute it and/or
5 5 # modify it under the terms of the GNU General Public License
6 6 # as published by the Free Software Foundation; either version 2
7 7 # of the License, or (at your option) any later version.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU General Public License
15 15 # along with this program; if not, write to the Free Software
16 16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17 17
18 18 class Project < ActiveRecord::Base
19 19 # Project statuses
20 20 STATUS_ACTIVE = 1
21 21 STATUS_ARCHIVED = 9
22 22
23 23 # Specific overidden Activities
24 24 has_many :time_entry_activities
25 25 has_many :members, :include => [:user, :roles], :conditions => "#{User.table_name}.type='User' AND #{User.table_name}.status=#{User::STATUS_ACTIVE}"
26 26 has_many :memberships, :class_name => 'Member'
27 27 has_many :member_principals, :class_name => 'Member',
28 28 :include => :principal,
29 29 :conditions => "#{Principal.table_name}.type='Group' OR (#{Principal.table_name}.type='User' AND #{Principal.table_name}.status=#{User::STATUS_ACTIVE})"
30 30 has_many :users, :through => :members
31 31 has_many :principals, :through => :member_principals, :source => :principal
32 32
33 33 has_many :enabled_modules, :dependent => :delete_all
34 34 has_and_belongs_to_many :trackers, :order => "#{Tracker.table_name}.position"
35 35 has_many :issues, :dependent => :destroy, :order => "#{Issue.table_name}.created_on DESC", :include => [:status, :tracker]
36 36 has_many :issue_changes, :through => :issues, :source => :journals
37 37 has_many :versions, :dependent => :destroy, :order => "#{Version.table_name}.effective_date DESC, #{Version.table_name}.name DESC"
38 38 has_many :time_entries, :dependent => :delete_all
39 39 has_many :queries, :dependent => :delete_all
40 40 has_many :documents, :dependent => :destroy
41 41 has_many :news, :dependent => :delete_all, :include => :author
42 42 has_many :issue_categories, :dependent => :delete_all, :order => "#{IssueCategory.table_name}.name"
43 43 has_many :boards, :dependent => :destroy, :order => "position ASC"
44 44 has_one :repository, :dependent => :destroy
45 45 has_many :changesets, :through => :repository
46 46 has_one :wiki, :dependent => :destroy
47 47 # Custom field for the project issues
48 48 has_and_belongs_to_many :issue_custom_fields,
49 49 :class_name => 'IssueCustomField',
50 50 :order => "#{CustomField.table_name}.position",
51 51 :join_table => "#{table_name_prefix}custom_fields_projects#{table_name_suffix}",
52 52 :association_foreign_key => 'custom_field_id'
53 53
54 54 acts_as_nested_set :order => 'name'
55 55 acts_as_attachable :view_permission => :view_files,
56 56 :delete_permission => :manage_files
57 57
58 58 acts_as_customizable
59 59 acts_as_searchable :columns => ['name', 'identifier', 'description'], :project_key => 'id', :permission => nil
60 60 acts_as_event :title => Proc.new {|o| "#{l(:label_project)}: #{o.name}"},
61 61 :url => Proc.new {|o| {:controller => 'projects', :action => 'show', :id => o}},
62 62 :author => nil
63 63
64 64 attr_protected :status, :enabled_module_names
65 65
66 66 validates_presence_of :name, :identifier
67 67 validates_uniqueness_of :name, :identifier
68 68 validates_associated :repository, :wiki
69 69 validates_length_of :name, :maximum => 30
70 70 validates_length_of :homepage, :maximum => 255
71 71 validates_length_of :identifier, :in => 1..20
72 72 # donwcase letters, digits, dashes but not digits only
73 73 validates_format_of :identifier, :with => /^(?!\d+$)[a-z0-9\-]*$/, :if => Proc.new { |p| p.identifier_changed? }
74 74 # reserved words
75 75 validates_exclusion_of :identifier, :in => %w( new )
76 76
77 77 before_destroy :delete_all_members, :destroy_children
78 78
79 79 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] } }
80 80 named_scope :active, { :conditions => "#{Project.table_name}.status = #{STATUS_ACTIVE}"}
81 81 named_scope :all_public, { :conditions => { :is_public => true } }
82 82 named_scope :visible, lambda { { :conditions => Project.visible_by(User.current) } }
83 83
84 84 def identifier=(identifier)
85 85 super unless identifier_frozen?
86 86 end
87 87
88 88 def identifier_frozen?
89 89 errors[:identifier].nil? && !(new_record? || identifier.blank?)
90 90 end
91 91
92 92 # returns latest created projects
93 93 # non public projects will be returned only if user is a member of those
94 94 def self.latest(user=nil, count=5)
95 95 find(:all, :limit => count, :conditions => visible_by(user), :order => "created_on DESC")
96 96 end
97 97
98 98 # Returns a SQL :conditions string used to find all active projects for the specified user.
99 99 #
100 100 # Examples:
101 101 # Projects.visible_by(admin) => "projects.status = 1"
102 102 # Projects.visible_by(normal_user) => "projects.status = 1 AND projects.is_public = 1"
103 103 def self.visible_by(user=nil)
104 104 user ||= User.current
105 105 if user && user.admin?
106 106 return "#{Project.table_name}.status=#{Project::STATUS_ACTIVE}"
107 107 elsif user && user.memberships.any?
108 108 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(',')}))"
109 109 else
110 110 return "#{Project.table_name}.status=#{Project::STATUS_ACTIVE} AND #{Project.table_name}.is_public = #{connection.quoted_true}"
111 111 end
112 112 end
113 113
114 114 def self.allowed_to_condition(user, permission, options={})
115 115 statements = []
116 116 base_statement = "#{Project.table_name}.status=#{Project::STATUS_ACTIVE}"
117 117 if perm = Redmine::AccessControl.permission(permission)
118 118 unless perm.project_module.nil?
119 119 # If the permission belongs to a project module, make sure the module is enabled
120 120 base_statement << " AND #{Project.table_name}.id IN (SELECT em.project_id FROM #{EnabledModule.table_name} em WHERE em.name='#{perm.project_module}')"
121 121 end
122 122 end
123 123 if options[:project]
124 124 project_statement = "#{Project.table_name}.id = #{options[:project].id}"
125 125 project_statement << " OR (#{Project.table_name}.lft > #{options[:project].lft} AND #{Project.table_name}.rgt < #{options[:project].rgt})" if options[:with_subprojects]
126 126 base_statement = "(#{project_statement}) AND (#{base_statement})"
127 127 end
128 128 if user.admin?
129 129 # no restriction
130 130 else
131 131 statements << "1=0"
132 132 if user.logged?
133 133 if Role.non_member.allowed_to?(permission) && !options[:member]
134 134 statements << "#{Project.table_name}.is_public = #{connection.quoted_true}"
135 135 end
136 136 allowed_project_ids = user.memberships.select {|m| m.roles.detect {|role| role.allowed_to?(permission)}}.collect {|m| m.project_id}
137 137 statements << "#{Project.table_name}.id IN (#{allowed_project_ids.join(',')})" if allowed_project_ids.any?
138 138 else
139 139 if Role.anonymous.allowed_to?(permission) && !options[:member]
140 140 # anonymous user allowed on public project
141 141 statements << "#{Project.table_name}.is_public = #{connection.quoted_true}"
142 142 end
143 143 end
144 144 end
145 145 statements.empty? ? base_statement : "((#{base_statement}) AND (#{statements.join(' OR ')}))"
146 146 end
147 147
148 148 # Returns the Systemwide and project specific activities
149 149 def activities(include_inactive=false)
150 150 if include_inactive
151 151 return all_activities
152 152 else
153 153 return active_activities
154 154 end
155 155 end
156 156
157 157 # Will create a new Project specific Activity or update an existing one
158 158 #
159 159 # This will raise a ActiveRecord::Rollback if the TimeEntryActivity
160 160 # does not successfully save.
161 161 def update_or_create_time_entry_activity(id, activity_hash)
162 162 if activity_hash.respond_to?(:has_key?) && activity_hash.has_key?('parent_id')
163 163 self.create_time_entry_activity_if_needed(activity_hash)
164 164 else
165 165 activity = project.time_entry_activities.find_by_id(id.to_i)
166 166 activity.update_attributes(activity_hash) if activity
167 167 end
168 168 end
169 169
170 170 # Create a new TimeEntryActivity if it overrides a system TimeEntryActivity
171 171 #
172 172 # This will raise a ActiveRecord::Rollback if the TimeEntryActivity
173 173 # does not successfully save.
174 174 def create_time_entry_activity_if_needed(activity)
175 175 if activity['parent_id']
176 176
177 177 parent_activity = TimeEntryActivity.find(activity['parent_id'])
178 178 activity['name'] = parent_activity.name
179 179 activity['position'] = parent_activity.position
180 180
181 181 if Enumeration.overridding_change?(activity, parent_activity)
182 182 project_activity = self.time_entry_activities.create(activity)
183 183
184 184 if project_activity.new_record?
185 185 raise ActiveRecord::Rollback, "Overridding TimeEntryActivity was not successfully saved"
186 186 else
187 187 self.time_entries.update_all("activity_id = #{project_activity.id}", ["activity_id = ?", parent_activity.id])
188 188 end
189 189 end
190 190 end
191 191 end
192 192
193 193 # Returns a :conditions SQL string that can be used to find the issues associated with this project.
194 194 #
195 195 # Examples:
196 196 # project.project_condition(true) => "(projects.id = 1 OR (projects.lft > 1 AND projects.rgt < 10))"
197 197 # project.project_condition(false) => "projects.id = 1"
198 198 def project_condition(with_subprojects)
199 199 cond = "#{Project.table_name}.id = #{id}"
200 200 cond = "(#{cond} OR (#{Project.table_name}.lft > #{lft} AND #{Project.table_name}.rgt < #{rgt}))" if with_subprojects
201 201 cond
202 202 end
203 203
204 204 def self.find(*args)
205 205 if args.first && args.first.is_a?(String) && !args.first.match(/^\d*$/)
206 206 project = find_by_identifier(*args)
207 207 raise ActiveRecord::RecordNotFound, "Couldn't find Project with identifier=#{args.first}" if project.nil?
208 208 project
209 209 else
210 210 super
211 211 end
212 212 end
213 213
214 214 def to_param
215 215 # id is used for projects with a numeric identifier (compatibility)
216 216 @to_param ||= (identifier.to_s =~ %r{^\d*$} ? id : identifier)
217 217 end
218 218
219 219 def active?
220 220 self.status == STATUS_ACTIVE
221 221 end
222 222
223 223 # Archives the project and its descendants
224 224 def archive
225 225 # Check that there is no issue of a non descendant project that is assigned
226 226 # to one of the project or descendant versions
227 227 v_ids = self_and_descendants.collect {|p| p.version_ids}.flatten
228 228 if v_ids.any? && Issue.find(:first, :include => :project,
229 229 :conditions => ["(#{Project.table_name}.lft < ? OR #{Project.table_name}.rgt > ?)" +
230 230 " AND #{Issue.table_name}.fixed_version_id IN (?)", lft, rgt, v_ids])
231 231 return false
232 232 end
233 233 Project.transaction do
234 234 archive!
235 235 end
236 236 true
237 237 end
238 238
239 239 # Unarchives the project
240 240 # All its ancestors must be active
241 241 def unarchive
242 242 return false if ancestors.detect {|a| !a.active?}
243 243 update_attribute :status, STATUS_ACTIVE
244 244 end
245 245
246 246 # Returns an array of projects the project can be moved to
247 247 # by the current user
248 248 def allowed_parents
249 249 return @allowed_parents if @allowed_parents
250 250 @allowed_parents = Project.find(:all, :conditions => Project.allowed_to_condition(User.current, :add_subprojects))
251 251 @allowed_parents = @allowed_parents - self_and_descendants
252 252 if User.current.allowed_to?(:add_project, nil, :global => true) || (!new_record? && parent.nil?)
253 253 @allowed_parents << nil
254 254 end
255 255 unless parent.nil? || @allowed_parents.empty? || @allowed_parents.include?(parent)
256 256 @allowed_parents << parent
257 257 end
258 258 @allowed_parents
259 259 end
260 260
261 261 # Sets the parent of the project with authorization check
262 262 def set_allowed_parent!(p)
263 263 unless p.nil? || p.is_a?(Project)
264 264 if p.to_s.blank?
265 265 p = nil
266 266 else
267 267 p = Project.find_by_id(p)
268 268 return false unless p
269 269 end
270 270 end
271 271 if p.nil?
272 272 if !new_record? && allowed_parents.empty?
273 273 return false
274 274 end
275 275 elsif !allowed_parents.include?(p)
276 276 return false
277 277 end
278 278 set_parent!(p)
279 279 end
280 280
281 281 # Sets the parent of the project
282 282 # Argument can be either a Project, a String, a Fixnum or nil
283 283 def set_parent!(p)
284 284 unless p.nil? || p.is_a?(Project)
285 285 if p.to_s.blank?
286 286 p = nil
287 287 else
288 288 p = Project.find_by_id(p)
289 289 return false unless p
290 290 end
291 291 end
292 292 if p == parent && !p.nil?
293 293 # Nothing to do
294 294 true
295 295 elsif p.nil? || (p.active? && move_possible?(p))
296 296 # Insert the project so that target's children or root projects stay alphabetically sorted
297 297 sibs = (p.nil? ? self.class.roots : p.children)
298 298 to_be_inserted_before = sibs.detect {|c| c.name.to_s.downcase > name.to_s.downcase }
299 299 if to_be_inserted_before
300 300 move_to_left_of(to_be_inserted_before)
301 301 elsif p.nil?
302 302 if sibs.empty?
303 303 # move_to_root adds the project in first (ie. left) position
304 304 move_to_root
305 305 else
306 306 move_to_right_of(sibs.last) unless self == sibs.last
307 307 end
308 308 else
309 309 # move_to_child_of adds the project in last (ie.right) position
310 310 move_to_child_of(p)
311 311 end
312 312 Issue.update_versions_from_hierarchy_change(self)
313 313 true
314 314 else
315 315 # Can not move to the given target
316 316 false
317 317 end
318 318 end
319 319
320 320 # Returns an array of the trackers used by the project and its active sub projects
321 321 def rolled_up_trackers
322 322 @rolled_up_trackers ||=
323 323 Tracker.find(:all, :include => :projects,
324 324 :select => "DISTINCT #{Tracker.table_name}.*",
325 325 :conditions => ["#{Project.table_name}.lft >= ? AND #{Project.table_name}.rgt <= ? AND #{Project.table_name}.status = #{STATUS_ACTIVE}", lft, rgt],
326 326 :order => "#{Tracker.table_name}.position")
327 327 end
328 328
329 329 # Closes open and locked project versions that are completed
330 330 def close_completed_versions
331 331 Version.transaction do
332 332 versions.find(:all, :conditions => {:status => %w(open locked)}).each do |version|
333 333 if version.completed?
334 334 version.update_attribute(:status, 'closed')
335 335 end
336 336 end
337 337 end
338 338 end
339 339
340 340 # Returns a scope of the Versions on subprojects
341 341 def rolled_up_versions
342 342 @rolled_up_versions ||=
343 343 Version.scoped(:include => :project,
344 344 :conditions => ["#{Project.table_name}.lft >= ? AND #{Project.table_name}.rgt <= ? AND #{Project.table_name}.status = #{STATUS_ACTIVE}", lft, rgt])
345 345 end
346 346
347 347 # Returns a scope of the Versions used by the project
348 348 def shared_versions
349 349 @shared_versions ||=
350 350 Version.scoped(:include => :project,
351 351 :conditions => "#{Project.table_name}.id = #{id}" +
352 352 " OR (#{Project.table_name}.status = #{Project::STATUS_ACTIVE} AND (" +
353 353 " #{Version.table_name}.sharing = 'system'" +
354 354 " OR (#{Project.table_name}.lft >= #{root.lft} AND #{Project.table_name}.rgt <= #{root.rgt} AND #{Version.table_name}.sharing = 'tree')" +
355 355 " OR (#{Project.table_name}.lft < #{lft} AND #{Project.table_name}.rgt > #{rgt} AND #{Version.table_name}.sharing IN ('hierarchy', 'descendants'))" +
356 356 " OR (#{Project.table_name}.lft > #{lft} AND #{Project.table_name}.rgt < #{rgt} AND #{Version.table_name}.sharing = 'hierarchy')" +
357 357 "))")
358 358 end
359 359
360 360 # Returns a hash of project users grouped by role
361 361 def users_by_role
362 362 members.find(:all, :include => [:user, :roles]).inject({}) do |h, m|
363 363 m.roles.each do |r|
364 364 h[r] ||= []
365 365 h[r] << m.user
366 366 end
367 367 h
368 368 end
369 369 end
370 370
371 371 # Deletes all project's members
372 372 def delete_all_members
373 373 me, mr = Member.table_name, MemberRole.table_name
374 374 connection.delete("DELETE FROM #{mr} WHERE #{mr}.member_id IN (SELECT #{me}.id FROM #{me} WHERE #{me}.project_id = #{id})")
375 375 Member.delete_all(['project_id = ?', id])
376 376 end
377 377
378 378 # Users issues can be assigned to
379 379 def assignable_users
380 380 members.select {|m| m.roles.detect {|role| role.assignable?}}.collect {|m| m.user}.sort
381 381 end
382 382
383 383 # Returns the mail adresses of users that should be always notified on project events
384 384 def recipients
385 385 members.select {|m| m.mail_notification? || m.user.mail_notification?}.collect {|m| m.user.mail}
386 386 end
387 387
388 388 # Returns the users that should be notified on project events
389 389 def notified_users
390 390 members.select {|m| m.mail_notification? || m.user.mail_notification?}.collect {|m| m.user}
391 391 end
392 392
393 393 # Returns an array of all custom fields enabled for project issues
394 394 # (explictly associated custom fields and custom fields enabled for all projects)
395 395 def all_issue_custom_fields
396 396 @all_issue_custom_fields ||= (IssueCustomField.for_all + issue_custom_fields).uniq.sort
397 397 end
398 398
399 399 def project
400 400 self
401 401 end
402 402
403 403 def <=>(project)
404 404 name.downcase <=> project.name.downcase
405 405 end
406 406
407 407 def to_s
408 408 name
409 409 end
410 410
411 411 # Returns a short description of the projects (first lines)
412 412 def short_description(length = 255)
413 413 description.gsub(/^(.{#{length}}[^\n\r]*).*$/m, '\1...').strip if description
414 414 end
415 415
416 416 def css_classes
417 417 s = 'project'
418 418 s << ' root' if root?
419 419 s << ' child' if child?
420 420 s << (leaf? ? ' leaf' : ' parent')
421 421 s
422 422 end
423 423
424 424 # The earliest start date of a project, based on it's issues and versions
425 425 def start_date
426 426 if module_enabled?(:issue_tracking)
427 427 [
428 428 issues.minimum('start_date'),
429 429 shared_versions.collect(&:effective_date),
430 430 shared_versions.collect {|v| v.fixed_issues.minimum('start_date')}
431 431 ].flatten.compact.min
432 432 end
433 433 end
434 434
435 435 # The latest due date of an issue or version
436 436 def due_date
437 437 if module_enabled?(:issue_tracking)
438 438 [
439 439 issues.maximum('due_date'),
440 440 shared_versions.collect(&:effective_date),
441 441 shared_versions.collect {|v| v.fixed_issues.maximum('due_date')}
442 442 ].flatten.compact.max
443 443 end
444 444 end
445 445
446 446 def overdue?
447 447 active? && !due_date.nil? && (due_date < Date.today)
448 448 end
449 449
450 450 # Returns the percent completed for this project, based on the
451 451 # progress on it's versions.
452 452 def completed_percent(options={:include_subprojects => false})
453 453 if options.delete(:include_subprojects)
454 454 total = self_and_descendants.collect(&:completed_percent).sum
455 455
456 456 total / self_and_descendants.count
457 457 else
458 458 if versions.count > 0
459 459 total = versions.collect(&:completed_pourcent).sum
460 460
461 461 total / versions.count
462 462 else
463 463 100
464 464 end
465 465 end
466 466 end
467 467
468 468 # Return true if this project is allowed to do the specified action.
469 469 # action can be:
470 470 # * a parameter-like Hash (eg. :controller => 'projects', :action => 'edit')
471 471 # * a permission Symbol (eg. :edit_project)
472 472 def allows_to?(action)
473 473 if action.is_a? Hash
474 474 allowed_actions.include? "#{action[:controller]}/#{action[:action]}"
475 475 else
476 476 allowed_permissions.include? action
477 477 end
478 478 end
479 479
480 480 def module_enabled?(module_name)
481 481 module_name = module_name.to_s
482 482 enabled_modules.detect {|m| m.name == module_name}
483 483 end
484 484
485 485 def enabled_module_names=(module_names)
486 486 if module_names && module_names.is_a?(Array)
487 487 module_names = module_names.collect(&:to_s)
488 488 # remove disabled modules
489 489 enabled_modules.each {|mod| mod.destroy unless module_names.include?(mod.name)}
490 490 # add new modules
491 491 module_names.reject {|name| module_enabled?(name)}.each {|name| enabled_modules << EnabledModule.new(:name => name)}
492 492 else
493 493 enabled_modules.clear
494 494 end
495 495 end
496 496
497 # Returns an array of projects that are in this project's hierarchy
498 #
499 # Example: parents, children, siblings
500 def hierarchy
501 parents = project.self_and_ancestors || []
502 descendants = project.descendants || []
503 project_hierarchy = parents | descendants # Set union
504 end
505
497 506 # Returns an auto-generated project identifier based on the last identifier used
498 507 def self.next_identifier
499 508 p = Project.find(:first, :order => 'created_on DESC')
500 509 p.nil? ? nil : p.identifier.to_s.succ
501 510 end
502 511
503 512 # Copies and saves the Project instance based on the +project+.
504 513 # Duplicates the source project's:
505 514 # * Wiki
506 515 # * Versions
507 516 # * Categories
508 517 # * Issues
509 518 # * Members
510 519 # * Queries
511 520 #
512 521 # Accepts an +options+ argument to specify what to copy
513 522 #
514 523 # Examples:
515 524 # project.copy(1) # => copies everything
516 525 # project.copy(1, :only => 'members') # => copies members only
517 526 # project.copy(1, :only => ['members', 'versions']) # => copies members and versions
518 527 def copy(project, options={})
519 528 project = project.is_a?(Project) ? project : Project.find(project)
520 529
521 530 to_be_copied = %w(wiki versions issue_categories issues members queries boards)
522 531 to_be_copied = to_be_copied & options[:only].to_a unless options[:only].nil?
523 532
524 533 Project.transaction do
525 534 if save
526 535 reload
527 536 to_be_copied.each do |name|
528 537 send "copy_#{name}", project
529 538 end
530 539 Redmine::Hook.call_hook(:model_project_copy_before_save, :source_project => project, :destination_project => self)
531 540 save
532 541 end
533 542 end
534 543 end
535 544
536 545
537 546 # Copies +project+ and returns the new instance. This will not save
538 547 # the copy
539 548 def self.copy_from(project)
540 549 begin
541 550 project = project.is_a?(Project) ? project : Project.find(project)
542 551 if project
543 552 # clear unique attributes
544 553 attributes = project.attributes.dup.except('id', 'name', 'identifier', 'status', 'parent_id', 'lft', 'rgt')
545 554 copy = Project.new(attributes)
546 555 copy.enabled_modules = project.enabled_modules
547 556 copy.trackers = project.trackers
548 557 copy.custom_values = project.custom_values.collect {|v| v.clone}
549 558 copy.issue_custom_fields = project.issue_custom_fields
550 559 return copy
551 560 else
552 561 return nil
553 562 end
554 563 rescue ActiveRecord::RecordNotFound
555 564 return nil
556 565 end
557 566 end
558 567
559 568 private
560 569
561 570 # Destroys children before destroying self
562 571 def destroy_children
563 572 children.each do |child|
564 573 child.destroy
565 574 end
566 575 end
567 576
568 577 # Copies wiki from +project+
569 578 def copy_wiki(project)
570 579 # Check that the source project has a wiki first
571 580 unless project.wiki.nil?
572 581 self.wiki ||= Wiki.new
573 582 wiki.attributes = project.wiki.attributes.dup.except("id", "project_id")
574 583 wiki_pages_map = {}
575 584 project.wiki.pages.each do |page|
576 585 # Skip pages without content
577 586 next if page.content.nil?
578 587 new_wiki_content = WikiContent.new(page.content.attributes.dup.except("id", "page_id", "updated_on"))
579 588 new_wiki_page = WikiPage.new(page.attributes.dup.except("id", "wiki_id", "created_on", "parent_id"))
580 589 new_wiki_page.content = new_wiki_content
581 590 wiki.pages << new_wiki_page
582 591 wiki_pages_map[page.id] = new_wiki_page
583 592 end
584 593 wiki.save
585 594 # Reproduce page hierarchy
586 595 project.wiki.pages.each do |page|
587 596 if page.parent_id && wiki_pages_map[page.id]
588 597 wiki_pages_map[page.id].parent = wiki_pages_map[page.parent_id]
589 598 wiki_pages_map[page.id].save
590 599 end
591 600 end
592 601 end
593 602 end
594 603
595 604 # Copies versions from +project+
596 605 def copy_versions(project)
597 606 project.versions.each do |version|
598 607 new_version = Version.new
599 608 new_version.attributes = version.attributes.dup.except("id", "project_id", "created_on", "updated_on")
600 609 self.versions << new_version
601 610 end
602 611 end
603 612
604 613 # Copies issue categories from +project+
605 614 def copy_issue_categories(project)
606 615 project.issue_categories.each do |issue_category|
607 616 new_issue_category = IssueCategory.new
608 617 new_issue_category.attributes = issue_category.attributes.dup.except("id", "project_id")
609 618 self.issue_categories << new_issue_category
610 619 end
611 620 end
612 621
613 622 # Copies issues from +project+
614 623 def copy_issues(project)
615 624 # Stores the source issue id as a key and the copied issues as the
616 625 # value. Used to map the two togeather for issue relations.
617 626 issues_map = {}
618 627
619 628 # Get issues sorted by root_id, lft so that parent issues
620 629 # get copied before their children
621 630 project.issues.find(:all, :order => 'root_id, lft').each do |issue|
622 631 new_issue = Issue.new
623 632 new_issue.copy_from(issue)
624 633 new_issue.project = self
625 634 # Reassign fixed_versions by name, since names are unique per
626 635 # project and the versions for self are not yet saved
627 636 if issue.fixed_version
628 637 new_issue.fixed_version = self.versions.select {|v| v.name == issue.fixed_version.name}.first
629 638 end
630 639 # Reassign the category by name, since names are unique per
631 640 # project and the categories for self are not yet saved
632 641 if issue.category
633 642 new_issue.category = self.issue_categories.select {|c| c.name == issue.category.name}.first
634 643 end
635 644 # Parent issue
636 645 if issue.parent_id
637 646 if copied_parent = issues_map[issue.parent_id]
638 647 new_issue.parent_issue_id = copied_parent.id
639 648 end
640 649 end
641 650
642 651 self.issues << new_issue
643 652 issues_map[issue.id] = new_issue
644 653 end
645 654
646 655 # Relations after in case issues related each other
647 656 project.issues.each do |issue|
648 657 new_issue = issues_map[issue.id]
649 658
650 659 # Relations
651 660 issue.relations_from.each do |source_relation|
652 661 new_issue_relation = IssueRelation.new
653 662 new_issue_relation.attributes = source_relation.attributes.dup.except("id", "issue_from_id", "issue_to_id")
654 663 new_issue_relation.issue_to = issues_map[source_relation.issue_to_id]
655 664 if new_issue_relation.issue_to.nil? && Setting.cross_project_issue_relations?
656 665 new_issue_relation.issue_to = source_relation.issue_to
657 666 end
658 667 new_issue.relations_from << new_issue_relation
659 668 end
660 669
661 670 issue.relations_to.each do |source_relation|
662 671 new_issue_relation = IssueRelation.new
663 672 new_issue_relation.attributes = source_relation.attributes.dup.except("id", "issue_from_id", "issue_to_id")
664 673 new_issue_relation.issue_from = issues_map[source_relation.issue_from_id]
665 674 if new_issue_relation.issue_from.nil? && Setting.cross_project_issue_relations?
666 675 new_issue_relation.issue_from = source_relation.issue_from
667 676 end
668 677 new_issue.relations_to << new_issue_relation
669 678 end
670 679 end
671 680 end
672 681
673 682 # Copies members from +project+
674 683 def copy_members(project)
675 684 project.memberships.each do |member|
676 685 new_member = Member.new
677 686 new_member.attributes = member.attributes.dup.except("id", "project_id", "created_on")
678 687 # only copy non inherited roles
679 688 # inherited roles will be added when copying the group membership
680 689 role_ids = member.member_roles.reject(&:inherited?).collect(&:role_id)
681 690 next if role_ids.empty?
682 691 new_member.role_ids = role_ids
683 692 new_member.project = self
684 693 self.members << new_member
685 694 end
686 695 end
687 696
688 697 # Copies queries from +project+
689 698 def copy_queries(project)
690 699 project.queries.each do |query|
691 700 new_query = Query.new
692 701 new_query.attributes = query.attributes.dup.except("id", "project_id", "sort_criteria")
693 702 new_query.sort_criteria = query.sort_criteria if query.sort_criteria
694 703 new_query.project = self
695 704 self.queries << new_query
696 705 end
697 706 end
698 707
699 708 # Copies boards from +project+
700 709 def copy_boards(project)
701 710 project.boards.each do |board|
702 711 new_board = Board.new
703 712 new_board.attributes = board.attributes.dup.except("id", "project_id", "topics_count", "messages_count", "last_message_id")
704 713 new_board.project = self
705 714 self.boards << new_board
706 715 end
707 716 end
708 717
709 718 def allowed_permissions
710 719 @allowed_permissions ||= begin
711 720 module_names = enabled_modules.all(:select => :name).collect {|m| m.name}
712 721 Redmine::AccessControl.modules_permissions(module_names).collect {|p| p.name}
713 722 end
714 723 end
715 724
716 725 def allowed_actions
717 726 @actions_allowed ||= allowed_permissions.inject([]) { |actions, permission| actions += Redmine::AccessControl.allowed_actions(permission) }.flatten
718 727 end
719 728
720 729 # Returns all the active Systemwide and project specific activities
721 730 def active_activities
722 731 overridden_activity_ids = self.time_entry_activities.collect(&:parent_id)
723 732
724 733 if overridden_activity_ids.empty?
725 734 return TimeEntryActivity.shared.active
726 735 else
727 736 return system_activities_and_project_overrides
728 737 end
729 738 end
730 739
731 740 # Returns all the Systemwide and project specific activities
732 741 # (inactive and active)
733 742 def all_activities
734 743 overridden_activity_ids = self.time_entry_activities.collect(&:parent_id)
735 744
736 745 if overridden_activity_ids.empty?
737 746 return TimeEntryActivity.shared
738 747 else
739 748 return system_activities_and_project_overrides(true)
740 749 end
741 750 end
742 751
743 752 # Returns the systemwide active activities merged with the project specific overrides
744 753 def system_activities_and_project_overrides(include_inactive=false)
745 754 if include_inactive
746 755 return TimeEntryActivity.shared.
747 756 find(:all,
748 757 :conditions => ["id NOT IN (?)", self.time_entry_activities.collect(&:parent_id)]) +
749 758 self.time_entry_activities
750 759 else
751 760 return TimeEntryActivity.shared.active.
752 761 find(:all,
753 762 :conditions => ["id NOT IN (?)", self.time_entry_activities.collect(&:parent_id)]) +
754 763 self.time_entry_activities.active
755 764 end
756 765 end
757 766
758 767 # Archives subprojects recursively
759 768 def archive!
760 769 children.each do |subproject|
761 770 subproject.send :archive!
762 771 end
763 772 update_attribute :status, STATUS_ARCHIVED
764 773 end
765 774 end
@@ -1,92 +1,100
1 1 # redMine - project management software
2 2 # Copyright (C) 2006-2008 Jean-Philippe Lang
3 3 #
4 4 # This program is free software; you can redistribute it and/or
5 5 # modify it under the terms of the GNU General Public License
6 6 # as published by the Free Software Foundation; either version 2
7 7 # of the License, or (at your option) any later version.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU General Public License
15 15 # along with this program; if not, write to the Free Software
16 16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17 17
18 18 class TimeEntry < ActiveRecord::Base
19 19 # could have used polymorphic association
20 20 # project association here allows easy loading of time entries at project level with one database trip
21 21 belongs_to :project
22 22 belongs_to :issue
23 23 belongs_to :user
24 24 belongs_to :activity, :class_name => 'TimeEntryActivity', :foreign_key => 'activity_id'
25 25
26 26 attr_protected :project_id, :user_id, :tyear, :tmonth, :tweek
27 27
28 28 acts_as_customizable
29 29 acts_as_event :title => Proc.new {|o| "#{l_hours(o.hours)} (#{(o.issue || o.project).event_title})"},
30 30 :url => Proc.new {|o| {:controller => 'timelog', :action => 'details', :project_id => o.project, :issue_id => o.issue}},
31 31 :author => :user,
32 32 :description => :comments
33 33
34 34 acts_as_activity_provider :timestamp => "#{table_name}.created_on",
35 35 :author_key => :user_id,
36 36 :find_options => {:include => :project}
37 37
38 38 validates_presence_of :user_id, :activity_id, :project_id, :hours, :spent_on
39 39 validates_numericality_of :hours, :allow_nil => true, :message => :invalid
40 40 validates_length_of :comments, :maximum => 255, :allow_nil => true
41 41
42 42 def after_initialize
43 43 if new_record? && self.activity.nil?
44 44 if default_activity = TimeEntryActivity.default
45 45 self.activity_id = default_activity.id
46 46 end
47 47 self.hours = nil if hours == 0
48 48 end
49 49 end
50 50
51 51 def before_validation
52 52 self.project = issue.project if issue && project.nil?
53 53 end
54 54
55 55 def validate
56 56 errors.add :hours, :invalid if hours && (hours < 0 || hours >= 1000)
57 57 errors.add :project_id, :invalid if project.nil?
58 58 errors.add :issue_id, :invalid if (issue_id && !issue) || (issue && project!=issue.project)
59 59 end
60 60
61 61 def hours=(h)
62 62 write_attribute :hours, (h.is_a?(String) ? (h.to_hours || h) : h)
63 63 end
64 64
65 65 # tyear, tmonth, tweek assigned where setting spent_on attributes
66 66 # these attributes make time aggregations easier
67 67 def spent_on=(date)
68 68 super
69 69 self.tyear = spent_on ? spent_on.year : nil
70 70 self.tmonth = spent_on ? spent_on.month : nil
71 71 self.tweek = spent_on ? Date.civil(spent_on.year, spent_on.month, spent_on.day).cweek : nil
72 72 end
73 73
74 74 # Returns true if the time entry can be edited by usr, otherwise false
75 75 def editable_by?(usr)
76 76 (usr == user && usr.allowed_to?(:edit_own_time_entries, project)) || usr.allowed_to?(:edit_time_entries, project)
77 77 end
78 78
79 79 def self.visible_by(usr)
80 80 with_scope(:find => { :conditions => Project.allowed_to_condition(usr, :view_time_entries) }) do
81 81 yield
82 82 end
83 83 end
84 84
85 def self.earilest_date_for_project
86 TimeEntry.minimum(:spent_on, :include => :project, :conditions => Project.allowed_to_condition(User.current, :view_time_entries))
85 def self.earilest_date_for_project(project=nil)
86 finder_conditions = ARCondition.new(Project.allowed_to_condition(User.current, :view_time_entries))
87 if project
88 finder_conditions << ["project_id IN (?)", project.hierarchy.collect(&:id)]
89 end
90 TimeEntry.minimum(:spent_on, :include => :project, :conditions => finder_conditions.conditions)
87 91 end
88 92
89 def self.latest_date_for_project
90 TimeEntry.maximum(:spent_on, :include => :project, :conditions => Project.allowed_to_condition(User.current, :view_time_entries))
93 def self.latest_date_for_project(project=nil)
94 finder_conditions = ARCondition.new(Project.allowed_to_condition(User.current, :view_time_entries))
95 if project
96 finder_conditions << ["project_id IN (?)", project.hierarchy.collect(&:id)]
97 end
98 TimeEntry.maximum(:spent_on, :include => :project, :conditions => finder_conditions.conditions)
91 99 end
92 100 end
@@ -1,358 +1,358
1 1 # -*- coding: utf-8 -*-
2 2 # redMine - project management software
3 3 # Copyright (C) 2006-2007 Jean-Philippe Lang
4 4 #
5 5 # This program is free software; you can redistribute it and/or
6 6 # modify it under the terms of the GNU General Public License
7 7 # as published by the Free Software Foundation; either version 2
8 8 # of the License, or (at your option) any later version.
9 9 #
10 10 # This program is distributed in the hope that it will be useful,
11 11 # but WITHOUT ANY WARRANTY; without even the implied warranty of
12 12 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13 13 # GNU General Public License for more details.
14 14 #
15 15 # You should have received a copy of the GNU General Public License
16 16 # along with this program; if not, write to the Free Software
17 17 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
18 18
19 19 require File.dirname(__FILE__) + '/../test_helper'
20 20 require 'timelog_controller'
21 21
22 22 # Re-raise errors caught by the controller.
23 23 class TimelogController; def rescue_action(e) raise e end; end
24 24
25 25 class TimelogControllerTest < ActionController::TestCase
26 26 fixtures :projects, :enabled_modules, :roles, :members, :member_roles, :issues, :time_entries, :users, :trackers, :enumerations, :issue_statuses, :custom_fields, :custom_values
27 27
28 28 def setup
29 29 @controller = TimelogController.new
30 30 @request = ActionController::TestRequest.new
31 31 @response = ActionController::TestResponse.new
32 32 end
33 33
34 34 def test_get_edit
35 35 @request.session[:user_id] = 3
36 36 get :edit, :project_id => 1
37 37 assert_response :success
38 38 assert_template 'edit'
39 39 # Default activity selected
40 40 assert_tag :tag => 'option', :attributes => { :selected => 'selected' },
41 41 :content => 'Development'
42 42 end
43 43
44 44 def test_get_edit_existing_time
45 45 @request.session[:user_id] = 2
46 46 get :edit, :id => 2, :project_id => nil
47 47 assert_response :success
48 48 assert_template 'edit'
49 49 # Default activity selected
50 50 assert_tag :tag => 'form', :attributes => { :action => '/projects/ecookbook/timelog/edit/2' }
51 51 end
52 52
53 53 def test_get_edit_should_only_show_active_time_entry_activities
54 54 @request.session[:user_id] = 3
55 55 get :edit, :project_id => 1
56 56 assert_response :success
57 57 assert_template 'edit'
58 58 assert_no_tag :tag => 'option', :content => 'Inactive Activity'
59 59
60 60 end
61 61
62 62 def test_get_edit_with_an_existing_time_entry_with_inactive_activity
63 63 te = TimeEntry.find(1)
64 64 te.activity = TimeEntryActivity.find_by_name("Inactive Activity")
65 65 te.save!
66 66
67 67 @request.session[:user_id] = 1
68 68 get :edit, :project_id => 1, :id => 1
69 69 assert_response :success
70 70 assert_template 'edit'
71 71 # Blank option since nothing is pre-selected
72 72 assert_tag :tag => 'option', :content => '--- Please select ---'
73 73 end
74 74
75 75 def test_post_edit
76 76 # TODO: should POST to issues’ time log instead of project. change form
77 77 # and routing
78 78 @request.session[:user_id] = 3
79 79 post :edit, :project_id => 1,
80 80 :time_entry => {:comments => 'Some work on TimelogControllerTest',
81 81 # Not the default activity
82 82 :activity_id => '11',
83 83 :spent_on => '2008-03-14',
84 84 :issue_id => '1',
85 85 :hours => '7.3'}
86 86 assert_redirected_to :action => 'details', :project_id => 'ecookbook'
87 87
88 88 i = Issue.find(1)
89 89 t = TimeEntry.find_by_comments('Some work on TimelogControllerTest')
90 90 assert_not_nil t
91 91 assert_equal 11, t.activity_id
92 92 assert_equal 7.3, t.hours
93 93 assert_equal 3, t.user_id
94 94 assert_equal i, t.issue
95 95 assert_equal i.project, t.project
96 96 end
97 97
98 98 def test_update
99 99 entry = TimeEntry.find(1)
100 100 assert_equal 1, entry.issue_id
101 101 assert_equal 2, entry.user_id
102 102
103 103 @request.session[:user_id] = 1
104 104 post :edit, :id => 1,
105 105 :time_entry => {:issue_id => '2',
106 106 :hours => '8'}
107 107 assert_redirected_to :action => 'details', :project_id => 'ecookbook'
108 108 entry.reload
109 109
110 110 assert_equal 8, entry.hours
111 111 assert_equal 2, entry.issue_id
112 112 assert_equal 2, entry.user_id
113 113 end
114 114
115 115 def test_destroy
116 116 @request.session[:user_id] = 2
117 117 post :destroy, :id => 1
118 118 assert_redirected_to :action => 'details', :project_id => 'ecookbook'
119 119 assert_equal I18n.t(:notice_successful_delete), flash[:notice]
120 120 assert_nil TimeEntry.find_by_id(1)
121 121 end
122 122
123 123 def test_destroy_should_fail
124 124 # simulate that this fails (e.g. due to a plugin), see #5700
125 125 TimeEntry.class_eval do
126 126 before_destroy :stop_callback_chain
127 127 def stop_callback_chain ; return false ; end
128 128 end
129 129
130 130 @request.session[:user_id] = 2
131 131 post :destroy, :id => 1
132 132 assert_redirected_to :action => 'details', :project_id => 'ecookbook'
133 133 assert_equal I18n.t(:notice_unable_delete_time_entry), flash[:error]
134 134 assert_not_nil TimeEntry.find_by_id(1)
135 135
136 136 # remove the simulation
137 137 TimeEntry.before_destroy.reject! {|callback| callback.method == :stop_callback_chain }
138 138 end
139 139
140 140 def test_report_no_criteria
141 141 get :report, :project_id => 1
142 142 assert_response :success
143 143 assert_template 'report'
144 144 end
145 145
146 146 def test_report_all_projects
147 147 get :report
148 148 assert_response :success
149 149 assert_template 'report'
150 150 end
151 151
152 152 def test_report_all_projects_denied
153 153 r = Role.anonymous
154 154 r.permissions.delete(:view_time_entries)
155 155 r.permissions_will_change!
156 156 r.save
157 157 get :report
158 158 assert_redirected_to '/login?back_url=http%3A%2F%2Ftest.host%2Ftime_entries%2Freport'
159 159 end
160 160
161 161 def test_report_all_projects_one_criteria
162 162 get :report, :columns => 'week', :from => "2007-04-01", :to => "2007-04-30", :criterias => ['project']
163 163 assert_response :success
164 164 assert_template 'report'
165 165 assert_not_nil assigns(:total_hours)
166 166 assert_equal "8.65", "%.2f" % assigns(:total_hours)
167 167 end
168 168
169 169 def test_report_all_time
170 170 get :report, :project_id => 1, :criterias => ['project', 'issue']
171 171 assert_response :success
172 172 assert_template 'report'
173 173 assert_not_nil assigns(:total_hours)
174 174 assert_equal "162.90", "%.2f" % assigns(:total_hours)
175 175 end
176 176
177 177 def test_report_all_time_by_day
178 178 get :report, :project_id => 1, :criterias => ['project', 'issue'], :columns => 'day'
179 179 assert_response :success
180 180 assert_template 'report'
181 181 assert_not_nil assigns(:total_hours)
182 182 assert_equal "162.90", "%.2f" % assigns(:total_hours)
183 183 assert_tag :tag => 'th', :content => '2007-03-12'
184 184 end
185 185
186 186 def test_report_one_criteria
187 187 get :report, :project_id => 1, :columns => 'week', :from => "2007-04-01", :to => "2007-04-30", :criterias => ['project']
188 188 assert_response :success
189 189 assert_template 'report'
190 190 assert_not_nil assigns(:total_hours)
191 191 assert_equal "8.65", "%.2f" % assigns(:total_hours)
192 192 end
193 193
194 194 def test_report_two_criterias
195 195 get :report, :project_id => 1, :columns => 'month', :from => "2007-01-01", :to => "2007-12-31", :criterias => ["member", "activity"]
196 196 assert_response :success
197 197 assert_template 'report'
198 198 assert_not_nil assigns(:total_hours)
199 199 assert_equal "162.90", "%.2f" % assigns(:total_hours)
200 200 end
201 201
202 202 def test_report_one_day
203 203 get :report, :project_id => 1, :columns => 'day', :from => "2007-03-23", :to => "2007-03-23", :criterias => ["member", "activity"]
204 204 assert_response :success
205 205 assert_template 'report'
206 206 assert_not_nil assigns(:total_hours)
207 207 assert_equal "4.25", "%.2f" % assigns(:total_hours)
208 208 end
209 209
210 210 def test_report_at_issue_level
211 211 get :report, :project_id => 1, :issue_id => 1, :columns => 'month', :from => "2007-01-01", :to => "2007-12-31", :criterias => ["member", "activity"]
212 212 assert_response :success
213 213 assert_template 'report'
214 214 assert_not_nil assigns(:total_hours)
215 215 assert_equal "154.25", "%.2f" % assigns(:total_hours)
216 216 end
217 217
218 218 def test_report_custom_field_criteria
219 219 get :report, :project_id => 1, :criterias => ['project', 'cf_1', 'cf_7']
220 220 assert_response :success
221 221 assert_template 'report'
222 222 assert_not_nil assigns(:total_hours)
223 223 assert_not_nil assigns(:criterias)
224 224 assert_equal 3, assigns(:criterias).size
225 225 assert_equal "162.90", "%.2f" % assigns(:total_hours)
226 226 # Custom field column
227 227 assert_tag :tag => 'th', :content => 'Database'
228 228 # Custom field row
229 229 assert_tag :tag => 'td', :content => 'MySQL',
230 230 :sibling => { :tag => 'td', :attributes => { :class => 'hours' },
231 231 :child => { :tag => 'span', :attributes => { :class => 'hours hours-int' },
232 232 :content => '1' }}
233 233 # Second custom field column
234 234 assert_tag :tag => 'th', :content => 'Billable'
235 235 end
236 236
237 237 def test_report_one_criteria_no_result
238 238 get :report, :project_id => 1, :columns => 'week', :from => "1998-04-01", :to => "1998-04-30", :criterias => ['project']
239 239 assert_response :success
240 240 assert_template 'report'
241 241 assert_not_nil assigns(:total_hours)
242 242 assert_equal "0.00", "%.2f" % assigns(:total_hours)
243 243 end
244 244
245 245 def test_report_all_projects_csv_export
246 246 get :report, :columns => 'month', :from => "2007-01-01", :to => "2007-06-30", :criterias => ["project", "member", "activity"], :format => "csv"
247 247 assert_response :success
248 248 assert_equal 'text/csv', @response.content_type
249 249 lines = @response.body.chomp.split("\n")
250 250 # Headers
251 251 assert_equal 'Project,Member,Activity,2007-1,2007-2,2007-3,2007-4,2007-5,2007-6,Total', lines.first
252 252 # Total row
253 253 assert_equal 'Total,"","","","",154.25,8.65,"","",162.90', lines.last
254 254 end
255 255
256 256 def test_report_csv_export
257 257 get :report, :project_id => 1, :columns => 'month', :from => "2007-01-01", :to => "2007-06-30", :criterias => ["project", "member", "activity"], :format => "csv"
258 258 assert_response :success
259 259 assert_equal 'text/csv', @response.content_type
260 260 lines = @response.body.chomp.split("\n")
261 261 # Headers
262 262 assert_equal 'Project,Member,Activity,2007-1,2007-2,2007-3,2007-4,2007-5,2007-6,Total', lines.first
263 263 # Total row
264 264 assert_equal 'Total,"","","","",154.25,8.65,"","",162.90', lines.last
265 265 end
266 266
267 267 def test_details_all_projects
268 268 get :details
269 269 assert_response :success
270 270 assert_template 'details'
271 271 assert_not_nil assigns(:total_hours)
272 272 assert_equal "162.90", "%.2f" % assigns(:total_hours)
273 273 end
274 274
275 275 def test_details_at_project_level
276 276 get :details, :project_id => 1
277 277 assert_response :success
278 278 assert_template 'details'
279 279 assert_not_nil assigns(:entries)
280 280 assert_equal 4, assigns(:entries).size
281 281 # project and subproject
282 282 assert_equal [1, 3], assigns(:entries).collect(&:project_id).uniq.sort
283 283 assert_not_nil assigns(:total_hours)
284 284 assert_equal "162.90", "%.2f" % assigns(:total_hours)
285 285 # display all time by default
286 assert_equal '2007-03-11'.to_date, assigns(:from)
286 assert_equal '2007-03-12'.to_date, assigns(:from)
287 287 assert_equal '2007-04-22'.to_date, assigns(:to)
288 288 end
289 289
290 290 def test_details_at_project_level_with_date_range
291 291 get :details, :project_id => 1, :from => '2007-03-20', :to => '2007-04-30'
292 292 assert_response :success
293 293 assert_template 'details'
294 294 assert_not_nil assigns(:entries)
295 295 assert_equal 3, assigns(:entries).size
296 296 assert_not_nil assigns(:total_hours)
297 297 assert_equal "12.90", "%.2f" % assigns(:total_hours)
298 298 assert_equal '2007-03-20'.to_date, assigns(:from)
299 299 assert_equal '2007-04-30'.to_date, assigns(:to)
300 300 end
301 301
302 302 def test_details_at_project_level_with_period
303 303 get :details, :project_id => 1, :period => '7_days'
304 304 assert_response :success
305 305 assert_template 'details'
306 306 assert_not_nil assigns(:entries)
307 307 assert_not_nil assigns(:total_hours)
308 308 assert_equal Date.today - 7, assigns(:from)
309 309 assert_equal Date.today, assigns(:to)
310 310 end
311 311
312 312 def test_details_one_day
313 313 get :details, :project_id => 1, :from => "2007-03-23", :to => "2007-03-23"
314 314 assert_response :success
315 315 assert_template 'details'
316 316 assert_not_nil assigns(:total_hours)
317 317 assert_equal "4.25", "%.2f" % assigns(:total_hours)
318 318 end
319 319
320 320 def test_details_at_issue_level
321 321 get :details, :issue_id => 1
322 322 assert_response :success
323 323 assert_template 'details'
324 324 assert_not_nil assigns(:entries)
325 325 assert_equal 2, assigns(:entries).size
326 326 assert_not_nil assigns(:total_hours)
327 327 assert_equal 154.25, assigns(:total_hours)
328 # display all time by default
329 assert_equal '2007-03-11'.to_date, assigns(:from)
328 # display all time based on what's been logged
329 assert_equal '2007-03-12'.to_date, assigns(:from)
330 330 assert_equal '2007-04-22'.to_date, assigns(:to)
331 331 end
332 332
333 333 def test_details_atom_feed
334 334 get :details, :project_id => 1, :format => 'atom'
335 335 assert_response :success
336 336 assert_equal 'application/atom+xml', @response.content_type
337 337 assert_not_nil assigns(:items)
338 338 assert assigns(:items).first.is_a?(TimeEntry)
339 339 end
340 340
341 341 def test_details_all_projects_csv_export
342 342 Setting.date_format = '%m/%d/%Y'
343 343 get :details, :format => 'csv'
344 344 assert_response :success
345 345 assert_equal 'text/csv', @response.content_type
346 346 assert @response.body.include?("Date,User,Activity,Project,Issue,Tracker,Subject,Hours,Comment\n")
347 347 assert @response.body.include?("\n04/21/2007,redMine Admin,Design,eCookbook,3,Bug,Error 281 when updating a recipe,1.0,\"\"\n")
348 348 end
349 349
350 350 def test_details_csv_export
351 351 Setting.date_format = '%m/%d/%Y'
352 352 get :details, :project_id => 1, :format => 'csv'
353 353 assert_response :success
354 354 assert_equal 'text/csv', @response.content_type
355 355 assert @response.body.include?("Date,User,Activity,Project,Issue,Tracker,Subject,Hours,Comment\n")
356 356 assert @response.body.include?("\n04/21/2007,redMine Admin,Design,eCookbook,3,Bug,Error 281 when updating a recipe,1.0,\"\"\n")
357 357 end
358 358 end
@@ -1,66 +1,99
1 1 # redMine - project management software
2 2 # Copyright (C) 2006-2008 Jean-Philippe Lang
3 3 #
4 4 # This program is free software; you can redistribute it and/or
5 5 # modify it under the terms of the GNU General Public License
6 6 # as published by the Free Software Foundation; either version 2
7 7 # of the License, or (at your option) any later version.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU General Public License
15 15 # along with this program; if not, write to the Free Software
16 16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17 17
18 18 require File.dirname(__FILE__) + '/../test_helper'
19 19
20 20 class TimeEntryTest < ActiveSupport::TestCase
21 21 fixtures :issues, :projects, :users, :time_entries
22 22
23 23 def test_hours_format
24 24 assertions = { "2" => 2.0,
25 25 "21.1" => 21.1,
26 26 "2,1" => 2.1,
27 27 "1,5h" => 1.5,
28 28 "7:12" => 7.2,
29 29 "10h" => 10.0,
30 30 "10 h" => 10.0,
31 31 "45m" => 0.75,
32 32 "45 m" => 0.75,
33 33 "3h15" => 3.25,
34 34 "3h 15" => 3.25,
35 35 "3 h 15" => 3.25,
36 36 "3 h 15m" => 3.25,
37 37 "3 h 15 m" => 3.25,
38 38 "3 hours" => 3.0,
39 39 "12min" => 0.2,
40 40 }
41 41
42 42 assertions.each do |k, v|
43 43 t = TimeEntry.new(:hours => k)
44 44 assert_equal v, t.hours, "Converting #{k} failed:"
45 45 end
46 46 end
47 47
48 48 def test_hours_should_default_to_nil
49 49 assert_nil TimeEntry.new.hours
50 50 end
51 51
52 52 context "#earilest_date_for_project" do
53 should "return the lowest spent_on value that is visible to the current user" do
53 setup do
54 54 User.current = nil
55 @public_project = Project.generate!(:is_public => true)
56 @issue = Issue.generate_for_project!(@public_project)
57 TimeEntry.generate!(:spent_on => '2010-01-01',
58 :issue => @issue,
59 :project => @public_project)
60 end
61
62 context "without a project" do
63 should "return the lowest spent_on value that is visible to the current user" do
55 64 assert_equal "2007-03-12", TimeEntry.earilest_date_for_project.to_s
56 65 end
57 66 end
58 67
68 context "with a project" do
69 should "return the lowest spent_on value that is visible to the current user for that project and it's subprojects only" do
70 assert_equal "2010-01-01", TimeEntry.earilest_date_for_project(@public_project).to_s
71 end
72 end
73
74 end
75
59 76 context "#latest_date_for_project" do
60 should "return the highest spent_on value that is visible to the current user" do
77 setup do
61 78 User.current = nil
62 assert_equal "2007-04-22", TimeEntry.latest_date_for_project.to_s
79 @public_project = Project.generate!(:is_public => true)
80 @issue = Issue.generate_for_project!(@public_project)
81 TimeEntry.generate!(:spent_on => '2010-01-01',
82 :issue => @issue,
83 :project => @public_project)
84 end
85
86 context "without a project" do
87 should "return the highest spent_on value that is visible to the current user" do
88 assert_equal "2010-01-01", TimeEntry.latest_date_for_project.to_s
63 89 end
64 90 end
65 91
92 context "with a project" do
93 should "return the highest spent_on value that is visible to the current user for that project and it's subprojects only" do
94 project = Project.find(1)
95 assert_equal "2007-04-22", TimeEntry.latest_date_for_project(project).to_s
96 end
97 end
98 end
66 99 end
General Comments 0
You need to be logged in to leave comments. Login now