##// END OF EJS Templates
Adds cross-project time reports support (#994)....
Jean-Philippe Lang -
r1777:696d21f8c8c8
parent child
Show More
@@ -1,221 +1,225
1 1 # redMine - project management software
2 2 # Copyright (C) 2006-2007 Jean-Philippe Lang
3 3 #
4 4 # This program is free software; you can redistribute it and/or
5 5 # modify it under the terms of the GNU General Public License
6 6 # as published by the Free Software Foundation; either version 2
7 7 # of the License, or (at your option) any later version.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU General Public License
15 15 # along with this program; if not, write to the Free Software
16 16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17 17
18 18 require 'uri'
19 19
20 20 class ApplicationController < ActionController::Base
21 21 layout 'base'
22 22
23 23 before_filter :user_setup, :check_if_login_required, :set_localization
24 24 filter_parameter_logging :password
25 25
26 26 include Redmine::MenuManager::MenuController
27 27 helper Redmine::MenuManager::MenuHelper
28 28
29 29 REDMINE_SUPPORTED_SCM.each do |scm|
30 30 require_dependency "repository/#{scm.underscore}"
31 31 end
32 32
33 33 def current_role
34 34 @current_role ||= User.current.role_for_project(@project)
35 35 end
36 36
37 37 def user_setup
38 38 # Check the settings cache for each request
39 39 Setting.check_cache
40 40 # Find the current user
41 41 User.current = find_current_user
42 42 end
43 43
44 44 # Returns the current user or nil if no user is logged in
45 45 def find_current_user
46 46 if session[:user_id]
47 47 # existing session
48 48 (User.find_active(session[:user_id]) rescue nil)
49 49 elsif cookies[:autologin] && Setting.autologin?
50 50 # auto-login feature
51 51 User.find_by_autologin_key(cookies[:autologin])
52 52 elsif params[:key] && accept_key_auth_actions.include?(params[:action])
53 53 # RSS key authentication
54 54 User.find_by_rss_key(params[:key])
55 55 end
56 56 end
57 57
58 58 # check if login is globally required to access the application
59 59 def check_if_login_required
60 60 # no check needed if user is already logged in
61 61 return true if User.current.logged?
62 62 require_login if Setting.login_required?
63 63 end
64 64
65 65 def set_localization
66 66 User.current.language = nil unless User.current.logged?
67 67 lang = begin
68 68 if !User.current.language.blank? && GLoc.valid_language?(User.current.language)
69 69 User.current.language
70 70 elsif request.env['HTTP_ACCEPT_LANGUAGE']
71 71 accept_lang = parse_qvalues(request.env['HTTP_ACCEPT_LANGUAGE']).first.downcase
72 72 if !accept_lang.blank? && (GLoc.valid_language?(accept_lang) || GLoc.valid_language?(accept_lang = accept_lang.split('-').first))
73 73 User.current.language = accept_lang
74 74 end
75 75 end
76 76 rescue
77 77 nil
78 78 end || Setting.default_language
79 79 set_language_if_valid(lang)
80 80 end
81 81
82 82 def require_login
83 83 if !User.current.logged?
84 84 redirect_to :controller => "account", :action => "login", :back_url => request.request_uri
85 85 return false
86 86 end
87 87 true
88 88 end
89 89
90 90 def require_admin
91 91 return unless require_login
92 92 if !User.current.admin?
93 93 render_403
94 94 return false
95 95 end
96 96 true
97 97 end
98 98
99 def deny_access
100 User.current.logged? ? render_403 : require_login
101 end
102
99 103 # Authorize the user for the requested action
100 104 def authorize(ctrl = params[:controller], action = params[:action])
101 105 allowed = User.current.allowed_to?({:controller => ctrl, :action => action}, @project)
102 allowed ? true : (User.current.logged? ? render_403 : require_login)
106 allowed ? true : deny_access
103 107 end
104 108
105 109 # make sure that the user is a member of the project (or admin) if project is private
106 110 # used as a before_filter for actions that do not require any particular permission on the project
107 111 def check_project_privacy
108 112 if @project && @project.active?
109 113 if @project.is_public? || User.current.member_of?(@project) || User.current.admin?
110 114 true
111 115 else
112 116 User.current.logged? ? render_403 : require_login
113 117 end
114 118 else
115 119 @project = nil
116 120 render_404
117 121 false
118 122 end
119 123 end
120 124
121 125 def redirect_back_or_default(default)
122 126 back_url = params[:back_url]
123 127 if !back_url.blank?
124 128 uri = URI.parse(back_url)
125 129 # do not redirect user to another host
126 130 if uri.relative? || (uri.host == request.host)
127 131 redirect_to(back_url) and return
128 132 end
129 133 end
130 134 redirect_to default
131 135 end
132 136
133 137 def render_403
134 138 @project = nil
135 139 render :template => "common/403", :layout => !request.xhr?, :status => 403
136 140 return false
137 141 end
138 142
139 143 def render_404
140 144 render :template => "common/404", :layout => !request.xhr?, :status => 404
141 145 return false
142 146 end
143 147
144 148 def render_error(msg)
145 149 flash.now[:error] = msg
146 150 render :nothing => true, :layout => !request.xhr?, :status => 500
147 151 end
148 152
149 153 def render_feed(items, options={})
150 154 @items = items || []
151 155 @items.sort! {|x,y| y.event_datetime <=> x.event_datetime }
152 156 @items = @items.slice(0, Setting.feeds_limit.to_i)
153 157 @title = options[:title] || Setting.app_title
154 158 render :template => "common/feed.atom.rxml", :layout => false, :content_type => 'application/atom+xml'
155 159 end
156 160
157 161 def self.accept_key_auth(*actions)
158 162 actions = actions.flatten.map(&:to_s)
159 163 write_inheritable_attribute('accept_key_auth_actions', actions)
160 164 end
161 165
162 166 def accept_key_auth_actions
163 167 self.class.read_inheritable_attribute('accept_key_auth_actions') || []
164 168 end
165 169
166 170 # TODO: move to model
167 171 def attach_files(obj, attachments)
168 172 attached = []
169 173 if attachments && attachments.is_a?(Hash)
170 174 attachments.each_value do |attachment|
171 175 file = attachment['file']
172 176 next unless file && file.size > 0
173 177 a = Attachment.create(:container => obj,
174 178 :file => file,
175 179 :description => attachment['description'].to_s.strip,
176 180 :author => User.current)
177 181 attached << a unless a.new_record?
178 182 end
179 183 end
180 184 attached
181 185 end
182 186
183 187 # Returns the number of objects that should be displayed
184 188 # on the paginated list
185 189 def per_page_option
186 190 per_page = nil
187 191 if params[:per_page] && Setting.per_page_options_array.include?(params[:per_page].to_s.to_i)
188 192 per_page = params[:per_page].to_s.to_i
189 193 session[:per_page] = per_page
190 194 elsif session[:per_page]
191 195 per_page = session[:per_page]
192 196 else
193 197 per_page = Setting.per_page_options_array.first || 25
194 198 end
195 199 per_page
196 200 end
197 201
198 202 # qvalues http header parser
199 203 # code taken from webrick
200 204 def parse_qvalues(value)
201 205 tmp = []
202 206 if value
203 207 parts = value.split(/,\s*/)
204 208 parts.each {|part|
205 209 if m = %r{^([^\s,]+?)(?:;\s*q=(\d+(?:\.\d+)?))?$}.match(part)
206 210 val = m[1]
207 211 q = (m[2] or 1).to_f
208 212 tmp.push([val, q])
209 213 end
210 214 }
211 215 tmp = tmp.sort_by{|val, q| -q}
212 216 tmp.collect!{|val, q| val}
213 217 end
214 218 return tmp
215 219 end
216 220
217 221 # Returns a string that can be used as filename value in Content-Disposition header
218 222 def filename_for_content_disposition(name)
219 223 request.env['HTTP_USER_AGENT'] =~ %r{MSIE} ? ERB::Util.url_encode(name) : name
220 224 end
221 225 end
@@ -1,267 +1,285
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 before_filter :find_project, :authorize
20 before_filter :find_project, :authorize, :only => [:edit, :destroy]
21 before_filter :find_optional_project, :only => [:report, :details]
21 22
22 23 verify :method => :post, :only => :destroy, :redirect_to => { :action => :details }
23 24
24 25 helper :sort
25 26 include SortHelper
26 27 helper :issues
27 28 include TimelogHelper
28 29 helper :custom_fields
29 30 include CustomFieldsHelper
30 31
31 32 def report
32 33 @available_criterias = { 'project' => {:sql => "#{TimeEntry.table_name}.project_id",
33 34 :klass => Project,
34 35 :label => :label_project},
35 36 'version' => {:sql => "#{Issue.table_name}.fixed_version_id",
36 37 :klass => Version,
37 38 :label => :label_version},
38 39 'category' => {:sql => "#{Issue.table_name}.category_id",
39 40 :klass => IssueCategory,
40 41 :label => :field_category},
41 42 'member' => {:sql => "#{TimeEntry.table_name}.user_id",
42 43 :klass => User,
43 44 :label => :label_member},
44 45 'tracker' => {:sql => "#{Issue.table_name}.tracker_id",
45 46 :klass => Tracker,
46 47 :label => :label_tracker},
47 48 'activity' => {:sql => "#{TimeEntry.table_name}.activity_id",
48 49 :klass => Enumeration,
49 50 :label => :label_activity},
50 51 'issue' => {:sql => "#{TimeEntry.table_name}.issue_id",
51 52 :klass => Issue,
52 53 :label => :label_issue}
53 54 }
54 55
55 56 # Add list and boolean custom fields as available criterias
56 @project.all_issue_custom_fields.select {|cf| %w(list bool).include? cf.field_format }.each do |cf|
57 custom_fields = (@project.nil? ? IssueCustomField.for_all : @project.all_issue_custom_fields)
58 custom_fields.select {|cf| %w(list bool).include? cf.field_format }.each do |cf|
57 59 @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)",
58 60 :format => cf.field_format,
59 61 :label => cf.name}
60 end
62 end if @project
61 63
62 64 # Add list and boolean time entry custom fields
63 65 TimeEntryCustomField.find(:all).select {|cf| %w(list bool).include? cf.field_format }.each do |cf|
64 66 @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)",
65 67 :format => cf.field_format,
66 68 :label => cf.name}
67 69 end
68 70
69 71 @criterias = params[:criterias] || []
70 72 @criterias = @criterias.select{|criteria| @available_criterias.has_key? criteria}
71 73 @criterias.uniq!
72 74 @criterias = @criterias[0,3]
73 75
74 76 @columns = (params[:columns] && %w(year month week day).include?(params[:columns])) ? params[:columns] : 'month'
75 77
76 78 retrieve_date_range
77 79
78 80 unless @criterias.empty?
79 81 sql_select = @criterias.collect{|criteria| @available_criterias[criteria][:sql] + " AS " + criteria}.join(', ')
80 82 sql_group_by = @criterias.collect{|criteria| @available_criterias[criteria][:sql]}.join(', ')
81 83
82 84 sql = "SELECT #{sql_select}, tyear, tmonth, tweek, spent_on, SUM(hours) AS hours"
83 85 sql << " FROM #{TimeEntry.table_name}"
84 86 sql << " LEFT JOIN #{Issue.table_name} ON #{TimeEntry.table_name}.issue_id = #{Issue.table_name}.id"
85 87 sql << " LEFT JOIN #{Project.table_name} ON #{TimeEntry.table_name}.project_id = #{Project.table_name}.id"
86 sql << " WHERE (%s)" % @project.project_condition(Setting.display_subprojects_issues?)
87 sql << " AND (%s)" % Project.allowed_to_condition(User.current, :view_time_entries)
88 sql << " AND spent_on BETWEEN '%s' AND '%s'" % [ActiveRecord::Base.connection.quoted_date(@from.to_time), ActiveRecord::Base.connection.quoted_date(@to.to_time)]
88 sql << " WHERE"
89 sql << " (%s) AND" % @project.project_condition(Setting.display_subprojects_issues?) if @project
90 sql << " (%s) AND" % Project.allowed_to_condition(User.current, :view_time_entries)
91 sql << " (spent_on BETWEEN '%s' AND '%s')" % [ActiveRecord::Base.connection.quoted_date(@from.to_time), ActiveRecord::Base.connection.quoted_date(@to.to_time)]
89 92 sql << " GROUP BY #{sql_group_by}, tyear, tmonth, tweek, spent_on"
90 93
91 94 @hours = ActiveRecord::Base.connection.select_all(sql)
92 95
93 96 @hours.each do |row|
94 97 case @columns
95 98 when 'year'
96 99 row['year'] = row['tyear']
97 100 when 'month'
98 101 row['month'] = "#{row['tyear']}-#{row['tmonth']}"
99 102 when 'week'
100 103 row['week'] = "#{row['tyear']}-#{row['tweek']}"
101 104 when 'day'
102 105 row['day'] = "#{row['spent_on']}"
103 106 end
104 107 end
105 108
106 109 @total_hours = @hours.inject(0) {|s,k| s = s + k['hours'].to_f}
107 110
108 111 @periods = []
109 112 # Date#at_beginning_of_ not supported in Rails 1.2.x
110 113 date_from = @from.to_time
111 114 # 100 columns max
112 115 while date_from <= @to.to_time && @periods.length < 100
113 116 case @columns
114 117 when 'year'
115 118 @periods << "#{date_from.year}"
116 119 date_from = (date_from + 1.year).at_beginning_of_year
117 120 when 'month'
118 121 @periods << "#{date_from.year}-#{date_from.month}"
119 122 date_from = (date_from + 1.month).at_beginning_of_month
120 123 when 'week'
121 124 @periods << "#{date_from.year}-#{date_from.to_date.cweek}"
122 125 date_from = (date_from + 7.day).at_beginning_of_week
123 126 when 'day'
124 127 @periods << "#{date_from.to_date}"
125 128 date_from = date_from + 1.day
126 129 end
127 130 end
128 131 end
129 132
130 133 respond_to do |format|
131 134 format.html { render :layout => !request.xhr? }
132 135 format.csv { send_data(report_to_csv(@criterias, @periods, @hours).read, :type => 'text/csv; header=present', :filename => 'timelog.csv') }
133 136 end
134 137 end
135 138
136 139 def details
137 140 sort_init 'spent_on', 'desc'
138 141 sort_update
139 142
140 143 cond = ARCondition.new
141 cond << (@issue.nil? ? @project.project_condition(Setting.display_subprojects_issues?) :
142 ["#{TimeEntry.table_name}.issue_id = ?", @issue.id])
144 if @project.nil?
145 cond << Project.allowed_to_condition(User.current, :view_time_entries)
146 elsif @issue.nil?
147 cond << @project.project_condition(Setting.display_subprojects_issues?)
148 else
149 cond << ["#{TimeEntry.table_name}.issue_id = ?", @issue.id]
150 end
143 151
144 152 retrieve_date_range
145 153 cond << ['spent_on BETWEEN ? AND ?', @from, @to]
146 154
147 155 TimeEntry.visible_by(User.current) do
148 156 respond_to do |format|
149 157 format.html {
150 158 # Paginate results
151 159 @entry_count = TimeEntry.count(:include => :project, :conditions => cond.conditions)
152 160 @entry_pages = Paginator.new self, @entry_count, per_page_option, params['page']
153 161 @entries = TimeEntry.find(:all,
154 162 :include => [:project, :activity, :user, {:issue => :tracker}],
155 163 :conditions => cond.conditions,
156 164 :order => sort_clause,
157 165 :limit => @entry_pages.items_per_page,
158 166 :offset => @entry_pages.current.offset)
159 167 @total_hours = TimeEntry.sum(:hours, :include => :project, :conditions => cond.conditions).to_f
160 168
161 169 render :layout => !request.xhr?
162 170 }
163 171 format.atom {
164 172 entries = TimeEntry.find(:all,
165 173 :include => [:project, :activity, :user, {:issue => :tracker}],
166 174 :conditions => cond.conditions,
167 175 :order => "#{TimeEntry.table_name}.created_on DESC",
168 176 :limit => Setting.feeds_limit.to_i)
169 177 render_feed(entries, :title => l(:label_spent_time))
170 178 }
171 179 format.csv {
172 180 # Export all entries
173 181 @entries = TimeEntry.find(:all,
174 182 :include => [:project, :activity, :user, {:issue => [:tracker, :assigned_to, :priority]}],
175 183 :conditions => cond.conditions,
176 184 :order => sort_clause)
177 185 send_data(entries_to_csv(@entries).read, :type => 'text/csv; header=present', :filename => 'timelog.csv')
178 186 }
179 187 end
180 188 end
181 189 end
182 190
183 191 def edit
184 192 render_403 and return if @time_entry && !@time_entry.editable_by?(User.current)
185 193 @time_entry ||= TimeEntry.new(:project => @project, :issue => @issue, :user => User.current, :spent_on => Date.today)
186 194 @time_entry.attributes = params[:time_entry]
187 195 if request.post? and @time_entry.save
188 196 flash[:notice] = l(:notice_successful_update)
189 197 redirect_to(params[:back_url].blank? ? {:action => 'details', :project_id => @time_entry.project} : params[:back_url])
190 198 return
191 199 end
192 200 end
193 201
194 202 def destroy
195 203 render_404 and return unless @time_entry
196 204 render_403 and return unless @time_entry.editable_by?(User.current)
197 205 @time_entry.destroy
198 206 flash[:notice] = l(:notice_successful_delete)
199 207 redirect_to :back
200 rescue RedirectBackError
208 rescue ::ActionController::RedirectBackError
201 209 redirect_to :action => 'details', :project_id => @time_entry.project
202 210 end
203 211
204 212 private
205 213 def find_project
206 214 if params[:id]
207 215 @time_entry = TimeEntry.find(params[:id])
208 216 @project = @time_entry.project
209 217 elsif params[:issue_id]
210 218 @issue = Issue.find(params[:issue_id])
211 219 @project = @issue.project
212 220 elsif params[:project_id]
213 221 @project = Project.find(params[:project_id])
214 222 else
215 223 render_404
216 224 return false
217 225 end
218 226 rescue ActiveRecord::RecordNotFound
219 227 render_404
220 228 end
221 229
230 def find_optional_project
231 if !params[:issue_id].blank?
232 @issue = Issue.find(params[:issue_id])
233 @project = @issue.project
234 elsif !params[:project_id].blank?
235 @project = Project.find(params[:project_id])
236 end
237 deny_access unless User.current.allowed_to?(:view_time_entries, @project, :global => true)
238 end
239
222 240 # Retrieves the date range based on predefined ranges or specific from/to param dates
223 241 def retrieve_date_range
224 242 @free_period = false
225 243 @from, @to = nil, nil
226 244
227 245 if params[:period_type] == '1' || (params[:period_type].nil? && !params[:period].nil?)
228 246 case params[:period].to_s
229 247 when 'today'
230 248 @from = @to = Date.today
231 249 when 'yesterday'
232 250 @from = @to = Date.today - 1
233 251 when 'current_week'
234 252 @from = Date.today - (Date.today.cwday - 1)%7
235 253 @to = @from + 6
236 254 when 'last_week'
237 255 @from = Date.today - 7 - (Date.today.cwday - 1)%7
238 256 @to = @from + 6
239 257 when '7_days'
240 258 @from = Date.today - 7
241 259 @to = Date.today
242 260 when 'current_month'
243 261 @from = Date.civil(Date.today.year, Date.today.month, 1)
244 262 @to = (@from >> 1) - 1
245 263 when 'last_month'
246 264 @from = Date.civil(Date.today.year, Date.today.month, 1) << 1
247 265 @to = (@from >> 1) - 1
248 266 when '30_days'
249 267 @from = Date.today - 30
250 268 @to = Date.today
251 269 when 'current_year'
252 270 @from = Date.civil(Date.today.year, 1, 1)
253 271 @to = Date.civil(Date.today.year, 12, 31)
254 272 end
255 273 elsif params[:period_type] == '2' || (params[:period_type].nil? && (!params[:from].nil? || !params[:to].nil?))
256 274 begin; @from = params[:from].to_s.to_date unless params[:from].blank?; rescue; end
257 275 begin; @to = params[:to].to_s.to_date unless params[:to].blank?; rescue; end
258 276 @free_period = true
259 277 else
260 278 # default
261 279 end
262 280
263 281 @from, @to = @to, @from if @from && @to && @from > @to
264 @from ||= (TimeEntry.minimum(:spent_on, :include => :project, :conditions => @project.project_condition(Setting.display_subprojects_issues?)) || Date.today) - 1
265 @to ||= (TimeEntry.maximum(:spent_on, :include => :project, :conditions => @project.project_condition(Setting.display_subprojects_issues?)) || Date.today)
282 @from ||= (TimeEntry.minimum(:spent_on, :include => :project, :conditions => Project.allowed_to_condition(User.current, :view_time_entries)) || Date.today) - 1
283 @to ||= (TimeEntry.maximum(:spent_on, :include => :project, :conditions => Project.allowed_to_condition(User.current, :view_time_entries)) || Date.today)
266 284 end
267 285 end
@@ -1,150 +1,158
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 module TimelogHelper
19 def render_timelog_breadcrumb
20 links = []
21 links << link_to(l(:label_project_all), {:project_id => nil, :issue_id => nil})
22 links << link_to(h(@project), {:project_id => @project, :issue_id => nil}) if @project
23 links << link_to_issue(@issue) if @issue
24 breadcrumb links
25 end
26
19 27 def activity_collection_for_select_options
20 28 activities = Enumeration::get_values('ACTI')
21 29 collection = []
22 30 collection << [ "--- #{l(:actionview_instancetag_blank_option)} ---", '' ] unless activities.detect(&:is_default)
23 31 activities.each { |a| collection << [a.name, a.id] }
24 32 collection
25 33 end
26 34
27 35 def select_hours(data, criteria, value)
28 36 data.select {|row| row[criteria] == value}
29 37 end
30 38
31 39 def sum_hours(data)
32 40 sum = 0
33 41 data.each do |row|
34 42 sum += row['hours'].to_f
35 43 end
36 44 sum
37 45 end
38 46
39 47 def options_for_period_select(value)
40 48 options_for_select([[l(:label_all_time), 'all'],
41 49 [l(:label_today), 'today'],
42 50 [l(:label_yesterday), 'yesterday'],
43 51 [l(:label_this_week), 'current_week'],
44 52 [l(:label_last_week), 'last_week'],
45 53 [l(:label_last_n_days, 7), '7_days'],
46 54 [l(:label_this_month), 'current_month'],
47 55 [l(:label_last_month), 'last_month'],
48 56 [l(:label_last_n_days, 30), '30_days'],
49 57 [l(:label_this_year), 'current_year']],
50 58 value)
51 59 end
52 60
53 61 def entries_to_csv(entries)
54 62 ic = Iconv.new(l(:general_csv_encoding), 'UTF-8')
55 63 decimal_separator = l(:general_csv_decimal_separator)
56 64 custom_fields = TimeEntryCustomField.find(:all)
57 65 export = StringIO.new
58 66 CSV::Writer.generate(export, l(:general_csv_separator)) do |csv|
59 67 # csv header fields
60 68 headers = [l(:field_spent_on),
61 69 l(:field_user),
62 70 l(:field_activity),
63 71 l(:field_project),
64 72 l(:field_issue),
65 73 l(:field_tracker),
66 74 l(:field_subject),
67 75 l(:field_hours),
68 76 l(:field_comments)
69 77 ]
70 78 # Export custom fields
71 79 headers += custom_fields.collect(&:name)
72 80
73 81 csv << headers.collect {|c| begin; ic.iconv(c.to_s); rescue; c.to_s; end }
74 82 # csv lines
75 83 entries.each do |entry|
76 84 fields = [l_date(entry.spent_on),
77 85 entry.user,
78 86 entry.activity,
79 87 entry.project,
80 88 (entry.issue ? entry.issue.id : nil),
81 89 (entry.issue ? entry.issue.tracker : nil),
82 90 (entry.issue ? entry.issue.subject : nil),
83 91 entry.hours.to_s.gsub('.', decimal_separator),
84 92 entry.comments
85 93 ]
86 94 fields += custom_fields.collect {|f| show_value(entry.custom_value_for(f)) }
87 95
88 96 csv << fields.collect {|c| begin; ic.iconv(c.to_s); rescue; c.to_s; end }
89 97 end
90 98 end
91 99 export.rewind
92 100 export
93 101 end
94 102
95 103 def format_criteria_value(criteria, value)
96 104 value.blank? ? l(:label_none) : ((k = @available_criterias[criteria][:klass]) ? k.find_by_id(value.to_i) : format_value(value, @available_criterias[criteria][:format]))
97 105 end
98 106
99 107 def report_to_csv(criterias, periods, hours)
100 108 export = StringIO.new
101 109 CSV::Writer.generate(export, l(:general_csv_separator)) do |csv|
102 110 # Column headers
103 111 headers = criterias.collect {|criteria| l(@available_criterias[criteria][:label]) }
104 112 headers += periods
105 113 headers << l(:label_total)
106 114 csv << headers.collect {|c| to_utf8(c) }
107 115 # Content
108 116 report_criteria_to_csv(csv, criterias, periods, hours)
109 117 # Total row
110 118 row = [ l(:label_total) ] + [''] * (criterias.size - 1)
111 119 total = 0
112 120 periods.each do |period|
113 121 sum = sum_hours(select_hours(hours, @columns, period.to_s))
114 122 total += sum
115 123 row << (sum > 0 ? "%.2f" % sum : '')
116 124 end
117 125 row << "%.2f" %total
118 126 csv << row
119 127 end
120 128 export.rewind
121 129 export
122 130 end
123 131
124 132 def report_criteria_to_csv(csv, criterias, periods, hours, level=0)
125 133 hours.collect {|h| h[criterias[level]].to_s}.uniq.each do |value|
126 134 hours_for_value = select_hours(hours, criterias[level], value)
127 135 next if hours_for_value.empty?
128 136 row = [''] * level
129 137 row << to_utf8(format_criteria_value(criterias[level], value))
130 138 row += [''] * (criterias.length - level - 1)
131 139 total = 0
132 140 periods.each do |period|
133 141 sum = sum_hours(select_hours(hours_for_value, @columns, period.to_s))
134 142 total += sum
135 143 row << (sum > 0 ? "%.2f" % sum : '')
136 144 end
137 145 row << "%.2f" %total
138 146 csv << row
139 147
140 148 if criterias.length > level + 1
141 149 report_criteria_to_csv(csv, criterias, periods, hours_for_value, level + 1)
142 150 end
143 151 end
144 152 end
145 153
146 154 def to_utf8(s)
147 155 @ic ||= Iconv.new(l(:general_csv_encoding), 'UTF-8')
148 156 begin; @ic.iconv(s.to_s); rescue; s.to_s; end
149 157 end
150 158 end
@@ -1,294 +1,294
1 1 # redMine - project management software
2 2 # Copyright (C) 2006-2007 Jean-Philippe Lang
3 3 #
4 4 # This program is free software; you can redistribute it and/or
5 5 # modify it under the terms of the GNU General Public License
6 6 # as published by the Free Software Foundation; either version 2
7 7 # of the License, or (at your option) any later version.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU General Public License
15 15 # along with this program; if not, write to the Free Software
16 16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17 17
18 18 require "digest/sha1"
19 19
20 20 class User < ActiveRecord::Base
21 21
22 22 # Account statuses
23 23 STATUS_ANONYMOUS = 0
24 24 STATUS_ACTIVE = 1
25 25 STATUS_REGISTERED = 2
26 26 STATUS_LOCKED = 3
27 27
28 28 USER_FORMATS = {
29 29 :firstname_lastname => '#{firstname} #{lastname}',
30 30 :firstname => '#{firstname}',
31 31 :lastname_firstname => '#{lastname} #{firstname}',
32 32 :lastname_coma_firstname => '#{lastname}, #{firstname}',
33 33 :username => '#{login}'
34 34 }
35 35
36 36 has_many :memberships, :class_name => 'Member', :include => [ :project, :role ], :conditions => "#{Project.table_name}.status=#{Project::STATUS_ACTIVE}", :order => "#{Project.table_name}.name"
37 37 has_many :members, :dependent => :delete_all
38 38 has_many :projects, :through => :memberships
39 39 has_many :issue_categories, :foreign_key => 'assigned_to_id', :dependent => :nullify
40 40 has_one :preference, :dependent => :destroy, :class_name => 'UserPreference'
41 41 has_one :rss_token, :dependent => :destroy, :class_name => 'Token', :conditions => "action='feeds'"
42 42 belongs_to :auth_source
43 43
44 44 acts_as_customizable
45 45
46 46 attr_accessor :password, :password_confirmation
47 47 attr_accessor :last_before_login_on
48 48 # Prevents unauthorized assignments
49 49 attr_protected :login, :admin, :password, :password_confirmation, :hashed_password
50 50
51 51 validates_presence_of :login, :firstname, :lastname, :mail, :if => Proc.new { |user| !user.is_a?(AnonymousUser) }
52 52 validates_uniqueness_of :login, :if => Proc.new { |user| !user.login.blank? }
53 53 validates_uniqueness_of :mail, :if => Proc.new { |user| !user.mail.blank? }
54 54 # Login must contain lettres, numbers, underscores only
55 55 validates_format_of :login, :with => /^[a-z0-9_\-@\.]*$/i
56 56 validates_length_of :login, :maximum => 30
57 57 validates_format_of :firstname, :lastname, :with => /^[\w\s\'\-\.]*$/i
58 58 validates_length_of :firstname, :lastname, :maximum => 30
59 59 validates_format_of :mail, :with => /^([^@\s]+)@((?:[-a-z0-9]+\.)+[a-z]{2,})$/i, :allow_nil => true
60 60 validates_length_of :mail, :maximum => 60, :allow_nil => true
61 61 validates_length_of :password, :minimum => 4, :allow_nil => true
62 62 validates_confirmation_of :password, :allow_nil => true
63 63
64 64 def before_create
65 65 self.mail_notification = false
66 66 true
67 67 end
68 68
69 69 def before_save
70 70 # update hashed_password if password was set
71 71 self.hashed_password = User.hash_password(self.password) if self.password
72 72 end
73 73
74 74 def self.active
75 75 with_scope :find => { :conditions => [ "status = ?", STATUS_ACTIVE ] } do
76 76 yield
77 77 end
78 78 end
79 79
80 80 def self.find_active(*args)
81 81 active do
82 82 find(*args)
83 83 end
84 84 end
85 85
86 86 # Returns the user that matches provided login and password, or nil
87 87 def self.try_to_login(login, password)
88 88 # Make sure no one can sign in with an empty password
89 89 return nil if password.to_s.empty?
90 90 user = find(:first, :conditions => ["login=?", login])
91 91 if user
92 92 # user is already in local database
93 93 return nil if !user.active?
94 94 if user.auth_source
95 95 # user has an external authentication method
96 96 return nil unless user.auth_source.authenticate(login, password)
97 97 else
98 98 # authentication with local password
99 99 return nil unless User.hash_password(password) == user.hashed_password
100 100 end
101 101 else
102 102 # user is not yet registered, try to authenticate with available sources
103 103 attrs = AuthSource.authenticate(login, password)
104 104 if attrs
105 105 user = new(*attrs)
106 106 user.login = login
107 107 user.language = Setting.default_language
108 108 if user.save
109 109 user.reload
110 110 logger.info("User '#{user.login}' created from the LDAP") if logger
111 111 end
112 112 end
113 113 end
114 114 user.update_attribute(:last_login_on, Time.now) if user && !user.new_record?
115 115 user
116 116 rescue => text
117 117 raise text
118 118 end
119 119
120 120 # Return user's full name for display
121 121 def name(formatter = nil)
122 122 f = USER_FORMATS[formatter || Setting.user_format] || USER_FORMATS[:firstname_lastname]
123 123 eval '"' + f + '"'
124 124 end
125 125
126 126 def active?
127 127 self.status == STATUS_ACTIVE
128 128 end
129 129
130 130 def registered?
131 131 self.status == STATUS_REGISTERED
132 132 end
133 133
134 134 def locked?
135 135 self.status == STATUS_LOCKED
136 136 end
137 137
138 138 def check_password?(clear_password)
139 139 User.hash_password(clear_password) == self.hashed_password
140 140 end
141 141
142 142 def pref
143 143 self.preference ||= UserPreference.new(:user => self)
144 144 end
145 145
146 146 def time_zone
147 147 self.pref.time_zone.nil? ? nil : TimeZone[self.pref.time_zone]
148 148 end
149 149
150 150 def wants_comments_in_reverse_order?
151 151 self.pref[:comments_sorting] == 'desc'
152 152 end
153 153
154 154 # Return user's RSS key (a 40 chars long string), used to access feeds
155 155 def rss_key
156 156 token = self.rss_token || Token.create(:user => self, :action => 'feeds')
157 157 token.value
158 158 end
159 159
160 160 # Return an array of project ids for which the user has explicitly turned mail notifications on
161 161 def notified_projects_ids
162 162 @notified_projects_ids ||= memberships.select {|m| m.mail_notification?}.collect(&:project_id)
163 163 end
164 164
165 165 def notified_project_ids=(ids)
166 166 Member.update_all("mail_notification = #{connection.quoted_false}", ['user_id = ?', id])
167 167 Member.update_all("mail_notification = #{connection.quoted_true}", ['user_id = ? AND project_id IN (?)', id, ids]) if ids && !ids.empty?
168 168 @notified_projects_ids = nil
169 169 notified_projects_ids
170 170 end
171 171
172 172 def self.find_by_rss_key(key)
173 173 token = Token.find_by_value(key)
174 174 token && token.user.active? ? token.user : nil
175 175 end
176 176
177 177 def self.find_by_autologin_key(key)
178 178 token = Token.find_by_action_and_value('autologin', key)
179 179 token && (token.created_on > Setting.autologin.to_i.day.ago) && token.user.active? ? token.user : nil
180 180 end
181 181
182 182 def <=>(user)
183 183 if user.nil?
184 184 -1
185 185 elsif lastname.to_s.downcase == user.lastname.to_s.downcase
186 186 firstname.to_s.downcase <=> user.firstname.to_s.downcase
187 187 else
188 188 lastname.to_s.downcase <=> user.lastname.to_s.downcase
189 189 end
190 190 end
191 191
192 192 def to_s
193 193 name
194 194 end
195 195
196 196 def logged?
197 197 true
198 198 end
199 199
200 200 def anonymous?
201 201 !logged?
202 202 end
203 203
204 204 # Return user's role for project
205 205 def role_for_project(project)
206 206 # No role on archived projects
207 207 return nil unless project && project.active?
208 208 if logged?
209 209 # Find project membership
210 210 membership = memberships.detect {|m| m.project_id == project.id}
211 211 if membership
212 212 membership.role
213 213 else
214 214 @role_non_member ||= Role.non_member
215 215 end
216 216 else
217 217 @role_anonymous ||= Role.anonymous
218 218 end
219 219 end
220 220
221 221 # Return true if the user is a member of project
222 222 def member_of?(project)
223 223 role_for_project(project).member?
224 224 end
225 225
226 226 # Return true if the user is allowed to do the specified action on project
227 227 # action can be:
228 228 # * a parameter-like Hash (eg. :controller => 'projects', :action => 'edit')
229 229 # * a permission Symbol (eg. :edit_project)
230 230 def allowed_to?(action, project, options={})
231 231 if project
232 232 # No action allowed on archived projects
233 233 return false unless project.active?
234 234 # No action allowed on disabled modules
235 235 return false unless project.allows_to?(action)
236 236 # Admin users are authorized for anything else
237 237 return true if admin?
238 238
239 239 role = role_for_project(project)
240 240 return false unless role
241 241 role.allowed_to?(action) && (project.is_public? || role.member?)
242 242
243 243 elsif options[:global]
244 244 # authorize if user has at least one role that has this permission
245 245 roles = memberships.collect {|m| m.role}.uniq
246 roles.detect {|r| r.allowed_to?(action)}
246 roles.detect {|r| r.allowed_to?(action)} || (self.logged? ? Role.non_member.allowed_to?(action) : Role.anonymous.allowed_to?(action))
247 247 else
248 248 false
249 249 end
250 250 end
251 251
252 252 def self.current=(user)
253 253 @current_user = user
254 254 end
255 255
256 256 def self.current
257 257 @current_user ||= User.anonymous
258 258 end
259 259
260 260 def self.anonymous
261 261 anonymous_user = AnonymousUser.find(:first)
262 262 if anonymous_user.nil?
263 263 anonymous_user = AnonymousUser.create(:lastname => 'Anonymous', :firstname => '', :mail => '', :login => '', :status => 0)
264 264 raise 'Unable to create the anonymous user.' if anonymous_user.new_record?
265 265 end
266 266 anonymous_user
267 267 end
268 268
269 269 private
270 270 # Return password digest
271 271 def self.hash_password(clear_password)
272 272 Digest::SHA1.hexdigest(clear_password || "")
273 273 end
274 274 end
275 275
276 276 class AnonymousUser < User
277 277
278 278 def validate_on_create
279 279 # There should be only one AnonymousUser in the database
280 280 errors.add_to_base 'An anonymous user already exists.' if AnonymousUser.find(:first)
281 281 end
282 282
283 283 def available_custom_fields
284 284 []
285 285 end
286 286
287 287 # Overrides a few properties
288 288 def logged?; false end
289 289 def admin; false end
290 290 def name; 'Anonymous' end
291 291 def mail; nil end
292 292 def time_zone; nil end
293 293 def rss_key; nil end
294 294 end
@@ -1,36 +1,34
1 1 <div class="contextual">
2 2 <%= link_to_if_authorized l(:button_log_time), {:controller => 'timelog', :action => 'edit', :project_id => @project, :issue_id => @issue}, :class => 'icon icon-time' %>
3 3 </div>
4 4
5 <h2><%= l(:label_spent_time) %></h2>
5 <%= render_timelog_breadcrumb %>
6 6
7 <% if @issue %>
8 <h3><%= link_to(@project.name, {:action => 'details', :project_id => @project}) %> / <%= link_to_issue(@issue) %></h3>
9 <% end %>
7 <h2><%= l(:label_spent_time) %></h2>
10 8
11 9 <% form_remote_tag( :url => {}, :method => :get, :update => 'content' ) do %>
12 10 <%= hidden_field_tag 'project_id', params[:project_id] %>
13 11 <%= hidden_field_tag 'issue_id', params[:issue_id] if @issue %>
14 12 <%= render :partial => 'date_range' %>
15 13 <% end %>
16 14
17 15 <div class="total-hours">
18 16 <p><%= l(:label_total) %>: <%= html_hours(lwr(:label_f_hour, @total_hours)) %></p>
19 17 </div>
20 18
21 19 <% unless @entries.empty? %>
22 20 <%= render :partial => 'list', :locals => { :entries => @entries }%>
23 21 <p class="pagination"><%= pagination_links_full @entry_pages, @entry_count %></p>
24 22
25 23 <p class="other-formats">
26 24 <%= l(:label_export_to) %>
27 25 <span><%= link_to 'Atom', {:issue_id => @issue, :format => 'atom', :key => User.current.rss_key}, :class => 'feed' %></span>
28 26 <span><%= link_to 'CSV', params.merge(:format => 'csv'), :class => 'csv' %></span>
29 27 </p>
30 28 <% end %>
31 29
32 30 <% html_title l(:label_spent_time), l(:label_details) %>
33 31
34 32 <% content_for :header_tags do %>
35 33 <%= auto_discovery_link_tag(:atom, {:issue_id => @issue, :format => 'atom', :key => User.current.rss_key}, :title => l(:label_spent_time)) %>
36 34 <% end %>
@@ -1,72 +1,74
1 1 <div class="contextual">
2 2 <%= link_to_if_authorized l(:button_log_time), {:controller => 'timelog', :action => 'edit', :project_id => @project, :issue_id => @issue}, :class => 'icon icon-time' %>
3 3 </div>
4 4
5 <%= render_timelog_breadcrumb %>
6
5 7 <h2><%= l(:label_spent_time) %></h2>
6 8
7 9 <% form_remote_tag(:url => {}, :update => 'content') do %>
8 10 <% @criterias.each do |criteria| %>
9 11 <%= hidden_field_tag 'criterias[]', criteria, :id => nil %>
10 12 <% end %>
11 13 <%= hidden_field_tag 'project_id', params[:project_id] %>
12 14 <%= render :partial => 'date_range' %>
13 15
14 16 <p><%= l(:label_details) %>: <%= select_tag 'columns', options_for_select([[l(:label_year), 'year'],
15 17 [l(:label_month), 'month'],
16 18 [l(:label_week), 'week'],
17 19 [l(:label_day_plural).titleize, 'day']], @columns),
18 20 :onchange => "this.form.onsubmit();" %>
19 21
20 22 <%= l(:button_add) %>: <%= select_tag('criterias[]', options_for_select([[]] + (@available_criterias.keys - @criterias).collect{|k| [l(@available_criterias[k][:label]), k]}),
21 23 :onchange => "this.form.onsubmit();",
22 24 :style => 'width: 200px',
23 25 :id => nil,
24 26 :disabled => (@criterias.length >= 3)) %>
25 27 <%= link_to_remote l(:button_clear), {:url => {:project_id => @project, :period_type => params[:period_type], :period => params[:period], :from => @from, :to => @to, :columns => @columns},
26 28 :update => 'content'
27 29 }, :class => 'icon icon-reload' %></p>
28 30 <% end %>
29 31
30 32 <% unless @criterias.empty? %>
31 33 <div class="total-hours">
32 34 <p><%= l(:label_total) %>: <%= html_hours(lwr(:label_f_hour, @total_hours)) %></p>
33 35 </div>
34 36
35 37 <% unless @hours.empty? %>
36 38 <table class="list" id="time-report">
37 39 <thead>
38 40 <tr>
39 41 <% @criterias.each do |criteria| %>
40 42 <th><%= l(@available_criterias[criteria][:label]) %></th>
41 43 <% end %>
42 44 <% columns_width = (40 / (@periods.length+1)).to_i %>
43 45 <% @periods.each do |period| %>
44 46 <th class="period" width="<%= columns_width %>%"><%= period %></th>
45 47 <% end %>
46 48 <th class="total" width="<%= columns_width %>%"><%= l(:label_total) %></th>
47 49 </tr>
48 50 </thead>
49 51 <tbody>
50 52 <%= render :partial => 'report_criteria', :locals => {:criterias => @criterias, :hours => @hours, :level => 0} %>
51 53 <tr class="total">
52 54 <td><%= l(:label_total) %></td>
53 55 <%= '<td></td>' * (@criterias.size - 1) %>
54 56 <% total = 0 -%>
55 57 <% @periods.each do |period| -%>
56 58 <% sum = sum_hours(select_hours(@hours, @columns, period.to_s)); total += sum -%>
57 59 <td class="hours"><%= html_hours("%.2f" % sum) if sum > 0 %></td>
58 60 <% end -%>
59 61 <td class="hours"><%= html_hours("%.2f" % total) if total > 0 %></td>
60 62 </tr>
61 63 </tbody>
62 64 </table>
63 65
64 66 <p class="other-formats">
65 67 <%= l(:label_export_to) %>
66 68 <span><%= link_to 'CSV', params.merge({:format => 'csv'}), :class => 'csv' %></span>
67 69 </p>
68 70 <% end %>
69 71 <% end %>
70 72
71 73 <% html_title l(:label_spent_time), l(:label_report) %>
72 74
@@ -1,46 +1,46
1 1 ActionController::Routing::Routes.draw do |map|
2 2 # Add your own custom routes here.
3 3 # The priority is based upon order of creation: first created -> highest priority.
4 4
5 5 # Here's a sample route:
6 6 # map.connect 'products/:id', :controller => 'catalog', :action => 'view'
7 7 # Keep in mind you can assign values other than :controller and :action
8 8
9 9 map.home '', :controller => 'welcome'
10 10 map.signin 'login', :controller => 'account', :action => 'login'
11 11 map.signout 'logout', :controller => 'account', :action => 'logout'
12 12
13 13 map.connect 'wiki/:id/:page/:action', :controller => 'wiki', :page => nil
14 14 map.connect 'roles/workflow/:id/:role_id/:tracker_id', :controller => 'roles', :action => 'workflow'
15 15 map.connect 'help/:ctrl/:page', :controller => 'help'
16 16 #map.connect ':controller/:action/:id/:sort_key/:sort_order'
17 17
18 18 map.connect 'issues/:issue_id/relations/:action/:id', :controller => 'issue_relations'
19 19 map.connect 'projects/:project_id/issues/:action', :controller => 'issues'
20 20 map.connect 'projects/:project_id/news/:action', :controller => 'news'
21 21 map.connect 'projects/:project_id/documents/:action', :controller => 'documents'
22 22 map.connect 'projects/:project_id/boards/:action/:id', :controller => 'boards'
23 map.connect 'projects/:project_id/timelog/:action/:id', :controller => 'timelog'
23 map.connect 'projects/:project_id/timelog/:action/:id', :controller => 'timelog', :project_id => /.+/
24 24 map.connect 'boards/:board_id/topics/:action/:id', :controller => 'messages'
25 25
26 26 map.with_options :controller => 'repositories' do |omap|
27 27 omap.repositories_show 'repositories/browse/:id/*path', :action => 'browse'
28 28 omap.repositories_changes 'repositories/changes/:id/*path', :action => 'changes'
29 29 omap.repositories_diff 'repositories/diff/:id/*path', :action => 'diff'
30 30 omap.repositories_entry 'repositories/entry/:id/*path', :action => 'entry'
31 31 omap.repositories_entry 'repositories/annotate/:id/*path', :action => 'annotate'
32 32 omap.repositories_revision 'repositories/revision/:id/:rev', :action => 'revision'
33 33 end
34 34
35 35 map.connect 'attachments/:id', :controller => 'attachments', :action => 'show', :id => /\d+/
36 36 map.connect 'attachments/:id/:filename', :controller => 'attachments', :action => 'show', :id => /\d+/, :filename => /.*/
37 37 map.connect 'attachments/download/:id/:filename', :controller => 'attachments', :action => 'download', :id => /\d+/, :filename => /.*/
38 38
39 39 # Allow downloading Web Service WSDL as a file with an extension
40 40 # instead of a file named 'wsdl'
41 41 map.connect ':controller/service.wsdl', :action => 'wsdl'
42 42
43 43
44 44 # Install the default route as the lowest priority.
45 45 map.connect ':controller/:action/:id'
46 46 end
@@ -1,228 +1,278
1 1 # redMine - project management software
2 2 # Copyright (C) 2006-2007 Jean-Philippe Lang
3 3 #
4 4 # This program is free software; you can redistribute it and/or
5 5 # modify it under the terms of the GNU General Public License
6 6 # as published by the Free Software Foundation; either version 2
7 7 # of the License, or (at your option) any later version.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU General Public License
15 15 # along with this program; if not, write to the Free Software
16 16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17 17
18 18 require File.dirname(__FILE__) + '/../test_helper'
19 19 require 'timelog_controller'
20 20
21 21 # Re-raise errors caught by the controller.
22 22 class TimelogController; def rescue_action(e) raise e end; end
23 23
24 24 class TimelogControllerTest < Test::Unit::TestCase
25 25 fixtures :projects, :enabled_modules, :roles, :members, :issues, :time_entries, :users, :trackers, :enumerations, :issue_statuses, :custom_fields, :custom_values
26 26
27 27 def setup
28 28 @controller = TimelogController.new
29 29 @request = ActionController::TestRequest.new
30 30 @response = ActionController::TestResponse.new
31 31 end
32 32
33 33 def test_get_edit
34 34 @request.session[:user_id] = 3
35 35 get :edit, :project_id => 1
36 36 assert_response :success
37 37 assert_template 'edit'
38 38 # Default activity selected
39 39 assert_tag :tag => 'option', :attributes => { :selected => 'selected' },
40 40 :content => 'Development'
41 41 end
42 42
43 43 def test_post_edit
44 44 @request.session[:user_id] = 3
45 45 post :edit, :project_id => 1,
46 46 :time_entry => {:comments => 'Some work on TimelogControllerTest',
47 47 # Not the default activity
48 48 :activity_id => '11',
49 49 :spent_on => '2008-03-14',
50 50 :issue_id => '1',
51 51 :hours => '7.3'}
52 52 assert_redirected_to 'projects/ecookbook/timelog/details'
53 53
54 54 i = Issue.find(1)
55 55 t = TimeEntry.find_by_comments('Some work on TimelogControllerTest')
56 56 assert_not_nil t
57 57 assert_equal 11, t.activity_id
58 58 assert_equal 7.3, t.hours
59 59 assert_equal 3, t.user_id
60 60 assert_equal i, t.issue
61 61 assert_equal i.project, t.project
62 62 end
63 63
64 64 def test_update
65 65 entry = TimeEntry.find(1)
66 66 assert_equal 1, entry.issue_id
67 67 assert_equal 2, entry.user_id
68 68
69 69 @request.session[:user_id] = 1
70 70 post :edit, :id => 1,
71 71 :time_entry => {:issue_id => '2',
72 72 :hours => '8'}
73 73 assert_redirected_to 'projects/ecookbook/timelog/details'
74 74 entry.reload
75 75
76 76 assert_equal 8, entry.hours
77 77 assert_equal 2, entry.issue_id
78 78 assert_equal 2, entry.user_id
79 79 end
80 80
81 def destroy
81 def test_destroy
82 82 @request.session[:user_id] = 2
83 83 post :destroy, :id => 1
84 84 assert_redirected_to 'projects/ecookbook/timelog/details'
85 85 assert_nil TimeEntry.find_by_id(1)
86 86 end
87 87
88 88 def test_report_no_criteria
89 89 get :report, :project_id => 1
90 90 assert_response :success
91 91 assert_template 'report'
92 92 end
93 93
94 def test_report_all_projects
95 get :report
96 assert_response :success
97 assert_template 'report'
98 end
99
100 def test_report_all_projects_denied
101 r = Role.anonymous
102 r.permissions.delete(:view_time_entries)
103 r.permissions_will_change!
104 r.save
105 get :report
106 assert_redirected_to '/account/login'
107 end
108
109 def test_report_all_projects_one_criteria
110 get :report, :columns => 'week', :from => "2007-04-01", :to => "2007-04-30", :criterias => ['project']
111 assert_response :success
112 assert_template 'report'
113 assert_not_nil assigns(:total_hours)
114 assert_equal "8.65", "%.2f" % assigns(:total_hours)
115 end
116
94 117 def test_report_all_time
95 118 get :report, :project_id => 1, :criterias => ['project', 'issue']
96 119 assert_response :success
97 120 assert_template 'report'
98 121 assert_not_nil assigns(:total_hours)
99 122 assert_equal "162.90", "%.2f" % assigns(:total_hours)
100 123 end
101 124
102 125 def test_report_all_time_by_day
103 126 get :report, :project_id => 1, :criterias => ['project', 'issue'], :columns => 'day'
104 127 assert_response :success
105 128 assert_template 'report'
106 129 assert_not_nil assigns(:total_hours)
107 130 assert_equal "162.90", "%.2f" % assigns(:total_hours)
108 131 assert_tag :tag => 'th', :content => '2007-03-12'
109 132 end
110 133
111 134 def test_report_one_criteria
112 135 get :report, :project_id => 1, :columns => 'week', :from => "2007-04-01", :to => "2007-04-30", :criterias => ['project']
113 136 assert_response :success
114 137 assert_template 'report'
115 138 assert_not_nil assigns(:total_hours)
116 139 assert_equal "8.65", "%.2f" % assigns(:total_hours)
117 140 end
118 141
119 142 def test_report_two_criterias
120 143 get :report, :project_id => 1, :columns => 'month', :from => "2007-01-01", :to => "2007-12-31", :criterias => ["member", "activity"]
121 144 assert_response :success
122 145 assert_template 'report'
123 146 assert_not_nil assigns(:total_hours)
124 147 assert_equal "162.90", "%.2f" % assigns(:total_hours)
125 148 end
126 149
127 150 def test_report_custom_field_criteria
128 151 get :report, :project_id => 1, :criterias => ['project', 'cf_1']
129 152 assert_response :success
130 153 assert_template 'report'
131 154 assert_not_nil assigns(:total_hours)
132 155 assert_not_nil assigns(:criterias)
133 156 assert_equal 2, assigns(:criterias).size
134 157 assert_equal "162.90", "%.2f" % assigns(:total_hours)
135 158 # Custom field column
136 159 assert_tag :tag => 'th', :content => 'Database'
137 160 # Custom field row
138 161 assert_tag :tag => 'td', :content => 'MySQL',
139 162 :sibling => { :tag => 'td', :attributes => { :class => 'hours' },
140 163 :child => { :tag => 'span', :attributes => { :class => 'hours hours-int' },
141 164 :content => '1' }}
142 165 end
143 166
144 167 def test_report_one_criteria_no_result
145 168 get :report, :project_id => 1, :columns => 'week', :from => "1998-04-01", :to => "1998-04-30", :criterias => ['project']
146 169 assert_response :success
147 170 assert_template 'report'
148 171 assert_not_nil assigns(:total_hours)
149 172 assert_equal "0.00", "%.2f" % assigns(:total_hours)
150 173 end
151 174
175 def test_report_all_projects_csv_export
176 get :report, :columns => 'month', :from => "2007-01-01", :to => "2007-06-30", :criterias => ["project", "member", "activity"], :format => "csv"
177 assert_response :success
178 assert_equal 'text/csv', @response.content_type
179 lines = @response.body.chomp.split("\n")
180 # Headers
181 assert_equal 'Project,Member,Activity,2007-1,2007-2,2007-3,2007-4,2007-5,2007-6,Total', lines.first
182 # Total row
183 assert_equal 'Total,"","","","",154.25,8.65,"","",162.90', lines.last
184 end
185
152 186 def test_report_csv_export
153 187 get :report, :project_id => 1, :columns => 'month', :from => "2007-01-01", :to => "2007-06-30", :criterias => ["project", "member", "activity"], :format => "csv"
154 188 assert_response :success
155 189 assert_equal 'text/csv', @response.content_type
156 190 lines = @response.body.chomp.split("\n")
157 191 # Headers
158 192 assert_equal 'Project,Member,Activity,2007-1,2007-2,2007-3,2007-4,2007-5,2007-6,Total', lines.first
159 193 # Total row
160 194 assert_equal 'Total,"","","","",154.25,8.65,"","",162.90', lines.last
161 195 end
162 196
197 def test_details_all_projects
198 get :details
199 assert_response :success
200 assert_template 'details'
201 assert_not_nil assigns(:total_hours)
202 assert_equal "162.90", "%.2f" % assigns(:total_hours)
203 end
204
163 205 def test_details_at_project_level
164 206 get :details, :project_id => 1
165 207 assert_response :success
166 208 assert_template 'details'
167 209 assert_not_nil assigns(:entries)
168 210 assert_equal 4, assigns(:entries).size
169 211 # project and subproject
170 212 assert_equal [1, 3], assigns(:entries).collect(&:project_id).uniq.sort
171 213 assert_not_nil assigns(:total_hours)
172 214 assert_equal "162.90", "%.2f" % assigns(:total_hours)
173 215 # display all time by default
174 216 assert_equal '2007-03-11'.to_date, assigns(:from)
175 217 assert_equal '2007-04-22'.to_date, assigns(:to)
176 218 end
177 219
178 220 def test_details_at_project_level_with_date_range
179 221 get :details, :project_id => 1, :from => '2007-03-20', :to => '2007-04-30'
180 222 assert_response :success
181 223 assert_template 'details'
182 224 assert_not_nil assigns(:entries)
183 225 assert_equal 3, assigns(:entries).size
184 226 assert_not_nil assigns(:total_hours)
185 227 assert_equal "12.90", "%.2f" % assigns(:total_hours)
186 228 assert_equal '2007-03-20'.to_date, assigns(:from)
187 229 assert_equal '2007-04-30'.to_date, assigns(:to)
188 230 end
189 231
190 232 def test_details_at_project_level_with_period
191 233 get :details, :project_id => 1, :period => '7_days'
192 234 assert_response :success
193 235 assert_template 'details'
194 236 assert_not_nil assigns(:entries)
195 237 assert_not_nil assigns(:total_hours)
196 238 assert_equal Date.today - 7, assigns(:from)
197 239 assert_equal Date.today, assigns(:to)
198 240 end
199 241
200 242 def test_details_at_issue_level
201 243 get :details, :issue_id => 1
202 244 assert_response :success
203 245 assert_template 'details'
204 246 assert_not_nil assigns(:entries)
205 247 assert_equal 2, assigns(:entries).size
206 248 assert_not_nil assigns(:total_hours)
207 249 assert_equal 154.25, assigns(:total_hours)
208 250 # display all time by default
209 251 assert_equal '2007-03-11'.to_date, assigns(:from)
210 252 assert_equal '2007-04-22'.to_date, assigns(:to)
211 253 end
212 254
213 255 def test_details_atom_feed
214 256 get :details, :project_id => 1, :format => 'atom'
215 257 assert_response :success
216 258 assert_equal 'application/atom+xml', @response.content_type
217 259 assert_not_nil assigns(:items)
218 260 assert assigns(:items).first.is_a?(TimeEntry)
219 261 end
220 262
263 def test_details_all_projects_csv_export
264 get :details, :format => 'csv'
265 assert_response :success
266 assert_equal 'text/csv', @response.content_type
267 assert @response.body.include?("Date,User,Activity,Project,Issue,Tracker,Subject,Hours,Comment\n")
268 assert @response.body.include?("\n04/21/2007,redMine Admin,Design,eCookbook,3,Bug,Error 281 when updating a recipe,1.0,\"\"\n")
269 end
270
221 271 def test_details_csv_export
222 272 get :details, :project_id => 1, :format => 'csv'
223 273 assert_response :success
224 274 assert_equal 'text/csv', @response.content_type
225 275 assert @response.body.include?("Date,User,Activity,Project,Issue,Tracker,Subject,Hours,Comment\n")
226 276 assert @response.body.include?("\n04/21/2007,redMine Admin,Design,eCookbook,3,Bug,Error 281 when updating a recipe,1.0,\"\"\n")
227 277 end
228 278 end
General Comments 0
You need to be logged in to leave comments. Login now