##// END OF EJS Templates
Remove special behaviour for listing issue time entries, use a filter for that....
Jean-Philippe Lang -
r15262:03dbf8abb881
parent child
Show More
@@ -1,277 +1,280
1 1 # Redmine - project management software
2 2 # Copyright (C) 2006-2016 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
21 21 before_filter :find_time_entry, :only => [:show, :edit, :update]
22 22 before_filter :find_time_entries, :only => [:bulk_edit, :bulk_update, :destroy]
23 23 before_filter :authorize, :only => [:show, :edit, :update, :bulk_edit, :bulk_update, :destroy]
24 24
25 before_filter :find_optional_project, :only => [:new, :create, :index, :report]
25 before_filter :find_optional_issue, :only => [:new, :create]
26 before_filter :find_optional_project, :only => [:index, :report]
26 27 before_filter :authorize_global, :only => [:new, :create, :index, :report]
27 28
28 29 accept_rss_auth :index
29 30 accept_api_auth :index, :show, :create, :update, :destroy
30 31
31 32 rescue_from Query::StatementInvalid, :with => :query_statement_invalid
32 33
33 34 helper :sort
34 35 include SortHelper
35 36 helper :issues
36 37 include TimelogHelper
37 38 helper :custom_fields
38 39 include CustomFieldsHelper
39 40 helper :queries
40 41 include QueriesHelper
41 42
42 43 def index
43 44 retrieve_time_entry_query
44 45 sort_init(@query.sort_criteria.empty? ? [['spent_on', 'desc']] : @query.sort_criteria)
45 46 sort_update(@query.sortable_columns)
46 47 scope = time_entry_scope(:order => sort_clause).
47 48 includes(:project, :user, :issue).
48 49 preload(:issue => [:project, :tracker, :status, :assigned_to, :priority])
49 50
50 51 respond_to do |format|
51 52 format.html {
52 53 @entry_count = scope.count
53 54 @entry_pages = Paginator.new @entry_count, per_page_option, params['page']
54 55 @entries = scope.offset(@entry_pages.offset).limit(@entry_pages.per_page).to_a
55 56 @total_hours = scope.sum(:hours).to_f
56 57
57 58 render :layout => !request.xhr?
58 59 }
59 60 format.api {
60 61 @entry_count = scope.count
61 62 @offset, @limit = api_offset_and_limit
62 63 @entries = scope.offset(@offset).limit(@limit).preload(:custom_values => :custom_field).to_a
63 64 }
64 65 format.atom {
65 66 entries = scope.limit(Setting.feeds_limit.to_i).reorder("#{TimeEntry.table_name}.created_on DESC").to_a
66 67 render_feed(entries, :title => l(:label_spent_time))
67 68 }
68 69 format.csv {
69 70 # Export all entries
70 71 @entries = scope.to_a
71 72 send_data(query_to_csv(@entries, @query, params), :type => 'text/csv; header=present', :filename => 'timelog.csv')
72 73 }
73 74 end
74 75 end
75 76
76 77 def report
77 78 retrieve_time_entry_query
78 79 scope = time_entry_scope
79 80
80 81 @report = Redmine::Helpers::TimeReport.new(@project, @issue, params[:criteria], params[:columns], scope)
81 82
82 83 respond_to do |format|
83 84 format.html { render :layout => !request.xhr? }
84 85 format.csv { send_data(report_to_csv(@report), :type => 'text/csv; header=present', :filename => 'timelog.csv') }
85 86 end
86 87 end
87 88
88 89 def show
89 90 respond_to do |format|
90 91 # TODO: Implement html response
91 92 format.html { render :nothing => true, :status => 406 }
92 93 format.api
93 94 end
94 95 end
95 96
96 97 def new
97 98 @time_entry ||= TimeEntry.new(:project => @project, :issue => @issue, :user => User.current, :spent_on => User.current.today)
98 99 @time_entry.safe_attributes = params[:time_entry]
99 100 end
100 101
101 102 def create
102 103 @time_entry ||= TimeEntry.new(:project => @project, :issue => @issue, :user => User.current, :spent_on => User.current.today)
103 104 @time_entry.safe_attributes = params[:time_entry]
104 105 if @time_entry.project && !User.current.allowed_to?(:log_time, @time_entry.project)
105 106 render_403
106 107 return
107 108 end
108 109
109 110 call_hook(:controller_timelog_edit_before_save, { :params => params, :time_entry => @time_entry })
110 111
111 112 if @time_entry.save
112 113 respond_to do |format|
113 114 format.html {
114 115 flash[:notice] = l(:notice_successful_create)
115 116 if params[:continue]
116 117 options = {
117 118 :time_entry => {
118 119 :project_id => params[:time_entry][:project_id],
119 120 :issue_id => @time_entry.issue_id,
120 121 :activity_id => @time_entry.activity_id
121 122 },
122 123 :back_url => params[:back_url]
123 124 }
124 125 if params[:project_id] && @time_entry.project
125 126 redirect_to new_project_time_entry_path(@time_entry.project, options)
126 127 elsif params[:issue_id] && @time_entry.issue
127 128 redirect_to new_issue_time_entry_path(@time_entry.issue, options)
128 129 else
129 130 redirect_to new_time_entry_path(options)
130 131 end
131 132 else
132 133 redirect_back_or_default project_time_entries_path(@time_entry.project)
133 134 end
134 135 }
135 136 format.api { render :action => 'show', :status => :created, :location => time_entry_url(@time_entry) }
136 137 end
137 138 else
138 139 respond_to do |format|
139 140 format.html { render :action => 'new' }
140 141 format.api { render_validation_errors(@time_entry) }
141 142 end
142 143 end
143 144 end
144 145
145 146 def edit
146 147 @time_entry.safe_attributes = params[:time_entry]
147 148 end
148 149
149 150 def update
150 151 @time_entry.safe_attributes = params[:time_entry]
151 152
152 153 call_hook(:controller_timelog_edit_before_save, { :params => params, :time_entry => @time_entry })
153 154
154 155 if @time_entry.save
155 156 respond_to do |format|
156 157 format.html {
157 158 flash[:notice] = l(:notice_successful_update)
158 159 redirect_back_or_default project_time_entries_path(@time_entry.project)
159 160 }
160 161 format.api { render_api_ok }
161 162 end
162 163 else
163 164 respond_to do |format|
164 165 format.html { render :action => 'edit' }
165 166 format.api { render_validation_errors(@time_entry) }
166 167 end
167 168 end
168 169 end
169 170
170 171 def bulk_edit
171 172 @available_activities = TimeEntryActivity.shared.active
172 173 @custom_fields = TimeEntry.first.available_custom_fields
173 174 end
174 175
175 176 def bulk_update
176 177 attributes = parse_params_for_bulk_update(params[:time_entry])
177 178
178 179 unsaved_time_entry_ids = []
179 180 @time_entries.each do |time_entry|
180 181 time_entry.reload
181 182 time_entry.safe_attributes = attributes
182 183 call_hook(:controller_time_entries_bulk_edit_before_save, { :params => params, :time_entry => time_entry })
183 184 unless time_entry.save
184 185 logger.info "time entry could not be updated: #{time_entry.errors.full_messages}" if logger && logger.info?
185 186 # Keep unsaved time_entry ids to display them in flash error
186 187 unsaved_time_entry_ids << time_entry.id
187 188 end
188 189 end
189 190 set_flash_from_bulk_time_entry_save(@time_entries, unsaved_time_entry_ids)
190 191 redirect_back_or_default project_time_entries_path(@projects.first)
191 192 end
192 193
193 194 def destroy
194 195 destroyed = TimeEntry.transaction do
195 196 @time_entries.each do |t|
196 197 unless t.destroy && t.destroyed?
197 198 raise ActiveRecord::Rollback
198 199 end
199 200 end
200 201 end
201 202
202 203 respond_to do |format|
203 204 format.html {
204 205 if destroyed
205 206 flash[:notice] = l(:notice_successful_delete)
206 207 else
207 208 flash[:error] = l(:notice_unable_delete_time_entry)
208 209 end
209 210 redirect_back_or_default project_time_entries_path(@projects.first)
210 211 }
211 212 format.api {
212 213 if destroyed
213 214 render_api_ok
214 215 else
215 216 render_validation_errors(@time_entries)
216 217 end
217 218 }
218 219 end
219 220 end
220 221
221 222 private
222 223 def find_time_entry
223 224 @time_entry = TimeEntry.find(params[:id])
224 225 unless @time_entry.editable_by?(User.current)
225 226 render_403
226 227 return false
227 228 end
228 229 @project = @time_entry.project
229 230 rescue ActiveRecord::RecordNotFound
230 231 render_404
231 232 end
232 233
233 234 def find_time_entries
234 235 @time_entries = TimeEntry.where(:id => params[:id] || params[:ids]).to_a
235 236 raise ActiveRecord::RecordNotFound if @time_entries.empty?
236 237 raise Unauthorized unless @time_entries.all? {|t| t.editable_by?(User.current)}
237 238 @projects = @time_entries.collect(&:project).compact.uniq
238 239 @project = @projects.first if @projects.size == 1
239 240 rescue ActiveRecord::RecordNotFound
240 241 render_404
241 242 end
242 243
243 244 def set_flash_from_bulk_time_entry_save(time_entries, unsaved_time_entry_ids)
244 245 if unsaved_time_entry_ids.empty?
245 246 flash[:notice] = l(:notice_successful_update) unless time_entries.empty?
246 247 else
247 248 flash[:error] = l(:notice_failed_to_save_time_entries,
248 249 :count => unsaved_time_entry_ids.size,
249 250 :total => time_entries.size,
250 251 :ids => '#' + unsaved_time_entry_ids.join(', #'))
251 252 end
252 253 end
253 254
254 def find_optional_project
255 def find_optional_issue
255 256 if params[:issue_id].present?
256 257 @issue = Issue.find(params[:issue_id])
257 258 @project = @issue.project
258 elsif params[:project_id].present?
259 else
260 find_optional_project
261 end
262 end
263
264 def find_optional_project
265 if params[:project_id].present?
259 266 @project = Project.find(params[:project_id])
260 267 end
261 268 rescue ActiveRecord::RecordNotFound
262 269 render_404
263 270 end
264 271
265 272 # Returns the TimeEntry scope for index and report actions
266 273 def time_entry_scope(options={})
267 scope = @query.results_scope(options)
268 if @issue
269 scope = scope.on_issue(@issue)
270 end
271 scope
274 @query.results_scope(options)
272 275 end
273 276
274 277 def retrieve_time_entry_query
275 278 retrieve_query(TimeEntryQuery, false)
276 279 end
277 280 end
@@ -1,492 +1,494
1 1 # encoding: utf-8
2 2 #
3 3 # Redmine - project management software
4 4 # Copyright (C) 2006-2016 Jean-Philippe Lang
5 5 #
6 6 # This program is free software; you can redistribute it and/or
7 7 # modify it under the terms of the GNU General Public License
8 8 # as published by the Free Software Foundation; either version 2
9 9 # of the License, or (at your option) any later version.
10 10 #
11 11 # This program is distributed in the hope that it will be useful,
12 12 # but WITHOUT ANY WARRANTY; without even the implied warranty of
13 13 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14 14 # GNU General Public License for more details.
15 15 #
16 16 # You should have received a copy of the GNU General Public License
17 17 # along with this program; if not, write to the Free Software
18 18 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
19 19
20 20 module IssuesHelper
21 21 include ApplicationHelper
22 22 include Redmine::Export::PDF::IssuesPdfHelper
23 23
24 24 def issue_list(issues, &block)
25 25 ancestors = []
26 26 issues.each do |issue|
27 27 while (ancestors.any? && !issue.is_descendant_of?(ancestors.last))
28 28 ancestors.pop
29 29 end
30 30 yield issue, ancestors.size
31 31 ancestors << issue unless issue.leaf?
32 32 end
33 33 end
34 34
35 35 def grouped_issue_list(issues, query, issue_count_by_group, &block)
36 36 previous_group, first = false, true
37 37 totals_by_group = query.totalable_columns.inject({}) do |h, column|
38 38 h[column] = query.total_by_group_for(column)
39 39 h
40 40 end
41 41 issue_list(issues) do |issue, level|
42 42 group_name = group_count = nil
43 43 if query.grouped?
44 44 group = query.group_by_column.value(issue)
45 45 if first || group != previous_group
46 46 if group.blank? && group != false
47 47 group_name = "(#{l(:label_blank_value)})"
48 48 else
49 49 group_name = format_object(group)
50 50 end
51 51 group_name ||= ""
52 52 group_count = issue_count_by_group[group]
53 53 group_totals = totals_by_group.map {|column, t| total_tag(column, t[group] || 0)}.join(" ").html_safe
54 54 end
55 55 end
56 56 yield issue, level, group_name, group_count, group_totals
57 57 previous_group, first = group, false
58 58 end
59 59 end
60 60
61 61 # Renders a HTML/CSS tooltip
62 62 #
63 63 # To use, a trigger div is needed. This is a div with the class of "tooltip"
64 64 # that contains this method wrapped in a span with the class of "tip"
65 65 #
66 66 # <div class="tooltip"><%= link_to_issue(issue) %>
67 67 # <span class="tip"><%= render_issue_tooltip(issue) %></span>
68 68 # </div>
69 69 #
70 70 def render_issue_tooltip(issue)
71 71 @cached_label_status ||= l(:field_status)
72 72 @cached_label_start_date ||= l(:field_start_date)
73 73 @cached_label_due_date ||= l(:field_due_date)
74 74 @cached_label_assigned_to ||= l(:field_assigned_to)
75 75 @cached_label_priority ||= l(:field_priority)
76 76 @cached_label_project ||= l(:field_project)
77 77
78 78 link_to_issue(issue) + "<br /><br />".html_safe +
79 79 "<strong>#{@cached_label_project}</strong>: #{link_to_project(issue.project)}<br />".html_safe +
80 80 "<strong>#{@cached_label_status}</strong>: #{h(issue.status.name)}<br />".html_safe +
81 81 "<strong>#{@cached_label_start_date}</strong>: #{format_date(issue.start_date)}<br />".html_safe +
82 82 "<strong>#{@cached_label_due_date}</strong>: #{format_date(issue.due_date)}<br />".html_safe +
83 83 "<strong>#{@cached_label_assigned_to}</strong>: #{h(issue.assigned_to)}<br />".html_safe +
84 84 "<strong>#{@cached_label_priority}</strong>: #{h(issue.priority.name)}".html_safe
85 85 end
86 86
87 87 def issue_heading(issue)
88 88 h("#{issue.tracker} ##{issue.id}")
89 89 end
90 90
91 91 def render_issue_subject_with_tree(issue)
92 92 s = ''
93 93 ancestors = issue.root? ? [] : issue.ancestors.visible.to_a
94 94 ancestors.each do |ancestor|
95 95 s << '<div>' + content_tag('p', link_to_issue(ancestor, :project => (issue.project_id != ancestor.project_id)))
96 96 end
97 97 s << '<div>'
98 98 subject = h(issue.subject)
99 99 if issue.is_private?
100 100 subject = content_tag('span', l(:field_is_private), :class => 'private') + ' ' + subject
101 101 end
102 102 s << content_tag('h3', subject)
103 103 s << '</div>' * (ancestors.size + 1)
104 104 s.html_safe
105 105 end
106 106
107 107 def render_descendants_tree(issue)
108 108 s = '<form><table class="list issues">'
109 109 issue_list(issue.descendants.visible.preload(:status, :priority, :tracker, :assigned_to).sort_by(&:lft)) do |child, level|
110 110 css = "issue issue-#{child.id} hascontextmenu #{issue.css_classes}"
111 111 css << " idnt idnt-#{level}" if level > 0
112 112 s << content_tag('tr',
113 113 content_tag('td', check_box_tag("ids[]", child.id, false, :id => nil), :class => 'checkbox') +
114 114 content_tag('td', link_to_issue(child, :project => (issue.project_id != child.project_id)), :class => 'subject', :style => 'width: 50%') +
115 115 content_tag('td', h(child.status), :class => 'status') +
116 116 content_tag('td', link_to_user(child.assigned_to), :class => 'assigned_to') +
117 117 content_tag('td', child.disabled_core_fields.include?('done_ratio') ? '' : progress_bar(child.done_ratio), :class=> 'done_ratio'),
118 118 :class => css)
119 119 end
120 120 s << '</table></form>'
121 121 s.html_safe
122 122 end
123 123
124 124 def issue_estimated_hours_details(issue)
125 125 if issue.total_estimated_hours.present?
126 126 if issue.total_estimated_hours == issue.estimated_hours
127 127 l_hours_short(issue.estimated_hours)
128 128 else
129 129 s = issue.estimated_hours.present? ? l_hours_short(issue.estimated_hours) : ""
130 130 s << " (#{l(:label_total)}: #{l_hours_short(issue.total_estimated_hours)})"
131 131 s.html_safe
132 132 end
133 133 end
134 134 end
135 135
136 136 def issue_spent_hours_details(issue)
137 137 if issue.total_spent_hours > 0
138 path = project_time_entries_path(issue.project, :issue_id => "~#{issue.id}")
139
138 140 if issue.total_spent_hours == issue.spent_hours
139 link_to(l_hours_short(issue.spent_hours), issue_time_entries_path(issue))
141 link_to(l_hours_short(issue.spent_hours), path)
140 142 else
141 143 s = issue.spent_hours > 0 ? l_hours_short(issue.spent_hours) : ""
142 s << " (#{l(:label_total)}: #{link_to l_hours_short(issue.total_spent_hours), issue_time_entries_path(issue)})"
144 s << " (#{l(:label_total)}: #{link_to l_hours_short(issue.total_spent_hours), path})"
143 145 s.html_safe
144 146 end
145 147 end
146 148 end
147 149
148 150 # Returns an array of error messages for bulk edited issues
149 151 def bulk_edit_error_messages(issues)
150 152 messages = {}
151 153 issues.each do |issue|
152 154 issue.errors.full_messages.each do |message|
153 155 messages[message] ||= []
154 156 messages[message] << issue
155 157 end
156 158 end
157 159 messages.map { |message, issues|
158 160 "#{message}: " + issues.map {|i| "##{i.id}"}.join(', ')
159 161 }
160 162 end
161 163
162 164 # Returns a link for adding a new subtask to the given issue
163 165 def link_to_new_subtask(issue)
164 166 attrs = {
165 167 :parent_issue_id => issue
166 168 }
167 169 attrs[:tracker_id] = issue.tracker unless issue.tracker.disabled_core_fields.include?('parent_issue_id')
168 170 link_to(l(:button_add), new_project_issue_path(issue.project, :issue => attrs))
169 171 end
170 172
171 173 def trackers_options_for_select(issue)
172 174 trackers = issue.allowed_target_trackers
173 175 if issue.new_record? && issue.parent_issue_id.present?
174 176 trackers = trackers.reject do |tracker|
175 177 issue.tracker_id != tracker.id && tracker.disabled_core_fields.include?('parent_issue_id')
176 178 end
177 179 end
178 180 trackers.collect {|t| [t.name, t.id]}
179 181 end
180 182
181 183 class IssueFieldsRows
182 184 include ActionView::Helpers::TagHelper
183 185
184 186 def initialize
185 187 @left = []
186 188 @right = []
187 189 end
188 190
189 191 def left(*args)
190 192 args.any? ? @left << cells(*args) : @left
191 193 end
192 194
193 195 def right(*args)
194 196 args.any? ? @right << cells(*args) : @right
195 197 end
196 198
197 199 def size
198 200 @left.size > @right.size ? @left.size : @right.size
199 201 end
200 202
201 203 def to_html
202 204 content =
203 205 content_tag('div', @left.reduce(&:+), :class => 'splitcontentleft') +
204 206 content_tag('div', @right.reduce(&:+), :class => 'splitcontentleft')
205 207
206 208 content_tag('div', content, :class => 'splitcontent')
207 209 end
208 210
209 211 def cells(label, text, options={})
210 212 options[:class] = [options[:class] || "", 'attribute'].join(' ')
211 213 content_tag 'div',
212 214 content_tag('div', label + ":", :class => 'label') + content_tag('div', text, :class => 'value'),
213 215 options
214 216 end
215 217 end
216 218
217 219 def issue_fields_rows
218 220 r = IssueFieldsRows.new
219 221 yield r
220 222 r.to_html
221 223 end
222 224
223 225 def render_custom_fields_rows(issue)
224 226 values = issue.visible_custom_field_values
225 227 return if values.empty?
226 228 half = (values.size / 2.0).ceil
227 229 issue_fields_rows do |rows|
228 230 values.each_with_index do |value, i|
229 231 css = "cf_#{value.custom_field.id}"
230 232 m = (i < half ? :left : :right)
231 233 rows.send m, custom_field_name_tag(value.custom_field), show_value(value), :class => css
232 234 end
233 235 end
234 236 end
235 237
236 238 # Returns the path for updating the issue form
237 239 # with project as the current project
238 240 def update_issue_form_path(project, issue)
239 241 options = {:format => 'js'}
240 242 if issue.new_record?
241 243 if project
242 244 new_project_issue_path(project, options)
243 245 else
244 246 new_issue_path(options)
245 247 end
246 248 else
247 249 edit_issue_path(issue, options)
248 250 end
249 251 end
250 252
251 253 # Returns the number of descendants for an array of issues
252 254 def issues_descendant_count(issues)
253 255 ids = issues.reject(&:leaf?).map {|issue| issue.descendants.ids}.flatten.uniq
254 256 ids -= issues.map(&:id)
255 257 ids.size
256 258 end
257 259
258 260 def issues_destroy_confirmation_message(issues)
259 261 issues = [issues] unless issues.is_a?(Array)
260 262 message = l(:text_issues_destroy_confirmation)
261 263
262 264 descendant_count = issues_descendant_count(issues)
263 265 if descendant_count > 0
264 266 message << "\n" + l(:text_issues_destroy_descendants_confirmation, :count => descendant_count)
265 267 end
266 268 message
267 269 end
268 270
269 271 # Returns an array of users that are proposed as watchers
270 272 # on the new issue form
271 273 def users_for_new_issue_watchers(issue)
272 274 users = issue.watcher_users
273 275 if issue.project.users.count <= 20
274 276 users = (users + issue.project.users.sort).uniq
275 277 end
276 278 users
277 279 end
278 280
279 281 def email_issue_attributes(issue, user)
280 282 items = []
281 283 %w(author status priority assigned_to category fixed_version).each do |attribute|
282 284 unless issue.disabled_core_fields.include?(attribute+"_id")
283 285 items << "#{l("field_#{attribute}")}: #{issue.send attribute}"
284 286 end
285 287 end
286 288 issue.visible_custom_field_values(user).each do |value|
287 289 items << "#{value.custom_field.name}: #{show_value(value, false)}"
288 290 end
289 291 items
290 292 end
291 293
292 294 def render_email_issue_attributes(issue, user, html=false)
293 295 items = email_issue_attributes(issue, user)
294 296 if html
295 297 content_tag('ul', items.map{|s| content_tag('li', s)}.join("\n").html_safe)
296 298 else
297 299 items.map{|s| "* #{s}"}.join("\n")
298 300 end
299 301 end
300 302
301 303 # Returns the textual representation of a journal details
302 304 # as an array of strings
303 305 def details_to_strings(details, no_html=false, options={})
304 306 options[:only_path] = (options[:only_path] == false ? false : true)
305 307 strings = []
306 308 values_by_field = {}
307 309 details.each do |detail|
308 310 if detail.property == 'cf'
309 311 field = detail.custom_field
310 312 if field && field.multiple?
311 313 values_by_field[field] ||= {:added => [], :deleted => []}
312 314 if detail.old_value
313 315 values_by_field[field][:deleted] << detail.old_value
314 316 end
315 317 if detail.value
316 318 values_by_field[field][:added] << detail.value
317 319 end
318 320 next
319 321 end
320 322 end
321 323 strings << show_detail(detail, no_html, options)
322 324 end
323 325 if values_by_field.present?
324 326 multiple_values_detail = Struct.new(:property, :prop_key, :custom_field, :old_value, :value)
325 327 values_by_field.each do |field, changes|
326 328 if changes[:added].any?
327 329 detail = multiple_values_detail.new('cf', field.id.to_s, field)
328 330 detail.value = changes[:added]
329 331 strings << show_detail(detail, no_html, options)
330 332 end
331 333 if changes[:deleted].any?
332 334 detail = multiple_values_detail.new('cf', field.id.to_s, field)
333 335 detail.old_value = changes[:deleted]
334 336 strings << show_detail(detail, no_html, options)
335 337 end
336 338 end
337 339 end
338 340 strings
339 341 end
340 342
341 343 # Returns the textual representation of a single journal detail
342 344 def show_detail(detail, no_html=false, options={})
343 345 multiple = false
344 346 show_diff = false
345 347
346 348 case detail.property
347 349 when 'attr'
348 350 field = detail.prop_key.to_s.gsub(/\_id$/, "")
349 351 label = l(("field_" + field).to_sym)
350 352 case detail.prop_key
351 353 when 'due_date', 'start_date'
352 354 value = format_date(detail.value.to_date) if detail.value
353 355 old_value = format_date(detail.old_value.to_date) if detail.old_value
354 356
355 357 when 'project_id', 'status_id', 'tracker_id', 'assigned_to_id',
356 358 'priority_id', 'category_id', 'fixed_version_id'
357 359 value = find_name_by_reflection(field, detail.value)
358 360 old_value = find_name_by_reflection(field, detail.old_value)
359 361
360 362 when 'estimated_hours'
361 363 value = l_hours_short(detail.value.to_f) unless detail.value.blank?
362 364 old_value = l_hours_short(detail.old_value.to_f) unless detail.old_value.blank?
363 365
364 366 when 'parent_id'
365 367 label = l(:field_parent_issue)
366 368 value = "##{detail.value}" unless detail.value.blank?
367 369 old_value = "##{detail.old_value}" unless detail.old_value.blank?
368 370
369 371 when 'is_private'
370 372 value = l(detail.value == "0" ? :general_text_No : :general_text_Yes) unless detail.value.blank?
371 373 old_value = l(detail.old_value == "0" ? :general_text_No : :general_text_Yes) unless detail.old_value.blank?
372 374
373 375 when 'description'
374 376 show_diff = true
375 377 end
376 378 when 'cf'
377 379 custom_field = detail.custom_field
378 380 if custom_field
379 381 label = custom_field.name
380 382 if custom_field.format.class.change_as_diff
381 383 show_diff = true
382 384 else
383 385 multiple = custom_field.multiple?
384 386 value = format_value(detail.value, custom_field) if detail.value
385 387 old_value = format_value(detail.old_value, custom_field) if detail.old_value
386 388 end
387 389 end
388 390 when 'attachment'
389 391 label = l(:label_attachment)
390 392 when 'relation'
391 393 if detail.value && !detail.old_value
392 394 rel_issue = Issue.visible.find_by_id(detail.value)
393 395 value = rel_issue.nil? ? "#{l(:label_issue)} ##{detail.value}" :
394 396 (no_html ? rel_issue : link_to_issue(rel_issue, :only_path => options[:only_path]))
395 397 elsif detail.old_value && !detail.value
396 398 rel_issue = Issue.visible.find_by_id(detail.old_value)
397 399 old_value = rel_issue.nil? ? "#{l(:label_issue)} ##{detail.old_value}" :
398 400 (no_html ? rel_issue : link_to_issue(rel_issue, :only_path => options[:only_path]))
399 401 end
400 402 relation_type = IssueRelation::TYPES[detail.prop_key]
401 403 label = l(relation_type[:name]) if relation_type
402 404 end
403 405 call_hook(:helper_issues_show_detail_after_setting,
404 406 {:detail => detail, :label => label, :value => value, :old_value => old_value })
405 407
406 408 label ||= detail.prop_key
407 409 value ||= detail.value
408 410 old_value ||= detail.old_value
409 411
410 412 unless no_html
411 413 label = content_tag('strong', label)
412 414 old_value = content_tag("i", h(old_value)) if detail.old_value
413 415 if detail.old_value && detail.value.blank? && detail.property != 'relation'
414 416 old_value = content_tag("del", old_value)
415 417 end
416 418 if detail.property == 'attachment' && value.present? &&
417 419 atta = detail.journal.journalized.attachments.detect {|a| a.id == detail.prop_key.to_i}
418 420 # Link to the attachment if it has not been removed
419 421 value = link_to_attachment(atta, :download => true, :only_path => options[:only_path])
420 422 if options[:only_path] != false && (atta.is_text? || atta.is_image?)
421 423 value += ' '
422 424 value += link_to(l(:button_view),
423 425 { :controller => 'attachments', :action => 'show',
424 426 :id => atta, :filename => atta.filename },
425 427 :class => 'icon-only icon-magnifier',
426 428 :title => l(:button_view))
427 429 end
428 430 else
429 431 value = content_tag("i", h(value)) if value
430 432 end
431 433 end
432 434
433 435 if show_diff
434 436 s = l(:text_journal_changed_no_detail, :label => label)
435 437 unless no_html
436 438 diff_link = link_to 'diff',
437 439 diff_journal_url(detail.journal_id, :detail_id => detail.id, :only_path => options[:only_path]),
438 440 :title => l(:label_view_diff)
439 441 s << " (#{ diff_link })"
440 442 end
441 443 s.html_safe
442 444 elsif detail.value.present?
443 445 case detail.property
444 446 when 'attr', 'cf'
445 447 if detail.old_value.present?
446 448 l(:text_journal_changed, :label => label, :old => old_value, :new => value).html_safe
447 449 elsif multiple
448 450 l(:text_journal_added, :label => label, :value => value).html_safe
449 451 else
450 452 l(:text_journal_set_to, :label => label, :value => value).html_safe
451 453 end
452 454 when 'attachment', 'relation'
453 455 l(:text_journal_added, :label => label, :value => value).html_safe
454 456 end
455 457 else
456 458 l(:text_journal_deleted, :label => label, :old => old_value).html_safe
457 459 end
458 460 end
459 461
460 462 # Find the name of an associated record stored in the field attribute
461 463 def find_name_by_reflection(field, id)
462 464 unless id.present?
463 465 return nil
464 466 end
465 467 @detail_value_name_by_reflection ||= Hash.new do |hash, key|
466 468 association = Issue.reflect_on_association(key.first.to_sym)
467 469 name = nil
468 470 if association
469 471 record = association.klass.find_by_id(key.last)
470 472 if record
471 473 name = record.name.force_encoding('UTF-8')
472 474 end
473 475 end
474 476 hash[key] = name
475 477 end
476 478 @detail_value_name_by_reflection[[field, id]]
477 479 end
478 480
479 481 # Renders issue children recursively
480 482 def render_api_issue_children(issue, api)
481 483 return if issue.leaf?
482 484 api.array :children do
483 485 issue.children.each do |child|
484 486 api.issue(:id => child.id) do
485 487 api.tracker(:id => child.tracker_id, :name => child.tracker.name) unless child.tracker.nil?
486 488 api.subject child.subject
487 489 render_api_issue_children(child, api)
488 490 end
489 491 end
490 492 end
491 493 end
492 494 end
@@ -1,312 +1,314
1 1 # encoding: utf-8
2 2 #
3 3 # Redmine - project management software
4 4 # Copyright (C) 2006-2016 Jean-Philippe Lang
5 5 #
6 6 # This program is free software; you can redistribute it and/or
7 7 # modify it under the terms of the GNU General Public License
8 8 # as published by the Free Software Foundation; either version 2
9 9 # of the License, or (at your option) any later version.
10 10 #
11 11 # This program is distributed in the hope that it will be useful,
12 12 # but WITHOUT ANY WARRANTY; without even the implied warranty of
13 13 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14 14 # GNU General Public License for more details.
15 15 #
16 16 # You should have received a copy of the GNU General Public License
17 17 # along with this program; if not, write to the Free Software
18 18 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
19 19
20 20 module QueriesHelper
21 21 include ApplicationHelper
22 22
23 23 def filters_options_for_select(query)
24 24 ungrouped = []
25 25 grouped = {}
26 26 query.available_filters.map do |field, field_options|
27 if [:tree, :relation].include?(field_options[:type])
27 if field_options[:type] == :relation
28 28 group = :label_relations
29 elsif field_options[:type] == :tree
30 group = query.is_a?(IssueQuery) ? :label_relations : nil
29 31 elsif field =~ /^(.+)\./
30 32 # association filters
31 33 group = "field_#{$1}"
32 34 elsif %w(member_of_group assigned_to_role).include?(field)
33 35 group = :field_assigned_to
34 36 elsif field_options[:type] == :date_past || field_options[:type] == :date
35 37 group = :label_date
36 38 end
37 39 if group
38 40 (grouped[group] ||= []) << [field_options[:name], field]
39 41 else
40 42 ungrouped << [field_options[:name], field]
41 43 end
42 44 end
43 45 # Don't group dates if there's only one (eg. time entries filters)
44 46 if grouped[:label_date].try(:size) == 1
45 47 ungrouped << grouped.delete(:label_date).first
46 48 end
47 49 s = options_for_select([[]] + ungrouped)
48 50 if grouped.present?
49 51 localized_grouped = grouped.map {|k,v| [l(k), v]}
50 52 s << grouped_options_for_select(localized_grouped)
51 53 end
52 54 s
53 55 end
54 56
55 57 def query_filters_hidden_tags(query)
56 58 tags = ''.html_safe
57 59 query.filters.each do |field, options|
58 60 tags << hidden_field_tag("f[]", field, :id => nil)
59 61 tags << hidden_field_tag("op[#{field}]", options[:operator], :id => nil)
60 62 options[:values].each do |value|
61 63 tags << hidden_field_tag("v[#{field}][]", value, :id => nil)
62 64 end
63 65 end
64 66 tags
65 67 end
66 68
67 69 def query_columns_hidden_tags(query)
68 70 tags = ''.html_safe
69 71 query.columns.each do |column|
70 72 tags << hidden_field_tag("c[]", column.name, :id => nil)
71 73 end
72 74 tags
73 75 end
74 76
75 77 def query_hidden_tags(query)
76 78 query_filters_hidden_tags(query) + query_columns_hidden_tags(query)
77 79 end
78 80
79 81 def available_block_columns_tags(query)
80 82 tags = ''.html_safe
81 83 query.available_block_columns.each do |column|
82 84 tags << content_tag('label', check_box_tag('c[]', column.name.to_s, query.has_column?(column), :id => nil) + " #{column.caption}", :class => 'inline')
83 85 end
84 86 tags
85 87 end
86 88
87 89 def available_totalable_columns_tags(query)
88 90 tags = ''.html_safe
89 91 query.available_totalable_columns.each do |column|
90 92 tags << content_tag('label', check_box_tag('t[]', column.name.to_s, query.totalable_columns.include?(column), :id => nil) + " #{column.caption}", :class => 'inline')
91 93 end
92 94 tags << hidden_field_tag('t[]', '')
93 95 tags
94 96 end
95 97
96 98 def query_available_inline_columns_options(query)
97 99 (query.available_inline_columns - query.columns).reject(&:frozen?).collect {|column| [column.caption, column.name]}
98 100 end
99 101
100 102 def query_selected_inline_columns_options(query)
101 103 (query.inline_columns & query.available_inline_columns).reject(&:frozen?).collect {|column| [column.caption, column.name]}
102 104 end
103 105
104 106 def render_query_columns_selection(query, options={})
105 107 tag_name = (options[:name] || 'c') + '[]'
106 108 render :partial => 'queries/columns', :locals => {:query => query, :tag_name => tag_name}
107 109 end
108 110
109 111 def render_query_totals(query)
110 112 return unless query.totalable_columns.present?
111 113 totals = query.totalable_columns.map do |column|
112 114 total_tag(column, query.total_for(column))
113 115 end
114 116 content_tag('p', totals.join(" ").html_safe, :class => "query-totals")
115 117 end
116 118
117 119 def total_tag(column, value)
118 120 label = content_tag('span', "#{column.caption}:")
119 121 value = content_tag('span', format_object(value), :class => 'value')
120 122 content_tag('span', label + " " + value, :class => "total-for-#{column.name.to_s.dasherize}")
121 123 end
122 124
123 125 def column_header(column)
124 126 column.sortable ? sort_header_tag(column.name.to_s, :caption => column.caption,
125 127 :default_order => column.default_order) :
126 128 content_tag('th', h(column.caption))
127 129 end
128 130
129 131 def column_content(column, issue)
130 132 value = column.value_object(issue)
131 133 if value.is_a?(Array)
132 134 value.collect {|v| column_value(column, issue, v)}.compact.join(', ').html_safe
133 135 else
134 136 column_value(column, issue, value)
135 137 end
136 138 end
137 139
138 140 def column_value(column, issue, value)
139 141 case column.name
140 142 when :id
141 143 link_to value, issue_path(issue)
142 144 when :subject
143 145 link_to value, issue_path(issue)
144 146 when :parent
145 147 value ? (value.visible? ? link_to_issue(value, :subject => false) : "##{value.id}") : ''
146 148 when :description
147 149 issue.description? ? content_tag('div', textilizable(issue, :description), :class => "wiki") : ''
148 150 when :done_ratio
149 151 progress_bar(value)
150 152 when :relations
151 153 content_tag('span',
152 154 value.to_s(issue) {|other| link_to_issue(other, :subject => false, :tracker => false)}.html_safe,
153 155 :class => value.css_classes_for(issue))
154 156 else
155 157 format_object(value)
156 158 end
157 159 end
158 160
159 161 def csv_content(column, issue)
160 162 value = column.value_object(issue)
161 163 if value.is_a?(Array)
162 164 value.collect {|v| csv_value(column, issue, v)}.compact.join(', ')
163 165 else
164 166 csv_value(column, issue, value)
165 167 end
166 168 end
167 169
168 170 def csv_value(column, object, value)
169 171 format_object(value, false) do |value|
170 172 case value.class.name
171 173 when 'Float'
172 174 sprintf("%.2f", value).gsub('.', l(:general_csv_decimal_separator))
173 175 when 'IssueRelation'
174 176 value.to_s(object)
175 177 when 'Issue'
176 178 if object.is_a?(TimeEntry)
177 179 "#{value.tracker} ##{value.id}: #{value.subject}"
178 180 else
179 181 value.id
180 182 end
181 183 else
182 184 value
183 185 end
184 186 end
185 187 end
186 188
187 189 def query_to_csv(items, query, options={})
188 190 options ||= {}
189 191 columns = (options[:columns] == 'all' ? query.available_inline_columns : query.inline_columns)
190 192 query.available_block_columns.each do |column|
191 193 if options[column.name].present?
192 194 columns << column
193 195 end
194 196 end
195 197
196 198 Redmine::Export::CSV.generate do |csv|
197 199 # csv header fields
198 200 csv << columns.map {|c| c.caption.to_s}
199 201 # csv lines
200 202 items.each do |item|
201 203 csv << columns.map {|c| csv_content(c, item)}
202 204 end
203 205 end
204 206 end
205 207
206 208 # Retrieve query from session or build a new query
207 209 def retrieve_query(klass=IssueQuery, use_session=true)
208 210 session_key = klass.name.underscore.to_sym
209 211
210 212 if params[:query_id].present?
211 213 cond = "project_id IS NULL"
212 214 cond << " OR project_id = #{@project.id}" if @project
213 215 @query = klass.where(cond).find(params[:query_id])
214 216 raise ::Unauthorized unless @query.visible?
215 217 @query.project = @project
216 218 session[session_key] = {:id => @query.id, :project_id => @query.project_id} if use_session
217 219 sort_clear
218 220 elsif api_request? || params[:set_filter] || !use_session || session[session_key].nil? || session[session_key][:project_id] != (@project ? @project.id : nil)
219 221 # Give it a name, required to be valid
220 222 @query = klass.new(:name => "_", :project => @project)
221 223 @query.build_from_params(params)
222 224 session[session_key] = {:project_id => @query.project_id, :filters => @query.filters, :group_by => @query.group_by, :column_names => @query.column_names, :totalable_names => @query.totalable_names} if use_session
223 225 else
224 226 # retrieve from session
225 227 @query = nil
226 228 @query = klass.find_by_id(session[session_key][:id]) if session[session_key][:id]
227 229 @query ||= klass.new(:name => "_", :filters => session[session_key][:filters], :group_by => session[session_key][:group_by], :column_names => session[session_key][:column_names], :totalable_names => session[session_key][:totalable_names])
228 230 @query.project = @project
229 231 end
230 232 end
231 233
232 234 def retrieve_query_from_session
233 235 if session[:query]
234 236 if session[:query][:id]
235 237 @query = IssueQuery.find_by_id(session[:query][:id])
236 238 return unless @query
237 239 else
238 240 @query = IssueQuery.new(:name => "_", :filters => session[:query][:filters], :group_by => session[:query][:group_by], :column_names => session[:query][:column_names], :totalable_names => session[:query][:totalable_names])
239 241 end
240 242 if session[:query].has_key?(:project_id)
241 243 @query.project_id = session[:query][:project_id]
242 244 else
243 245 @query.project = @project
244 246 end
245 247 @query
246 248 end
247 249 end
248 250
249 251 # Returns the query definition as hidden field tags
250 252 def query_as_hidden_field_tags(query)
251 253 tags = hidden_field_tag("set_filter", "1", :id => nil)
252 254
253 255 if query.filters.present?
254 256 query.filters.each do |field, filter|
255 257 tags << hidden_field_tag("f[]", field, :id => nil)
256 258 tags << hidden_field_tag("op[#{field}]", filter[:operator], :id => nil)
257 259 filter[:values].each do |value|
258 260 tags << hidden_field_tag("v[#{field}][]", value, :id => nil)
259 261 end
260 262 end
261 263 else
262 264 tags << hidden_field_tag("f[]", "", :id => nil)
263 265 end
264 266 if query.column_names.present?
265 267 query.column_names.each do |name|
266 268 tags << hidden_field_tag("c[]", name, :id => nil)
267 269 end
268 270 end
269 271 if query.totalable_names.present?
270 272 query.totalable_names.each do |name|
271 273 tags << hidden_field_tag("t[]", name, :id => nil)
272 274 end
273 275 end
274 276 if query.group_by.present?
275 277 tags << hidden_field_tag("group_by", query.group_by, :id => nil)
276 278 end
277 279
278 280 tags
279 281 end
280 282
281 283 # Returns the queries that are rendered in the sidebar
282 284 def sidebar_queries(klass, project)
283 285 klass.visible.global_or_on_project(@project).sorted.to_a
284 286 end
285 287
286 288 # Renders a group of queries
287 289 def query_links(title, queries)
288 290 return '' if queries.empty?
289 291 # links to #index on issues/show
290 292 url_params = controller_name == 'issues' ? {:controller => 'issues', :action => 'index', :project_id => @project} : {}
291 293
292 294 content_tag('h3', title) + "\n" +
293 295 content_tag('ul',
294 296 queries.collect {|query|
295 297 css = 'query'
296 298 css << ' selected' if query == @query
297 299 content_tag('li', link_to(query.name, url_params.merge(:query_id => query), :class => css))
298 300 }.join("\n").html_safe,
299 301 :class => 'queries'
300 302 ) + "\n"
301 303 end
302 304
303 305 # Renders the list of queries for the sidebar
304 306 def render_sidebar_queries(klass, project)
305 307 queries = sidebar_queries(klass, project)
306 308
307 309 out = ''.html_safe
308 310 out << query_links(l(:label_my_queries), queries.select(&:is_private?))
309 311 out << query_links(l(:label_query_plural), queries.reject(&:is_private?))
310 312 out
311 313 end
312 314 end
@@ -1,89 +1,85
1 1 # encoding: utf-8
2 2 #
3 3 # Redmine - project management software
4 4 # Copyright (C) 2006-2016 Jean-Philippe Lang
5 5 #
6 6 # This program is free software; you can redistribute it and/or
7 7 # modify it under the terms of the GNU General Public License
8 8 # as published by the Free Software Foundation; either version 2
9 9 # of the License, or (at your option) any later version.
10 10 #
11 11 # This program is distributed in the hope that it will be useful,
12 12 # but WITHOUT ANY WARRANTY; without even the implied warranty of
13 13 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14 14 # GNU General Public License for more details.
15 15 #
16 16 # You should have received a copy of the GNU General Public License
17 17 # along with this program; if not, write to the Free Software
18 18 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
19 19
20 20 module RoutesHelper
21 21
22 22 # Returns the path to project issues or to the cross-project
23 23 # issue list if project is nil
24 24 def _project_issues_path(project, *args)
25 25 if project
26 26 project_issues_path(project, *args)
27 27 else
28 28 issues_path(*args)
29 29 end
30 30 end
31 31
32 32 def _project_news_path(project, *args)
33 33 if project
34 34 project_news_index_path(project, *args)
35 35 else
36 36 news_index_path(*args)
37 37 end
38 38 end
39 39
40 40 def _new_project_issue_path(project, *args)
41 41 if project
42 42 new_project_issue_path(project, *args)
43 43 else
44 44 new_issue_path(*args)
45 45 end
46 46 end
47 47
48 48 def _project_calendar_path(project, *args)
49 49 project ? project_calendar_path(project, *args) : issues_calendar_path(*args)
50 50 end
51 51
52 52 def _project_gantt_path(project, *args)
53 53 project ? project_gantt_path(project, *args) : issues_gantt_path(*args)
54 54 end
55 55
56 56 def _time_entries_path(project, issue, *args)
57 if issue
58 issue_time_entries_path(issue, *args)
59 elsif project
57 if project
60 58 project_time_entries_path(project, *args)
61 59 else
62 60 time_entries_path(*args)
63 61 end
64 62 end
65 63
66 64 def _report_time_entries_path(project, issue, *args)
67 if issue
68 report_issue_time_entries_path(issue, *args)
69 elsif project
65 if project
70 66 report_project_time_entries_path(project, *args)
71 67 else
72 68 report_time_entries_path(*args)
73 69 end
74 70 end
75 71
76 72 def _new_time_entry_path(project, issue, *args)
77 73 if issue
78 74 new_issue_time_entry_path(issue, *args)
79 75 elsif project
80 76 new_project_time_entry_path(project, *args)
81 77 else
82 78 new_time_entry_path(*args)
83 79 end
84 80 end
85 81
86 82 def board_path(board, *args)
87 83 project_board_path(board.project, board, *args)
88 84 end
89 85 end
@@ -1,135 +1,121
1 1 # encoding: utf-8
2 2 #
3 3 # Redmine - project management software
4 4 # Copyright (C) 2006-2016 Jean-Philippe Lang
5 5 #
6 6 # This program is free software; you can redistribute it and/or
7 7 # modify it under the terms of the GNU General Public License
8 8 # as published by the Free Software Foundation; either version 2
9 9 # of the License, or (at your option) any later version.
10 10 #
11 11 # This program is distributed in the hope that it will be useful,
12 12 # but WITHOUT ANY WARRANTY; without even the implied warranty of
13 13 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14 14 # GNU General Public License for more details.
15 15 #
16 16 # You should have received a copy of the GNU General Public License
17 17 # along with this program; if not, write to the Free Software
18 18 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
19 19
20 20 module TimelogHelper
21 21 include ApplicationHelper
22 22
23 def render_timelog_breadcrumb
24 links = []
25 links << link_to(l(:label_project_all), {:project_id => nil, :issue_id => nil})
26 links << link_to(h(@project), {:project_id => @project, :issue_id => nil}) if @project
27 if @issue
28 if @issue.visible?
29 links << link_to_issue(@issue, :subject => false)
30 else
31 links << "##{@issue.id}"
32 end
33 end
34 breadcrumb links
35 end
36
37 23 # Returns a collection of activities for a select field. time_entry
38 24 # is optional and will be used to check if the selected TimeEntryActivity
39 25 # is active.
40 26 def activity_collection_for_select_options(time_entry=nil, project=nil)
41 27 project ||= time_entry.try(:project)
42 28 project ||= @project
43 29 if project.nil?
44 30 activities = TimeEntryActivity.shared.active
45 31 else
46 32 activities = project.activities
47 33 end
48 34
49 35 collection = []
50 36 if time_entry && time_entry.activity && !time_entry.activity.active?
51 37 collection << [ "--- #{l(:actionview_instancetag_blank_option)} ---", '' ]
52 38 else
53 39 collection << [ "--- #{l(:actionview_instancetag_blank_option)} ---", '' ] unless activities.detect(&:is_default)
54 40 end
55 41 activities.each { |a| collection << [a.name, a.id] }
56 42 collection
57 43 end
58 44
59 45 def select_hours(data, criteria, value)
60 46 if value.to_s.empty?
61 47 data.select {|row| row[criteria].blank? }
62 48 else
63 49 data.select {|row| row[criteria].to_s == value.to_s}
64 50 end
65 51 end
66 52
67 53 def sum_hours(data)
68 54 sum = 0
69 55 data.each do |row|
70 56 sum += row['hours'].to_f
71 57 end
72 58 sum
73 59 end
74 60
75 61 def format_criteria_value(criteria_options, value)
76 62 if value.blank?
77 63 "[#{l(:label_none)}]"
78 64 elsif k = criteria_options[:klass]
79 65 obj = k.find_by_id(value.to_i)
80 66 if obj.is_a?(Issue)
81 67 obj.visible? ? "#{obj.tracker} ##{obj.id}: #{obj.subject}" : "##{obj.id}"
82 68 else
83 69 obj
84 70 end
85 71 elsif cf = criteria_options[:custom_field]
86 72 format_value(value, cf)
87 73 else
88 74 value.to_s
89 75 end
90 76 end
91 77
92 78 def report_to_csv(report)
93 79 Redmine::Export::CSV.generate do |csv|
94 80 # Column headers
95 81 headers = report.criteria.collect {|criteria| l(report.available_criteria[criteria][:label]) }
96 82 headers += report.periods
97 83 headers << l(:label_total_time)
98 84 csv << headers
99 85 # Content
100 86 report_criteria_to_csv(csv, report.available_criteria, report.columns, report.criteria, report.periods, report.hours)
101 87 # Total row
102 88 str_total = l(:label_total_time)
103 89 row = [ str_total ] + [''] * (report.criteria.size - 1)
104 90 total = 0
105 91 report.periods.each do |period|
106 92 sum = sum_hours(select_hours(report.hours, report.columns, period.to_s))
107 93 total += sum
108 94 row << (sum > 0 ? sum : '')
109 95 end
110 96 row << total
111 97 csv << row
112 98 end
113 99 end
114 100
115 101 def report_criteria_to_csv(csv, available_criteria, columns, criteria, periods, hours, level=0)
116 102 hours.collect {|h| h[criteria[level]].to_s}.uniq.each do |value|
117 103 hours_for_value = select_hours(hours, criteria[level], value)
118 104 next if hours_for_value.empty?
119 105 row = [''] * level
120 106 row << format_criteria_value(available_criteria[criteria[level]], value).to_s
121 107 row += [''] * (criteria.length - level - 1)
122 108 total = 0
123 109 periods.each do |period|
124 110 sum = sum_hours(select_hours(hours_for_value, columns, period.to_s))
125 111 total += sum
126 112 row << (sum > 0 ? sum : '')
127 113 end
128 114 row << total
129 115 csv << row
130 116 if criteria.length > level + 1
131 117 report_criteria_to_csv(csv, available_criteria, columns, criteria, periods, hours_for_value, level + 1)
132 118 end
133 119 end
134 120 end
135 121 end
@@ -1,1111 +1,1111
1 1 # Redmine - project management software
2 2 # Copyright (C) 2006-2016 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 QueryColumn
19 19 attr_accessor :name, :sortable, :groupable, :totalable, :default_order
20 20 include Redmine::I18n
21 21
22 22 def initialize(name, options={})
23 23 self.name = name
24 24 self.sortable = options[:sortable]
25 25 self.groupable = options[:groupable] || false
26 26 if groupable == true
27 27 self.groupable = name.to_s
28 28 end
29 29 self.totalable = options[:totalable] || false
30 30 self.default_order = options[:default_order]
31 31 @inline = options.key?(:inline) ? options[:inline] : true
32 32 @caption_key = options[:caption] || "field_#{name}".to_sym
33 33 @frozen = options[:frozen]
34 34 end
35 35
36 36 def caption
37 37 case @caption_key
38 38 when Symbol
39 39 l(@caption_key)
40 40 when Proc
41 41 @caption_key.call
42 42 else
43 43 @caption_key
44 44 end
45 45 end
46 46
47 47 # Returns true if the column is sortable, otherwise false
48 48 def sortable?
49 49 !@sortable.nil?
50 50 end
51 51
52 52 def sortable
53 53 @sortable.is_a?(Proc) ? @sortable.call : @sortable
54 54 end
55 55
56 56 def inline?
57 57 @inline
58 58 end
59 59
60 60 def frozen?
61 61 @frozen
62 62 end
63 63
64 64 def value(object)
65 65 object.send name
66 66 end
67 67
68 68 def value_object(object)
69 69 object.send name
70 70 end
71 71
72 72 def css_classes
73 73 name
74 74 end
75 75 end
76 76
77 77 class QueryCustomFieldColumn < QueryColumn
78 78
79 79 def initialize(custom_field)
80 80 self.name = "cf_#{custom_field.id}".to_sym
81 81 self.sortable = custom_field.order_statement || false
82 82 self.groupable = custom_field.group_statement || false
83 83 self.totalable = custom_field.totalable?
84 84 @inline = true
85 85 @cf = custom_field
86 86 end
87 87
88 88 def caption
89 89 @cf.name
90 90 end
91 91
92 92 def custom_field
93 93 @cf
94 94 end
95 95
96 96 def value_object(object)
97 97 if custom_field.visible_by?(object.project, User.current)
98 98 cv = object.custom_values.select {|v| v.custom_field_id == @cf.id}
99 99 cv.size > 1 ? cv.sort {|a,b| a.value.to_s <=> b.value.to_s} : cv.first
100 100 else
101 101 nil
102 102 end
103 103 end
104 104
105 105 def value(object)
106 106 raw = value_object(object)
107 107 if raw.is_a?(Array)
108 108 raw.map {|r| @cf.cast_value(r.value)}
109 109 elsif raw
110 110 @cf.cast_value(raw.value)
111 111 else
112 112 nil
113 113 end
114 114 end
115 115
116 116 def css_classes
117 117 @css_classes ||= "#{name} #{@cf.field_format}"
118 118 end
119 119 end
120 120
121 121 class QueryAssociationCustomFieldColumn < QueryCustomFieldColumn
122 122
123 123 def initialize(association, custom_field)
124 124 super(custom_field)
125 125 self.name = "#{association}.cf_#{custom_field.id}".to_sym
126 126 # TODO: support sorting/grouping by association custom field
127 127 self.sortable = false
128 128 self.groupable = false
129 129 @association = association
130 130 end
131 131
132 132 def value_object(object)
133 133 if assoc = object.send(@association)
134 134 super(assoc)
135 135 end
136 136 end
137 137
138 138 def css_classes
139 139 @css_classes ||= "#{@association}_cf_#{@cf.id} #{@cf.field_format}"
140 140 end
141 141 end
142 142
143 143 class Query < ActiveRecord::Base
144 144 class StatementInvalid < ::ActiveRecord::StatementInvalid
145 145 end
146 146
147 147 include Redmine::SubclassFactory
148 148
149 149 VISIBILITY_PRIVATE = 0
150 150 VISIBILITY_ROLES = 1
151 151 VISIBILITY_PUBLIC = 2
152 152
153 153 belongs_to :project
154 154 belongs_to :user
155 155 has_and_belongs_to_many :roles, :join_table => "#{table_name_prefix}queries_roles#{table_name_suffix}", :foreign_key => "query_id"
156 156 serialize :filters
157 157 serialize :column_names
158 158 serialize :sort_criteria, Array
159 159 serialize :options, Hash
160 160
161 161 attr_protected :project_id, :user_id
162 162
163 163 validates_presence_of :name
164 164 validates_length_of :name, :maximum => 255
165 165 validates :visibility, :inclusion => { :in => [VISIBILITY_PUBLIC, VISIBILITY_ROLES, VISIBILITY_PRIVATE] }
166 166 validate :validate_query_filters
167 167 validate do |query|
168 168 errors.add(:base, l(:label_role_plural) + ' ' + l('activerecord.errors.messages.blank')) if query.visibility == VISIBILITY_ROLES && roles.blank?
169 169 end
170 170
171 171 after_save do |query|
172 172 if query.visibility_changed? && query.visibility != VISIBILITY_ROLES
173 173 query.roles.clear
174 174 end
175 175 end
176 176
177 177 class_attribute :operators
178 178 self.operators = {
179 179 "=" => :label_equals,
180 180 "!" => :label_not_equals,
181 181 "o" => :label_open_issues,
182 182 "c" => :label_closed_issues,
183 183 "!*" => :label_none,
184 184 "*" => :label_any,
185 185 ">=" => :label_greater_or_equal,
186 186 "<=" => :label_less_or_equal,
187 187 "><" => :label_between,
188 188 "<t+" => :label_in_less_than,
189 189 ">t+" => :label_in_more_than,
190 190 "><t+"=> :label_in_the_next_days,
191 191 "t+" => :label_in,
192 192 "t" => :label_today,
193 193 "ld" => :label_yesterday,
194 194 "w" => :label_this_week,
195 195 "lw" => :label_last_week,
196 196 "l2w" => [:label_last_n_weeks, {:count => 2}],
197 197 "m" => :label_this_month,
198 198 "lm" => :label_last_month,
199 199 "y" => :label_this_year,
200 200 ">t-" => :label_less_than_ago,
201 201 "<t-" => :label_more_than_ago,
202 202 "><t-"=> :label_in_the_past_days,
203 203 "t-" => :label_ago,
204 204 "~" => :label_contains,
205 205 "!~" => :label_not_contains,
206 206 "=p" => :label_any_issues_in_project,
207 207 "=!p" => :label_any_issues_not_in_project,
208 208 "!p" => :label_no_issues_in_project,
209 209 "*o" => :label_any_open_issues,
210 210 "!o" => :label_no_open_issues
211 211 }
212 212
213 213 class_attribute :operators_by_filter_type
214 214 self.operators_by_filter_type = {
215 215 :list => [ "=", "!" ],
216 216 :list_status => [ "o", "=", "!", "c", "*" ],
217 217 :list_optional => [ "=", "!", "!*", "*" ],
218 218 :list_subprojects => [ "*", "!*", "=" ],
219 219 :date => [ "=", ">=", "<=", "><", "<t+", ">t+", "><t+", "t+", "t", "ld", "w", "lw", "l2w", "m", "lm", "y", ">t-", "<t-", "><t-", "t-", "!*", "*" ],
220 220 :date_past => [ "=", ">=", "<=", "><", ">t-", "<t-", "><t-", "t-", "t", "ld", "w", "lw", "l2w", "m", "lm", "y", "!*", "*" ],
221 221 :string => [ "=", "~", "!", "!~", "!*", "*" ],
222 222 :text => [ "~", "!~", "!*", "*" ],
223 223 :integer => [ "=", ">=", "<=", "><", "!*", "*" ],
224 224 :float => [ "=", ">=", "<=", "><", "!*", "*" ],
225 225 :relation => ["=", "=p", "=!p", "!p", "*o", "!o", "!*", "*"],
226 226 :tree => ["=", "~", "!*", "*"]
227 227 }
228 228
229 229 class_attribute :available_columns
230 230 self.available_columns = []
231 231
232 232 class_attribute :queried_class
233 233
234 234 # Permission required to view the queries, set on subclasses.
235 235 class_attribute :view_permission
236 236
237 237 # Scope of queries that are global or on the given project
238 238 scope :global_or_on_project, lambda {|project|
239 239 where(:project_id => (project.nil? ? nil : [nil, project.id]))
240 240 }
241 241
242 242 scope :sorted, lambda {order(:name, :id)}
243 243
244 244 # Scope of visible queries, can be used from subclasses only.
245 245 # Unlike other visible scopes, a class methods is used as it
246 246 # let handle inheritance more nicely than scope DSL.
247 247 def self.visible(*args)
248 248 if self == ::Query
249 249 # Visibility depends on permissions for each subclass,
250 250 # raise an error if the scope is called from Query (eg. Query.visible)
251 251 raise Exception.new("Cannot call .visible scope from the base Query class, but from subclasses only.")
252 252 end
253 253
254 254 user = args.shift || User.current
255 255 base = Project.allowed_to_condition(user, view_permission, *args)
256 256 scope = joins("LEFT OUTER JOIN #{Project.table_name} ON #{table_name}.project_id = #{Project.table_name}.id").
257 257 where("#{table_name}.project_id IS NULL OR (#{base})")
258 258
259 259 if user.admin?
260 260 scope.where("#{table_name}.visibility <> ? OR #{table_name}.user_id = ?", VISIBILITY_PRIVATE, user.id)
261 261 elsif user.memberships.any?
262 262 scope.where("#{table_name}.visibility = ?" +
263 263 " OR (#{table_name}.visibility = ? AND #{table_name}.id IN (" +
264 264 "SELECT DISTINCT q.id FROM #{table_name} q" +
265 265 " INNER JOIN #{table_name_prefix}queries_roles#{table_name_suffix} qr on qr.query_id = q.id" +
266 266 " INNER JOIN #{MemberRole.table_name} mr ON mr.role_id = qr.role_id" +
267 267 " INNER JOIN #{Member.table_name} m ON m.id = mr.member_id AND m.user_id = ?" +
268 268 " WHERE q.project_id IS NULL OR q.project_id = m.project_id))" +
269 269 " OR #{table_name}.user_id = ?",
270 270 VISIBILITY_PUBLIC, VISIBILITY_ROLES, user.id, user.id)
271 271 elsif user.logged?
272 272 scope.where("#{table_name}.visibility = ? OR #{table_name}.user_id = ?", VISIBILITY_PUBLIC, user.id)
273 273 else
274 274 scope.where("#{table_name}.visibility = ?", VISIBILITY_PUBLIC)
275 275 end
276 276 end
277 277
278 278 # Returns true if the query is visible to +user+ or the current user.
279 279 def visible?(user=User.current)
280 280 return true if user.admin?
281 281 return false unless project.nil? || user.allowed_to?(self.class.view_permission, project)
282 282 case visibility
283 283 when VISIBILITY_PUBLIC
284 284 true
285 285 when VISIBILITY_ROLES
286 286 if project
287 287 (user.roles_for_project(project) & roles).any?
288 288 else
289 289 Member.where(:user_id => user.id).joins(:roles).where(:member_roles => {:role_id => roles.map(&:id)}).any?
290 290 end
291 291 else
292 292 user == self.user
293 293 end
294 294 end
295 295
296 296 def is_private?
297 297 visibility == VISIBILITY_PRIVATE
298 298 end
299 299
300 300 def is_public?
301 301 !is_private?
302 302 end
303 303
304 304 def queried_table_name
305 305 @queried_table_name ||= self.class.queried_class.table_name
306 306 end
307 307
308 308 def initialize(attributes=nil, *args)
309 309 super attributes
310 310 @is_for_all = project.nil?
311 311 end
312 312
313 313 # Builds the query from the given params
314 314 def build_from_params(params)
315 315 if params[:fields] || params[:f]
316 316 self.filters = {}
317 317 add_filters(params[:fields] || params[:f], params[:operators] || params[:op], params[:values] || params[:v])
318 318 else
319 319 available_filters.keys.each do |field|
320 320 add_short_filter(field, params[field]) if params[field]
321 321 end
322 322 end
323 323 self.group_by = params[:group_by] || (params[:query] && params[:query][:group_by])
324 324 self.column_names = params[:c] || (params[:query] && params[:query][:column_names])
325 325 self.totalable_names = params[:t] || (params[:query] && params[:query][:totalable_names])
326 326 self
327 327 end
328 328
329 329 # Builds a new query from the given params and attributes
330 330 def self.build_from_params(params, attributes={})
331 331 new(attributes).build_from_params(params)
332 332 end
333 333
334 334 def validate_query_filters
335 335 filters.each_key do |field|
336 336 if values_for(field)
337 337 case type_for(field)
338 338 when :integer
339 339 add_filter_error(field, :invalid) if values_for(field).detect {|v| v.present? && !v.match(/\A[+-]?\d+(,[+-]?\d+)*\z/) }
340 340 when :float
341 341 add_filter_error(field, :invalid) if values_for(field).detect {|v| v.present? && !v.match(/\A[+-]?\d+(\.\d*)?\z/) }
342 342 when :date, :date_past
343 343 case operator_for(field)
344 344 when "=", ">=", "<=", "><"
345 345 add_filter_error(field, :invalid) if values_for(field).detect {|v|
346 346 v.present? && (!v.match(/\A\d{4}-\d{2}-\d{2}(T\d{2}((:)?\d{2}){0,2}(Z|\d{2}:?\d{2})?)?\z/) || parse_date(v).nil?)
347 347 }
348 348 when ">t-", "<t-", "t-", ">t+", "<t+", "t+", "><t+", "><t-"
349 349 add_filter_error(field, :invalid) if values_for(field).detect {|v| v.present? && !v.match(/^\d+$/) }
350 350 end
351 351 end
352 352 end
353 353
354 354 add_filter_error(field, :blank) unless
355 355 # filter requires one or more values
356 356 (values_for(field) and !values_for(field).first.blank?) or
357 357 # filter doesn't require any value
358 358 ["o", "c", "!*", "*", "t", "ld", "w", "lw", "l2w", "m", "lm", "y", "*o", "!o"].include? operator_for(field)
359 359 end if filters
360 360 end
361 361
362 362 def add_filter_error(field, message)
363 363 m = label_for(field) + " " + l(message, :scope => 'activerecord.errors.messages')
364 364 errors.add(:base, m)
365 365 end
366 366
367 367 def editable_by?(user)
368 368 return false unless user
369 369 # Admin can edit them all and regular users can edit their private queries
370 370 return true if user.admin? || (is_private? && self.user_id == user.id)
371 371 # Members can not edit public queries that are for all project (only admin is allowed to)
372 372 is_public? && !@is_for_all && user.allowed_to?(:manage_public_queries, project)
373 373 end
374 374
375 375 def trackers
376 376 @trackers ||= (project.nil? ? Tracker.all : project.rolled_up_trackers).visible.sorted
377 377 end
378 378
379 379 # Returns a hash of localized labels for all filter operators
380 380 def self.operators_labels
381 381 operators.inject({}) {|h, operator| h[operator.first] = l(*operator.last); h}
382 382 end
383 383
384 384 # Returns a representation of the available filters for JSON serialization
385 385 def available_filters_as_json
386 386 json = {}
387 387 available_filters.each do |field, options|
388 388 options = options.slice(:type, :name, :values)
389 389 if options[:values] && values_for(field)
390 390 missing = Array(values_for(field)).select(&:present?) - options[:values].map(&:last)
391 391 if missing.any? && respond_to?(method = "find_#{field}_filter_values")
392 392 options[:values] += send(method, missing)
393 393 end
394 394 end
395 395 json[field] = options.stringify_keys
396 396 end
397 397 json
398 398 end
399 399
400 400 def all_projects
401 401 @all_projects ||= Project.visible.to_a
402 402 end
403 403
404 404 def all_projects_values
405 405 return @all_projects_values if @all_projects_values
406 406
407 407 values = []
408 408 Project.project_tree(all_projects) do |p, level|
409 409 prefix = (level > 0 ? ('--' * level + ' ') : '')
410 410 values << ["#{prefix}#{p.name}", p.id.to_s]
411 411 end
412 412 @all_projects_values = values
413 413 end
414 414
415 415 # Adds available filters
416 416 def initialize_available_filters
417 417 # implemented by sub-classes
418 418 end
419 419 protected :initialize_available_filters
420 420
421 421 # Adds an available filter
422 422 def add_available_filter(field, options)
423 423 @available_filters ||= ActiveSupport::OrderedHash.new
424 424 @available_filters[field] = options
425 425 @available_filters
426 426 end
427 427
428 428 # Removes an available filter
429 429 def delete_available_filter(field)
430 430 if @available_filters
431 431 @available_filters.delete(field)
432 432 end
433 433 end
434 434
435 435 # Return a hash of available filters
436 436 def available_filters
437 437 unless @available_filters
438 438 initialize_available_filters
439 439 @available_filters.each do |field, options|
440 440 options[:name] ||= l(options[:label] || "field_#{field}".gsub(/_id$/, ''))
441 441 end
442 442 end
443 443 @available_filters
444 444 end
445 445
446 446 def add_filter(field, operator, values=nil)
447 447 # values must be an array
448 448 return unless values.nil? || values.is_a?(Array)
449 449 # check if field is defined as an available filter
450 450 if available_filters.has_key? field
451 451 filter_options = available_filters[field]
452 452 filters[field] = {:operator => operator, :values => (values || [''])}
453 453 end
454 454 end
455 455
456 456 def add_short_filter(field, expression)
457 457 return unless expression && available_filters.has_key?(field)
458 458 field_type = available_filters[field][:type]
459 459 operators_by_filter_type[field_type].sort.reverse.detect do |operator|
460 460 next unless expression =~ /^#{Regexp.escape(operator)}(.*)$/
461 461 values = $1
462 462 add_filter field, operator, values.present? ? values.split('|') : ['']
463 end || add_filter(field, '=', expression.split('|'))
463 end || add_filter(field, '=', expression.to_s.split('|'))
464 464 end
465 465
466 466 # Add multiple filters using +add_filter+
467 467 def add_filters(fields, operators, values)
468 468 if fields.is_a?(Array) && operators.is_a?(Hash) && (values.nil? || values.is_a?(Hash))
469 469 fields.each do |field|
470 470 add_filter(field, operators[field], values && values[field])
471 471 end
472 472 end
473 473 end
474 474
475 475 def has_filter?(field)
476 476 filters and filters[field]
477 477 end
478 478
479 479 def type_for(field)
480 480 available_filters[field][:type] if available_filters.has_key?(field)
481 481 end
482 482
483 483 def operator_for(field)
484 484 has_filter?(field) ? filters[field][:operator] : nil
485 485 end
486 486
487 487 def values_for(field)
488 488 has_filter?(field) ? filters[field][:values] : nil
489 489 end
490 490
491 491 def value_for(field, index=0)
492 492 (values_for(field) || [])[index]
493 493 end
494 494
495 495 def label_for(field)
496 496 label = available_filters[field][:name] if available_filters.has_key?(field)
497 497 label ||= queried_class.human_attribute_name(field, :default => field)
498 498 end
499 499
500 500 def self.add_available_column(column)
501 501 self.available_columns << (column) if column.is_a?(QueryColumn)
502 502 end
503 503
504 504 # Returns an array of columns that can be used to group the results
505 505 def groupable_columns
506 506 available_columns.select {|c| c.groupable}
507 507 end
508 508
509 509 # Returns a Hash of columns and the key for sorting
510 510 def sortable_columns
511 511 available_columns.inject({}) {|h, column|
512 512 h[column.name.to_s] = column.sortable
513 513 h
514 514 }
515 515 end
516 516
517 517 def columns
518 518 # preserve the column_names order
519 519 cols = (has_default_columns? ? default_columns_names : column_names).collect do |name|
520 520 available_columns.find { |col| col.name == name }
521 521 end.compact
522 522 available_columns.select(&:frozen?) | cols
523 523 end
524 524
525 525 def inline_columns
526 526 columns.select(&:inline?)
527 527 end
528 528
529 529 def block_columns
530 530 columns.reject(&:inline?)
531 531 end
532 532
533 533 def available_inline_columns
534 534 available_columns.select(&:inline?)
535 535 end
536 536
537 537 def available_block_columns
538 538 available_columns.reject(&:inline?)
539 539 end
540 540
541 541 def available_totalable_columns
542 542 available_columns.select(&:totalable)
543 543 end
544 544
545 545 def default_columns_names
546 546 []
547 547 end
548 548
549 549 def column_names=(names)
550 550 if names
551 551 names = names.select {|n| n.is_a?(Symbol) || !n.blank? }
552 552 names = names.collect {|n| n.is_a?(Symbol) ? n : n.to_sym }
553 553 # Set column_names to nil if default columns
554 554 if names == default_columns_names
555 555 names = nil
556 556 end
557 557 end
558 558 write_attribute(:column_names, names)
559 559 end
560 560
561 561 def has_column?(column)
562 562 column_names && column_names.include?(column.is_a?(QueryColumn) ? column.name : column)
563 563 end
564 564
565 565 def has_custom_field_column?
566 566 columns.any? {|column| column.is_a? QueryCustomFieldColumn}
567 567 end
568 568
569 569 def has_default_columns?
570 570 column_names.nil? || column_names.empty?
571 571 end
572 572
573 573 def totalable_columns
574 574 names = totalable_names
575 575 available_totalable_columns.select {|column| names.include?(column.name)}
576 576 end
577 577
578 578 def totalable_names=(names)
579 579 if names
580 580 names = names.select(&:present?).map {|n| n.is_a?(Symbol) ? n : n.to_sym}
581 581 end
582 582 options[:totalable_names] = names
583 583 end
584 584
585 585 def totalable_names
586 586 options[:totalable_names] || Setting.issue_list_default_totals.map(&:to_sym) || []
587 587 end
588 588
589 589 def sort_criteria=(arg)
590 590 c = []
591 591 if arg.is_a?(Hash)
592 592 arg = arg.keys.sort.collect {|k| arg[k]}
593 593 end
594 594 if arg
595 595 c = arg.select {|k,o| !k.to_s.blank?}.slice(0,3).collect {|k,o| [k.to_s, (o == 'desc' || o == false) ? 'desc' : 'asc']}
596 596 end
597 597 write_attribute(:sort_criteria, c)
598 598 end
599 599
600 600 def sort_criteria
601 601 read_attribute(:sort_criteria) || []
602 602 end
603 603
604 604 def sort_criteria_key(arg)
605 605 sort_criteria && sort_criteria[arg] && sort_criteria[arg].first
606 606 end
607 607
608 608 def sort_criteria_order(arg)
609 609 sort_criteria && sort_criteria[arg] && sort_criteria[arg].last
610 610 end
611 611
612 612 def sort_criteria_order_for(key)
613 613 sort_criteria.detect {|k, order| key.to_s == k}.try(:last)
614 614 end
615 615
616 616 # Returns the SQL sort order that should be prepended for grouping
617 617 def group_by_sort_order
618 618 if column = group_by_column
619 619 order = (sort_criteria_order_for(column.name) || column.default_order).try(:upcase)
620 620 Array(column.sortable).map {|s| "#{s} #{order}"}
621 621 end
622 622 end
623 623
624 624 # Returns true if the query is a grouped query
625 625 def grouped?
626 626 !group_by_column.nil?
627 627 end
628 628
629 629 def group_by_column
630 630 groupable_columns.detect {|c| c.groupable && c.name.to_s == group_by}
631 631 end
632 632
633 633 def group_by_statement
634 634 group_by_column.try(:groupable)
635 635 end
636 636
637 637 def project_statement
638 638 project_clauses = []
639 639 if project && !project.descendants.active.empty?
640 640 if has_filter?("subproject_id")
641 641 case operator_for("subproject_id")
642 642 when '='
643 643 # include the selected subprojects
644 644 ids = [project.id] + values_for("subproject_id").each(&:to_i)
645 645 project_clauses << "#{Project.table_name}.id IN (%s)" % ids.join(',')
646 646 when '!*'
647 647 # main project only
648 648 project_clauses << "#{Project.table_name}.id = %d" % project.id
649 649 else
650 650 # all subprojects
651 651 project_clauses << "#{Project.table_name}.lft >= #{project.lft} AND #{Project.table_name}.rgt <= #{project.rgt}"
652 652 end
653 653 elsif Setting.display_subprojects_issues?
654 654 project_clauses << "#{Project.table_name}.lft >= #{project.lft} AND #{Project.table_name}.rgt <= #{project.rgt}"
655 655 else
656 656 project_clauses << "#{Project.table_name}.id = %d" % project.id
657 657 end
658 658 elsif project
659 659 project_clauses << "#{Project.table_name}.id = %d" % project.id
660 660 end
661 661 project_clauses.any? ? project_clauses.join(' AND ') : nil
662 662 end
663 663
664 664 def statement
665 665 # filters clauses
666 666 filters_clauses = []
667 667 filters.each_key do |field|
668 668 next if field == "subproject_id"
669 669 v = values_for(field).clone
670 670 next unless v and !v.empty?
671 671 operator = operator_for(field)
672 672
673 673 # "me" value substitution
674 674 if %w(assigned_to_id author_id user_id watcher_id).include?(field)
675 675 if v.delete("me")
676 676 if User.current.logged?
677 677 v.push(User.current.id.to_s)
678 678 v += User.current.group_ids.map(&:to_s) if field == 'assigned_to_id'
679 679 else
680 680 v.push("0")
681 681 end
682 682 end
683 683 end
684 684
685 685 if field == 'project_id'
686 686 if v.delete('mine')
687 687 v += User.current.memberships.map(&:project_id).map(&:to_s)
688 688 end
689 689 end
690 690
691 691 if field =~ /cf_(\d+)$/
692 692 # custom field
693 693 filters_clauses << sql_for_custom_field(field, operator, v, $1)
694 694 elsif respond_to?("sql_for_#{field}_field")
695 695 # specific statement
696 696 filters_clauses << send("sql_for_#{field}_field", field, operator, v)
697 697 else
698 698 # regular field
699 699 filters_clauses << '(' + sql_for_field(field, operator, v, queried_table_name, field) + ')'
700 700 end
701 701 end if filters and valid?
702 702
703 703 if (c = group_by_column) && c.is_a?(QueryCustomFieldColumn)
704 704 # Excludes results for which the grouped custom field is not visible
705 705 filters_clauses << c.custom_field.visibility_by_project_condition
706 706 end
707 707
708 708 filters_clauses << project_statement
709 709 filters_clauses.reject!(&:blank?)
710 710
711 711 filters_clauses.any? ? filters_clauses.join(' AND ') : nil
712 712 end
713 713
714 714 # Returns the sum of values for the given column
715 715 def total_for(column)
716 716 total_with_scope(column, base_scope)
717 717 end
718 718
719 719 # Returns a hash of the sum of the given column for each group,
720 720 # or nil if the query is not grouped
721 721 def total_by_group_for(column)
722 722 grouped_query do |scope|
723 723 total_with_scope(column, scope)
724 724 end
725 725 end
726 726
727 727 def totals
728 728 totals = totalable_columns.map {|column| [column, total_for(column)]}
729 729 yield totals if block_given?
730 730 totals
731 731 end
732 732
733 733 def totals_by_group
734 734 totals = totalable_columns.map {|column| [column, total_by_group_for(column)]}
735 735 yield totals if block_given?
736 736 totals
737 737 end
738 738
739 739 private
740 740
741 741 def grouped_query(&block)
742 742 r = nil
743 743 if grouped?
744 744 begin
745 745 # Rails3 will raise an (unexpected) RecordNotFound if there's only a nil group value
746 746 r = yield base_group_scope
747 747 rescue ActiveRecord::RecordNotFound
748 748 r = {nil => yield(base_scope)}
749 749 end
750 750 c = group_by_column
751 751 if c.is_a?(QueryCustomFieldColumn)
752 752 r = r.keys.inject({}) {|h, k| h[c.custom_field.cast_value(k)] = r[k]; h}
753 753 end
754 754 end
755 755 r
756 756 rescue ::ActiveRecord::StatementInvalid => e
757 757 raise StatementInvalid.new(e.message)
758 758 end
759 759
760 760 def total_with_scope(column, scope)
761 761 unless column.is_a?(QueryColumn)
762 762 column = column.to_sym
763 763 column = available_totalable_columns.detect {|c| c.name == column}
764 764 end
765 765 if column.is_a?(QueryCustomFieldColumn)
766 766 custom_field = column.custom_field
767 767 send "total_for_custom_field", custom_field, scope
768 768 else
769 769 send "total_for_#{column.name}", scope
770 770 end
771 771 rescue ::ActiveRecord::StatementInvalid => e
772 772 raise StatementInvalid.new(e.message)
773 773 end
774 774
775 775 def base_scope
776 776 raise "unimplemented"
777 777 end
778 778
779 779 def base_group_scope
780 780 base_scope.
781 781 joins(joins_for_order_statement(group_by_statement)).
782 782 group(group_by_statement)
783 783 end
784 784
785 785 def total_for_custom_field(custom_field, scope, &block)
786 786 total = custom_field.format.total_for_scope(custom_field, scope)
787 787 total = map_total(total) {|t| custom_field.format.cast_total_value(custom_field, t)}
788 788 total
789 789 end
790 790
791 791 def map_total(total, &block)
792 792 if total.is_a?(Hash)
793 793 total.keys.each {|k| total[k] = yield total[k]}
794 794 else
795 795 total = yield total
796 796 end
797 797 total
798 798 end
799 799
800 800 def sql_for_custom_field(field, operator, value, custom_field_id)
801 801 db_table = CustomValue.table_name
802 802 db_field = 'value'
803 803 filter = @available_filters[field]
804 804 return nil unless filter
805 805 if filter[:field].format.target_class && filter[:field].format.target_class <= User
806 806 if value.delete('me')
807 807 value.push User.current.id.to_s
808 808 end
809 809 end
810 810 not_in = nil
811 811 if operator == '!'
812 812 # Makes ! operator work for custom fields with multiple values
813 813 operator = '='
814 814 not_in = 'NOT'
815 815 end
816 816 customized_key = "id"
817 817 customized_class = queried_class
818 818 if field =~ /^(.+)\.cf_/
819 819 assoc = $1
820 820 customized_key = "#{assoc}_id"
821 821 customized_class = queried_class.reflect_on_association(assoc.to_sym).klass.base_class rescue nil
822 822 raise "Unknown #{queried_class.name} association #{assoc}" unless customized_class
823 823 end
824 824 where = sql_for_field(field, operator, value, db_table, db_field, true)
825 825 if operator =~ /[<>]/
826 826 where = "(#{where}) AND #{db_table}.#{db_field} <> ''"
827 827 end
828 828 "#{queried_table_name}.#{customized_key} #{not_in} IN (" +
829 829 "SELECT #{customized_class.table_name}.id FROM #{customized_class.table_name}" +
830 830 " LEFT OUTER JOIN #{db_table} ON #{db_table}.customized_type='#{customized_class}' AND #{db_table}.customized_id=#{customized_class.table_name}.id AND #{db_table}.custom_field_id=#{custom_field_id}" +
831 831 " WHERE (#{where}) AND (#{filter[:field].visibility_by_project_condition}))"
832 832 end
833 833
834 834 # Helper method to generate the WHERE sql for a +field+, +operator+ and a +value+
835 835 def sql_for_field(field, operator, value, db_table, db_field, is_custom_filter=false)
836 836 sql = ''
837 837 case operator
838 838 when "="
839 839 if value.any?
840 840 case type_for(field)
841 841 when :date, :date_past
842 842 sql = date_clause(db_table, db_field, parse_date(value.first), parse_date(value.first), is_custom_filter)
843 843 when :integer
844 844 int_values = value.first.to_s.scan(/[+-]?\d+/).map(&:to_i).join(",")
845 845 if int_values.present?
846 846 if is_custom_filter
847 847 sql = "(#{db_table}.#{db_field} <> '' AND CAST(CASE #{db_table}.#{db_field} WHEN '' THEN '0' ELSE #{db_table}.#{db_field} END AS decimal(30,3)) IN (#{int_values}))"
848 848 else
849 849 sql = "#{db_table}.#{db_field} IN (#{int_values})"
850 850 end
851 851 else
852 852 sql = "1=0"
853 853 end
854 854 when :float
855 855 if is_custom_filter
856 856 sql = "(#{db_table}.#{db_field} <> '' AND CAST(CASE #{db_table}.#{db_field} WHEN '' THEN '0' ELSE #{db_table}.#{db_field} END AS decimal(30,3)) BETWEEN #{value.first.to_f - 1e-5} AND #{value.first.to_f + 1e-5})"
857 857 else
858 858 sql = "#{db_table}.#{db_field} BETWEEN #{value.first.to_f - 1e-5} AND #{value.first.to_f + 1e-5}"
859 859 end
860 860 else
861 861 sql = queried_class.send(:sanitize_sql_for_conditions, ["#{db_table}.#{db_field} IN (?)", value])
862 862 end
863 863 else
864 864 # IN an empty set
865 865 sql = "1=0"
866 866 end
867 867 when "!"
868 868 if value.any?
869 869 sql = queried_class.send(:sanitize_sql_for_conditions, ["(#{db_table}.#{db_field} IS NULL OR #{db_table}.#{db_field} NOT IN (?))", value])
870 870 else
871 871 # NOT IN an empty set
872 872 sql = "1=1"
873 873 end
874 874 when "!*"
875 875 sql = "#{db_table}.#{db_field} IS NULL"
876 876 sql << " OR #{db_table}.#{db_field} = ''" if is_custom_filter
877 877 when "*"
878 878 sql = "#{db_table}.#{db_field} IS NOT NULL"
879 879 sql << " AND #{db_table}.#{db_field} <> ''" if is_custom_filter
880 880 when ">="
881 881 if [:date, :date_past].include?(type_for(field))
882 882 sql = date_clause(db_table, db_field, parse_date(value.first), nil, is_custom_filter)
883 883 else
884 884 if is_custom_filter
885 885 sql = "(#{db_table}.#{db_field} <> '' AND CAST(CASE #{db_table}.#{db_field} WHEN '' THEN '0' ELSE #{db_table}.#{db_field} END AS decimal(30,3)) >= #{value.first.to_f})"
886 886 else
887 887 sql = "#{db_table}.#{db_field} >= #{value.first.to_f}"
888 888 end
889 889 end
890 890 when "<="
891 891 if [:date, :date_past].include?(type_for(field))
892 892 sql = date_clause(db_table, db_field, nil, parse_date(value.first), is_custom_filter)
893 893 else
894 894 if is_custom_filter
895 895 sql = "(#{db_table}.#{db_field} <> '' AND CAST(CASE #{db_table}.#{db_field} WHEN '' THEN '0' ELSE #{db_table}.#{db_field} END AS decimal(30,3)) <= #{value.first.to_f})"
896 896 else
897 897 sql = "#{db_table}.#{db_field} <= #{value.first.to_f}"
898 898 end
899 899 end
900 900 when "><"
901 901 if [:date, :date_past].include?(type_for(field))
902 902 sql = date_clause(db_table, db_field, parse_date(value[0]), parse_date(value[1]), is_custom_filter)
903 903 else
904 904 if is_custom_filter
905 905 sql = "(#{db_table}.#{db_field} <> '' AND CAST(CASE #{db_table}.#{db_field} WHEN '' THEN '0' ELSE #{db_table}.#{db_field} END AS decimal(30,3)) BETWEEN #{value[0].to_f} AND #{value[1].to_f})"
906 906 else
907 907 sql = "#{db_table}.#{db_field} BETWEEN #{value[0].to_f} AND #{value[1].to_f}"
908 908 end
909 909 end
910 910 when "o"
911 911 sql = "#{queried_table_name}.status_id IN (SELECT id FROM #{IssueStatus.table_name} WHERE is_closed=#{self.class.connection.quoted_false})" if field == "status_id"
912 912 when "c"
913 913 sql = "#{queried_table_name}.status_id IN (SELECT id FROM #{IssueStatus.table_name} WHERE is_closed=#{self.class.connection.quoted_true})" if field == "status_id"
914 914 when "><t-"
915 915 # between today - n days and today
916 916 sql = relative_date_clause(db_table, db_field, - value.first.to_i, 0, is_custom_filter)
917 917 when ">t-"
918 918 # >= today - n days
919 919 sql = relative_date_clause(db_table, db_field, - value.first.to_i, nil, is_custom_filter)
920 920 when "<t-"
921 921 # <= today - n days
922 922 sql = relative_date_clause(db_table, db_field, nil, - value.first.to_i, is_custom_filter)
923 923 when "t-"
924 924 # = n days in past
925 925 sql = relative_date_clause(db_table, db_field, - value.first.to_i, - value.first.to_i, is_custom_filter)
926 926 when "><t+"
927 927 # between today and today + n days
928 928 sql = relative_date_clause(db_table, db_field, 0, value.first.to_i, is_custom_filter)
929 929 when ">t+"
930 930 # >= today + n days
931 931 sql = relative_date_clause(db_table, db_field, value.first.to_i, nil, is_custom_filter)
932 932 when "<t+"
933 933 # <= today + n days
934 934 sql = relative_date_clause(db_table, db_field, nil, value.first.to_i, is_custom_filter)
935 935 when "t+"
936 936 # = today + n days
937 937 sql = relative_date_clause(db_table, db_field, value.first.to_i, value.first.to_i, is_custom_filter)
938 938 when "t"
939 939 # = today
940 940 sql = relative_date_clause(db_table, db_field, 0, 0, is_custom_filter)
941 941 when "ld"
942 942 # = yesterday
943 943 sql = relative_date_clause(db_table, db_field, -1, -1, is_custom_filter)
944 944 when "w"
945 945 # = this week
946 946 first_day_of_week = l(:general_first_day_of_week).to_i
947 947 day_of_week = User.current.today.cwday
948 948 days_ago = (day_of_week >= first_day_of_week ? day_of_week - first_day_of_week : day_of_week + 7 - first_day_of_week)
949 949 sql = relative_date_clause(db_table, db_field, - days_ago, - days_ago + 6, is_custom_filter)
950 950 when "lw"
951 951 # = last week
952 952 first_day_of_week = l(:general_first_day_of_week).to_i
953 953 day_of_week = User.current.today.cwday
954 954 days_ago = (day_of_week >= first_day_of_week ? day_of_week - first_day_of_week : day_of_week + 7 - first_day_of_week)
955 955 sql = relative_date_clause(db_table, db_field, - days_ago - 7, - days_ago - 1, is_custom_filter)
956 956 when "l2w"
957 957 # = last 2 weeks
958 958 first_day_of_week = l(:general_first_day_of_week).to_i
959 959 day_of_week = User.current.today.cwday
960 960 days_ago = (day_of_week >= first_day_of_week ? day_of_week - first_day_of_week : day_of_week + 7 - first_day_of_week)
961 961 sql = relative_date_clause(db_table, db_field, - days_ago - 14, - days_ago - 1, is_custom_filter)
962 962 when "m"
963 963 # = this month
964 964 date = User.current.today
965 965 sql = date_clause(db_table, db_field, date.beginning_of_month, date.end_of_month, is_custom_filter)
966 966 when "lm"
967 967 # = last month
968 968 date = User.current.today.prev_month
969 969 sql = date_clause(db_table, db_field, date.beginning_of_month, date.end_of_month, is_custom_filter)
970 970 when "y"
971 971 # = this year
972 972 date = User.current.today
973 973 sql = date_clause(db_table, db_field, date.beginning_of_year, date.end_of_year, is_custom_filter)
974 974 when "~"
975 975 sql = sql_contains("#{db_table}.#{db_field}", value.first)
976 976 when "!~"
977 977 sql = sql_contains("#{db_table}.#{db_field}", value.first, false)
978 978 else
979 979 raise "Unknown query operator #{operator}"
980 980 end
981 981
982 982 return sql
983 983 end
984 984
985 985 # Returns a SQL LIKE statement with wildcards
986 986 def sql_contains(db_field, value, match=true)
987 987 queried_class.send :sanitize_sql_for_conditions,
988 988 [Redmine::Database.like(db_field, '?', :match => match), "%#{value}%"]
989 989 end
990 990
991 991 # Adds a filter for the given custom field
992 992 def add_custom_field_filter(field, assoc=nil)
993 993 options = field.query_filter_options(self)
994 994 if field.format.target_class && field.format.target_class <= User
995 995 if options[:values].is_a?(Array) && User.current.logged?
996 996 options[:values].unshift ["<< #{l(:label_me)} >>", "me"]
997 997 end
998 998 end
999 999
1000 1000 filter_id = "cf_#{field.id}"
1001 1001 filter_name = field.name
1002 1002 if assoc.present?
1003 1003 filter_id = "#{assoc}.#{filter_id}"
1004 1004 filter_name = l("label_attribute_of_#{assoc}", :name => filter_name)
1005 1005 end
1006 1006 add_available_filter filter_id, options.merge({
1007 1007 :name => filter_name,
1008 1008 :field => field
1009 1009 })
1010 1010 end
1011 1011
1012 1012 # Adds filters for the given custom fields scope
1013 1013 def add_custom_fields_filters(scope, assoc=nil)
1014 1014 scope.visible.where(:is_filter => true).sorted.each do |field|
1015 1015 add_custom_field_filter(field, assoc)
1016 1016 end
1017 1017 end
1018 1018
1019 1019 # Adds filters for the given associations custom fields
1020 1020 def add_associations_custom_fields_filters(*associations)
1021 1021 fields_by_class = CustomField.visible.where(:is_filter => true).group_by(&:class)
1022 1022 associations.each do |assoc|
1023 1023 association_klass = queried_class.reflect_on_association(assoc).klass
1024 1024 fields_by_class.each do |field_class, fields|
1025 1025 if field_class.customized_class <= association_klass
1026 1026 fields.sort.each do |field|
1027 1027 add_custom_field_filter(field, assoc)
1028 1028 end
1029 1029 end
1030 1030 end
1031 1031 end
1032 1032 end
1033 1033
1034 1034 def quoted_time(time, is_custom_filter)
1035 1035 if is_custom_filter
1036 1036 # Custom field values are stored as strings in the DB
1037 1037 # using this format that does not depend on DB date representation
1038 1038 time.strftime("%Y-%m-%d %H:%M:%S")
1039 1039 else
1040 1040 self.class.connection.quoted_date(time)
1041 1041 end
1042 1042 end
1043 1043
1044 1044 def date_for_user_time_zone(y, m, d)
1045 1045 if tz = User.current.time_zone
1046 1046 tz.local y, m, d
1047 1047 else
1048 1048 Time.local y, m, d
1049 1049 end
1050 1050 end
1051 1051
1052 1052 # Returns a SQL clause for a date or datetime field.
1053 1053 def date_clause(table, field, from, to, is_custom_filter)
1054 1054 s = []
1055 1055 if from
1056 1056 if from.is_a?(Date)
1057 1057 from = date_for_user_time_zone(from.year, from.month, from.day).yesterday.end_of_day
1058 1058 else
1059 1059 from = from - 1 # second
1060 1060 end
1061 1061 if self.class.default_timezone == :utc
1062 1062 from = from.utc
1063 1063 end
1064 1064 s << ("#{table}.#{field} > '%s'" % [quoted_time(from, is_custom_filter)])
1065 1065 end
1066 1066 if to
1067 1067 if to.is_a?(Date)
1068 1068 to = date_for_user_time_zone(to.year, to.month, to.day).end_of_day
1069 1069 end
1070 1070 if self.class.default_timezone == :utc
1071 1071 to = to.utc
1072 1072 end
1073 1073 s << ("#{table}.#{field} <= '%s'" % [quoted_time(to, is_custom_filter)])
1074 1074 end
1075 1075 s.join(' AND ')
1076 1076 end
1077 1077
1078 1078 # Returns a SQL clause for a date or datetime field using relative dates.
1079 1079 def relative_date_clause(table, field, days_from, days_to, is_custom_filter)
1080 1080 date_clause(table, field, (days_from ? User.current.today + days_from : nil), (days_to ? User.current.today + days_to : nil), is_custom_filter)
1081 1081 end
1082 1082
1083 1083 # Returns a Date or Time from the given filter value
1084 1084 def parse_date(arg)
1085 1085 if arg.to_s =~ /\A\d{4}-\d{2}-\d{2}T/
1086 1086 Time.parse(arg) rescue nil
1087 1087 else
1088 1088 Date.parse(arg) rescue nil
1089 1089 end
1090 1090 end
1091 1091
1092 1092 # Additional joins required for the given sort options
1093 1093 def joins_for_order_statement(order_options)
1094 1094 joins = []
1095 1095
1096 1096 if order_options
1097 1097 if order_options.include?('authors')
1098 1098 joins << "LEFT OUTER JOIN #{User.table_name} authors ON authors.id = #{queried_table_name}.author_id"
1099 1099 end
1100 1100 order_options.scan(/cf_\d+/).uniq.each do |name|
1101 1101 column = available_columns.detect {|c| c.name.to_s == name}
1102 1102 join = column && column.custom_field.join_for_order_statement
1103 1103 if join
1104 1104 joins << join
1105 1105 end
1106 1106 end
1107 1107 end
1108 1108
1109 1109 joins.any? ? joins.join(' ') : nil
1110 1110 end
1111 1111 end
@@ -1,142 +1,163
1 1 # Redmine - project management software
2 2 # Copyright (C) 2006-2016 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 TimeEntryQuery < Query
19 19
20 20 self.queried_class = TimeEntry
21 21 self.view_permission = :view_time_entries
22 22
23 23 self.available_columns = [
24 24 QueryColumn.new(:project, :sortable => "#{Project.table_name}.name", :groupable => true),
25 25 QueryColumn.new(:spent_on, :sortable => ["#{TimeEntry.table_name}.spent_on", "#{TimeEntry.table_name}.created_on"], :default_order => 'desc', :groupable => true),
26 26 QueryColumn.new(:tweek, :sortable => ["#{TimeEntry.table_name}.spent_on", "#{TimeEntry.table_name}.created_on"], :caption => l(:label_week)),
27 27 QueryColumn.new(:user, :sortable => lambda {User.fields_for_order_statement}, :groupable => true),
28 28 QueryColumn.new(:activity, :sortable => "#{TimeEntryActivity.table_name}.position", :groupable => true),
29 29 QueryColumn.new(:issue, :sortable => "#{Issue.table_name}.id"),
30 30 QueryColumn.new(:comments),
31 31 QueryColumn.new(:hours, :sortable => "#{TimeEntry.table_name}.hours"),
32 32 ]
33 33
34 34 def initialize(attributes=nil, *args)
35 35 super attributes
36 36 self.filters ||= {}
37 37 add_filter('spent_on', '*') unless filters.present?
38 38 end
39 39
40 40 def initialize_available_filters
41 41 add_available_filter "spent_on", :type => :date_past
42 42
43 43 principals = []
44 44 if project
45 45 principals += project.principals.visible.sort
46 46 unless project.leaf?
47 47 subprojects = project.descendants.visible.to_a
48 48 if subprojects.any?
49 49 add_available_filter "subproject_id",
50 50 :type => :list_subprojects,
51 51 :values => subprojects.collect{|s| [s.name, s.id.to_s] }
52 52 principals += Principal.member_of(subprojects).visible
53 53 end
54 54 end
55 55 else
56 56 if all_projects.any?
57 57 # members of visible projects
58 58 principals += Principal.member_of(all_projects).visible
59 59 # project filter
60 60 project_values = []
61 61 if User.current.logged? && User.current.memberships.any?
62 62 project_values << ["<< #{l(:label_my_projects).downcase} >>", "mine"]
63 63 end
64 64 project_values += all_projects_values
65 65 add_available_filter("project_id",
66 66 :type => :list, :values => project_values
67 67 ) unless project_values.empty?
68 68 end
69 69 end
70
71 add_available_filter("issue_id", :type => :tree, :label => :label_issue)
72
70 73 principals.uniq!
71 74 principals.sort!
72 75 users = principals.select {|p| p.is_a?(User)}
73 76
74 77 users_values = []
75 78 users_values << ["<< #{l(:label_me)} >>", "me"] if User.current.logged?
76 79 users_values += users.collect{|s| [s.name, s.id.to_s] }
77 80 add_available_filter("user_id",
78 81 :type => :list_optional, :values => users_values
79 82 ) unless users_values.empty?
80 83
81 84 activities = (project ? project.activities : TimeEntryActivity.shared)
82 85 add_available_filter("activity_id",
83 86 :type => :list, :values => activities.map {|a| [a.name, a.id.to_s]}
84 87 ) unless activities.empty?
85 88
86 89 add_available_filter "comments", :type => :text
87 90 add_available_filter "hours", :type => :float
88 91
89 92 add_custom_fields_filters(TimeEntryCustomField)
90 93 add_associations_custom_fields_filters :project, :issue, :user
91 94 end
92 95
93 96 def available_columns
94 97 return @available_columns if @available_columns
95 98 @available_columns = self.class.available_columns.dup
96 99 @available_columns += TimeEntryCustomField.visible.
97 100 map {|cf| QueryCustomFieldColumn.new(cf) }
98 101 @available_columns += IssueCustomField.visible.
99 102 map {|cf| QueryAssociationCustomFieldColumn.new(:issue, cf) }
100 103 @available_columns
101 104 end
102 105
103 106 def default_columns_names
104 107 @default_columns_names ||= [:project, :spent_on, :user, :activity, :issue, :comments, :hours]
105 108 end
106 109
107 110 def results_scope(options={})
108 111 order_option = [group_by_sort_order, options[:order]].flatten.reject(&:blank?)
109 112
110 113 TimeEntry.visible.
111 114 where(statement).
112 115 order(order_option).
113 116 joins(joins_for_order_statement(order_option.join(','))).
114 117 includes(:activity).
115 118 references(:activity)
116 119 end
120
121 def sql_for_issue_id_field(field, operator, value)
122 case operator
123 when "="
124 "#{TimeEntry.table_name}.issue_id = #{value.first.to_i}"
125 when "~"
126 issue = Issue.where(:id => value.first.to_i).first
127 if issue && (issue_ids = issue.self_and_descendants.pluck(:id)).any?
128 "#{TimeEntry.table_name}.issue_id IN (#{issue_ids.join(',')})"
129 else
130 "1=0"
131 end
132 when "!*"
133 "#{TimeEntry.table_name}.issue_id IS NULL"
134 when "*"
135 "#{TimeEntry.table_name}.issue_id IS NOT NULL"
136 end
137 end
117 138
118 139 def sql_for_activity_id_field(field, operator, value)
119 140 condition_on_id = sql_for_field(field, operator, value, Enumeration.table_name, 'id')
120 141 condition_on_parent_id = sql_for_field(field, operator, value, Enumeration.table_name, 'parent_id')
121 142 ids = value.map(&:to_i).join(',')
122 143 table_name = Enumeration.table_name
123 144 if operator == '='
124 145 "(#{table_name}.id IN (#{ids}) OR #{table_name}.parent_id IN (#{ids}))"
125 146 else
126 147 "(#{table_name}.id NOT IN (#{ids}) AND (#{table_name}.parent_id IS NULL OR #{table_name}.parent_id NOT IN (#{ids})))"
127 148 end
128 149 end
129 150
130 151 # Accepts :from/:to params as shortcut filters
131 152 def build_from_params(params)
132 153 super
133 154 if params[:from].present? && params[:to].present?
134 155 add_filter('spent_on', '><', [params[:from], params[:to]])
135 156 elsif params[:from].present?
136 157 add_filter('spent_on', '>=', [params[:from]])
137 158 elsif params[:to].present?
138 159 add_filter('spent_on', '<=', [params[:to]])
139 160 end
140 161 self
141 162 end
142 163 end
@@ -1,51 +1,51
1 1 <div id="query_form_with_buttons" class="hide-when-print">
2 2 <%= hidden_field_tag 'set_filter', '1' %>
3 3 <div id="query_form_content">
4 4 <fieldset id="filters" class="collapsible <%= @query.new_record? ? "" : "collapsed" %>">
5 5 <legend onclick="toggleFieldset(this);"><%= l(:label_filter_plural) %></legend>
6 6 <div style="<%= @query.new_record? ? "" : "display: none;" %>">
7 7 <%= render :partial => 'queries/filters', :locals => {:query => @query} %>
8 8 </div>
9 9 </fieldset>
10 10 <fieldset id="options" class="collapsible collapsed">
11 11 <legend onclick="toggleFieldset(this);"><%= l(:label_options) %></legend>
12 12 <div style="display: none;">
13 13 <table>
14 14 <tr>
15 15 <td class="field"><%= l(:field_column_names) %></td>
16 16 <td><%= render_query_columns_selection(@query) %></td>
17 17 </tr>
18 18 </table>
19 19 </div>
20 20 </fieldset>
21 21 </div>
22 22
23 23 <p class="buttons">
24 24 <%= link_to_function l(:button_apply), '$("#query_form").submit()', :class => 'icon icon-checked' %>
25 25 <%= link_to l(:button_clear), {:project_id => @project, :issue_id => @issue}, :class => 'icon icon-reload' %>
26 26 <% if @query.new_record? %>
27 27 <% if User.current.allowed_to?(:save_queries, @project, :global => true) %>
28 28 <%= link_to_function l(:button_save),
29 29 "$('#query_form').attr('action', '#{ @project ? new_project_query_path(@project) : new_query_path }').submit()",
30 30 :class => 'icon icon-save' %>
31 31 <% end %>
32 32 <% else %>
33 33 <% if @query.editable_by?(User.current) %>
34 34 <%= link_to l(:button_edit), edit_query_path(@query), :class => 'icon icon-edit' %>
35 35 <%= delete_link query_path(@query) %>
36 36 <% end %>
37 37 <% end %>
38 38 </p>
39 39
40 40 <%= hidden_field_tag 'type', 'TimeEntryQuery' %>
41 41 </div>
42 42
43 43 <div class="tabs hide-when-print">
44 <% query_params = params.slice(:f, :op, :v, :sort, :query_id) %>
44 <% query_params = request.query_parameters %>
45 45 <ul>
46 <li><%= link_to(l(:label_details), _time_entries_path(@project, @issue, query_params),
46 <li><%= link_to(l(:label_details), _time_entries_path(@project, nil, :params => query_params),
47 47 :class => (action_name == 'index' ? 'selected' : nil)) %></li>
48 <li><%= link_to(l(:label_report), _report_time_entries_path(@project, @issue, query_params),
48 <li><%= link_to(l(:label_report), _report_time_entries_path(@project, nil, :params => query_params),
49 49 :class => (action_name == 'report' ? 'selected' : nil)) %></li>
50 50 </ul>
51 51 </div>
@@ -1,52 +1,50
1 1 <div class="contextual">
2 2 <%= link_to l(:button_log_time),
3 3 _new_time_entry_path(@project, @issue),
4 4 :class => 'icon icon-time-add' if User.current.allowed_to?(:log_time, @project, :global => true) %>
5 5 </div>
6 6
7 <%= render_timelog_breadcrumb %>
8
9 7 <h2><%= @query.new_record? ? l(:label_spent_time) : @query.name %></h2>
10 8
11 <%= form_tag(params.slice(:project_id, :issue_id), :method => :get, :id => 'query_form') do %>
9 <%= form_tag(_time_entries_path(@project, nil), :method => :get, :id => 'query_form') do %>
12 10 <%= render :partial => 'date_range' %>
13 11 <% end %>
14 12
15 13 <div class="total-hours">
16 14 <p><%= l(:label_total_time) %>: <%= html_hours(l_hours(@total_hours)) %></p>
17 15 </div>
18 16
19 17 <% unless @entries.empty? %>
20 18 <%= render :partial => 'list', :locals => { :entries => @entries }%>
21 19 <span class="pagination"><%= pagination_links_full @entry_pages, @entry_count %></span>
22 20
23 21 <% other_formats_links do |f| %>
24 22 <%= f.link_to_with_query_parameters 'Atom', :key => User.current.rss_key %>
25 23 <%= f.link_to_with_query_parameters 'CSV', {}, :onclick => "showModal('csv-export-options', '330px'); return false;" %>
26 24 <% end %>
27 25
28 26 <div id="csv-export-options" style="display:none;">
29 27 <h3 class="title"><%= l(:label_export_options, :export_format => 'CSV') %></h3>
30 28 <%= form_tag(params.slice(:project_id, :issue_id).merge(:format => 'csv', :page=>nil), :method => :get, :id => 'csv-export-form') do %>
31 29 <%= query_hidden_tags @query %>
32 30 <p>
33 31 <label><%= radio_button_tag 'columns', '', true %> <%= l(:description_selected_columns) %></label><br />
34 32 <label><%= radio_button_tag 'columns', 'all' %> <%= l(:description_all_columns) %></label>
35 33 </p>
36 34 <p class="buttons">
37 35 <%= submit_tag l(:button_export), :name => nil, :onclick => "hideModal(this);" %>
38 36 <%= submit_tag l(:button_cancel), :name => nil, :onclick => "hideModal(this);", :type => 'button' %>
39 37 </p>
40 38 <% end %>
41 39 </div>
42 40 <% end %>
43 41
44 42 <% content_for :sidebar do %>
45 43 <%= render_sidebar_queries(TimeEntryQuery, @project) %>
46 44 <% end %>
47 45
48 46 <% html_title(@query.new_record? ? l(:label_spent_time) : @query.name, l(:label_details)) %>
49 47
50 48 <% content_for :header_tags do %>
51 49 <%= auto_discovery_link_tag(:atom, {:issue_id => @issue, :format => 'atom', :key => User.current.rss_key}, :title => l(:label_spent_time)) %>
52 50 <% end %>
@@ -1,78 +1,76
1 1 <div class="contextual">
2 2 <%= link_to l(:button_log_time),
3 3 _new_time_entry_path(@project, @issue),
4 4 :class => 'icon icon-time-add' if User.current.allowed_to?(:log_time, @project, :global => true) %>
5 5 </div>
6 6
7 <%= render_timelog_breadcrumb %>
8
9 7 <h2><%= @query.new_record? ? l(:label_spent_time) : @query.name %></h2>
10 8
11 <%= form_tag(params.slice(:project_id, :issue_id), :method => :get, :id => 'query_form') do %>
9 <%= form_tag(_report_time_entries_path(@project, nil), :method => :get, :id => 'query_form') do %>
12 10 <% @report.criteria.each do |criterion| %>
13 11 <%= hidden_field_tag 'criteria[]', criterion, :id => nil %>
14 12 <% end %>
15 13 <%= render :partial => 'timelog/date_range' %>
16 14
17 15 <p><label for='columns'><%= l(:label_details) %></label>: <%= select_tag 'columns', options_for_select([[l(:label_year), 'year'],
18 16 [l(:label_month), 'month'],
19 17 [l(:label_week), 'week'],
20 18 [l(:label_day_plural).titleize, 'day']], @report.columns),
21 19 :onchange => "this.form.submit();" %>
22 20
23 21 <label for='criterias'><%= l(:button_add) %></label>: <%= select_tag('criteria[]', options_for_select([[]] + (@report.available_criteria.keys - @report.criteria).collect{|k| [l_or_humanize(@report.available_criteria[k][:label]), k]}),
24 22 :onchange => "this.form.submit();",
25 23 :style => 'width: 200px',
26 24 :disabled => (@report.criteria.length >= 3),
27 25 :id => "criterias") %>
28 26 <%= link_to l(:button_clear), {:project_id => @project, :issue_id => @issue, :period_type => params[:period_type], :period => params[:period], :from => @from, :to => @to, :columns => @report.columns}, :class => 'icon icon-reload' %></p>
29 27 <% end %>
30 28
31 29 <% unless @report.criteria.empty? %>
32 30 <div class="total-hours">
33 31 <p><%= l(:label_total_time) %>: <%= html_hours(l_hours(@report.total_hours)) %></p>
34 32 </div>
35 33
36 34 <% unless @report.hours.empty? %>
37 35 <div class="autoscroll">
38 36 <table class="list" id="time-report">
39 37 <thead>
40 38 <tr>
41 39 <% @report.criteria.each do |criteria| %>
42 40 <th><%= l_or_humanize(@report.available_criteria[criteria][:label]) %></th>
43 41 <% end %>
44 42 <% columns_width = (40 / (@report.periods.length+1)).to_i %>
45 43 <% @report.periods.each do |period| %>
46 44 <th class="period" style="width:<%= columns_width %>%;"><%= period %></th>
47 45 <% end %>
48 46 <th class="total" style="width:<%= columns_width %>%;"><%= l(:label_total_time) %></th>
49 47 </tr>
50 48 </thead>
51 49 <tbody>
52 50 <%= render :partial => 'report_criteria', :locals => {:criterias => @report.criteria, :hours => @report.hours, :level => 0} %>
53 51 <tr class="total">
54 52 <td><%= l(:label_total_time) %></td>
55 53 <%= ('<td></td>' * (@report.criteria.size - 1)).html_safe %>
56 54 <% total = 0 -%>
57 55 <% @report.periods.each do |period| -%>
58 56 <% sum = sum_hours(select_hours(@report.hours, @report.columns, period.to_s)); total += sum -%>
59 57 <td class="hours"><%= html_hours("%.2f" % sum) if sum > 0 %></td>
60 58 <% end -%>
61 59 <td class="hours"><%= html_hours("%.2f" % total) if total > 0 %></td>
62 60 </tr>
63 61 </tbody>
64 62 </table>
65 63 </div>
66 64
67 65 <% other_formats_links do |f| %>
68 66 <%= f.link_to 'CSV', :url => params %>
69 67 <% end %>
70 68 <% end %>
71 69 <% end %>
72 70
73 71 <% content_for :sidebar do %>
74 72 <%= render_sidebar_queries(TimeEntryQuery, @project) %>
75 73 <% end %>
76 74
77 75 <% html_title(@query.new_record? ? l(:label_spent_time) : @query.name, l(:label_report)) %>
78 76
@@ -1,387 +1,383
1 1 # Redmine - project management software
2 2 # Copyright (C) 2006-2016 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 Rails.application.routes.draw do
19 19 root :to => 'welcome#index', :as => 'home'
20 20
21 21 match 'login', :to => 'account#login', :as => 'signin', :via => [:get, :post]
22 22 match 'logout', :to => 'account#logout', :as => 'signout', :via => [:get, :post]
23 23 match 'account/register', :to => 'account#register', :via => [:get, :post], :as => 'register'
24 24 match 'account/lost_password', :to => 'account#lost_password', :via => [:get, :post], :as => 'lost_password'
25 25 match 'account/activate', :to => 'account#activate', :via => :get
26 26 get 'account/activation_email', :to => 'account#activation_email', :as => 'activation_email'
27 27
28 28 match '/news/preview', :controller => 'previews', :action => 'news', :as => 'preview_news', :via => [:get, :post, :put, :patch]
29 29 match '/issues/preview/new/:project_id', :to => 'previews#issue', :as => 'preview_new_issue', :via => [:get, :post, :put, :patch]
30 30 match '/issues/preview/edit/:id', :to => 'previews#issue', :as => 'preview_edit_issue', :via => [:get, :post, :put, :patch]
31 31 match '/issues/preview', :to => 'previews#issue', :as => 'preview_issue', :via => [:get, :post, :put, :patch]
32 32
33 33 match 'projects/:id/wiki', :to => 'wikis#edit', :via => :post
34 34 match 'projects/:id/wiki/destroy', :to => 'wikis#destroy', :via => [:get, :post]
35 35
36 36 match 'boards/:board_id/topics/new', :to => 'messages#new', :via => [:get, :post], :as => 'new_board_message'
37 37 get 'boards/:board_id/topics/:id', :to => 'messages#show', :as => 'board_message'
38 38 match 'boards/:board_id/topics/quote/:id', :to => 'messages#quote', :via => [:get, :post]
39 39 get 'boards/:board_id/topics/:id/edit', :to => 'messages#edit'
40 40
41 41 post 'boards/:board_id/topics/preview', :to => 'messages#preview', :as => 'preview_board_message'
42 42 post 'boards/:board_id/topics/:id/replies', :to => 'messages#reply'
43 43 post 'boards/:board_id/topics/:id/edit', :to => 'messages#edit'
44 44 post 'boards/:board_id/topics/:id/destroy', :to => 'messages#destroy'
45 45
46 46 # Misc issue routes. TODO: move into resources
47 47 match '/issues/auto_complete', :to => 'auto_completes#issues', :via => :get, :as => 'auto_complete_issues'
48 48 match '/issues/context_menu', :to => 'context_menus#issues', :as => 'issues_context_menu', :via => [:get, :post]
49 49 match '/issues/changes', :to => 'journals#index', :as => 'issue_changes', :via => :get
50 50 match '/issues/:id/quoted', :to => 'journals#new', :id => /\d+/, :via => :post, :as => 'quoted_issue'
51 51
52 52 resources :journals, :only => [:edit, :update] do
53 53 member do
54 54 get 'diff'
55 55 end
56 56 end
57 57
58 58 get '/projects/:project_id/issues/gantt', :to => 'gantts#show', :as => 'project_gantt'
59 59 get '/issues/gantt', :to => 'gantts#show'
60 60
61 61 get '/projects/:project_id/issues/calendar', :to => 'calendars#show', :as => 'project_calendar'
62 62 get '/issues/calendar', :to => 'calendars#show'
63 63
64 64 get 'projects/:id/issues/report', :to => 'reports#issue_report', :as => 'project_issues_report'
65 65 get 'projects/:id/issues/report/:detail', :to => 'reports#issue_report_details', :as => 'project_issues_report_details'
66 66
67 67 get '/issues/imports/new', :to => 'imports#new', :as => 'new_issues_import'
68 68 post '/imports', :to => 'imports#create', :as => 'imports'
69 69 get '/imports/:id', :to => 'imports#show', :as => 'import'
70 70 match '/imports/:id/settings', :to => 'imports#settings', :via => [:get, :post], :as => 'import_settings'
71 71 match '/imports/:id/mapping', :to => 'imports#mapping', :via => [:get, :post], :as => 'import_mapping'
72 72 match '/imports/:id/run', :to => 'imports#run', :via => [:get, :post], :as => 'import_run'
73 73
74 74 match 'my/account', :controller => 'my', :action => 'account', :via => [:get, :post]
75 75 match 'my/account/destroy', :controller => 'my', :action => 'destroy', :via => [:get, :post]
76 76 match 'my/page', :controller => 'my', :action => 'page', :via => :get
77 77 match 'my', :controller => 'my', :action => 'index', :via => :get # Redirects to my/page
78 78 get 'my/api_key', :to => 'my#show_api_key', :as => 'my_api_key'
79 79 post 'my/api_key', :to => 'my#reset_api_key'
80 80 post 'my/rss_key', :to => 'my#reset_rss_key', :as => 'my_rss_key'
81 81 match 'my/password', :controller => 'my', :action => 'password', :via => [:get, :post]
82 82 match 'my/page_layout', :controller => 'my', :action => 'page_layout', :via => :get
83 83 match 'my/add_block', :controller => 'my', :action => 'add_block', :via => :post
84 84 match 'my/remove_block', :controller => 'my', :action => 'remove_block', :via => :post
85 85 match 'my/order_blocks', :controller => 'my', :action => 'order_blocks', :via => :post
86 86
87 87 resources :users do
88 88 resources :memberships, :controller => 'principal_memberships'
89 89 resources :email_addresses, :only => [:index, :create, :update, :destroy]
90 90 end
91 91
92 92 post 'watchers/watch', :to => 'watchers#watch', :as => 'watch'
93 93 delete 'watchers/watch', :to => 'watchers#unwatch'
94 94 get 'watchers/new', :to => 'watchers#new', :as => 'new_watchers'
95 95 post 'watchers', :to => 'watchers#create'
96 96 post 'watchers/append', :to => 'watchers#append'
97 97 delete 'watchers', :to => 'watchers#destroy'
98 98 get 'watchers/autocomplete_for_user', :to => 'watchers#autocomplete_for_user'
99 99 # Specific routes for issue watchers API
100 100 post 'issues/:object_id/watchers', :to => 'watchers#create', :object_type => 'issue'
101 101 delete 'issues/:object_id/watchers/:user_id' => 'watchers#destroy', :object_type => 'issue'
102 102
103 103 resources :projects do
104 104 member do
105 105 get 'settings(/:tab)', :action => 'settings', :as => 'settings'
106 106 post 'modules'
107 107 post 'archive'
108 108 post 'unarchive'
109 109 post 'close'
110 110 post 'reopen'
111 111 match 'copy', :via => [:get, :post]
112 112 end
113 113
114 114 shallow do
115 115 resources :memberships, :controller => 'members', :only => [:index, :show, :new, :create, :update, :destroy] do
116 116 collection do
117 117 get 'autocomplete'
118 118 end
119 119 end
120 120 end
121 121
122 122 resource :enumerations, :controller => 'project_enumerations', :only => [:update, :destroy]
123 123
124 124 get 'issues/:copy_from/copy', :to => 'issues#new', :as => 'copy_issue'
125 125 resources :issues, :only => [:index, :new, :create]
126 126 # Used when updating the form of a new issue
127 127 post 'issues/new', :to => 'issues#new'
128 128
129 129 resources :files, :only => [:index, :new, :create]
130 130
131 131 resources :versions, :except => [:index, :show, :edit, :update, :destroy] do
132 132 collection do
133 133 put 'close_completed'
134 134 end
135 135 end
136 136 get 'versions.:format', :to => 'versions#index'
137 137 get 'roadmap', :to => 'versions#index', :format => false
138 138 get 'versions', :to => 'versions#index'
139 139
140 140 resources :news, :except => [:show, :edit, :update, :destroy]
141 141 resources :time_entries, :controller => 'timelog', :except => [:show, :edit, :update, :destroy] do
142 142 get 'report', :on => :collection
143 143 end
144 144 resources :queries, :only => [:new, :create]
145 145 shallow do
146 146 resources :issue_categories
147 147 end
148 148 resources :documents, :except => [:show, :edit, :update, :destroy]
149 149 resources :boards
150 150 shallow do
151 151 resources :repositories, :except => [:index, :show] do
152 152 member do
153 153 match 'committers', :via => [:get, :post]
154 154 end
155 155 end
156 156 end
157 157
158 158 match 'wiki/index', :controller => 'wiki', :action => 'index', :via => :get
159 159 resources :wiki, :except => [:index, :create], :as => 'wiki_page' do
160 160 member do
161 161 get 'rename'
162 162 post 'rename'
163 163 get 'history'
164 164 get 'diff'
165 165 match 'preview', :via => [:post, :put, :patch]
166 166 post 'protect'
167 167 post 'add_attachment'
168 168 end
169 169 collection do
170 170 get 'export'
171 171 get 'date_index'
172 172 post 'new'
173 173 end
174 174 end
175 175 match 'wiki', :controller => 'wiki', :action => 'show', :via => :get
176 176 get 'wiki/:id/:version', :to => 'wiki#show', :constraints => {:version => /\d+/}
177 177 delete 'wiki/:id/:version', :to => 'wiki#destroy_version'
178 178 get 'wiki/:id/:version/annotate', :to => 'wiki#annotate'
179 179 get 'wiki/:id/:version/diff', :to => 'wiki#diff'
180 180 end
181 181
182 182 resources :issues do
183 183 member do
184 184 # Used when updating the form of an existing issue
185 185 patch 'edit', :to => 'issues#edit'
186 186 end
187 187 collection do
188 188 match 'bulk_edit', :via => [:get, :post]
189 189 post 'bulk_update'
190 190 end
191 resources :time_entries, :controller => 'timelog', :except => [:show, :edit, :update, :destroy] do
192 collection do
193 get 'report'
194 end
195 end
191 resources :time_entries, :controller => 'timelog', :only => [:new, :create]
196 192 shallow do
197 193 resources :relations, :controller => 'issue_relations', :only => [:index, :show, :create, :destroy]
198 194 end
199 195 end
200 196 # Used when updating the form of a new issue outside a project
201 197 post '/issues/new', :to => 'issues#new'
202 198 match '/issues', :controller => 'issues', :action => 'destroy', :via => :delete
203 199
204 200 resources :queries, :except => [:show]
205 201
206 202 resources :news, :only => [:index, :show, :edit, :update, :destroy]
207 203 match '/news/:id/comments', :to => 'comments#create', :via => :post
208 204 match '/news/:id/comments/:comment_id', :to => 'comments#destroy', :via => :delete
209 205
210 206 resources :versions, :only => [:show, :edit, :update, :destroy] do
211 207 post 'status_by', :on => :member
212 208 end
213 209
214 210 resources :documents, :only => [:show, :edit, :update, :destroy] do
215 211 post 'add_attachment', :on => :member
216 212 end
217 213
218 214 match '/time_entries/context_menu', :to => 'context_menus#time_entries', :as => :time_entries_context_menu, :via => [:get, :post]
219 215
220 216 resources :time_entries, :controller => 'timelog', :except => :destroy do
221 217 collection do
222 218 get 'report'
223 219 get 'bulk_edit'
224 220 post 'bulk_update'
225 221 end
226 222 end
227 223 match '/time_entries/:id', :to => 'timelog#destroy', :via => :delete, :id => /\d+/
228 224 # TODO: delete /time_entries for bulk deletion
229 225 match '/time_entries/destroy', :to => 'timelog#destroy', :via => :delete
230 226 # Used to update the new time entry form
231 227 post '/time_entries/new', :to => 'timelog#new'
232 228
233 229 get 'projects/:id/activity', :to => 'activities#index', :as => :project_activity
234 230 get 'activity', :to => 'activities#index'
235 231
236 232 # repositories routes
237 233 get 'projects/:id/repository/:repository_id/statistics', :to => 'repositories#stats'
238 234 get 'projects/:id/repository/:repository_id/graph', :to => 'repositories#graph'
239 235
240 236 get 'projects/:id/repository/:repository_id/changes(/*path)',
241 237 :to => 'repositories#changes',
242 238 :format => false
243 239
244 240 get 'projects/:id/repository/:repository_id/revisions/:rev', :to => 'repositories#revision'
245 241 get 'projects/:id/repository/:repository_id/revision', :to => 'repositories#revision'
246 242 post 'projects/:id/repository/:repository_id/revisions/:rev/issues', :to => 'repositories#add_related_issue'
247 243 delete 'projects/:id/repository/:repository_id/revisions/:rev/issues/:issue_id', :to => 'repositories#remove_related_issue'
248 244 get 'projects/:id/repository/:repository_id/revisions', :to => 'repositories#revisions'
249 245 %w(browse show entry raw annotate diff).each do |action|
250 246 get "projects/:id/repository/:repository_id/revisions/:rev/#{action}(/*path)",
251 247 :controller => 'repositories',
252 248 :action => action,
253 249 :format => false,
254 250 :constraints => {:rev => /[a-z0-9\.\-_]+/}
255 251 end
256 252
257 253 get 'projects/:id/repository/statistics', :to => 'repositories#stats'
258 254 get 'projects/:id/repository/graph', :to => 'repositories#graph'
259 255
260 256 get 'projects/:id/repository/changes(/*path)',
261 257 :to => 'repositories#changes',
262 258 :format => false
263 259
264 260 get 'projects/:id/repository/revisions', :to => 'repositories#revisions'
265 261 get 'projects/:id/repository/revisions/:rev', :to => 'repositories#revision'
266 262 get 'projects/:id/repository/revision', :to => 'repositories#revision'
267 263 post 'projects/:id/repository/revisions/:rev/issues', :to => 'repositories#add_related_issue'
268 264 delete 'projects/:id/repository/revisions/:rev/issues/:issue_id', :to => 'repositories#remove_related_issue'
269 265 %w(browse show entry raw annotate diff).each do |action|
270 266 get "projects/:id/repository/revisions/:rev/#{action}(/*path)",
271 267 :controller => 'repositories',
272 268 :action => action,
273 269 :format => false,
274 270 :constraints => {:rev => /[a-z0-9\.\-_]+/}
275 271 end
276 272 %w(browse entry raw changes annotate diff).each do |action|
277 273 get "projects/:id/repository/:repository_id/#{action}(/*path)",
278 274 :controller => 'repositories',
279 275 :action => action,
280 276 :format => false
281 277 end
282 278 %w(browse entry raw changes annotate diff).each do |action|
283 279 get "projects/:id/repository/#{action}(/*path)",
284 280 :controller => 'repositories',
285 281 :action => action,
286 282 :format => false
287 283 end
288 284
289 285 get 'projects/:id/repository/:repository_id/show/*path', :to => 'repositories#show', :format => false
290 286 get 'projects/:id/repository/show/*path', :to => 'repositories#show', :format => false
291 287
292 288 get 'projects/:id/repository/:repository_id', :to => 'repositories#show', :path => nil
293 289 get 'projects/:id/repository', :to => 'repositories#show', :path => nil
294 290
295 291 # additional routes for having the file name at the end of url
296 292 get 'attachments/:id/:filename', :to => 'attachments#show', :id => /\d+/, :filename => /.*/, :as => 'named_attachment'
297 293 get 'attachments/download/:id/:filename', :to => 'attachments#download', :id => /\d+/, :filename => /.*/, :as => 'download_named_attachment'
298 294 get 'attachments/download/:id', :to => 'attachments#download', :id => /\d+/
299 295 get 'attachments/thumbnail/:id(/:size)', :to => 'attachments#thumbnail', :id => /\d+/, :size => /\d+/, :as => 'thumbnail'
300 296 resources :attachments, :only => [:show, :destroy]
301 297 get 'attachments/:object_type/:object_id/edit', :to => 'attachments#edit', :as => :object_attachments_edit
302 298 patch 'attachments/:object_type/:object_id', :to => 'attachments#update', :as => :object_attachments
303 299
304 300 resources :groups do
305 301 resources :memberships, :controller => 'principal_memberships'
306 302 member do
307 303 get 'autocomplete_for_user'
308 304 end
309 305 end
310 306
311 307 get 'groups/:id/users/new', :to => 'groups#new_users', :id => /\d+/, :as => 'new_group_users'
312 308 post 'groups/:id/users', :to => 'groups#add_users', :id => /\d+/, :as => 'group_users'
313 309 delete 'groups/:id/users/:user_id', :to => 'groups#remove_user', :id => /\d+/, :as => 'group_user'
314 310
315 311 resources :trackers, :except => :show do
316 312 collection do
317 313 match 'fields', :via => [:get, :post]
318 314 end
319 315 end
320 316 resources :issue_statuses, :except => :show do
321 317 collection do
322 318 post 'update_issue_done_ratio'
323 319 end
324 320 end
325 321 resources :custom_fields, :except => :show do
326 322 resources :enumerations, :controller => 'custom_field_enumerations', :except => [:show, :new, :edit]
327 323 put 'enumerations', :to => 'custom_field_enumerations#update_each'
328 324 end
329 325 resources :roles do
330 326 collection do
331 327 match 'permissions', :via => [:get, :post]
332 328 end
333 329 end
334 330 resources :enumerations, :except => :show
335 331 match 'enumerations/:type', :to => 'enumerations#index', :via => :get
336 332
337 333 get 'projects/:id/search', :controller => 'search', :action => 'index'
338 334 get 'search', :controller => 'search', :action => 'index'
339 335
340 336
341 337 get 'mail_handler', :to => 'mail_handler#new'
342 338 post 'mail_handler', :to => 'mail_handler#index'
343 339
344 340 get 'admin', :to => 'admin#index'
345 341 get 'admin/projects', :to => 'admin#projects'
346 342 get 'admin/plugins', :to => 'admin#plugins'
347 343 get 'admin/info', :to => 'admin#info'
348 344 post 'admin/test_email', :to => 'admin#test_email', :as => 'test_email'
349 345 post 'admin/default_configuration', :to => 'admin#default_configuration'
350 346
351 347 resources :auth_sources do
352 348 member do
353 349 get 'test_connection', :as => 'try_connection'
354 350 end
355 351 collection do
356 352 get 'autocomplete_for_new_user'
357 353 end
358 354 end
359 355
360 356 match 'workflows', :controller => 'workflows', :action => 'index', :via => :get
361 357 match 'workflows/edit', :controller => 'workflows', :action => 'edit', :via => [:get, :post]
362 358 match 'workflows/permissions', :controller => 'workflows', :action => 'permissions', :via => [:get, :post]
363 359 match 'workflows/copy', :controller => 'workflows', :action => 'copy', :via => [:get, :post]
364 360 match 'settings', :controller => 'settings', :action => 'index', :via => :get
365 361 match 'settings/edit', :controller => 'settings', :action => 'edit', :via => [:get, :post]
366 362 match 'settings/plugin/:id', :controller => 'settings', :action => 'plugin', :via => [:get, :post], :as => 'plugin_settings'
367 363
368 364 match 'sys/projects', :to => 'sys#projects', :via => :get
369 365 match 'sys/projects/:id/repository', :to => 'sys#create_project_repository', :via => :post
370 366 match 'sys/fetch_changesets', :to => 'sys#fetch_changesets', :via => [:get, :post]
371 367
372 368 match 'uploads', :to => 'attachments#upload', :via => :post
373 369
374 370 get 'robots.txt', :to => 'welcome#robots'
375 371
376 372 Dir.glob File.expand_path("plugins/*", Rails.root) do |plugin_dir|
377 373 file = File.join(plugin_dir, "config/routes.rb")
378 374 if File.exists?(file)
379 375 begin
380 376 instance_eval File.read(file)
381 377 rescue Exception => e
382 378 puts "An error occurred while loading the routes definition of #{File.basename(plugin_dir)} plugin (#{file}): #{e.message}."
383 379 exit 1
384 380 end
385 381 end
386 382 end
387 383 end
@@ -1,354 +1,345
1 1 # -*- coding: utf-8 -*-
2 2 # Redmine - project management software
3 3 # Copyright (C) 2006-2016 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.expand_path('../../test_helper', __FILE__)
20 20
21 21 class TimeEntryReportsControllerTest < ActionController::TestCase
22 22 tests TimelogController
23 23
24 24 fixtures :projects, :enabled_modules, :roles, :members, :member_roles,
25 25 :email_addresses,
26 26 :issues, :time_entries, :users, :trackers, :enumerations,
27 27 :issue_statuses, :custom_fields, :custom_values,
28 28 :projects_trackers, :custom_fields_trackers,
29 29 :custom_fields_projects
30 30
31 31 include Redmine::I18n
32 32
33 33 def setup
34 34 Setting.default_language = "en"
35 35 end
36 36
37 37 def test_report_at_project_level
38 38 get :report, :project_id => 'ecookbook'
39 39 assert_response :success
40 40 assert_template 'report'
41 41 assert_select 'form#query_form[action=?]', '/projects/ecookbook/time_entries/report'
42 42 end
43 43
44 44 def test_report_all_projects
45 45 get :report
46 46 assert_response :success
47 47 assert_template 'report'
48 48 assert_select 'form#query_form[action=?]', '/time_entries/report'
49 49 end
50 50
51 51 def test_report_all_projects_denied
52 52 r = Role.anonymous
53 53 r.permissions.delete(:view_time_entries)
54 54 r.permissions_will_change!
55 55 r.save
56 56 get :report
57 57 assert_redirected_to '/login?back_url=http%3A%2F%2Ftest.host%2Ftime_entries%2Freport'
58 58 end
59 59
60 60 def test_report_all_projects_one_criteria
61 61 get :report, :columns => 'week', :from => "2007-04-01", :to => "2007-04-30", :criteria => ['project']
62 62 assert_response :success
63 63 assert_template 'report'
64 64 assert_not_nil assigns(:report)
65 65 assert_equal "8.65", "%.2f" % assigns(:report).total_hours
66 66 end
67 67
68 68 def test_report_all_time
69 69 get :report, :project_id => 1, :criteria => ['project', 'issue']
70 70 assert_response :success
71 71 assert_template 'report'
72 72 assert_not_nil assigns(:report)
73 73 assert_equal "162.90", "%.2f" % assigns(:report).total_hours
74 74 end
75 75
76 76 def test_report_all_time_by_day
77 77 get :report, :project_id => 1, :criteria => ['project', 'issue'], :columns => 'day'
78 78 assert_response :success
79 79 assert_template 'report'
80 80 assert_not_nil assigns(:report)
81 81 assert_equal "162.90", "%.2f" % assigns(:report).total_hours
82 82 assert_select 'th', :text => '2007-03-12'
83 83 end
84 84
85 85 def test_report_one_criteria
86 86 get :report, :project_id => 1, :columns => 'week', :from => "2007-04-01", :to => "2007-04-30", :criteria => ['project']
87 87 assert_response :success
88 88 assert_template 'report'
89 89 assert_not_nil assigns(:report)
90 90 assert_equal "8.65", "%.2f" % assigns(:report).total_hours
91 91 end
92 92
93 93 def test_report_two_criteria
94 94 get :report, :project_id => 1, :columns => 'month', :from => "2007-01-01", :to => "2007-12-31", :criteria => ["user", "activity"]
95 95 assert_response :success
96 96 assert_template 'report'
97 97 assert_not_nil assigns(:report)
98 98 assert_equal "162.90", "%.2f" % assigns(:report).total_hours
99 99 end
100 100
101 101 def test_report_custom_field_criteria_with_multiple_values_on_single_value_custom_field_should_not_fail
102 102 field = TimeEntryCustomField.create!(:name => 'multi', :field_format => 'list', :possible_values => ['value1', 'value2'])
103 103 entry = TimeEntry.create!(:project => Project.find(1), :hours => 1, :activity_id => 10, :user => User.find(2), :spent_on => Date.today)
104 104 CustomValue.create!(:customized => entry, :custom_field => field, :value => 'value1')
105 105 CustomValue.create!(:customized => entry, :custom_field => field, :value => 'value2')
106 106
107 107 get :report, :project_id => 1, :columns => 'day', :criteria => ["cf_#{field.id}"]
108 108 assert_response :success
109 109 end
110 110
111 111 def test_report_multiple_values_custom_fields_should_not_be_proposed
112 112 TimeEntryCustomField.create!(:name => 'Single', :field_format => 'list', :possible_values => ['value1', 'value2'])
113 113 TimeEntryCustomField.create!(:name => 'Multi', :field_format => 'list', :multiple => true, :possible_values => ['value1', 'value2'])
114 114
115 115 get :report, :project_id => 1
116 116 assert_response :success
117 117 assert_select 'select[name=?]', 'criteria[]' do
118 118 assert_select 'option', :text => 'Single'
119 119 assert_select 'option', :text => 'Multi', :count => 0
120 120 end
121 121 end
122 122
123 123 def test_report_one_day
124 124 get :report, :project_id => 1, :columns => 'day', :from => "2007-03-23", :to => "2007-03-23", :criteria => ["user", "activity"]
125 125 assert_response :success
126 126 assert_template 'report'
127 127 assert_not_nil assigns(:report)
128 128 assert_equal "4.25", "%.2f" % assigns(:report).total_hours
129 129 end
130 130
131 def test_report_at_issue_level
132 get :report, :issue_id => 1, :columns => 'month', :from => "2007-01-01", :to => "2007-12-31", :criteria => ["user", "activity"]
133 assert_response :success
134 assert_template 'report'
135 assert_not_nil assigns(:report)
136 assert_equal "154.25", "%.2f" % assigns(:report).total_hours
137 assert_select 'form#query_form[action=?]', '/issues/1/time_entries/report'
138 end
139
140 131 def test_report_by_week_should_use_commercial_year
141 132 TimeEntry.delete_all
142 133 TimeEntry.generate!(:hours => '2', :spent_on => '2009-12-25') # 2009-52
143 134 TimeEntry.generate!(:hours => '4', :spent_on => '2009-12-31') # 2009-53
144 135 TimeEntry.generate!(:hours => '8', :spent_on => '2010-01-01') # 2009-53
145 136 TimeEntry.generate!(:hours => '16', :spent_on => '2010-01-05') # 2010-1
146 137
147 138 get :report, :columns => 'week', :from => "2009-12-25", :to => "2010-01-05", :criteria => ["project"]
148 139 assert_response :success
149 140
150 141 assert_select '#time-report thead tr' do
151 142 assert_select 'th:nth-child(1)', :text => 'Project'
152 143 assert_select 'th:nth-child(2)', :text => '2009-52'
153 144 assert_select 'th:nth-child(3)', :text => '2009-53'
154 145 assert_select 'th:nth-child(4)', :text => '2010-1'
155 146 assert_select 'th:nth-child(5)', :text => 'Total time'
156 147 end
157 148 assert_select '#time-report tbody tr' do
158 149 assert_select 'td:nth-child(1)', :text => 'eCookbook'
159 150 assert_select 'td:nth-child(2)', :text => '2.00'
160 151 assert_select 'td:nth-child(3)', :text => '12.00'
161 152 assert_select 'td:nth-child(4)', :text => '16.00'
162 153 assert_select 'td:nth-child(5)', :text => '30.00' # Total
163 154 end
164 155 end
165 156
166 157 def test_report_should_propose_association_custom_fields
167 158 get :report
168 159 assert_response :success
169 160 assert_template 'report'
170 161
171 162 assert_select 'select[name=?]', 'criteria[]' do
172 163 assert_select 'option[value=cf_1]', {:text => 'Database'}, 'Issue custom field not found'
173 164 assert_select 'option[value=cf_3]', {:text => 'Development status'}, 'Project custom field not found'
174 165 assert_select 'option[value=cf_7]', {:text => 'Billable'}, 'TimeEntryActivity custom field not found'
175 166 end
176 167 end
177 168
178 169 def test_report_with_association_custom_fields
179 170 get :report, :criteria => ['cf_1', 'cf_3', 'cf_7']
180 171 assert_response :success
181 172 assert_template 'report'
182 173 assert_not_nil assigns(:report)
183 174 assert_equal 3, assigns(:report).criteria.size
184 175 assert_equal "162.90", "%.2f" % assigns(:report).total_hours
185 176
186 177 # Custom fields columns
187 178 assert_select 'th', :text => 'Database'
188 179 assert_select 'th', :text => 'Development status'
189 180 assert_select 'th', :text => 'Billable'
190 181
191 182 # Custom field row
192 183 assert_select 'tr' do
193 184 assert_select 'td', :text => 'MySQL'
194 185 assert_select 'td.hours', :text => '1.00'
195 186 end
196 187 end
197 188
198 189 def test_report_one_criteria_no_result
199 190 get :report, :project_id => 1, :columns => 'week', :from => "1998-04-01", :to => "1998-04-30", :criteria => ['project']
200 191 assert_response :success
201 192 assert_template 'report'
202 193 assert_not_nil assigns(:report)
203 194 assert_equal "0.00", "%.2f" % assigns(:report).total_hours
204 195 end
205 196
206 197 def test_report_status_criterion
207 198 get :report, :project_id => 1, :criteria => ['status']
208 199 assert_response :success
209 200 assert_template 'report'
210 201 assert_select 'th', :text => 'Status'
211 202 assert_select 'td', :text => 'New'
212 203 end
213 204
214 205 def test_report_all_projects_csv_export
215 206 get :report, :columns => 'month', :from => "2007-01-01", :to => "2007-06-30",
216 207 :criteria => ["project", "user", "activity"], :format => "csv"
217 208 assert_response :success
218 209 assert_equal 'text/csv; header=present', @response.content_type
219 210 lines = @response.body.chomp.split("\n")
220 211 # Headers
221 212 assert_equal 'Project,User,Activity,2007-3,2007-4,Total time', lines.first
222 213 # Total row
223 214 assert_equal 'Total time,"","",154.25,8.65,162.90', lines.last
224 215 end
225 216
226 217 def test_report_csv_export
227 218 get :report, :project_id => 1, :columns => 'month',
228 219 :from => "2007-01-01", :to => "2007-06-30",
229 220 :criteria => ["project", "user", "activity"], :format => "csv"
230 221 assert_response :success
231 222 assert_equal 'text/csv; header=present', @response.content_type
232 223 lines = @response.body.chomp.split("\n")
233 224 # Headers
234 225 assert_equal 'Project,User,Activity,2007-3,2007-4,Total time', lines.first
235 226 # Total row
236 227 assert_equal 'Total time,"","",154.25,8.65,162.90', lines.last
237 228 end
238 229
239 230 def test_csv_big_5
240 231 str_utf8 = "\xe4\xb8\x80\xe6\x9c\x88".force_encoding('UTF-8')
241 232 str_big5 = "\xa4@\xa4\xeb".force_encoding('Big5')
242 233 user = User.find_by_id(3)
243 234 user.firstname = str_utf8
244 235 user.lastname = "test-lastname"
245 236 assert user.save
246 237 comments = "test_csv_big_5"
247 238 te1 = TimeEntry.create(:spent_on => '2011-11-11',
248 239 :hours => 7.3,
249 240 :project => Project.find(1),
250 241 :user => user,
251 242 :activity => TimeEntryActivity.find_by_name('Design'),
252 243 :comments => comments)
253 244
254 245 te2 = TimeEntry.find_by_comments(comments)
255 246 assert_not_nil te2
256 247 assert_equal 7.3, te2.hours
257 248 assert_equal 3, te2.user_id
258 249
259 250 with_settings :default_language => "zh-TW" do
260 251 get :report, :project_id => 1, :columns => 'day',
261 252 :from => "2011-11-11", :to => "2011-11-11",
262 253 :criteria => ["user"], :format => "csv"
263 254 end
264 255 assert_response :success
265 256 assert_equal 'text/csv; header=present', @response.content_type
266 257 lines = @response.body.chomp.split("\n")
267 258 # Headers
268 259 s1 = "\xa5\xce\xa4\xe1,2011-11-11,\xa4u\xae\xc9\xc1`\xadp".force_encoding('Big5')
269 260 s2 = "\xa4u\xae\xc9\xc1`\xadp".force_encoding('Big5')
270 261 assert_equal s1, lines.first
271 262 # Total row
272 263 assert_equal "#{str_big5} #{user.lastname},7.30,7.30", lines[1]
273 264 assert_equal "#{s2},7.30,7.30", lines[2]
274 265
275 266 str_tw = "Traditional Chinese (\xe7\xb9\x81\xe9\xab\x94\xe4\xb8\xad\xe6\x96\x87)".force_encoding('UTF-8')
276 267 assert_equal str_tw, l(:general_lang_name)
277 268 assert_equal 'Big5', l(:general_csv_encoding)
278 269 assert_equal ',', l(:general_csv_separator)
279 270 assert_equal '.', l(:general_csv_decimal_separator)
280 271 end
281 272
282 273 def test_csv_cannot_convert_should_be_replaced_big_5
283 274 str_utf8 = "\xe4\xbb\xa5\xe5\x86\x85".force_encoding('UTF-8')
284 275 user = User.find_by_id(3)
285 276 user.firstname = str_utf8
286 277 user.lastname = "test-lastname"
287 278 assert user.save
288 279 comments = "test_replaced"
289 280 te1 = TimeEntry.create(:spent_on => '2011-11-11',
290 281 :hours => 7.3,
291 282 :project => Project.find(1),
292 283 :user => user,
293 284 :activity => TimeEntryActivity.find_by_name('Design'),
294 285 :comments => comments)
295 286
296 287 te2 = TimeEntry.find_by_comments(comments)
297 288 assert_not_nil te2
298 289 assert_equal 7.3, te2.hours
299 290 assert_equal 3, te2.user_id
300 291
301 292 with_settings :default_language => "zh-TW" do
302 293 get :report, :project_id => 1, :columns => 'day',
303 294 :from => "2011-11-11", :to => "2011-11-11",
304 295 :criteria => ["user"], :format => "csv"
305 296 end
306 297 assert_response :success
307 298 assert_equal 'text/csv; header=present', @response.content_type
308 299 lines = @response.body.chomp.split("\n")
309 300 # Headers
310 301 s1 = "\xa5\xce\xa4\xe1,2011-11-11,\xa4u\xae\xc9\xc1`\xadp".force_encoding('Big5')
311 302 assert_equal s1, lines.first
312 303 # Total row
313 304 s2 = "\xa5H?".force_encoding('Big5')
314 305 assert_equal "#{s2} #{user.lastname},7.30,7.30", lines[1]
315 306 end
316 307
317 308 def test_csv_fr
318 309 with_settings :default_language => "fr" do
319 310 str1 = "test_csv_fr"
320 311 user = User.find_by_id(3)
321 312 te1 = TimeEntry.create(:spent_on => '2011-11-11',
322 313 :hours => 7.3,
323 314 :project => Project.find(1),
324 315 :user => user,
325 316 :activity => TimeEntryActivity.find_by_name('Design'),
326 317 :comments => str1)
327 318
328 319 te2 = TimeEntry.find_by_comments(str1)
329 320 assert_not_nil te2
330 321 assert_equal 7.3, te2.hours
331 322 assert_equal 3, te2.user_id
332 323
333 324 get :report, :project_id => 1, :columns => 'day',
334 325 :from => "2011-11-11", :to => "2011-11-11",
335 326 :criteria => ["user"], :format => "csv"
336 327 assert_response :success
337 328 assert_equal 'text/csv; header=present', @response.content_type
338 329 lines = @response.body.chomp.split("\n")
339 330 # Headers
340 331 s1 = "Utilisateur;2011-11-11;Temps total".force_encoding('ISO-8859-1')
341 332 s2 = "Temps total".force_encoding('ISO-8859-1')
342 333 assert_equal s1, lines.first
343 334 # Total row
344 335 assert_equal "#{user.firstname} #{user.lastname};7,30;7,30", lines[1]
345 336 assert_equal "#{s2};7,30;7,30", lines[2]
346 337
347 338 str_fr = "French (Fran\xc3\xa7ais)".force_encoding('UTF-8')
348 339 assert_equal str_fr, l(:general_lang_name)
349 340 assert_equal 'ISO-8859-1', l(:general_csv_encoding)
350 341 assert_equal ';', l(:general_csv_separator)
351 342 assert_equal ',', l(:general_csv_decimal_separator)
352 343 end
353 344 end
354 345 end
@@ -1,846 +1,823
1 1 # -*- coding: utf-8 -*-
2 2 # Redmine - project management software
3 3 # Copyright (C) 2006-2016 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.expand_path('../../test_helper', __FILE__)
20 20
21 21 class TimelogControllerTest < ActionController::TestCase
22 22 fixtures :projects, :enabled_modules, :roles, :members,
23 23 :member_roles, :issues, :time_entries, :users,
24 24 :trackers, :enumerations, :issue_statuses,
25 25 :custom_fields, :custom_values,
26 26 :projects_trackers, :custom_fields_trackers,
27 27 :custom_fields_projects
28 28
29 29 include Redmine::I18n
30 30
31 31 def test_new
32 32 @request.session[:user_id] = 3
33 33 get :new
34 34 assert_response :success
35 35 assert_template 'new'
36 36 assert_select 'input[name=?][type=hidden]', 'project_id', 0
37 37 assert_select 'input[name=?][type=hidden]', 'issue_id', 0
38 38 assert_select 'select[name=?]', 'time_entry[project_id]' do
39 39 # blank option for project
40 40 assert_select 'option[value=""]'
41 41 end
42 42 end
43 43
44 44 def test_new_with_project_id
45 45 @request.session[:user_id] = 3
46 46 get :new, :project_id => 1
47 47 assert_response :success
48 48 assert_template 'new'
49 49 assert_select 'input[name=?][type=hidden]', 'project_id'
50 50 assert_select 'input[name=?][type=hidden]', 'issue_id', 0
51 51 assert_select 'select[name=?]', 'time_entry[project_id]', 0
52 52 end
53 53
54 54 def test_new_with_issue_id
55 55 @request.session[:user_id] = 3
56 56 get :new, :issue_id => 2
57 57 assert_response :success
58 58 assert_template 'new'
59 59 assert_select 'input[name=?][type=hidden]', 'project_id', 0
60 60 assert_select 'input[name=?][type=hidden]', 'issue_id'
61 61 assert_select 'select[name=?]', 'time_entry[project_id]', 0
62 62 end
63 63
64 64 def test_new_without_project_should_prefill_the_form
65 65 @request.session[:user_id] = 3
66 66 get :new, :time_entry => {:project_id => '1'}
67 67 assert_response :success
68 68 assert_template 'new'
69 69 assert_select 'select[name=?]', 'time_entry[project_id]' do
70 70 assert_select 'option[value="1"][selected=selected]'
71 71 end
72 72 end
73 73
74 74 def test_new_without_project_should_deny_without_permission
75 75 Role.all.each {|role| role.remove_permission! :log_time}
76 76 @request.session[:user_id] = 3
77 77
78 78 get :new
79 79 assert_response 403
80 80 end
81 81
82 82 def test_new_should_select_default_activity
83 83 @request.session[:user_id] = 3
84 84 get :new, :project_id => 1
85 85 assert_response :success
86 86 assert_select 'select[name=?]', 'time_entry[activity_id]' do
87 87 assert_select 'option[selected=selected]', :text => 'Development'
88 88 end
89 89 end
90 90
91 91 def test_new_should_only_show_active_time_entry_activities
92 92 @request.session[:user_id] = 3
93 93 get :new, :project_id => 1
94 94 assert_response :success
95 95 assert_select 'option', :text => 'Inactive Activity', :count => 0
96 96 end
97 97
98 98 def test_post_new_as_js_should_update_activity_options
99 99 @request.session[:user_id] = 3
100 100 post :new, :time_entry => {:project_id => 1}, :format => 'js'
101 101 assert_response :success
102 102 assert_include '#time_entry_activity_id', response.body
103 103 end
104 104
105 105 def test_get_edit_existing_time
106 106 @request.session[:user_id] = 2
107 107 get :edit, :id => 2, :project_id => nil
108 108 assert_response :success
109 109 assert_template 'edit'
110 110 assert_select 'form[action=?]', '/time_entries/2'
111 111 end
112 112
113 113 def test_get_edit_with_an_existing_time_entry_with_inactive_activity
114 114 te = TimeEntry.find(1)
115 115 te.activity = TimeEntryActivity.find_by_name("Inactive Activity")
116 116 te.save!(:validate => false)
117 117
118 118 @request.session[:user_id] = 1
119 119 get :edit, :project_id => 1, :id => 1
120 120 assert_response :success
121 121 assert_template 'edit'
122 122 # Blank option since nothing is pre-selected
123 123 assert_select 'option', :text => '--- Please select ---'
124 124 end
125 125
126 126 def test_post_create
127 127 @request.session[:user_id] = 3
128 128 assert_difference 'TimeEntry.count' do
129 129 post :create, :project_id => 1,
130 130 :time_entry => {:comments => 'Some work on TimelogControllerTest',
131 131 # Not the default activity
132 132 :activity_id => '11',
133 133 :spent_on => '2008-03-14',
134 134 :issue_id => '1',
135 135 :hours => '7.3'}
136 136 assert_redirected_to '/projects/ecookbook/time_entries'
137 137 end
138 138
139 139 t = TimeEntry.order('id DESC').first
140 140 assert_not_nil t
141 141 assert_equal 'Some work on TimelogControllerTest', t.comments
142 142 assert_equal 1, t.project_id
143 143 assert_equal 1, t.issue_id
144 144 assert_equal 11, t.activity_id
145 145 assert_equal 7.3, t.hours
146 146 assert_equal 3, t.user_id
147 147 end
148 148
149 149 def test_post_create_with_blank_issue
150 150 @request.session[:user_id] = 3
151 151 assert_difference 'TimeEntry.count' do
152 152 post :create, :project_id => 1,
153 153 :time_entry => {:comments => 'Some work on TimelogControllerTest',
154 154 # Not the default activity
155 155 :activity_id => '11',
156 156 :issue_id => '',
157 157 :spent_on => '2008-03-14',
158 158 :hours => '7.3'}
159 159 assert_redirected_to '/projects/ecookbook/time_entries'
160 160 end
161 161
162 162 t = TimeEntry.order('id DESC').first
163 163 assert_not_nil t
164 164 assert_equal 'Some work on TimelogControllerTest', t.comments
165 165 assert_equal 1, t.project_id
166 166 assert_nil t.issue_id
167 167 assert_equal 11, t.activity_id
168 168 assert_equal 7.3, t.hours
169 169 assert_equal 3, t.user_id
170 170 end
171 171
172 172 def test_create_on_project_with_time_tracking_disabled_should_fail
173 173 Project.find(1).disable_module! :time_tracking
174 174
175 175 @request.session[:user_id] = 2
176 176 assert_no_difference 'TimeEntry.count' do
177 177 post :create, :time_entry => {
178 178 :project_id => '1', :issue_id => '',
179 179 :activity_id => '11', :spent_on => '2008-03-14', :hours => '7.3'
180 180 }
181 181 end
182 182 end
183 183
184 184 def test_create_on_project_without_permission_should_fail
185 185 Role.find(1).remove_permission! :log_time
186 186
187 187 @request.session[:user_id] = 2
188 188 assert_no_difference 'TimeEntry.count' do
189 189 post :create, :time_entry => {
190 190 :project_id => '1', :issue_id => '',
191 191 :activity_id => '11', :spent_on => '2008-03-14', :hours => '7.3'
192 192 }
193 193 end
194 194 end
195 195
196 196 def test_create_on_issue_in_project_with_time_tracking_disabled_should_fail
197 197 Project.find(1).disable_module! :time_tracking
198 198
199 199 @request.session[:user_id] = 2
200 200 assert_no_difference 'TimeEntry.count' do
201 201 post :create, :time_entry => {
202 202 :project_id => '', :issue_id => '1',
203 203 :activity_id => '11', :spent_on => '2008-03-14', :hours => '7.3'
204 204 }
205 205 assert_select_error /Issue is invalid/
206 206 end
207 207 end
208 208
209 209 def test_create_on_issue_in_project_without_permission_should_fail
210 210 Role.find(1).remove_permission! :log_time
211 211
212 212 @request.session[:user_id] = 2
213 213 assert_no_difference 'TimeEntry.count' do
214 214 post :create, :time_entry => {
215 215 :project_id => '', :issue_id => '1',
216 216 :activity_id => '11', :spent_on => '2008-03-14', :hours => '7.3'
217 217 }
218 218 assert_select_error /Issue is invalid/
219 219 end
220 220 end
221 221
222 222 def test_create_on_issue_that_is_not_visible_should_not_disclose_subject
223 223 issue = Issue.generate!(:subject => "issue_that_is_not_visible", :is_private => true)
224 224 assert !issue.visible?(User.find(3))
225 225
226 226 @request.session[:user_id] = 3
227 227 assert_no_difference 'TimeEntry.count' do
228 228 post :create, :time_entry => {
229 229 :project_id => '', :issue_id => issue.id.to_s,
230 230 :activity_id => '11', :spent_on => '2008-03-14', :hours => '7.3'
231 231 }
232 232 end
233 233 assert_select_error /Issue is invalid/
234 234 assert_select "input[name=?][value=?]", "time_entry[issue_id]", issue.id.to_s
235 235 assert_select "#time_entry_issue", 0
236 236 assert !response.body.include?('issue_that_is_not_visible')
237 237 end
238 238
239 239 def test_create_and_continue_at_project_level
240 240 @request.session[:user_id] = 2
241 241 assert_difference 'TimeEntry.count' do
242 242 post :create, :time_entry => {:project_id => '1',
243 243 :activity_id => '11',
244 244 :issue_id => '',
245 245 :spent_on => '2008-03-14',
246 246 :hours => '7.3'},
247 247 :continue => '1'
248 248 assert_redirected_to '/time_entries/new?time_entry%5Bactivity_id%5D=11&time_entry%5Bissue_id%5D=&time_entry%5Bproject_id%5D=1'
249 249 end
250 250 end
251 251
252 252 def test_create_and_continue_at_issue_level
253 253 @request.session[:user_id] = 2
254 254 assert_difference 'TimeEntry.count' do
255 255 post :create, :time_entry => {:project_id => '',
256 256 :activity_id => '11',
257 257 :issue_id => '1',
258 258 :spent_on => '2008-03-14',
259 259 :hours => '7.3'},
260 260 :continue => '1'
261 261 assert_redirected_to '/time_entries/new?time_entry%5Bactivity_id%5D=11&time_entry%5Bissue_id%5D=1&time_entry%5Bproject_id%5D='
262 262 end
263 263 end
264 264
265 265 def test_create_and_continue_with_project_id
266 266 @request.session[:user_id] = 2
267 267 assert_difference 'TimeEntry.count' do
268 268 post :create, :project_id => 1,
269 269 :time_entry => {:activity_id => '11',
270 270 :issue_id => '',
271 271 :spent_on => '2008-03-14',
272 272 :hours => '7.3'},
273 273 :continue => '1'
274 274 assert_redirected_to '/projects/ecookbook/time_entries/new?time_entry%5Bactivity_id%5D=11&time_entry%5Bissue_id%5D=&time_entry%5Bproject_id%5D='
275 275 end
276 276 end
277 277
278 278 def test_create_and_continue_with_issue_id
279 279 @request.session[:user_id] = 2
280 280 assert_difference 'TimeEntry.count' do
281 281 post :create, :issue_id => 1,
282 282 :time_entry => {:activity_id => '11',
283 283 :issue_id => '1',
284 284 :spent_on => '2008-03-14',
285 285 :hours => '7.3'},
286 286 :continue => '1'
287 287 assert_redirected_to '/issues/1/time_entries/new?time_entry%5Bactivity_id%5D=11&time_entry%5Bissue_id%5D=1&time_entry%5Bproject_id%5D='
288 288 end
289 289 end
290 290
291 291 def test_create_without_log_time_permission_should_be_denied
292 292 @request.session[:user_id] = 2
293 293 Role.find_by_name('Manager').remove_permission! :log_time
294 294 post :create, :project_id => 1,
295 295 :time_entry => {:activity_id => '11',
296 296 :issue_id => '',
297 297 :spent_on => '2008-03-14',
298 298 :hours => '7.3'}
299 299
300 300 assert_response 403
301 301 end
302 302
303 303 def test_create_without_project_and_issue_should_fail
304 304 @request.session[:user_id] = 2
305 305 post :create, :time_entry => {:issue_id => ''}
306 306
307 307 assert_response :success
308 308 assert_template 'new'
309 309 end
310 310
311 311 def test_create_with_failure
312 312 @request.session[:user_id] = 2
313 313 post :create, :project_id => 1,
314 314 :time_entry => {:activity_id => '',
315 315 :issue_id => '',
316 316 :spent_on => '2008-03-14',
317 317 :hours => '7.3'}
318 318
319 319 assert_response :success
320 320 assert_template 'new'
321 321 end
322 322
323 323 def test_create_without_project
324 324 @request.session[:user_id] = 2
325 325 assert_difference 'TimeEntry.count' do
326 326 post :create, :time_entry => {:project_id => '1',
327 327 :activity_id => '11',
328 328 :issue_id => '',
329 329 :spent_on => '2008-03-14',
330 330 :hours => '7.3'}
331 331 end
332 332
333 333 assert_redirected_to '/projects/ecookbook/time_entries'
334 334 time_entry = TimeEntry.order('id DESC').first
335 335 assert_equal 1, time_entry.project_id
336 336 end
337 337
338 338 def test_create_without_project_should_fail_with_issue_not_inside_project
339 339 @request.session[:user_id] = 2
340 340 assert_no_difference 'TimeEntry.count' do
341 341 post :create, :time_entry => {:project_id => '1',
342 342 :activity_id => '11',
343 343 :issue_id => '5',
344 344 :spent_on => '2008-03-14',
345 345 :hours => '7.3'}
346 346 end
347 347
348 348 assert_response :success
349 349 assert assigns(:time_entry).errors[:issue_id].present?
350 350 end
351 351
352 352 def test_create_without_project_should_deny_without_permission
353 353 @request.session[:user_id] = 2
354 354 Project.find(3).disable_module!(:time_tracking)
355 355
356 356 assert_no_difference 'TimeEntry.count' do
357 357 post :create, :time_entry => {:project_id => '3',
358 358 :activity_id => '11',
359 359 :issue_id => '',
360 360 :spent_on => '2008-03-14',
361 361 :hours => '7.3'}
362 362 end
363 363
364 364 assert_response 403
365 365 end
366 366
367 367 def test_create_without_project_with_failure
368 368 @request.session[:user_id] = 2
369 369 assert_no_difference 'TimeEntry.count' do
370 370 post :create, :time_entry => {:project_id => '1',
371 371 :activity_id => '11',
372 372 :issue_id => '',
373 373 :spent_on => '2008-03-14',
374 374 :hours => ''}
375 375 end
376 376
377 377 assert_response :success
378 378 assert_select 'select[name=?]', 'time_entry[project_id]' do
379 379 assert_select 'option[value="1"][selected=selected]'
380 380 end
381 381 end
382 382
383 383 def test_update
384 384 entry = TimeEntry.find(1)
385 385 assert_equal 1, entry.issue_id
386 386 assert_equal 2, entry.user_id
387 387
388 388 @request.session[:user_id] = 1
389 389 put :update, :id => 1,
390 390 :time_entry => {:issue_id => '2',
391 391 :hours => '8'}
392 392 assert_redirected_to :action => 'index', :project_id => 'ecookbook'
393 393 entry.reload
394 394
395 395 assert_equal 8, entry.hours
396 396 assert_equal 2, entry.issue_id
397 397 assert_equal 2, entry.user_id
398 398 end
399 399
400 400 def test_update_should_allow_to_change_issue_to_another_project
401 401 entry = TimeEntry.generate!(:issue_id => 1)
402 402
403 403 @request.session[:user_id] = 1
404 404 put :update, :id => entry.id, :time_entry => {:issue_id => '5'}
405 405 assert_response 302
406 406 entry.reload
407 407
408 408 assert_equal 5, entry.issue_id
409 409 assert_equal 3, entry.project_id
410 410 end
411 411
412 412 def test_update_should_not_allow_to_change_issue_to_an_invalid_project
413 413 entry = TimeEntry.generate!(:issue_id => 1)
414 414 Project.find(3).disable_module!(:time_tracking)
415 415
416 416 @request.session[:user_id] = 1
417 417 put :update, :id => entry.id, :time_entry => {:issue_id => '5'}
418 418 assert_response 200
419 419 assert_include "Issue is invalid", assigns(:time_entry).errors.full_messages
420 420 end
421 421
422 422 def test_get_bulk_edit
423 423 @request.session[:user_id] = 2
424 424 get :bulk_edit, :ids => [1, 2]
425 425 assert_response :success
426 426 assert_template 'bulk_edit'
427 427
428 428 assert_select 'ul#bulk-selection' do
429 429 assert_select 'li', 2
430 430 assert_select 'li a', :text => '03/23/2007 - eCookbook: 4.25 hours'
431 431 end
432 432
433 433 assert_select 'form#bulk_edit_form[action=?]', '/time_entries/bulk_update' do
434 434 # System wide custom field
435 435 assert_select 'select[name=?]', 'time_entry[custom_field_values][10]'
436 436
437 437 # Activities
438 438 assert_select 'select[name=?]', 'time_entry[activity_id]' do
439 439 assert_select 'option[value=""]', :text => '(No change)'
440 440 assert_select 'option[value="9"]', :text => 'Design'
441 441 end
442 442 end
443 443 end
444 444
445 445 def test_get_bulk_edit_on_different_projects
446 446 @request.session[:user_id] = 2
447 447 get :bulk_edit, :ids => [1, 2, 6]
448 448 assert_response :success
449 449 assert_template 'bulk_edit'
450 450 end
451 451
452 452 def test_bulk_edit_with_edit_own_time_entries_permission
453 453 @request.session[:user_id] = 2
454 454 Role.find_by_name('Manager').remove_permission! :edit_time_entries
455 455 Role.find_by_name('Manager').add_permission! :edit_own_time_entries
456 456 ids = (0..1).map {TimeEntry.generate!(:user => User.find(2)).id}
457 457
458 458 get :bulk_edit, :ids => ids
459 459 assert_response :success
460 460 end
461 461
462 462 def test_bulk_update
463 463 @request.session[:user_id] = 2
464 464 # update time entry activity
465 465 post :bulk_update, :ids => [1, 2], :time_entry => { :activity_id => 9}
466 466
467 467 assert_response 302
468 468 # check that the issues were updated
469 469 assert_equal [9, 9], TimeEntry.where(:id => [1, 2]).collect {|i| i.activity_id}
470 470 end
471 471
472 472 def test_bulk_update_with_failure
473 473 @request.session[:user_id] = 2
474 474 post :bulk_update, :ids => [1, 2], :time_entry => { :hours => 'A'}
475 475
476 476 assert_response 302
477 477 assert_match /Failed to save 2 time entrie/, flash[:error]
478 478 end
479 479
480 480 def test_bulk_update_on_different_projects
481 481 @request.session[:user_id] = 2
482 482 # makes user a manager on the other project
483 483 Member.create!(:user_id => 2, :project_id => 3, :role_ids => [1])
484 484
485 485 # update time entry activity
486 486 post :bulk_update, :ids => [1, 2, 4], :time_entry => { :activity_id => 9 }
487 487
488 488 assert_response 302
489 489 # check that the issues were updated
490 490 assert_equal [9, 9, 9], TimeEntry.where(:id => [1, 2, 4]).collect {|i| i.activity_id}
491 491 end
492 492
493 493 def test_bulk_update_on_different_projects_without_rights
494 494 @request.session[:user_id] = 3
495 495 user = User.find(3)
496 496 action = { :controller => "timelog", :action => "bulk_update" }
497 497 assert user.allowed_to?(action, TimeEntry.find(1).project)
498 498 assert ! user.allowed_to?(action, TimeEntry.find(5).project)
499 499 post :bulk_update, :ids => [1, 5], :time_entry => { :activity_id => 9 }
500 500 assert_response 403
501 501 end
502 502
503 503 def test_bulk_update_with_edit_own_time_entries_permission
504 504 @request.session[:user_id] = 2
505 505 Role.find_by_name('Manager').remove_permission! :edit_time_entries
506 506 Role.find_by_name('Manager').add_permission! :edit_own_time_entries
507 507 ids = (0..1).map {TimeEntry.generate!(:user => User.find(2)).id}
508 508
509 509 post :bulk_update, :ids => ids, :time_entry => { :activity_id => 9 }
510 510 assert_response 302
511 511 end
512 512
513 513 def test_bulk_update_with_edit_own_time_entries_permissions_should_be_denied_for_time_entries_of_other_user
514 514 @request.session[:user_id] = 2
515 515 Role.find_by_name('Manager').remove_permission! :edit_time_entries
516 516 Role.find_by_name('Manager').add_permission! :edit_own_time_entries
517 517
518 518 post :bulk_update, :ids => [1, 2], :time_entry => { :activity_id => 9 }
519 519 assert_response 403
520 520 end
521 521
522 522 def test_bulk_update_custom_field
523 523 @request.session[:user_id] = 2
524 524 post :bulk_update, :ids => [1, 2], :time_entry => { :custom_field_values => {'10' => '0'} }
525 525
526 526 assert_response 302
527 527 assert_equal ["0", "0"], TimeEntry.where(:id => [1, 2]).collect {|i| i.custom_value_for(10).value}
528 528 end
529 529
530 530 def test_bulk_update_clear_custom_field
531 531 field = TimeEntryCustomField.generate!(:field_format => 'string')
532 532 @request.session[:user_id] = 2
533 533 post :bulk_update, :ids => [1, 2], :time_entry => { :custom_field_values => {field.id.to_s => '__none__'} }
534 534
535 535 assert_response 302
536 536 assert_equal ["", ""], TimeEntry.where(:id => [1, 2]).collect {|i| i.custom_value_for(field).value}
537 537 end
538 538
539 539 def test_post_bulk_update_should_redirect_back_using_the_back_url_parameter
540 540 @request.session[:user_id] = 2
541 541 post :bulk_update, :ids => [1,2], :back_url => '/time_entries'
542 542
543 543 assert_response :redirect
544 544 assert_redirected_to '/time_entries'
545 545 end
546 546
547 547 def test_post_bulk_update_should_not_redirect_back_using_the_back_url_parameter_off_the_host
548 548 @request.session[:user_id] = 2
549 549 post :bulk_update, :ids => [1,2], :back_url => 'http://google.com'
550 550
551 551 assert_response :redirect
552 552 assert_redirected_to :controller => 'timelog', :action => 'index', :project_id => Project.find(1).identifier
553 553 end
554 554
555 555 def test_post_bulk_update_without_edit_permission_should_be_denied
556 556 @request.session[:user_id] = 2
557 557 Role.find_by_name('Manager').remove_permission! :edit_time_entries
558 558 post :bulk_update, :ids => [1,2]
559 559
560 560 assert_response 403
561 561 end
562 562
563 563 def test_destroy
564 564 @request.session[:user_id] = 2
565 565 delete :destroy, :id => 1
566 566 assert_redirected_to :action => 'index', :project_id => 'ecookbook'
567 567 assert_equal I18n.t(:notice_successful_delete), flash[:notice]
568 568 assert_nil TimeEntry.find_by_id(1)
569 569 end
570 570
571 571 def test_destroy_should_fail
572 572 # simulate that this fails (e.g. due to a plugin), see #5700
573 573 TimeEntry.any_instance.expects(:destroy).returns(false)
574 574
575 575 @request.session[:user_id] = 2
576 576 delete :destroy, :id => 1
577 577 assert_redirected_to :action => 'index', :project_id => 'ecookbook'
578 578 assert_equal I18n.t(:notice_unable_delete_time_entry), flash[:error]
579 579 assert_not_nil TimeEntry.find_by_id(1)
580 580 end
581 581
582 582 def test_index_all_projects
583 583 get :index
584 584 assert_response :success
585 585 assert_template 'index'
586 586 assert_not_nil assigns(:total_hours)
587 587 assert_equal "162.90", "%.2f" % assigns(:total_hours)
588 588 assert_select 'form#query_form[action=?]', '/time_entries'
589 589 end
590 590
591 591 def test_index_all_projects_should_show_log_time_link
592 592 @request.session[:user_id] = 2
593 593 get :index
594 594 assert_response :success
595 595 assert_template 'index'
596 596 assert_select 'a[href=?]', '/time_entries/new', :text => /Log time/
597 597 end
598 598
599 599 def test_index_my_spent_time
600 600 @request.session[:user_id] = 2
601 601 get :index, :user_id => 'me'
602 602 assert_response :success
603 603 assert_template 'index'
604 604 assert assigns(:entries).all? {|entry| entry.user_id == 2}
605 605 end
606 606
607 607 def test_index_at_project_level
608 608 get :index, :project_id => 'ecookbook'
609 609 assert_response :success
610 610 assert_template 'index'
611 611 assert_not_nil assigns(:entries)
612 612 assert_equal 4, assigns(:entries).size
613 613 # project and subproject
614 614 assert_equal [1, 3], assigns(:entries).collect(&:project_id).uniq.sort
615 615 assert_not_nil assigns(:total_hours)
616 616 assert_equal "162.90", "%.2f" % assigns(:total_hours)
617 617 assert_select 'form#query_form[action=?]', '/projects/ecookbook/time_entries'
618 618 end
619 619
620 620 def test_index_with_display_subprojects_issues_to_false_should_not_include_subproject_entries
621 621 entry = TimeEntry.generate!(:project => Project.find(3))
622 622
623 623 with_settings :display_subprojects_issues => '0' do
624 624 get :index, :project_id => 'ecookbook'
625 625 assert_response :success
626 626 assert_template 'index'
627 627 assert_not_include entry, assigns(:entries)
628 628 end
629 629 end
630 630
631 631 def test_index_with_display_subprojects_issues_to_false_and_subproject_filter_should_include_subproject_entries
632 632 entry = TimeEntry.generate!(:project => Project.find(3))
633 633
634 634 with_settings :display_subprojects_issues => '0' do
635 635 get :index, :project_id => 'ecookbook', :subproject_id => 3
636 636 assert_response :success
637 637 assert_template 'index'
638 638 assert_include entry, assigns(:entries)
639 639 end
640 640 end
641 641
642 642 def test_index_at_project_level_with_date_range
643 643 get :index, :project_id => 'ecookbook',
644 644 :f => ['spent_on'],
645 645 :op => {'spent_on' => '><'},
646 646 :v => {'spent_on' => ['2007-03-20', '2007-04-30']}
647 647 assert_response :success
648 648 assert_template 'index'
649 649 assert_not_nil assigns(:entries)
650 650 assert_equal 3, assigns(:entries).size
651 651 assert_not_nil assigns(:total_hours)
652 652 assert_equal "12.90", "%.2f" % assigns(:total_hours)
653 653 assert_select 'form#query_form[action=?]', '/projects/ecookbook/time_entries'
654 654 end
655 655
656 656 def test_index_at_project_level_with_date_range_using_from_and_to_params
657 657 get :index, :project_id => 'ecookbook', :from => '2007-03-20', :to => '2007-04-30'
658 658 assert_response :success
659 659 assert_template 'index'
660 660 assert_not_nil assigns(:entries)
661 661 assert_equal 3, assigns(:entries).size
662 662 assert_not_nil assigns(:total_hours)
663 663 assert_equal "12.90", "%.2f" % assigns(:total_hours)
664 664 assert_select 'form#query_form[action=?]', '/projects/ecookbook/time_entries'
665 665 end
666 666
667 667 def test_index_at_project_level_with_period
668 668 get :index, :project_id => 'ecookbook',
669 669 :f => ['spent_on'],
670 670 :op => {'spent_on' => '>t-'},
671 671 :v => {'spent_on' => ['7']}
672 672 assert_response :success
673 673 assert_template 'index'
674 674 assert_not_nil assigns(:entries)
675 675 assert_not_nil assigns(:total_hours)
676 676 assert_select 'form#query_form[action=?]', '/projects/ecookbook/time_entries'
677 677 end
678 678
679 def test_index_at_issue_level
680 get :index, :issue_id => 1
681 assert_response :success
682 assert_template 'index'
683 assert_not_nil assigns(:entries)
684 assert_equal 2, assigns(:entries).size
685 assert_not_nil assigns(:total_hours)
686 assert_equal 154.25, assigns(:total_hours)
687 # display all time
688 assert_nil assigns(:from)
689 assert_nil assigns(:to)
690 assert_select 'form#query_form[action=?]', '/issues/1/time_entries'
691 end
692
693 679 def test_index_should_sort_by_spent_on_and_created_on
694 680 t1 = TimeEntry.create!(:user => User.find(1), :project => Project.find(1), :hours => 1, :spent_on => '2012-06-16', :created_on => '2012-06-16 20:00:00', :activity_id => 10)
695 681 t2 = TimeEntry.create!(:user => User.find(1), :project => Project.find(1), :hours => 1, :spent_on => '2012-06-16', :created_on => '2012-06-16 20:05:00', :activity_id => 10)
696 682 t3 = TimeEntry.create!(:user => User.find(1), :project => Project.find(1), :hours => 1, :spent_on => '2012-06-15', :created_on => '2012-06-16 20:10:00', :activity_id => 10)
697 683
698 684 get :index, :project_id => 1,
699 685 :f => ['spent_on'],
700 686 :op => {'spent_on' => '><'},
701 687 :v => {'spent_on' => ['2012-06-15', '2012-06-16']}
702 688 assert_response :success
703 689 assert_equal [t2, t1, t3], assigns(:entries)
704 690
705 691 get :index, :project_id => 1,
706 692 :f => ['spent_on'],
707 693 :op => {'spent_on' => '><'},
708 694 :v => {'spent_on' => ['2012-06-15', '2012-06-16']},
709 695 :sort => 'spent_on'
710 696 assert_response :success
711 697 assert_equal [t3, t1, t2], assigns(:entries)
712 698 end
713 699
714 700 def test_index_with_filter_on_issue_custom_field
715 701 issue = Issue.generate!(:project_id => 1, :tracker_id => 1, :custom_field_values => {2 => 'filter_on_issue_custom_field'})
716 702 entry = TimeEntry.generate!(:issue => issue, :hours => 2.5)
717 703
718 704 get :index, :f => ['issue.cf_2'], :op => {'issue.cf_2' => '='}, :v => {'issue.cf_2' => ['filter_on_issue_custom_field']}
719 705 assert_response :success
720 706 assert_equal [entry], assigns(:entries)
721 707 end
722 708
723 709 def test_index_with_issue_custom_field_column
724 710 issue = Issue.generate!(:project_id => 1, :tracker_id => 1, :custom_field_values => {2 => 'filter_on_issue_custom_field'})
725 711 entry = TimeEntry.generate!(:issue => issue, :hours => 2.5)
726 712
727 713 get :index, :c => %w(project spent_on issue comments hours issue.cf_2)
728 714 assert_response :success
729 715 assert_include :'issue.cf_2', assigns(:query).column_names
730 716 assert_select 'td.issue_cf_2', :text => 'filter_on_issue_custom_field'
731 717 end
732 718
733 719 def test_index_with_time_entry_custom_field_column
734 720 field = TimeEntryCustomField.generate!(:field_format => 'string')
735 721 entry = TimeEntry.generate!(:hours => 2.5, :custom_field_values => {field.id => 'CF Value'})
736 722 field_name = "cf_#{field.id}"
737 723
738 724 get :index, :c => ["hours", field_name]
739 725 assert_response :success
740 726 assert_include field_name.to_sym, assigns(:query).column_names
741 727 assert_select "td.#{field_name}", :text => 'CF Value'
742 728 end
743 729
744 730 def test_index_with_time_entry_custom_field_sorting
745 731 field = TimeEntryCustomField.generate!(:field_format => 'string', :name => 'String Field')
746 732 TimeEntry.generate!(:hours => 2.5, :custom_field_values => {field.id => 'CF Value 1'})
747 733 TimeEntry.generate!(:hours => 2.5, :custom_field_values => {field.id => 'CF Value 3'})
748 734 TimeEntry.generate!(:hours => 2.5, :custom_field_values => {field.id => 'CF Value 2'})
749 735 field_name = "cf_#{field.id}"
750 736
751 737 get :index, :c => ["hours", field_name], :sort => field_name
752 738 assert_response :success
753 739 assert_include field_name.to_sym, assigns(:query).column_names
754 740 assert_select "th a.sort", :text => 'String Field'
755 741
756 742 # Make sure that values are properly sorted
757 743 values = assigns(:entries).map {|e| e.custom_field_value(field)}.compact
758 744 assert_equal 3, values.size
759 745 assert_equal values.sort, values
760 746 end
761 747
762 748 def test_index_with_query
763 749 query = TimeEntryQuery.new(:project_id => 1, :name => 'Time Entry Query', :visibility => 2)
764 750 query.save!
765 751 @request.session[:user_id] = 2
766 752
767 753 get :index, :project_id => 'ecookbook', :query_id => query.id
768 754 assert_response :success
769 755 assert_select 'h2', :text => query.name
770 756 assert_select '#sidebar a.selected', :text => query.name
771 757 end
772 758
773 759 def test_index_atom_feed
774 760 get :index, :project_id => 1, :format => 'atom'
775 761 assert_response :success
776 762 assert_equal 'application/atom+xml', @response.content_type
777 763 assert_not_nil assigns(:items)
778 764 assert assigns(:items).first.is_a?(TimeEntry)
779 765 end
780 766
781 767 def test_index_at_project_level_should_include_csv_export_dialog
782 768 get :index, :project_id => 'ecookbook',
783 769 :f => ['spent_on'],
784 770 :op => {'spent_on' => '>='},
785 771 :v => {'spent_on' => ['2007-04-01']},
786 772 :c => ['spent_on', 'user']
787 773 assert_response :success
788 774
789 775 assert_select '#csv-export-options' do
790 776 assert_select 'form[action=?][method=get]', '/projects/ecookbook/time_entries.csv' do
791 777 # filter
792 778 assert_select 'input[name=?][value=?]', 'f[]', 'spent_on'
793 779 assert_select 'input[name=?][value=?]', 'op[spent_on]', '>='
794 780 assert_select 'input[name=?][value=?]', 'v[spent_on][]', '2007-04-01'
795 781 # columns
796 782 assert_select 'input[name=?][value=?]', 'c[]', 'spent_on'
797 783 assert_select 'input[name=?][value=?]', 'c[]', 'user'
798 784 assert_select 'input[name=?]', 'c[]', 2
799 785 end
800 786 end
801 787 end
802 788
803 789 def test_index_cross_project_should_include_csv_export_dialog
804 790 get :index
805 791 assert_response :success
806 792
807 793 assert_select '#csv-export-options' do
808 794 assert_select 'form[action=?][method=get]', '/time_entries.csv'
809 795 end
810 796 end
811 797
812 def test_index_at_issue_level_should_include_csv_export_dialog
813 get :index, :issue_id => 3
814 assert_response :success
815
816 assert_select '#csv-export-options' do
817 assert_select 'form[action=?][method=get]', '/issues/3/time_entries.csv'
818 end
819 end
820
821 798 def test_index_csv_all_projects
822 799 with_settings :date_format => '%m/%d/%Y' do
823 800 get :index, :format => 'csv'
824 801 assert_response :success
825 802 assert_equal 'text/csv; header=present', response.content_type
826 803 end
827 804 end
828 805
829 806 def test_index_csv
830 807 with_settings :date_format => '%m/%d/%Y' do
831 808 get :index, :project_id => 1, :format => 'csv'
832 809 assert_response :success
833 810 assert_equal 'text/csv; header=present', response.content_type
834 811 end
835 812 end
836 813
837 814 def test_index_csv_should_fill_issue_column_with_tracker_id_and_subject
838 815 issue = Issue.find(1)
839 816 entry = TimeEntry.generate!(:issue => issue, :comments => "Issue column content test")
840 817
841 818 get :index, :format => 'csv'
842 819 line = response.body.split("\n").detect {|l| l.include?(entry.comments)}
843 820 assert_not_nil line
844 821 assert_include "#{issue.tracker} #1: #{issue.subject}", line
845 822 end
846 823 end
@@ -1,66 +1,60
1 1 # Redmine - project management software
2 2 # Copyright (C) 2006-2016 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.expand_path('../../../test_helper', __FILE__)
19 19
20 20 class RoutingTimelogsTest < Redmine::RoutingTest
21 21 def test_timelogs_global
22 22 should_route 'GET /time_entries' => 'timelog#index'
23 23 should_route 'GET /time_entries.csv' => 'timelog#index', :format => 'csv'
24 24 should_route 'GET /time_entries.atom' => 'timelog#index', :format => 'atom'
25 25 should_route 'GET /time_entries/new' => 'timelog#new'
26 26 should_route 'POST /time_entries/new' => 'timelog#new'
27 27 should_route 'POST /time_entries' => 'timelog#create'
28 28
29 29 should_route 'GET /time_entries/22/edit' => 'timelog#edit', :id => '22'
30 30 should_route 'PUT /time_entries/22' => 'timelog#update', :id => '22'
31 31 should_route 'DELETE /time_entries/22' => 'timelog#destroy', :id => '22'
32 32 end
33 33
34 34 def test_timelogs_scoped_under_project
35 35 should_route 'GET /projects/foo/time_entries' => 'timelog#index', :project_id => 'foo'
36 36 should_route 'GET /projects/foo/time_entries.csv' => 'timelog#index', :project_id => 'foo', :format => 'csv'
37 37 should_route 'GET /projects/foo/time_entries.atom' => 'timelog#index', :project_id => 'foo', :format => 'atom'
38 38 should_route 'GET /projects/foo/time_entries/new' => 'timelog#new', :project_id => 'foo'
39 39 should_route 'POST /projects/foo/time_entries' => 'timelog#create', :project_id => 'foo'
40 40 end
41 41
42 42 def test_timelogs_scoped_under_issues
43 should_route 'GET /issues/234/time_entries' => 'timelog#index', :issue_id => '234'
44 should_route 'GET /issues/234/time_entries.csv' => 'timelog#index', :issue_id => '234', :format => 'csv'
45 should_route 'GET /issues/234/time_entries.atom' => 'timelog#index', :issue_id => '234', :format => 'atom'
46 should_route 'GET /issues/234/time_entries/new' => 'timelog#new', :issue_id => '234'
43 should_route 'GET /issues/234/time_entries/new' => 'timelog#new', :issue_id => '234'
47 44 should_route 'POST /issues/234/time_entries' => 'timelog#create', :issue_id => '234'
48 45 end
49 46
50 47 def test_timelogs_report
51 48 should_route 'GET /time_entries/report' => 'timelog#report'
52 49 should_route 'GET /time_entries/report.csv' => 'timelog#report', :format => 'csv'
53 50
54 51 should_route 'GET /projects/foo/time_entries/report' => 'timelog#report', :project_id => 'foo'
55 52 should_route 'GET /projects/foo/time_entries/report.csv' => 'timelog#report', :project_id => 'foo', :format => 'csv'
56
57 should_route 'GET /issues/234/time_entries/report' => 'timelog#report', :issue_id => '234'
58 should_route 'GET /issues/234/time_entries/report.csv' => 'timelog#report', :issue_id => '234', :format => 'csv'
59 53 end
60 54
61 55 def test_timelogs_bulk_edit
62 56 should_route 'GET /time_entries/bulk_edit' => 'timelog#bulk_edit'
63 57 should_route 'POST /time_entries/bulk_update' => 'timelog#bulk_update'
64 58 should_route 'DELETE /time_entries/destroy' => 'timelog#destroy'
65 59 end
66 60 end
@@ -1,47 +1,43
1 1 # encoding: utf-8
2 2 #
3 3 # Redmine - project management software
4 4 # Copyright (C) 2006-2016 Jean-Philippe Lang
5 5 #
6 6 # This program is free software; you can redistribute it and/or
7 7 # modify it under the terms of the GNU General Public License
8 8 # as published by the Free Software Foundation; either version 2
9 9 # of the License, or (at your option) any later version.
10 10 #
11 11 # This program is distributed in the hope that it will be useful,
12 12 # but WITHOUT ANY WARRANTY; without even the implied warranty of
13 13 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14 14 # GNU General Public License for more details.
15 15 #
16 16 # You should have received a copy of the GNU General Public License
17 17 # along with this program; if not, write to the Free Software
18 18 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
19 19
20 20 require File.expand_path('../../../test_helper', __FILE__)
21 21
22 22 class RoutesHelperTest < ActionView::TestCase
23 23 fixtures :projects, :issues
24 24
25 25 include Rails.application.routes.url_helpers
26 26
27 27 def test_time_entries_path
28 28 assert_equal '/projects/ecookbook/time_entries', _time_entries_path(Project.find(1), nil)
29 assert_equal '/issues/1/time_entries', _time_entries_path(Project.find(1), Issue.find(1))
30 assert_equal '/issues/1/time_entries', _time_entries_path(nil, Issue.find(1))
31 29 assert_equal '/time_entries', _time_entries_path(nil, nil)
32 30 end
33 31
34 32 def test_report_time_entries_path
35 33 assert_equal '/projects/ecookbook/time_entries/report', _report_time_entries_path(Project.find(1), nil)
36 assert_equal '/issues/1/time_entries/report', _report_time_entries_path(Project.find(1), Issue.find(1))
37 assert_equal '/issues/1/time_entries/report', _report_time_entries_path(nil, Issue.find(1))
38 34 assert_equal '/time_entries/report', _report_time_entries_path(nil, nil)
39 35 end
40 36
41 37 def test_new_time_entry_path
42 38 assert_equal '/projects/ecookbook/time_entries/new', _new_time_entry_path(Project.find(1), nil)
43 39 assert_equal '/issues/1/time_entries/new', _new_time_entry_path(Project.find(1), Issue.find(1))
44 40 assert_equal '/issues/1/time_entries/new', _new_time_entry_path(nil, Issue.find(1))
45 41 assert_equal '/time_entries/new', _new_time_entry_path(nil, nil)
46 42 end
47 43 end
General Comments 0
You need to be logged in to leave comments. Login now