##// END OF EJS Templates
Let the new time_entry form be submitted without project (#17954)....
Jean-Philippe Lang -
r13058:15d5c331ebc1
parent child
Show More
@@ -1,297 +1,280
1 1 # Redmine - project management software
2 2 # Copyright (C) 2006-2014 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 before_filter :find_project_for_new_time_entry, :only => [:create]
22 21 before_filter :find_time_entry, :only => [:show, :edit, :update]
23 22 before_filter :find_time_entries, :only => [:bulk_edit, :bulk_update, :destroy]
24 before_filter :authorize, :except => [:new, :index, :report]
23 before_filter :authorize, :only => [:show, :edit, :update, :bulk_edit, :bulk_update, :destroy]
25 24
26 before_filter :find_optional_project, :only => [:index, :report]
27 before_filter :find_optional_project_for_new_time_entry, :only => [:new]
28 before_filter :authorize_global, :only => [:new, :index, :report]
25 before_filter :find_optional_project, :only => [:new, :create, :index, :report]
26 before_filter :authorize_global, :only => [:new, :create, :index, :report]
29 27
30 28 accept_rss_auth :index
31 29 accept_api_auth :index, :show, :create, :update, :destroy
32 30
33 31 rescue_from Query::StatementInvalid, :with => :query_statement_invalid
34 32
35 33 helper :sort
36 34 include SortHelper
37 35 helper :issues
38 36 include TimelogHelper
39 37 helper :custom_fields
40 38 include CustomFieldsHelper
41 39 helper :queries
42 40 include QueriesHelper
43 41
44 42 def index
45 43 @query = TimeEntryQuery.build_from_params(params, :project => @project, :name => '_')
46 44
47 45 sort_init(@query.sort_criteria.empty? ? [['spent_on', 'desc']] : @query.sort_criteria)
48 46 sort_update(@query.sortable_columns)
49 47 scope = time_entry_scope(:order => sort_clause).
50 48 includes(:project, :user, :issue).
51 49 preload(:issue => [:project, :tracker, :status, :assigned_to, :priority])
52 50
53 51 respond_to do |format|
54 52 format.html {
55 53 @entry_count = scope.count
56 54 @entry_pages = Paginator.new @entry_count, per_page_option, params['page']
57 55 @entries = scope.offset(@entry_pages.offset).limit(@entry_pages.per_page).all
58 56 @total_hours = scope.sum(:hours).to_f
59 57
60 58 render :layout => !request.xhr?
61 59 }
62 60 format.api {
63 61 @entry_count = scope.count
64 62 @offset, @limit = api_offset_and_limit
65 63 @entries = scope.offset(@offset).limit(@limit).preload(:custom_values => :custom_field).all
66 64 }
67 65 format.atom {
68 66 entries = scope.limit(Setting.feeds_limit.to_i).reorder("#{TimeEntry.table_name}.created_on DESC").all
69 67 render_feed(entries, :title => l(:label_spent_time))
70 68 }
71 69 format.csv {
72 70 # Export all entries
73 71 @entries = scope.all
74 72 send_data(query_to_csv(@entries, @query, params), :type => 'text/csv; header=present', :filename => 'timelog.csv')
75 73 }
76 74 end
77 75 end
78 76
79 77 def report
80 78 @query = TimeEntryQuery.build_from_params(params, :project => @project, :name => '_')
81 79 scope = time_entry_scope
82 80
83 81 @report = Redmine::Helpers::TimeReport.new(@project, @issue, params[:criteria], params[:columns], scope)
84 82
85 83 respond_to do |format|
86 84 format.html { render :layout => !request.xhr? }
87 85 format.csv { send_data(report_to_csv(@report), :type => 'text/csv; header=present', :filename => 'timelog.csv') }
88 86 end
89 87 end
90 88
91 89 def show
92 90 respond_to do |format|
93 91 # TODO: Implement html response
94 92 format.html { render :nothing => true, :status => 406 }
95 93 format.api
96 94 end
97 95 end
98 96
99 97 def new
100 98 @time_entry ||= TimeEntry.new(:project => @project, :issue => @issue, :user => User.current, :spent_on => User.current.today)
101 99 @time_entry.safe_attributes = params[:time_entry]
102 100 end
103 101
104 102 def create
105 103 @time_entry ||= TimeEntry.new(:project => @project, :issue => @issue, :user => User.current, :spent_on => User.current.today)
106 104 @time_entry.safe_attributes = params[:time_entry]
105 if @time_entry.project && !User.current.allowed_to?(:log_time, @time_entry.project)
106 render_403
107 return
108 end
107 109
108 110 call_hook(:controller_timelog_edit_before_save, { :params => params, :time_entry => @time_entry })
109 111
110 112 if @time_entry.save
111 113 respond_to do |format|
112 114 format.html {
113 115 flash[:notice] = l(:notice_successful_create)
114 116 if params[:continue]
115 if params[:project_id]
116 options = {
117 :time_entry => {:issue_id => @time_entry.issue_id, :activity_id => @time_entry.activity_id},
118 :back_url => params[:back_url]
119 }
120 if @time_entry.issue
121 redirect_to new_project_issue_time_entry_path(@time_entry.project, @time_entry.issue, options)
122 else
123 redirect_to new_project_time_entry_path(@time_entry.project, options)
124 end
117 options = {
118 :time_entry => {
119 :project_id => params[:time_entry][:project_id],
120 :issue_id => @time_entry.issue_id,
121 :activity_id => @time_entry.activity_id
122 },
123 :back_url => params[:back_url]
124 }
125 if params[:project_id] && @time_entry.project
126 redirect_to new_project_time_entry_path(@time_entry.project, options)
127 elsif params[:issue_id] && @time_entry.issue
128 redirect_to new_issue_time_entry_path(@time_entry.issue, options)
125 129 else
126 options = {
127 :time_entry => {:project_id => @time_entry.project_id, :issue_id => @time_entry.issue_id, :activity_id => @time_entry.activity_id},
128 :back_url => params[:back_url]
129 }
130 130 redirect_to new_time_entry_path(options)
131 131 end
132 132 else
133 133 redirect_back_or_default project_time_entries_path(@time_entry.project)
134 134 end
135 135 }
136 136 format.api { render :action => 'show', :status => :created, :location => time_entry_url(@time_entry) }
137 137 end
138 138 else
139 139 respond_to do |format|
140 140 format.html { render :action => 'new' }
141 141 format.api { render_validation_errors(@time_entry) }
142 142 end
143 143 end
144 144 end
145 145
146 146 def edit
147 147 @time_entry.safe_attributes = params[:time_entry]
148 148 end
149 149
150 150 def update
151 151 @time_entry.safe_attributes = params[:time_entry]
152 152
153 153 call_hook(:controller_timelog_edit_before_save, { :params => params, :time_entry => @time_entry })
154 154
155 155 if @time_entry.save
156 156 respond_to do |format|
157 157 format.html {
158 158 flash[:notice] = l(:notice_successful_update)
159 159 redirect_back_or_default project_time_entries_path(@time_entry.project)
160 160 }
161 161 format.api { render_api_ok }
162 162 end
163 163 else
164 164 respond_to do |format|
165 165 format.html { render :action => 'edit' }
166 166 format.api { render_validation_errors(@time_entry) }
167 167 end
168 168 end
169 169 end
170 170
171 171 def bulk_edit
172 172 @available_activities = TimeEntryActivity.shared.active
173 173 @custom_fields = TimeEntry.first.available_custom_fields
174 174 end
175 175
176 176 def bulk_update
177 177 attributes = parse_params_for_bulk_time_entry_attributes(params)
178 178
179 179 unsaved_time_entry_ids = []
180 180 @time_entries.each do |time_entry|
181 181 time_entry.reload
182 182 time_entry.safe_attributes = attributes
183 183 call_hook(:controller_time_entries_bulk_edit_before_save, { :params => params, :time_entry => time_entry })
184 184 unless time_entry.save
185 185 logger.info "time entry could not be updated: #{time_entry.errors.full_messages}" if logger && logger.info
186 186 # Keep unsaved time_entry ids to display them in flash error
187 187 unsaved_time_entry_ids << time_entry.id
188 188 end
189 189 end
190 190 set_flash_from_bulk_time_entry_save(@time_entries, unsaved_time_entry_ids)
191 191 redirect_back_or_default project_time_entries_path(@projects.first)
192 192 end
193 193
194 194 def destroy
195 195 destroyed = TimeEntry.transaction do
196 196 @time_entries.each do |t|
197 197 unless t.destroy && t.destroyed?
198 198 raise ActiveRecord::Rollback
199 199 end
200 200 end
201 201 end
202 202
203 203 respond_to do |format|
204 204 format.html {
205 205 if destroyed
206 206 flash[:notice] = l(:notice_successful_delete)
207 207 else
208 208 flash[:error] = l(:notice_unable_delete_time_entry)
209 209 end
210 210 redirect_back_or_default project_time_entries_path(@projects.first)
211 211 }
212 212 format.api {
213 213 if destroyed
214 214 render_api_ok
215 215 else
216 216 render_validation_errors(@time_entries)
217 217 end
218 218 }
219 219 end
220 220 end
221 221
222 222 private
223 223 def find_time_entry
224 224 @time_entry = TimeEntry.find(params[:id])
225 225 unless @time_entry.editable_by?(User.current)
226 226 render_403
227 227 return false
228 228 end
229 229 @project = @time_entry.project
230 230 rescue ActiveRecord::RecordNotFound
231 231 render_404
232 232 end
233 233
234 234 def find_time_entries
235 235 @time_entries = TimeEntry.where(:id => params[:id] || params[:ids]).all
236 236 raise ActiveRecord::RecordNotFound if @time_entries.empty?
237 237 @projects = @time_entries.collect(&:project).compact.uniq
238 238 @project = @projects.first if @projects.size == 1
239 239 rescue ActiveRecord::RecordNotFound
240 240 render_404
241 241 end
242 242
243 243 def set_flash_from_bulk_time_entry_save(time_entries, unsaved_time_entry_ids)
244 244 if unsaved_time_entry_ids.empty?
245 245 flash[:notice] = l(:notice_successful_update) unless time_entries.empty?
246 246 else
247 247 flash[:error] = l(:notice_failed_to_save_time_entries,
248 248 :count => unsaved_time_entry_ids.size,
249 249 :total => time_entries.size,
250 250 :ids => '#' + unsaved_time_entry_ids.join(', #'))
251 251 end
252 252 end
253 253
254 def find_optional_project_for_new_time_entry
255 if (project_id = (params[:project_id] || params[:time_entry] && params[:time_entry][:project_id])).present?
256 @project = Project.find(project_id)
257 end
258 if (issue_id = (params[:issue_id] || params[:time_entry] && params[:time_entry][:issue_id])).present?
259 @issue = Issue.find(issue_id)
260 @project ||= @issue.project
261 end
262 rescue ActiveRecord::RecordNotFound
263 render_404
264 end
265
266 def find_project_for_new_time_entry
267 find_optional_project_for_new_time_entry
268 if @project.nil?
269 render_404
270 end
271 end
272
273 254 def find_optional_project
274 if !params[:issue_id].blank?
255 if params[:issue_id].present?
275 256 @issue = Issue.find(params[:issue_id])
276 257 @project = @issue.project
277 elsif !params[:project_id].blank?
258 elsif params[:project_id].present?
278 259 @project = Project.find(params[:project_id])
279 260 end
261 rescue ActiveRecord::RecordNotFound
262 render_404
280 263 end
281 264
282 265 # Returns the TimeEntry scope for index and report actions
283 266 def time_entry_scope(options={})
284 267 scope = @query.results_scope(options)
285 268 if @issue
286 269 scope = scope.on_issue(@issue)
287 270 end
288 271 scope
289 272 end
290 273
291 274 def parse_params_for_bulk_time_entry_attributes(params)
292 275 attributes = (params[:time_entry] || {}).reject {|k,v| v.blank?}
293 276 attributes.keys.each {|k| attributes[k] = '' if attributes[k] == 'none'}
294 277 attributes[:custom_field_values].reject! {|k,v| v.blank?} if attributes[:custom_field_values]
295 278 attributes
296 279 end
297 280 end
@@ -1,1360 +1,1363
1 1 # encoding: utf-8
2 2 #
3 3 # Redmine - project management software
4 4 # Copyright (C) 2006-2014 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 'forwardable'
21 21 require 'cgi'
22 22
23 23 module ApplicationHelper
24 24 include Redmine::WikiFormatting::Macros::Definitions
25 25 include Redmine::I18n
26 26 include GravatarHelper::PublicMethods
27 27 include Redmine::Pagination::Helper
28 28
29 29 extend Forwardable
30 30 def_delegators :wiki_helper, :wikitoolbar_for, :heads_for_wiki_formatter
31 31
32 32 # Return true if user is authorized for controller/action, otherwise false
33 33 def authorize_for(controller, action)
34 34 User.current.allowed_to?({:controller => controller, :action => action}, @project)
35 35 end
36 36
37 37 # Display a link if user is authorized
38 38 #
39 39 # @param [String] name Anchor text (passed to link_to)
40 40 # @param [Hash] options Hash params. This will checked by authorize_for to see if the user is authorized
41 41 # @param [optional, Hash] html_options Options passed to link_to
42 42 # @param [optional, Hash] parameters_for_method_reference Extra parameters for link_to
43 43 def link_to_if_authorized(name, options = {}, html_options = nil, *parameters_for_method_reference)
44 44 link_to(name, options, html_options, *parameters_for_method_reference) if authorize_for(options[:controller] || params[:controller], options[:action])
45 45 end
46 46
47 47 # Displays a link to user's account page if active
48 48 def link_to_user(user, options={})
49 49 if user.is_a?(User)
50 50 name = h(user.name(options[:format]))
51 51 if user.active? || (User.current.admin? && user.logged?)
52 52 link_to name, user_path(user), :class => user.css_classes
53 53 else
54 54 name
55 55 end
56 56 else
57 57 h(user.to_s)
58 58 end
59 59 end
60 60
61 61 # Displays a link to +issue+ with its subject.
62 62 # Examples:
63 63 #
64 64 # link_to_issue(issue) # => Defect #6: This is the subject
65 65 # link_to_issue(issue, :truncate => 6) # => Defect #6: This i...
66 66 # link_to_issue(issue, :subject => false) # => Defect #6
67 67 # link_to_issue(issue, :project => true) # => Foo - Defect #6
68 68 # link_to_issue(issue, :subject => false, :tracker => false) # => #6
69 69 #
70 70 def link_to_issue(issue, options={})
71 71 title = nil
72 72 subject = nil
73 73 text = options[:tracker] == false ? "##{issue.id}" : "#{issue.tracker} ##{issue.id}"
74 74 if options[:subject] == false
75 75 title = issue.subject.truncate(60)
76 76 else
77 77 subject = issue.subject
78 78 if truncate_length = options[:truncate]
79 79 subject = subject.truncate(truncate_length)
80 80 end
81 81 end
82 82 only_path = options[:only_path].nil? ? true : options[:only_path]
83 83 s = link_to(text, issue_path(issue, :only_path => only_path),
84 84 :class => issue.css_classes, :title => title)
85 85 s << h(": #{subject}") if subject
86 86 s = h("#{issue.project} - ") + s if options[:project]
87 87 s
88 88 end
89 89
90 90 # Generates a link to an attachment.
91 91 # Options:
92 92 # * :text - Link text (default to attachment filename)
93 93 # * :download - Force download (default: false)
94 94 def link_to_attachment(attachment, options={})
95 95 text = options.delete(:text) || attachment.filename
96 96 route_method = options.delete(:download) ? :download_named_attachment_path : :named_attachment_path
97 97 html_options = options.slice!(:only_path)
98 98 url = send(route_method, attachment, attachment.filename, options)
99 99 link_to text, url, html_options
100 100 end
101 101
102 102 # Generates a link to a SCM revision
103 103 # Options:
104 104 # * :text - Link text (default to the formatted revision)
105 105 def link_to_revision(revision, repository, options={})
106 106 if repository.is_a?(Project)
107 107 repository = repository.repository
108 108 end
109 109 text = options.delete(:text) || format_revision(revision)
110 110 rev = revision.respond_to?(:identifier) ? revision.identifier : revision
111 111 link_to(
112 112 h(text),
113 113 {:controller => 'repositories', :action => 'revision', :id => repository.project, :repository_id => repository.identifier_param, :rev => rev},
114 114 :title => l(:label_revision_id, format_revision(revision))
115 115 )
116 116 end
117 117
118 118 # Generates a link to a message
119 119 def link_to_message(message, options={}, html_options = nil)
120 120 link_to(
121 121 message.subject.truncate(60),
122 122 board_message_path(message.board_id, message.parent_id || message.id, {
123 123 :r => (message.parent_id && message.id),
124 124 :anchor => (message.parent_id ? "message-#{message.id}" : nil)
125 125 }.merge(options)),
126 126 html_options
127 127 )
128 128 end
129 129
130 130 # Generates a link to a project if active
131 131 # Examples:
132 132 #
133 133 # link_to_project(project) # => link to the specified project overview
134 134 # link_to_project(project, {:only_path => false}, :class => "project") # => 3rd arg adds html options
135 135 # link_to_project(project, {}, :class => "project") # => html options with default url (project overview)
136 136 #
137 137 def link_to_project(project, options={}, html_options = nil)
138 138 if project.archived?
139 139 h(project.name)
140 140 elsif options.key?(:action)
141 141 ActiveSupport::Deprecation.warn "#link_to_project with :action option is deprecated and will be removed in Redmine 3.0."
142 142 url = {:controller => 'projects', :action => 'show', :id => project}.merge(options)
143 143 link_to project.name, url, html_options
144 144 else
145 145 link_to project.name, project_path(project, options), html_options
146 146 end
147 147 end
148 148
149 149 # Generates a link to a project settings if active
150 150 def link_to_project_settings(project, options={}, html_options=nil)
151 151 if project.active?
152 152 link_to project.name, settings_project_path(project, options), html_options
153 153 elsif project.archived?
154 154 h(project.name)
155 155 else
156 156 link_to project.name, project_path(project, options), html_options
157 157 end
158 158 end
159 159
160 160 # Generates a link to a version
161 161 def link_to_version(version, options = {})
162 162 return '' unless version && version.is_a?(Version)
163 163 options = {:title => format_date(version.effective_date)}.merge(options)
164 164 link_to_if version.visible?, format_version_name(version), version_path(version), options
165 165 end
166 166
167 167 # Helper that formats object for html or text rendering
168 168 def format_object(object, html=true, &block)
169 169 if block_given?
170 170 object = yield object
171 171 end
172 172 case object.class.name
173 173 when 'Array'
174 174 object.map {|o| format_object(o, html)}.join(', ').html_safe
175 175 when 'Time'
176 176 format_time(object)
177 177 when 'Date'
178 178 format_date(object)
179 179 when 'Fixnum'
180 180 object.to_s
181 181 when 'Float'
182 182 sprintf "%.2f", object
183 183 when 'User'
184 184 html ? link_to_user(object) : object.to_s
185 185 when 'Project'
186 186 html ? link_to_project(object) : object.to_s
187 187 when 'Version'
188 188 html ? link_to_version(object) : object.to_s
189 189 when 'TrueClass'
190 190 l(:general_text_Yes)
191 191 when 'FalseClass'
192 192 l(:general_text_No)
193 193 when 'Issue'
194 194 object.visible? && html ? link_to_issue(object) : "##{object.id}"
195 195 when 'CustomValue', 'CustomFieldValue'
196 196 if object.custom_field
197 197 f = object.custom_field.format.formatted_custom_value(self, object, html)
198 198 if f.nil? || f.is_a?(String)
199 199 f
200 200 else
201 201 format_object(f, html, &block)
202 202 end
203 203 else
204 204 object.value.to_s
205 205 end
206 206 else
207 207 html ? h(object) : object.to_s
208 208 end
209 209 end
210 210
211 211 def wiki_page_path(page, options={})
212 212 url_for({:controller => 'wiki', :action => 'show', :project_id => page.project, :id => page.title}.merge(options))
213 213 end
214 214
215 215 def thumbnail_tag(attachment)
216 216 link_to image_tag(thumbnail_path(attachment)),
217 217 named_attachment_path(attachment, attachment.filename),
218 218 :title => attachment.filename
219 219 end
220 220
221 221 def toggle_link(name, id, options={})
222 222 onclick = "$('##{id}').toggle(); "
223 223 onclick << (options[:focus] ? "$('##{options[:focus]}').focus(); " : "this.blur(); ")
224 224 onclick << "return false;"
225 225 link_to(name, "#", :onclick => onclick)
226 226 end
227 227
228 228 def image_to_function(name, function, html_options = {})
229 229 html_options.symbolize_keys!
230 230 tag(:input, html_options.merge({
231 231 :type => "image", :src => image_path(name),
232 232 :onclick => (html_options[:onclick] ? "#{html_options[:onclick]}; " : "") + "#{function};"
233 233 }))
234 234 end
235 235
236 236 def format_activity_title(text)
237 237 h(truncate_single_line_raw(text, 100))
238 238 end
239 239
240 240 def format_activity_day(date)
241 241 date == User.current.today ? l(:label_today).titleize : format_date(date)
242 242 end
243 243
244 244 def format_activity_description(text)
245 245 h(text.to_s.truncate(120).gsub(%r{[\r\n]*<(pre|code)>.*$}m, '...')
246 246 ).gsub(/[\r\n]+/, "<br />").html_safe
247 247 end
248 248
249 249 def format_version_name(version)
250 250 if !version.shared? || version.project == @project
251 251 h(version)
252 252 else
253 253 h("#{version.project} - #{version}")
254 254 end
255 255 end
256 256
257 257 def due_date_distance_in_words(date)
258 258 if date
259 259 l((date < Date.today ? :label_roadmap_overdue : :label_roadmap_due_in), distance_of_date_in_words(Date.today, date))
260 260 end
261 261 end
262 262
263 263 # Renders a tree of projects as a nested set of unordered lists
264 264 # The given collection may be a subset of the whole project tree
265 265 # (eg. some intermediate nodes are private and can not be seen)
266 266 def render_project_nested_lists(projects)
267 267 s = ''
268 268 if projects.any?
269 269 ancestors = []
270 270 original_project = @project
271 271 projects.sort_by(&:lft).each do |project|
272 272 # set the project environment to please macros.
273 273 @project = project
274 274 if (ancestors.empty? || project.is_descendant_of?(ancestors.last))
275 275 s << "<ul class='projects #{ ancestors.empty? ? 'root' : nil}'>\n"
276 276 else
277 277 ancestors.pop
278 278 s << "</li>"
279 279 while (ancestors.any? && !project.is_descendant_of?(ancestors.last))
280 280 ancestors.pop
281 281 s << "</ul></li>\n"
282 282 end
283 283 end
284 284 classes = (ancestors.empty? ? 'root' : 'child')
285 285 s << "<li class='#{classes}'><div class='#{classes}'>"
286 286 s << h(block_given? ? yield(project) : project.name)
287 287 s << "</div>\n"
288 288 ancestors << project
289 289 end
290 290 s << ("</li></ul>\n" * ancestors.size)
291 291 @project = original_project
292 292 end
293 293 s.html_safe
294 294 end
295 295
296 296 def render_page_hierarchy(pages, node=nil, options={})
297 297 content = ''
298 298 if pages[node]
299 299 content << "<ul class=\"pages-hierarchy\">\n"
300 300 pages[node].each do |page|
301 301 content << "<li>"
302 302 content << link_to(h(page.pretty_title), {:controller => 'wiki', :action => 'show', :project_id => page.project, :id => page.title, :version => nil},
303 303 :title => (options[:timestamp] && page.updated_on ? l(:label_updated_time, distance_of_time_in_words(Time.now, page.updated_on)) : nil))
304 304 content << "\n" + render_page_hierarchy(pages, page.id, options) if pages[page.id]
305 305 content << "</li>\n"
306 306 end
307 307 content << "</ul>\n"
308 308 end
309 309 content.html_safe
310 310 end
311 311
312 312 # Renders flash messages
313 313 def render_flash_messages
314 314 s = ''
315 315 flash.each do |k,v|
316 316 s << content_tag('div', v.html_safe, :class => "flash #{k}", :id => "flash_#{k}")
317 317 end
318 318 s.html_safe
319 319 end
320 320
321 321 # Renders tabs and their content
322 322 def render_tabs(tabs, selected=params[:tab])
323 323 if tabs.any?
324 324 unless tabs.detect {|tab| tab[:name] == selected}
325 325 selected = nil
326 326 end
327 327 selected ||= tabs.first[:name]
328 328 render :partial => 'common/tabs', :locals => {:tabs => tabs, :selected_tab => selected}
329 329 else
330 330 content_tag 'p', l(:label_no_data), :class => "nodata"
331 331 end
332 332 end
333 333
334 334 # Renders the project quick-jump box
335 335 def render_project_jump_box
336 336 return unless User.current.logged?
337 337 projects = User.current.memberships.collect(&:project).compact.select(&:active?).uniq
338 338 if projects.any?
339 339 options =
340 340 ("<option value=''>#{ l(:label_jump_to_a_project) }</option>" +
341 341 '<option value="" disabled="disabled">---</option>').html_safe
342 342
343 343 options << project_tree_options_for_select(projects, :selected => @project) do |p|
344 344 { :value => project_path(:id => p, :jump => current_menu_item) }
345 345 end
346 346
347 347 select_tag('project_quick_jump_box', options, :onchange => 'if (this.value != \'\') { window.location = this.value; }')
348 348 end
349 349 end
350 350
351 351 def project_tree_options_for_select(projects, options = {})
352 s = ''
352 s = ''.html_safe
353 if options[:include_blank]
354 s << content_tag('option', '&nbsp;'.html_safe, :value => '')
355 end
353 356 project_tree(projects) do |project, level|
354 357 name_prefix = (level > 0 ? '&nbsp;' * 2 * level + '&#187; ' : '').html_safe
355 358 tag_options = {:value => project.id}
356 359 if project == options[:selected] || (options[:selected].respond_to?(:include?) && options[:selected].include?(project))
357 360 tag_options[:selected] = 'selected'
358 361 else
359 362 tag_options[:selected] = nil
360 363 end
361 364 tag_options.merge!(yield(project)) if block_given?
362 365 s << content_tag('option', name_prefix + h(project), tag_options)
363 366 end
364 367 s.html_safe
365 368 end
366 369
367 370 # Yields the given block for each project with its level in the tree
368 371 #
369 372 # Wrapper for Project#project_tree
370 373 def project_tree(projects, &block)
371 374 Project.project_tree(projects, &block)
372 375 end
373 376
374 377 def principals_check_box_tags(name, principals)
375 378 s = ''
376 379 principals.each do |principal|
377 380 s << "<label>#{ check_box_tag name, principal.id, false, :id => nil } #{h principal}</label>\n"
378 381 end
379 382 s.html_safe
380 383 end
381 384
382 385 # Returns a string for users/groups option tags
383 386 def principals_options_for_select(collection, selected=nil)
384 387 s = ''
385 388 if collection.include?(User.current)
386 389 s << content_tag('option', "<< #{l(:label_me)} >>", :value => User.current.id)
387 390 end
388 391 groups = ''
389 392 collection.sort.each do |element|
390 393 selected_attribute = ' selected="selected"' if option_value_selected?(element, selected) || element.id.to_s == selected
391 394 (element.is_a?(Group) ? groups : s) << %(<option value="#{element.id}"#{selected_attribute}>#{h element.name}</option>)
392 395 end
393 396 unless groups.empty?
394 397 s << %(<optgroup label="#{h(l(:label_group_plural))}">#{groups}</optgroup>)
395 398 end
396 399 s.html_safe
397 400 end
398 401
399 402 # Options for the new membership projects combo-box
400 403 def options_for_membership_project_select(principal, projects)
401 404 options = content_tag('option', "--- #{l(:actionview_instancetag_blank_option)} ---")
402 405 options << project_tree_options_for_select(projects) do |p|
403 406 {:disabled => principal.projects.to_a.include?(p)}
404 407 end
405 408 options
406 409 end
407 410
408 411 def option_tag(name, text, value, selected=nil, options={})
409 412 content_tag 'option', value, options.merge(:value => value, :selected => (value == selected))
410 413 end
411 414
412 415 # Truncates and returns the string as a single line
413 416 def truncate_single_line(string, *args)
414 417 ActiveSupport::Deprecation.warn(
415 418 "ApplicationHelper#truncate_single_line is deprecated and will be removed in Rails 4 poring")
416 419 # Rails 4 ActionView::Helpers::TextHelper#truncate escapes.
417 420 # So, result is broken.
418 421 truncate(string.to_s, *args).gsub(%r{[\r\n]+}m, ' ')
419 422 end
420 423
421 424 def truncate_single_line_raw(string, length)
422 425 string.truncate(length).gsub(%r{[\r\n]+}m, ' ')
423 426 end
424 427
425 428 # Truncates at line break after 250 characters or options[:length]
426 429 def truncate_lines(string, options={})
427 430 length = options[:length] || 250
428 431 if string.to_s =~ /\A(.{#{length}}.*?)$/m
429 432 "#{$1}..."
430 433 else
431 434 string
432 435 end
433 436 end
434 437
435 438 def anchor(text)
436 439 text.to_s.gsub(' ', '_')
437 440 end
438 441
439 442 def html_hours(text)
440 443 text.gsub(%r{(\d+)\.(\d+)}, '<span class="hours hours-int">\1</span><span class="hours hours-dec">.\2</span>').html_safe
441 444 end
442 445
443 446 def authoring(created, author, options={})
444 447 l(options[:label] || :label_added_time_by, :author => link_to_user(author), :age => time_tag(created)).html_safe
445 448 end
446 449
447 450 def time_tag(time)
448 451 text = distance_of_time_in_words(Time.now, time)
449 452 if @project
450 453 link_to(text, {:controller => 'activities', :action => 'index', :id => @project, :from => User.current.time_to_date(time)}, :title => format_time(time))
451 454 else
452 455 content_tag('abbr', text, :title => format_time(time))
453 456 end
454 457 end
455 458
456 459 def syntax_highlight_lines(name, content)
457 460 lines = []
458 461 syntax_highlight(name, content).each_line { |line| lines << line }
459 462 lines
460 463 end
461 464
462 465 def syntax_highlight(name, content)
463 466 Redmine::SyntaxHighlighting.highlight_by_filename(content, name)
464 467 end
465 468
466 469 def to_path_param(path)
467 470 str = path.to_s.split(%r{[/\\]}).select{|p| !p.blank?}.join("/")
468 471 str.blank? ? nil : str
469 472 end
470 473
471 474 def reorder_links(name, url, method = :post)
472 475 link_to(image_tag('2uparrow.png', :alt => l(:label_sort_highest)),
473 476 url.merge({"#{name}[move_to]" => 'highest'}),
474 477 :method => method, :title => l(:label_sort_highest)) +
475 478 link_to(image_tag('1uparrow.png', :alt => l(:label_sort_higher)),
476 479 url.merge({"#{name}[move_to]" => 'higher'}),
477 480 :method => method, :title => l(:label_sort_higher)) +
478 481 link_to(image_tag('1downarrow.png', :alt => l(:label_sort_lower)),
479 482 url.merge({"#{name}[move_to]" => 'lower'}),
480 483 :method => method, :title => l(:label_sort_lower)) +
481 484 link_to(image_tag('2downarrow.png', :alt => l(:label_sort_lowest)),
482 485 url.merge({"#{name}[move_to]" => 'lowest'}),
483 486 :method => method, :title => l(:label_sort_lowest))
484 487 end
485 488
486 489 def breadcrumb(*args)
487 490 elements = args.flatten
488 491 elements.any? ? content_tag('p', (args.join(" \xc2\xbb ") + " \xc2\xbb ").html_safe, :class => 'breadcrumb') : nil
489 492 end
490 493
491 494 def other_formats_links(&block)
492 495 concat('<p class="other-formats">'.html_safe + l(:label_export_to))
493 496 yield Redmine::Views::OtherFormatsBuilder.new(self)
494 497 concat('</p>'.html_safe)
495 498 end
496 499
497 500 def page_header_title
498 501 if @project.nil? || @project.new_record?
499 502 h(Setting.app_title)
500 503 else
501 504 b = []
502 505 ancestors = (@project.root? ? [] : @project.ancestors.visible.all)
503 506 if ancestors.any?
504 507 root = ancestors.shift
505 508 b << link_to_project(root, {:jump => current_menu_item}, :class => 'root')
506 509 if ancestors.size > 2
507 510 b << "\xe2\x80\xa6"
508 511 ancestors = ancestors[-2, 2]
509 512 end
510 513 b += ancestors.collect {|p| link_to_project(p, {:jump => current_menu_item}, :class => 'ancestor') }
511 514 end
512 515 b << h(@project)
513 516 b.join(" \xc2\xbb ").html_safe
514 517 end
515 518 end
516 519
517 520 # Returns a h2 tag and sets the html title with the given arguments
518 521 def title(*args)
519 522 strings = args.map do |arg|
520 523 if arg.is_a?(Array) && arg.size >= 2
521 524 link_to(*arg)
522 525 else
523 526 h(arg.to_s)
524 527 end
525 528 end
526 529 html_title args.reverse.map {|s| (s.is_a?(Array) ? s.first : s).to_s}
527 530 content_tag('h2', strings.join(' &#187; ').html_safe)
528 531 end
529 532
530 533 # Sets the html title
531 534 # Returns the html title when called without arguments
532 535 # Current project name and app_title and automatically appended
533 536 # Exemples:
534 537 # html_title 'Foo', 'Bar'
535 538 # html_title # => 'Foo - Bar - My Project - Redmine'
536 539 def html_title(*args)
537 540 if args.empty?
538 541 title = @html_title || []
539 542 title << @project.name if @project
540 543 title << Setting.app_title unless Setting.app_title == title.last
541 544 title.reject(&:blank?).join(' - ')
542 545 else
543 546 @html_title ||= []
544 547 @html_title += args
545 548 end
546 549 end
547 550
548 551 # Returns the theme, controller name, and action as css classes for the
549 552 # HTML body.
550 553 def body_css_classes
551 554 css = []
552 555 if theme = Redmine::Themes.theme(Setting.ui_theme)
553 556 css << 'theme-' + theme.name
554 557 end
555 558
556 559 css << 'project-' + @project.identifier if @project && @project.identifier.present?
557 560 css << 'controller-' + controller_name
558 561 css << 'action-' + action_name
559 562 css.join(' ')
560 563 end
561 564
562 565 def accesskey(s)
563 566 @used_accesskeys ||= []
564 567 key = Redmine::AccessKeys.key_for(s)
565 568 return nil if @used_accesskeys.include?(key)
566 569 @used_accesskeys << key
567 570 key
568 571 end
569 572
570 573 # Formats text according to system settings.
571 574 # 2 ways to call this method:
572 575 # * with a String: textilizable(text, options)
573 576 # * with an object and one of its attribute: textilizable(issue, :description, options)
574 577 def textilizable(*args)
575 578 options = args.last.is_a?(Hash) ? args.pop : {}
576 579 case args.size
577 580 when 1
578 581 obj = options[:object]
579 582 text = args.shift
580 583 when 2
581 584 obj = args.shift
582 585 attr = args.shift
583 586 text = obj.send(attr).to_s
584 587 else
585 588 raise ArgumentError, 'invalid arguments to textilizable'
586 589 end
587 590 return '' if text.blank?
588 591 project = options[:project] || @project || (obj && obj.respond_to?(:project) ? obj.project : nil)
589 592 only_path = options.delete(:only_path) == false ? false : true
590 593
591 594 text = text.dup
592 595 macros = catch_macros(text)
593 596 text = Redmine::WikiFormatting.to_html(Setting.text_formatting, text, :object => obj, :attribute => attr)
594 597
595 598 @parsed_headings = []
596 599 @heading_anchors = {}
597 600 @current_section = 0 if options[:edit_section_links]
598 601
599 602 parse_sections(text, project, obj, attr, only_path, options)
600 603 text = parse_non_pre_blocks(text, obj, macros) do |text|
601 604 [:parse_inline_attachments, :parse_wiki_links, :parse_redmine_links].each do |method_name|
602 605 send method_name, text, project, obj, attr, only_path, options
603 606 end
604 607 end
605 608 parse_headings(text, project, obj, attr, only_path, options)
606 609
607 610 if @parsed_headings.any?
608 611 replace_toc(text, @parsed_headings)
609 612 end
610 613
611 614 text.html_safe
612 615 end
613 616
614 617 def parse_non_pre_blocks(text, obj, macros)
615 618 s = StringScanner.new(text)
616 619 tags = []
617 620 parsed = ''
618 621 while !s.eos?
619 622 s.scan(/(.*?)(<(\/)?(pre|code)(.*?)>|\z)/im)
620 623 text, full_tag, closing, tag = s[1], s[2], s[3], s[4]
621 624 if tags.empty?
622 625 yield text
623 626 inject_macros(text, obj, macros) if macros.any?
624 627 else
625 628 inject_macros(text, obj, macros, false) if macros.any?
626 629 end
627 630 parsed << text
628 631 if tag
629 632 if closing
630 633 if tags.last == tag.downcase
631 634 tags.pop
632 635 end
633 636 else
634 637 tags << tag.downcase
635 638 end
636 639 parsed << full_tag
637 640 end
638 641 end
639 642 # Close any non closing tags
640 643 while tag = tags.pop
641 644 parsed << "</#{tag}>"
642 645 end
643 646 parsed
644 647 end
645 648
646 649 def parse_inline_attachments(text, project, obj, attr, only_path, options)
647 650 # when using an image link, try to use an attachment, if possible
648 651 attachments = options[:attachments] || []
649 652 attachments += obj.attachments if obj.respond_to?(:attachments)
650 653 if attachments.present?
651 654 text.gsub!(/src="([^\/"]+\.(bmp|gif|jpg|jpe|jpeg|png))"(\s+alt="([^"]*)")?/i) do |m|
652 655 filename, ext, alt, alttext = $1.downcase, $2, $3, $4
653 656 # search for the picture in attachments
654 657 if found = Attachment.latest_attach(attachments, filename)
655 658 image_url = download_named_attachment_path(found, found.filename, :only_path => only_path)
656 659 desc = found.description.to_s.gsub('"', '')
657 660 if !desc.blank? && alttext.blank?
658 661 alt = " title=\"#{desc}\" alt=\"#{desc}\""
659 662 end
660 663 "src=\"#{image_url}\"#{alt}"
661 664 else
662 665 m
663 666 end
664 667 end
665 668 end
666 669 end
667 670
668 671 # Wiki links
669 672 #
670 673 # Examples:
671 674 # [[mypage]]
672 675 # [[mypage|mytext]]
673 676 # wiki links can refer other project wikis, using project name or identifier:
674 677 # [[project:]] -> wiki starting page
675 678 # [[project:|mytext]]
676 679 # [[project:mypage]]
677 680 # [[project:mypage|mytext]]
678 681 def parse_wiki_links(text, project, obj, attr, only_path, options)
679 682 text.gsub!(/(!)?(\[\[([^\]\n\|]+)(\|([^\]\n\|]+))?\]\])/) do |m|
680 683 link_project = project
681 684 esc, all, page, title = $1, $2, $3, $5
682 685 if esc.nil?
683 686 if page =~ /^([^\:]+)\:(.*)$/
684 687 identifier, page = $1, $2
685 688 link_project = Project.find_by_identifier(identifier) || Project.find_by_name(identifier)
686 689 title ||= identifier if page.blank?
687 690 end
688 691
689 692 if link_project && link_project.wiki
690 693 # extract anchor
691 694 anchor = nil
692 695 if page =~ /^(.+?)\#(.+)$/
693 696 page, anchor = $1, $2
694 697 end
695 698 anchor = sanitize_anchor_name(anchor) if anchor.present?
696 699 # check if page exists
697 700 wiki_page = link_project.wiki.find_page(page)
698 701 url = if anchor.present? && wiki_page.present? && (obj.is_a?(WikiContent) || obj.is_a?(WikiContent::Version)) && obj.page == wiki_page
699 702 "##{anchor}"
700 703 else
701 704 case options[:wiki_links]
702 705 when :local; "#{page.present? ? Wiki.titleize(page) : ''}.html" + (anchor.present? ? "##{anchor}" : '')
703 706 when :anchor; "##{page.present? ? Wiki.titleize(page) : title}" + (anchor.present? ? "_#{anchor}" : '') # used for single-file wiki export
704 707 else
705 708 wiki_page_id = page.present? ? Wiki.titleize(page) : nil
706 709 parent = wiki_page.nil? && obj.is_a?(WikiContent) && obj.page && project == link_project ? obj.page.title : nil
707 710 url_for(:only_path => only_path, :controller => 'wiki', :action => 'show', :project_id => link_project,
708 711 :id => wiki_page_id, :version => nil, :anchor => anchor, :parent => parent)
709 712 end
710 713 end
711 714 link_to(title.present? ? title.html_safe : h(page), url, :class => ('wiki-page' + (wiki_page ? '' : ' new')))
712 715 else
713 716 # project or wiki doesn't exist
714 717 all
715 718 end
716 719 else
717 720 all
718 721 end
719 722 end
720 723 end
721 724
722 725 # Redmine links
723 726 #
724 727 # Examples:
725 728 # Issues:
726 729 # #52 -> Link to issue #52
727 730 # Changesets:
728 731 # r52 -> Link to revision 52
729 732 # commit:a85130f -> Link to scmid starting with a85130f
730 733 # Documents:
731 734 # document#17 -> Link to document with id 17
732 735 # document:Greetings -> Link to the document with title "Greetings"
733 736 # document:"Some document" -> Link to the document with title "Some document"
734 737 # Versions:
735 738 # version#3 -> Link to version with id 3
736 739 # version:1.0.0 -> Link to version named "1.0.0"
737 740 # version:"1.0 beta 2" -> Link to version named "1.0 beta 2"
738 741 # Attachments:
739 742 # attachment:file.zip -> Link to the attachment of the current object named file.zip
740 743 # Source files:
741 744 # source:some/file -> Link to the file located at /some/file in the project's repository
742 745 # source:some/file@52 -> Link to the file's revision 52
743 746 # source:some/file#L120 -> Link to line 120 of the file
744 747 # source:some/file@52#L120 -> Link to line 120 of the file's revision 52
745 748 # export:some/file -> Force the download of the file
746 749 # Forum messages:
747 750 # message#1218 -> Link to message with id 1218
748 751 # Projects:
749 752 # project:someproject -> Link to project named "someproject"
750 753 # project#3 -> Link to project with id 3
751 754 #
752 755 # Links can refer other objects from other projects, using project identifier:
753 756 # identifier:r52
754 757 # identifier:document:"Some document"
755 758 # identifier:version:1.0.0
756 759 # identifier:source:some/file
757 760 def parse_redmine_links(text, default_project, obj, attr, only_path, options)
758 761 text.gsub!(%r{([\s\(,\-\[\>]|^)(!)?(([a-z0-9\-_]+):)?(attachment|document|version|forum|news|message|project|commit|source|export)?(((#)|((([a-z0-9\-_]+)\|)?(r)))((\d+)((#note)?-(\d+))?)|(:)([^"\s<>][^\s<>]*?|"[^"]+?"))(?=(?=[[:punct:]][^A-Za-z0-9_/])|,|\s|\]|<|$)}) do |m|
759 762 leading, esc, project_prefix, project_identifier, prefix, repo_prefix, repo_identifier, sep, identifier, comment_suffix, comment_id = $1, $2, $3, $4, $5, $10, $11, $8 || $12 || $18, $14 || $19, $15, $17
760 763 link = nil
761 764 project = default_project
762 765 if project_identifier
763 766 project = Project.visible.find_by_identifier(project_identifier)
764 767 end
765 768 if esc.nil?
766 769 if prefix.nil? && sep == 'r'
767 770 if project
768 771 repository = nil
769 772 if repo_identifier
770 773 repository = project.repositories.detect {|repo| repo.identifier == repo_identifier}
771 774 else
772 775 repository = project.repository
773 776 end
774 777 # project.changesets.visible raises an SQL error because of a double join on repositories
775 778 if repository &&
776 779 (changeset = Changeset.visible.
777 780 find_by_repository_id_and_revision(repository.id, identifier))
778 781 link = link_to(h("#{project_prefix}#{repo_prefix}r#{identifier}"),
779 782 {:only_path => only_path, :controller => 'repositories',
780 783 :action => 'revision', :id => project,
781 784 :repository_id => repository.identifier_param,
782 785 :rev => changeset.revision},
783 786 :class => 'changeset',
784 787 :title => truncate_single_line_raw(changeset.comments, 100))
785 788 end
786 789 end
787 790 elsif sep == '#'
788 791 oid = identifier.to_i
789 792 case prefix
790 793 when nil
791 794 if oid.to_s == identifier &&
792 795 issue = Issue.visible.includes(:status).find_by_id(oid)
793 796 anchor = comment_id ? "note-#{comment_id}" : nil
794 797 link = link_to(h("##{oid}#{comment_suffix}"),
795 798 {:only_path => only_path, :controller => 'issues',
796 799 :action => 'show', :id => oid, :anchor => anchor},
797 800 :class => issue.css_classes,
798 801 :title => "#{issue.subject.truncate(100)} (#{issue.status.name})")
799 802 end
800 803 when 'document'
801 804 if document = Document.visible.find_by_id(oid)
802 805 link = link_to h(document.title), {:only_path => only_path, :controller => 'documents', :action => 'show', :id => document},
803 806 :class => 'document'
804 807 end
805 808 when 'version'
806 809 if version = Version.visible.find_by_id(oid)
807 810 link = link_to h(version.name), {:only_path => only_path, :controller => 'versions', :action => 'show', :id => version},
808 811 :class => 'version'
809 812 end
810 813 when 'message'
811 814 if message = Message.visible.includes(:parent).find_by_id(oid)
812 815 link = link_to_message(message, {:only_path => only_path}, :class => 'message')
813 816 end
814 817 when 'forum'
815 818 if board = Board.visible.find_by_id(oid)
816 819 link = link_to h(board.name), {:only_path => only_path, :controller => 'boards', :action => 'show', :id => board, :project_id => board.project},
817 820 :class => 'board'
818 821 end
819 822 when 'news'
820 823 if news = News.visible.find_by_id(oid)
821 824 link = link_to h(news.title), {:only_path => only_path, :controller => 'news', :action => 'show', :id => news},
822 825 :class => 'news'
823 826 end
824 827 when 'project'
825 828 if p = Project.visible.find_by_id(oid)
826 829 link = link_to_project(p, {:only_path => only_path}, :class => 'project')
827 830 end
828 831 end
829 832 elsif sep == ':'
830 833 # removes the double quotes if any
831 834 name = identifier.gsub(%r{^"(.*)"$}, "\\1")
832 835 name = CGI.unescapeHTML(name)
833 836 case prefix
834 837 when 'document'
835 838 if project && document = project.documents.visible.find_by_title(name)
836 839 link = link_to h(document.title), {:only_path => only_path, :controller => 'documents', :action => 'show', :id => document},
837 840 :class => 'document'
838 841 end
839 842 when 'version'
840 843 if project && version = project.versions.visible.find_by_name(name)
841 844 link = link_to h(version.name), {:only_path => only_path, :controller => 'versions', :action => 'show', :id => version},
842 845 :class => 'version'
843 846 end
844 847 when 'forum'
845 848 if project && board = project.boards.visible.find_by_name(name)
846 849 link = link_to h(board.name), {:only_path => only_path, :controller => 'boards', :action => 'show', :id => board, :project_id => board.project},
847 850 :class => 'board'
848 851 end
849 852 when 'news'
850 853 if project && news = project.news.visible.find_by_title(name)
851 854 link = link_to h(news.title), {:only_path => only_path, :controller => 'news', :action => 'show', :id => news},
852 855 :class => 'news'
853 856 end
854 857 when 'commit', 'source', 'export'
855 858 if project
856 859 repository = nil
857 860 if name =~ %r{^(([a-z0-9\-_]+)\|)(.+)$}
858 861 repo_prefix, repo_identifier, name = $1, $2, $3
859 862 repository = project.repositories.detect {|repo| repo.identifier == repo_identifier}
860 863 else
861 864 repository = project.repository
862 865 end
863 866 if prefix == 'commit'
864 867 if repository && (changeset = Changeset.visible.where("repository_id = ? AND scmid LIKE ?", repository.id, "#{name}%").first)
865 868 link = link_to h("#{project_prefix}#{repo_prefix}#{name}"), {:only_path => only_path, :controller => 'repositories', :action => 'revision', :id => project, :repository_id => repository.identifier_param, :rev => changeset.identifier},
866 869 :class => 'changeset',
867 870 :title => truncate_single_line_raw(changeset.comments, 100)
868 871 end
869 872 else
870 873 if repository && User.current.allowed_to?(:browse_repository, project)
871 874 name =~ %r{^[/\\]*(.*?)(@([^/\\@]+?))?(#(L\d+))?$}
872 875 path, rev, anchor = $1, $3, $5
873 876 link = link_to h("#{project_prefix}#{prefix}:#{repo_prefix}#{name}"), {:only_path => only_path, :controller => 'repositories', :action => (prefix == 'export' ? 'raw' : 'entry'), :id => project, :repository_id => repository.identifier_param,
874 877 :path => to_path_param(path),
875 878 :rev => rev,
876 879 :anchor => anchor},
877 880 :class => (prefix == 'export' ? 'source download' : 'source')
878 881 end
879 882 end
880 883 repo_prefix = nil
881 884 end
882 885 when 'attachment'
883 886 attachments = options[:attachments] || []
884 887 attachments += obj.attachments if obj.respond_to?(:attachments)
885 888 if attachments && attachment = Attachment.latest_attach(attachments, name)
886 889 link = link_to_attachment(attachment, :only_path => only_path, :download => true, :class => 'attachment')
887 890 end
888 891 when 'project'
889 892 if p = Project.visible.where("identifier = :s OR LOWER(name) = :s", :s => name.downcase).first
890 893 link = link_to_project(p, {:only_path => only_path}, :class => 'project')
891 894 end
892 895 end
893 896 end
894 897 end
895 898 (leading + (link || "#{project_prefix}#{prefix}#{repo_prefix}#{sep}#{identifier}#{comment_suffix}"))
896 899 end
897 900 end
898 901
899 902 HEADING_RE = /(<h(\d)( [^>]+)?>(.+?)<\/h(\d)>)/i unless const_defined?(:HEADING_RE)
900 903
901 904 def parse_sections(text, project, obj, attr, only_path, options)
902 905 return unless options[:edit_section_links]
903 906 text.gsub!(HEADING_RE) do
904 907 heading = $1
905 908 @current_section += 1
906 909 if @current_section > 1
907 910 content_tag('div',
908 911 link_to(image_tag('edit.png'), options[:edit_section_links].merge(:section => @current_section)),
909 912 :class => 'contextual',
910 913 :title => l(:button_edit_section),
911 914 :id => "section-#{@current_section}") + heading.html_safe
912 915 else
913 916 heading
914 917 end
915 918 end
916 919 end
917 920
918 921 # Headings and TOC
919 922 # Adds ids and links to headings unless options[:headings] is set to false
920 923 def parse_headings(text, project, obj, attr, only_path, options)
921 924 return if options[:headings] == false
922 925
923 926 text.gsub!(HEADING_RE) do
924 927 level, attrs, content = $2.to_i, $3, $4
925 928 item = strip_tags(content).strip
926 929 anchor = sanitize_anchor_name(item)
927 930 # used for single-file wiki export
928 931 anchor = "#{obj.page.title}_#{anchor}" if options[:wiki_links] == :anchor && (obj.is_a?(WikiContent) || obj.is_a?(WikiContent::Version))
929 932 @heading_anchors[anchor] ||= 0
930 933 idx = (@heading_anchors[anchor] += 1)
931 934 if idx > 1
932 935 anchor = "#{anchor}-#{idx}"
933 936 end
934 937 @parsed_headings << [level, anchor, item]
935 938 "<a name=\"#{anchor}\"></a>\n<h#{level} #{attrs}>#{content}<a href=\"##{anchor}\" class=\"wiki-anchor\">&para;</a></h#{level}>"
936 939 end
937 940 end
938 941
939 942 MACROS_RE = /(
940 943 (!)? # escaping
941 944 (
942 945 \{\{ # opening tag
943 946 ([\w]+) # macro name
944 947 (\(([^\n\r]*?)\))? # optional arguments
945 948 ([\n\r].*?[\n\r])? # optional block of text
946 949 \}\} # closing tag
947 950 )
948 951 )/mx unless const_defined?(:MACROS_RE)
949 952
950 953 MACRO_SUB_RE = /(
951 954 \{\{
952 955 macro\((\d+)\)
953 956 \}\}
954 957 )/x unless const_defined?(:MACRO_SUB_RE)
955 958
956 959 # Extracts macros from text
957 960 def catch_macros(text)
958 961 macros = {}
959 962 text.gsub!(MACROS_RE) do
960 963 all, macro = $1, $4.downcase
961 964 if macro_exists?(macro) || all =~ MACRO_SUB_RE
962 965 index = macros.size
963 966 macros[index] = all
964 967 "{{macro(#{index})}}"
965 968 else
966 969 all
967 970 end
968 971 end
969 972 macros
970 973 end
971 974
972 975 # Executes and replaces macros in text
973 976 def inject_macros(text, obj, macros, execute=true)
974 977 text.gsub!(MACRO_SUB_RE) do
975 978 all, index = $1, $2.to_i
976 979 orig = macros.delete(index)
977 980 if execute && orig && orig =~ MACROS_RE
978 981 esc, all, macro, args, block = $2, $3, $4.downcase, $6.to_s, $7.try(:strip)
979 982 if esc.nil?
980 983 h(exec_macro(macro, obj, args, block) || all)
981 984 else
982 985 h(all)
983 986 end
984 987 elsif orig
985 988 h(orig)
986 989 else
987 990 h(all)
988 991 end
989 992 end
990 993 end
991 994
992 995 TOC_RE = /<p>\{\{((<|&lt;)|(>|&gt;))?toc\}\}<\/p>/i unless const_defined?(:TOC_RE)
993 996
994 997 # Renders the TOC with given headings
995 998 def replace_toc(text, headings)
996 999 text.gsub!(TOC_RE) do
997 1000 left_align, right_align = $2, $3
998 1001 # Keep only the 4 first levels
999 1002 headings = headings.select{|level, anchor, item| level <= 4}
1000 1003 if headings.empty?
1001 1004 ''
1002 1005 else
1003 1006 div_class = 'toc'
1004 1007 div_class << ' right' if right_align
1005 1008 div_class << ' left' if left_align
1006 1009 out = "<ul class=\"#{div_class}\"><li>"
1007 1010 root = headings.map(&:first).min
1008 1011 current = root
1009 1012 started = false
1010 1013 headings.each do |level, anchor, item|
1011 1014 if level > current
1012 1015 out << '<ul><li>' * (level - current)
1013 1016 elsif level < current
1014 1017 out << "</li></ul>\n" * (current - level) + "</li><li>"
1015 1018 elsif started
1016 1019 out << '</li><li>'
1017 1020 end
1018 1021 out << "<a href=\"##{anchor}\">#{item}</a>"
1019 1022 current = level
1020 1023 started = true
1021 1024 end
1022 1025 out << '</li></ul>' * (current - root)
1023 1026 out << '</li></ul>'
1024 1027 end
1025 1028 end
1026 1029 end
1027 1030
1028 1031 # Same as Rails' simple_format helper without using paragraphs
1029 1032 def simple_format_without_paragraph(text)
1030 1033 text.to_s.
1031 1034 gsub(/\r\n?/, "\n"). # \r\n and \r -> \n
1032 1035 gsub(/\n\n+/, "<br /><br />"). # 2+ newline -> 2 br
1033 1036 gsub(/([^\n]\n)(?=[^\n])/, '\1<br />'). # 1 newline -> br
1034 1037 html_safe
1035 1038 end
1036 1039
1037 1040 def lang_options_for_select(blank=true)
1038 1041 (blank ? [["(auto)", ""]] : []) + languages_options
1039 1042 end
1040 1043
1041 1044 def label_tag_for(name, option_tags = nil, options = {})
1042 1045 label_text = l(("field_"+field.to_s.gsub(/\_id$/, "")).to_sym) + (options.delete(:required) ? @template.content_tag("span", " *", :class => "required"): "")
1043 1046 content_tag("label", label_text)
1044 1047 end
1045 1048
1046 1049 def labelled_form_for(*args, &proc)
1047 1050 args << {} unless args.last.is_a?(Hash)
1048 1051 options = args.last
1049 1052 if args.first.is_a?(Symbol)
1050 1053 options.merge!(:as => args.shift)
1051 1054 end
1052 1055 options.merge!({:builder => Redmine::Views::LabelledFormBuilder})
1053 1056 form_for(*args, &proc)
1054 1057 end
1055 1058
1056 1059 def labelled_fields_for(*args, &proc)
1057 1060 args << {} unless args.last.is_a?(Hash)
1058 1061 options = args.last
1059 1062 options.merge!({:builder => Redmine::Views::LabelledFormBuilder})
1060 1063 fields_for(*args, &proc)
1061 1064 end
1062 1065
1063 1066 def labelled_remote_form_for(*args, &proc)
1064 1067 ActiveSupport::Deprecation.warn "ApplicationHelper#labelled_remote_form_for is deprecated and will be removed in Redmine 2.2."
1065 1068 args << {} unless args.last.is_a?(Hash)
1066 1069 options = args.last
1067 1070 options.merge!({:builder => Redmine::Views::LabelledFormBuilder, :remote => true})
1068 1071 form_for(*args, &proc)
1069 1072 end
1070 1073
1071 1074 def error_messages_for(*objects)
1072 1075 html = ""
1073 1076 objects = objects.map {|o| o.is_a?(String) ? instance_variable_get("@#{o}") : o}.compact
1074 1077 errors = objects.map {|o| o.errors.full_messages}.flatten
1075 1078 if errors.any?
1076 1079 html << "<div id='errorExplanation'><ul>\n"
1077 1080 errors.each do |error|
1078 1081 html << "<li>#{h error}</li>\n"
1079 1082 end
1080 1083 html << "</ul></div>\n"
1081 1084 end
1082 1085 html.html_safe
1083 1086 end
1084 1087
1085 1088 def delete_link(url, options={})
1086 1089 options = {
1087 1090 :method => :delete,
1088 1091 :data => {:confirm => l(:text_are_you_sure)},
1089 1092 :class => 'icon icon-del'
1090 1093 }.merge(options)
1091 1094
1092 1095 link_to l(:button_delete), url, options
1093 1096 end
1094 1097
1095 1098 def preview_link(url, form, target='preview', options={})
1096 1099 content_tag 'a', l(:label_preview), {
1097 1100 :href => "#",
1098 1101 :onclick => %|submitPreview("#{escape_javascript url_for(url)}", "#{escape_javascript form}", "#{escape_javascript target}"); return false;|,
1099 1102 :accesskey => accesskey(:preview)
1100 1103 }.merge(options)
1101 1104 end
1102 1105
1103 1106 def link_to_function(name, function, html_options={})
1104 1107 content_tag(:a, name, {:href => '#', :onclick => "#{function}; return false;"}.merge(html_options))
1105 1108 end
1106 1109
1107 1110 # Helper to render JSON in views
1108 1111 def raw_json(arg)
1109 1112 arg.to_json.to_s.gsub('/', '\/').html_safe
1110 1113 end
1111 1114
1112 1115 def back_url
1113 1116 url = params[:back_url]
1114 1117 if url.nil? && referer = request.env['HTTP_REFERER']
1115 1118 url = CGI.unescape(referer.to_s)
1116 1119 end
1117 1120 url
1118 1121 end
1119 1122
1120 1123 def back_url_hidden_field_tag
1121 1124 url = back_url
1122 1125 hidden_field_tag('back_url', url, :id => nil) unless url.blank?
1123 1126 end
1124 1127
1125 1128 def check_all_links(form_name)
1126 1129 link_to_function(l(:button_check_all), "checkAll('#{form_name}', true)") +
1127 1130 " | ".html_safe +
1128 1131 link_to_function(l(:button_uncheck_all), "checkAll('#{form_name}', false)")
1129 1132 end
1130 1133
1131 1134 def progress_bar(pcts, options={})
1132 1135 pcts = [pcts, pcts] unless pcts.is_a?(Array)
1133 1136 pcts = pcts.collect(&:round)
1134 1137 pcts[1] = pcts[1] - pcts[0]
1135 1138 pcts << (100 - pcts[1] - pcts[0])
1136 1139 width = options[:width] || '100px;'
1137 1140 legend = options[:legend] || ''
1138 1141 content_tag('table',
1139 1142 content_tag('tr',
1140 1143 (pcts[0] > 0 ? content_tag('td', '', :style => "width: #{pcts[0]}%;", :class => 'closed') : ''.html_safe) +
1141 1144 (pcts[1] > 0 ? content_tag('td', '', :style => "width: #{pcts[1]}%;", :class => 'done') : ''.html_safe) +
1142 1145 (pcts[2] > 0 ? content_tag('td', '', :style => "width: #{pcts[2]}%;", :class => 'todo') : ''.html_safe)
1143 1146 ), :class => "progress progress-#{pcts[0]}", :style => "width: #{width};").html_safe +
1144 1147 content_tag('p', legend, :class => 'percent').html_safe
1145 1148 end
1146 1149
1147 1150 def checked_image(checked=true)
1148 1151 if checked
1149 1152 image_tag 'toggle_check.png'
1150 1153 end
1151 1154 end
1152 1155
1153 1156 def context_menu(url)
1154 1157 unless @context_menu_included
1155 1158 content_for :header_tags do
1156 1159 javascript_include_tag('context_menu') +
1157 1160 stylesheet_link_tag('context_menu')
1158 1161 end
1159 1162 if l(:direction) == 'rtl'
1160 1163 content_for :header_tags do
1161 1164 stylesheet_link_tag('context_menu_rtl')
1162 1165 end
1163 1166 end
1164 1167 @context_menu_included = true
1165 1168 end
1166 1169 javascript_tag "contextMenuInit('#{ url_for(url) }')"
1167 1170 end
1168 1171
1169 1172 def calendar_for(field_id)
1170 1173 include_calendar_headers_tags
1171 1174 javascript_tag("$(function() { $('##{field_id}').datepicker(datepickerOptions); });")
1172 1175 end
1173 1176
1174 1177 def include_calendar_headers_tags
1175 1178 unless @calendar_headers_tags_included
1176 1179 tags = javascript_include_tag("datepicker")
1177 1180 @calendar_headers_tags_included = true
1178 1181 content_for :header_tags do
1179 1182 start_of_week = Setting.start_of_week
1180 1183 start_of_week = l(:general_first_day_of_week, :default => '1') if start_of_week.blank?
1181 1184 # Redmine uses 1..7 (monday..sunday) in settings and locales
1182 1185 # JQuery uses 0..6 (sunday..saturday), 7 needs to be changed to 0
1183 1186 start_of_week = start_of_week.to_i % 7
1184 1187 tags << javascript_tag(
1185 1188 "var datepickerOptions={dateFormat: 'yy-mm-dd', firstDay: #{start_of_week}, " +
1186 1189 "showOn: 'button', buttonImageOnly: true, buttonImage: '" +
1187 1190 path_to_image('/images/calendar.png') +
1188 1191 "', showButtonPanel: true, showWeek: true, showOtherMonths: true, " +
1189 1192 "selectOtherMonths: true, changeMonth: true, changeYear: true, " +
1190 1193 "beforeShow: beforeShowDatePicker};")
1191 1194 jquery_locale = l('jquery.locale', :default => current_language.to_s)
1192 1195 unless jquery_locale == 'en'
1193 1196 tags << javascript_include_tag("i18n/datepicker-#{jquery_locale}.js")
1194 1197 end
1195 1198 tags
1196 1199 end
1197 1200 end
1198 1201 end
1199 1202
1200 1203 # Overrides Rails' stylesheet_link_tag with themes and plugins support.
1201 1204 # Examples:
1202 1205 # stylesheet_link_tag('styles') # => picks styles.css from the current theme or defaults
1203 1206 # stylesheet_link_tag('styles', :plugin => 'foo) # => picks styles.css from plugin's assets
1204 1207 #
1205 1208 def stylesheet_link_tag(*sources)
1206 1209 options = sources.last.is_a?(Hash) ? sources.pop : {}
1207 1210 plugin = options.delete(:plugin)
1208 1211 sources = sources.map do |source|
1209 1212 if plugin
1210 1213 "/plugin_assets/#{plugin}/stylesheets/#{source}"
1211 1214 elsif current_theme && current_theme.stylesheets.include?(source)
1212 1215 current_theme.stylesheet_path(source)
1213 1216 else
1214 1217 source
1215 1218 end
1216 1219 end
1217 1220 super sources, options
1218 1221 end
1219 1222
1220 1223 # Overrides Rails' image_tag with themes and plugins support.
1221 1224 # Examples:
1222 1225 # image_tag('image.png') # => picks image.png from the current theme or defaults
1223 1226 # image_tag('image.png', :plugin => 'foo) # => picks image.png from plugin's assets
1224 1227 #
1225 1228 def image_tag(source, options={})
1226 1229 if plugin = options.delete(:plugin)
1227 1230 source = "/plugin_assets/#{plugin}/images/#{source}"
1228 1231 elsif current_theme && current_theme.images.include?(source)
1229 1232 source = current_theme.image_path(source)
1230 1233 end
1231 1234 super source, options
1232 1235 end
1233 1236
1234 1237 # Overrides Rails' javascript_include_tag with plugins support
1235 1238 # Examples:
1236 1239 # javascript_include_tag('scripts') # => picks scripts.js from defaults
1237 1240 # javascript_include_tag('scripts', :plugin => 'foo) # => picks scripts.js from plugin's assets
1238 1241 #
1239 1242 def javascript_include_tag(*sources)
1240 1243 options = sources.last.is_a?(Hash) ? sources.pop : {}
1241 1244 if plugin = options.delete(:plugin)
1242 1245 sources = sources.map do |source|
1243 1246 if plugin
1244 1247 "/plugin_assets/#{plugin}/javascripts/#{source}"
1245 1248 else
1246 1249 source
1247 1250 end
1248 1251 end
1249 1252 end
1250 1253 super sources, options
1251 1254 end
1252 1255
1253 1256 # TODO: remove this in 2.5.0
1254 1257 def has_content?(name)
1255 1258 content_for?(name)
1256 1259 end
1257 1260
1258 1261 def sidebar_content?
1259 1262 content_for?(:sidebar) || view_layouts_base_sidebar_hook_response.present?
1260 1263 end
1261 1264
1262 1265 def view_layouts_base_sidebar_hook_response
1263 1266 @view_layouts_base_sidebar_hook_response ||= call_hook(:view_layouts_base_sidebar)
1264 1267 end
1265 1268
1266 1269 def email_delivery_enabled?
1267 1270 !!ActionMailer::Base.perform_deliveries
1268 1271 end
1269 1272
1270 1273 # Returns the avatar image tag for the given +user+ if avatars are enabled
1271 1274 # +user+ can be a User or a string that will be scanned for an email address (eg. 'joe <joe@foo.bar>')
1272 1275 def avatar(user, options = { })
1273 1276 if Setting.gravatar_enabled?
1274 1277 options.merge!({:ssl => (request && request.ssl?), :default => Setting.gravatar_default})
1275 1278 email = nil
1276 1279 if user.respond_to?(:mail)
1277 1280 email = user.mail
1278 1281 elsif user.to_s =~ %r{<(.+?)>}
1279 1282 email = $1
1280 1283 end
1281 1284 return gravatar(email.to_s.downcase, options) unless email.blank? rescue nil
1282 1285 else
1283 1286 ''
1284 1287 end
1285 1288 end
1286 1289
1287 1290 def sanitize_anchor_name(anchor)
1288 1291 if ''.respond_to?(:encoding) || RUBY_PLATFORM == 'java'
1289 1292 anchor.gsub(%r{[^\s\-\p{Word}]}, '').gsub(%r{\s+(\-+\s*)?}, '-')
1290 1293 else
1291 1294 # TODO: remove when ruby1.8 is no longer supported
1292 1295 anchor.gsub(%r{[^\w\s\-]}, '').gsub(%r{\s+(\-+\s*)?}, '-')
1293 1296 end
1294 1297 end
1295 1298
1296 1299 # Returns the javascript tags that are included in the html layout head
1297 1300 def javascript_heads
1298 1301 tags = javascript_include_tag('jquery-1.11.1-ui-1.11.0-ujs-3.1.1', 'application')
1299 1302 unless User.current.pref.warn_on_leaving_unsaved == '0'
1300 1303 tags << "\n".html_safe + javascript_tag("$(window).load(function(){ warnLeavingUnsaved('#{escape_javascript l(:text_warn_on_leaving_unsaved)}'); });")
1301 1304 end
1302 1305 tags
1303 1306 end
1304 1307
1305 1308 def favicon
1306 1309 "<link rel='shortcut icon' href='#{favicon_path}' />".html_safe
1307 1310 end
1308 1311
1309 1312 # Returns the path to the favicon
1310 1313 def favicon_path
1311 1314 icon = (current_theme && current_theme.favicon?) ? current_theme.favicon_path : '/favicon.ico'
1312 1315 image_path(icon)
1313 1316 end
1314 1317
1315 1318 # Returns the full URL to the favicon
1316 1319 def favicon_url
1317 1320 # TODO: use #image_url introduced in Rails4
1318 1321 path = favicon_path
1319 1322 base = url_for(:controller => 'welcome', :action => 'index', :only_path => false)
1320 1323 base.sub(%r{/+$},'') + '/' + path.sub(%r{^/+},'')
1321 1324 end
1322 1325
1323 1326 def robot_exclusion_tag
1324 1327 '<meta name="robots" content="noindex,follow,noarchive" />'.html_safe
1325 1328 end
1326 1329
1327 1330 # Returns true if arg is expected in the API response
1328 1331 def include_in_api_response?(arg)
1329 1332 unless @included_in_api_response
1330 1333 param = params[:include]
1331 1334 @included_in_api_response = param.is_a?(Array) ? param.collect(&:to_s) : param.to_s.split(',')
1332 1335 @included_in_api_response.collect!(&:strip)
1333 1336 end
1334 1337 @included_in_api_response.include?(arg.to_s)
1335 1338 end
1336 1339
1337 1340 # Returns options or nil if nometa param or X-Redmine-Nometa header
1338 1341 # was set in the request
1339 1342 def api_meta(options)
1340 1343 if params[:nometa].present? || request.headers['X-Redmine-Nometa']
1341 1344 # compatibility mode for activeresource clients that raise
1342 1345 # an error when deserializing an array with attributes
1343 1346 nil
1344 1347 else
1345 1348 options
1346 1349 end
1347 1350 end
1348 1351
1349 1352 private
1350 1353
1351 1354 def wiki_helper
1352 1355 helper = Redmine::WikiFormatting.helper_for(Setting.text_formatting)
1353 1356 extend helper
1354 1357 return self
1355 1358 end
1356 1359
1357 1360 def link_to_content_update(text, url_params = {}, html_options = {})
1358 1361 link_to(text, url_params, html_options)
1359 1362 end
1360 1363 end
@@ -1,139 +1,141
1 1 # Redmine - project management software
2 2 # Copyright (C) 2006-2014 Jean-Philippe Lang
3 3 #
4 4 # This program is free software; you can redistribute it and/or
5 5 # modify it under the terms of the GNU General Public License
6 6 # as published by the Free Software Foundation; either version 2
7 7 # of the License, or (at your option) any later version.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU General Public License
15 15 # along with this program; if not, write to the Free Software
16 16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17 17
18 18 class TimeEntry < ActiveRecord::Base
19 19 include Redmine::SafeAttributes
20 20 # could have used polymorphic association
21 21 # project association here allows easy loading of time entries at project level with one database trip
22 22 belongs_to :project
23 23 belongs_to :issue
24 24 belongs_to :user
25 25 belongs_to :activity, :class_name => 'TimeEntryActivity', :foreign_key => 'activity_id'
26 26
27 attr_protected :project_id, :user_id, :tyear, :tmonth, :tweek
27 attr_protected :user_id, :tyear, :tmonth, :tweek
28 28
29 29 acts_as_customizable
30 30 acts_as_event :title => Proc.new {|o| "#{l_hours(o.hours)} (#{(o.issue || o.project).event_title})"},
31 31 :url => Proc.new {|o| {:controller => 'timelog', :action => 'index', :project_id => o.project, :issue_id => o.issue}},
32 32 :author => :user,
33 33 :group => :issue,
34 34 :description => :comments
35 35
36 36 acts_as_activity_provider :timestamp => "#{table_name}.created_on",
37 37 :author_key => :user_id,
38 38 :find_options => {:include => :project}
39 39
40 40 validates_presence_of :user_id, :activity_id, :project_id, :hours, :spent_on
41 41 validates_numericality_of :hours, :allow_nil => true, :message => :invalid
42 42 validates_length_of :comments, :maximum => 255, :allow_nil => true
43 43 validates :spent_on, :date => true
44 44 before_validation :set_project_if_nil
45 45 validate :validate_time_entry
46 46
47 47 scope :visible, lambda {|*args|
48 48 includes(:project).where(Project.allowed_to_condition(args.shift || User.current, :view_time_entries, *args))
49 49 }
50 50 scope :on_issue, lambda {|issue|
51 51 includes(:issue).where("#{Issue.table_name}.root_id = #{issue.root_id} AND #{Issue.table_name}.lft >= #{issue.lft} AND #{Issue.table_name}.rgt <= #{issue.rgt}")
52 52 }
53 53 scope :on_project, lambda {|project, include_subprojects|
54 54 includes(:project).where(project.project_condition(include_subprojects))
55 55 }
56 56 scope :spent_between, lambda {|from, to|
57 57 if from && to
58 58 where("#{TimeEntry.table_name}.spent_on BETWEEN ? AND ?", from, to)
59 59 elsif from
60 60 where("#{TimeEntry.table_name}.spent_on >= ?", from)
61 61 elsif to
62 62 where("#{TimeEntry.table_name}.spent_on <= ?", to)
63 63 else
64 64 where(nil)
65 65 end
66 66 }
67 67
68 safe_attributes 'hours', 'comments', 'issue_id', 'activity_id', 'spent_on', 'custom_field_values', 'custom_fields'
68 safe_attributes 'hours', 'comments', 'project_id', 'issue_id', 'activity_id', 'spent_on', 'custom_field_values', 'custom_fields'
69 69
70 70 def initialize(attributes=nil, *args)
71 71 super
72 72 if new_record? && self.activity.nil?
73 73 if default_activity = TimeEntryActivity.default
74 74 self.activity_id = default_activity.id
75 75 end
76 76 self.hours = nil if hours == 0
77 77 end
78 78 end
79 79
80 80 def safe_attributes=(attrs, user=User.current)
81 attrs = super
82 if !new_record? && issue && issue.project_id != project_id
83 if user.allowed_to?(:log_time, issue.project)
84 self.project_id = issue.project_id
81 if attrs
82 attrs = super(attrs)
83 if issue_id_changed? && attrs[:project_id].blank? && issue && issue.project_id != project_id
84 if user.allowed_to?(:log_time, issue.project)
85 self.project_id = issue.project_id
86 end
85 87 end
86 88 end
87 89 attrs
88 90 end
89 91
90 92 def set_project_if_nil
91 93 self.project = issue.project if issue && project.nil?
92 94 end
93 95
94 96 def validate_time_entry
95 97 errors.add :hours, :invalid if hours && (hours < 0 || hours >= 1000)
96 98 errors.add :project_id, :invalid if project.nil?
97 99 errors.add :issue_id, :invalid if (issue_id && !issue) || (issue && project!=issue.project)
98 100 end
99 101
100 102 def hours=(h)
101 103 write_attribute :hours, (h.is_a?(String) ? (h.to_hours || h) : h)
102 104 end
103 105
104 106 def hours
105 107 h = read_attribute(:hours)
106 108 if h.is_a?(Float)
107 109 h.round(2)
108 110 else
109 111 h
110 112 end
111 113 end
112 114
113 115 # tyear, tmonth, tweek assigned where setting spent_on attributes
114 116 # these attributes make time aggregations easier
115 117 def spent_on=(date)
116 118 super
117 119 if spent_on.is_a?(Time)
118 120 self.spent_on = spent_on.to_date
119 121 end
120 122 self.tyear = spent_on ? spent_on.year : nil
121 123 self.tmonth = spent_on ? spent_on.month : nil
122 124 self.tweek = spent_on ? Date.civil(spent_on.year, spent_on.month, spent_on.day).cweek : nil
123 125 end
124 126
125 127 # Returns true if the time entry can be edited by usr, otherwise false
126 128 def editable_by?(usr)
127 129 (usr == user && usr.allowed_to?(:edit_own_time_entries, project)) || usr.allowed_to?(:edit_time_entries, project)
128 130 end
129 131
130 132 # Returns the custom_field_values that can be edited by the given user
131 133 def editable_custom_field_values(user=nil)
132 134 visible_custom_field_values
133 135 end
134 136
135 137 # Returns the custom fields that can be edited by the given user
136 138 def editable_custom_fields(user=nil)
137 139 editable_custom_field_values(user).map(&:custom_field).uniq
138 140 end
139 141 end
@@ -1,158 +1,158
1 1 <%= render :partial => 'action_menu' %>
2 2
3 3 <h2><%= issue_heading(@issue) %></h2>
4 4
5 5 <div class="<%= @issue.css_classes %> details">
6 6 <% if @prev_issue_id || @next_issue_id %>
7 7 <div class="next-prev-links contextual">
8 8 <%= link_to_if @prev_issue_id,
9 9 "\xc2\xab #{l(:label_previous)}",
10 10 (@prev_issue_id ? issue_path(@prev_issue_id) : nil),
11 11 :title => "##{@prev_issue_id}" %> |
12 12 <% if @issue_position && @issue_count %>
13 13 <span class="position"><%= l(:label_item_position, :position => @issue_position, :count => @issue_count) %></span> |
14 14 <% end %>
15 15 <%= link_to_if @next_issue_id,
16 16 "#{l(:label_next)} \xc2\xbb",
17 17 (@next_issue_id ? issue_path(@next_issue_id) : nil),
18 18 :title => "##{@next_issue_id}" %>
19 19 </div>
20 20 <% end %>
21 21
22 22 <%= avatar(@issue.author, :size => "50") %>
23 23
24 24 <div class="subject">
25 25 <%= render_issue_subject_with_tree(@issue) %>
26 26 </div>
27 27 <p class="author">
28 28 <%= authoring @issue.created_on, @issue.author %>.
29 29 <% if @issue.created_on != @issue.updated_on %>
30 30 <%= l(:label_updated_time, time_tag(@issue.updated_on)).html_safe %>.
31 31 <% end %>
32 32 </p>
33 33
34 34 <table class="attributes">
35 35 <%= issue_fields_rows do |rows|
36 36 rows.left l(:field_status), h(@issue.status.name), :class => 'status'
37 37 rows.left l(:field_priority), h(@issue.priority.name), :class => 'priority'
38 38
39 39 unless @issue.disabled_core_fields.include?('assigned_to_id')
40 40 rows.left l(:field_assigned_to), avatar(@issue.assigned_to, :size => "14").to_s.html_safe + (@issue.assigned_to ? link_to_user(@issue.assigned_to) : "-"), :class => 'assigned-to'
41 41 end
42 42 unless @issue.disabled_core_fields.include?('category_id')
43 43 rows.left l(:field_category), h(@issue.category ? @issue.category.name : "-"), :class => 'category'
44 44 end
45 45 unless @issue.disabled_core_fields.include?('fixed_version_id')
46 46 rows.left l(:field_fixed_version), (@issue.fixed_version ? link_to_version(@issue.fixed_version) : "-"), :class => 'fixed-version'
47 47 end
48 48
49 49 unless @issue.disabled_core_fields.include?('start_date')
50 50 rows.right l(:field_start_date), format_date(@issue.start_date), :class => 'start-date'
51 51 end
52 52 unless @issue.disabled_core_fields.include?('due_date')
53 53 rows.right l(:field_due_date), format_date(@issue.due_date), :class => 'due-date'
54 54 end
55 55 unless @issue.disabled_core_fields.include?('done_ratio')
56 56 rows.right l(:field_done_ratio), progress_bar(@issue.done_ratio, :width => '80px', :legend => "#{@issue.done_ratio}%"), :class => 'progress'
57 57 end
58 58 unless @issue.disabled_core_fields.include?('estimated_hours')
59 59 unless @issue.estimated_hours.nil?
60 60 rows.right l(:field_estimated_hours), l_hours(@issue.estimated_hours), :class => 'estimated-hours'
61 61 end
62 62 end
63 63 if User.current.allowed_to?(:view_time_entries, @project)
64 rows.right l(:label_spent_time), (@issue.total_spent_hours > 0 ? link_to(l_hours(@issue.total_spent_hours), project_issue_time_entries_path(@project, @issue)) : "-"), :class => 'spent-time'
64 rows.right l(:label_spent_time), (@issue.total_spent_hours > 0 ? link_to(l_hours(@issue.total_spent_hours), issue_time_entries_path(@issue)) : "-"), :class => 'spent-time'
65 65 end
66 66 end %>
67 67 <%= render_custom_fields_rows(@issue) %>
68 68 <%= call_hook(:view_issues_show_details_bottom, :issue => @issue) %>
69 69 </table>
70 70
71 71 <% if @issue.description? || @issue.attachments.any? -%>
72 72 <hr />
73 73 <% if @issue.description? %>
74 74 <div class="description">
75 75 <div class="contextual">
76 76 <%= link_to l(:button_quote), quoted_issue_path(@issue), :remote => true, :method => 'post', :class => 'icon icon-comment' if authorize_for('issues', 'edit') %>
77 77 </div>
78 78
79 79 <p><strong><%=l(:field_description)%></strong></p>
80 80 <div class="wiki">
81 81 <%= textilizable @issue, :description, :attachments => @issue.attachments %>
82 82 </div>
83 83 </div>
84 84 <% end %>
85 85 <%= link_to_attachments @issue, :thumbnails => true %>
86 86 <% end -%>
87 87
88 88 <%= call_hook(:view_issues_show_description_bottom, :issue => @issue) %>
89 89
90 90 <% if !@issue.leaf? || User.current.allowed_to?(:manage_subtasks, @project) %>
91 91 <hr />
92 92 <div id="issue_tree">
93 93 <div class="contextual">
94 94 <%= link_to_new_subtask(@issue) if User.current.allowed_to?(:manage_subtasks, @project) %>
95 95 </div>
96 96 <p><strong><%=l(:label_subtask_plural)%></strong></p>
97 97 <%= render_descendants_tree(@issue) unless @issue.leaf? %>
98 98 </div>
99 99 <% end %>
100 100
101 101 <% if @relations.present? || User.current.allowed_to?(:manage_issue_relations, @project) %>
102 102 <hr />
103 103 <div id="relations">
104 104 <%= render :partial => 'relations' %>
105 105 </div>
106 106 <% end %>
107 107
108 108 </div>
109 109
110 110 <% if @changesets.present? %>
111 111 <div id="issue-changesets">
112 112 <h3><%=l(:label_associated_revisions)%></h3>
113 113 <%= render :partial => 'changesets', :locals => { :changesets => @changesets} %>
114 114 </div>
115 115 <% end %>
116 116
117 117 <% if @journals.present? %>
118 118 <div id="history">
119 119 <h3><%=l(:label_history)%></h3>
120 120 <%= render :partial => 'history', :locals => { :issue => @issue, :journals => @journals } %>
121 121 </div>
122 122 <% end %>
123 123
124 124
125 125 <div style="clear: both;"></div>
126 126 <%= render :partial => 'action_menu' %>
127 127
128 128 <div style="clear: both;"></div>
129 129 <% if @issue.editable? %>
130 130 <div id="update" style="display:none;">
131 131 <h3><%= l(:button_edit) %></h3>
132 132 <%= render :partial => 'edit' %>
133 133 </div>
134 134 <% end %>
135 135
136 136 <% other_formats_links do |f| %>
137 137 <%= f.link_to 'Atom', :url => {:key => User.current.rss_key} %>
138 138 <%= f.link_to 'PDF' %>
139 139 <% end %>
140 140
141 141 <% html_title "#{@issue.tracker.name} ##{@issue.id}: #{@issue.subject}" %>
142 142
143 143 <% content_for :sidebar do %>
144 144 <%= render :partial => 'issues/sidebar' %>
145 145
146 146 <% if User.current.allowed_to?(:add_issue_watchers, @project) ||
147 147 (@issue.watchers.present? && User.current.allowed_to?(:view_issue_watchers, @project)) %>
148 148 <div id="watchers">
149 149 <%= render :partial => 'watchers/watchers', :locals => {:watched => @issue} %>
150 150 </div>
151 151 <% end %>
152 152 <% end %>
153 153
154 154 <% content_for :header_tags do %>
155 155 <%= auto_discovery_link_tag(:atom, {:format => 'atom', :key => User.current.rss_key}, :title => "#{@issue.project} - #{@issue.tracker} ##{@issue.id}: #{@issue.subject}") %>
156 156 <% end %>
157 157
158 158 <%= context_menu issues_context_menu_path %>
@@ -1,32 +1,34
1 1 <%= error_messages_for 'time_entry' %>
2 2 <%= back_url_hidden_field_tag %>
3 3
4 4 <div class="box tabular">
5 5 <% if @time_entry.new_record? %>
6 <% if params[:project_id] || @time_entry.issue %>
7 <%= f.hidden_field :project_id %>
6 <% if params[:project_id] %>
7 <%= hidden_field_tag 'project_id', params[:project_id] %>
8 <% elsif params[:issue_id] %>
9 <%= hidden_field_tag 'issue_id', params[:issue_id] %>
8 10 <% else %>
9 <p><%= f.select :project_id, project_tree_options_for_select(Project.allowed_to(:log_time).all, :selected => @time_entry.project), :required => true %></p>
11 <p><%= f.select :project_id, project_tree_options_for_select(Project.allowed_to(:log_time).all, :selected => @time_entry.project, :include_blank => true) %></p>
10 12 <% end %>
11 13 <% end %>
12 14 <p>
13 15 <%= f.text_field :issue_id, :size => 6 %>
14 16 <span id="time_entry_issue"><%= h("#{@time_entry.issue.tracker.name} ##{@time_entry.issue.id}: #{@time_entry.issue.subject}") if @time_entry.issue %></span>
15 17 </p>
16 18 <p><%= f.text_field :spent_on, :size => 10, :required => true %><%= calendar_for('time_entry_spent_on') %></p>
17 19 <p><%= f.text_field :hours, :size => 6, :required => true %></p>
18 20 <p><%= f.text_field :comments, :size => 100, :maxlength => 255 %></p>
19 21 <p><%= f.select :activity_id, activity_collection_for_select_options(@time_entry), :required => true %></p>
20 22 <% @time_entry.custom_field_values.each do |value| %>
21 23 <p><%= custom_field_tag_with_label :time_entry, value %></p>
22 24 <% end %>
23 25 <%= call_hook(:view_timelog_edit_form_bottom, { :time_entry => @time_entry, :form => f }) %>
24 26 </div>
25 27
26 28 <%= javascript_tag do %>
27 29 observeAutocompleteField('time_entry_issue_id', '<%= escape_javascript auto_complete_issues_path(:project_id => @project, :scope => (@project ? nil : 'all'))%>', {
28 30 select: function(event, ui) {
29 31 $('#time_entry_issue').text(ui.item.label);
30 32 }
31 33 });
32 34 <% end %>
@@ -1,8 +1,7
1 1 <h2><%= l(:label_spent_time) %></h2>
2 2
3 3 <%= labelled_form_for @time_entry, :url => time_entries_path do |f| %>
4 <%= hidden_field_tag 'project_id', params[:project_id] if params[:project_id] %>
5 4 <%= render :partial => 'form', :locals => {:f => f} %>
6 5 <%= submit_tag l(:button_create) %>
7 6 <%= submit_tag l(:button_create_and_continue), :name => 'continue' %>
8 7 <% end %>
@@ -1,685 +1,717
1 1 # -*- coding: utf-8 -*-
2 2 # Redmine - project management software
3 3 # Copyright (C) 2006-2014 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 def test_new_with_project_id
31 def test_new
32 32 @request.session[:user_id] = 3
33 get :new, :project_id => 1
33 get :new
34 34 assert_response :success
35 35 assert_template 'new'
36 assert_select 'select[name=?]', 'time_entry[project_id]', 0
37 assert_select 'input[name=?][value=1][type=hidden]', 'time_entry[project_id]'
36 assert_select 'input[name=?][type=hidden]', 'project_id', 0
37 assert_select 'input[name=?][type=hidden]', 'issue_id', 0
38 assert_select 'select[name=?]', 'time_entry[project_id]' do
39 # blank option for project
40 assert_select 'option[value=]'
41 end
38 42 end
39 43
40 def test_new_with_issue_id
44 def test_new_with_project_id
41 45 @request.session[:user_id] = 3
42 get :new, :issue_id => 2
46 get :new, :project_id => 1
43 47 assert_response :success
44 48 assert_template 'new'
49 assert_select 'input[name=?][type=hidden]', 'project_id'
50 assert_select 'input[name=?][type=hidden]', 'issue_id', 0
45 51 assert_select 'select[name=?]', 'time_entry[project_id]', 0
46 assert_select 'input[name=?][value=1][type=hidden]', 'time_entry[project_id]'
47 52 end
48 53
49 def test_new_without_project
54 def test_new_with_issue_id
50 55 @request.session[:user_id] = 3
51 get :new
56 get :new, :issue_id => 2
52 57 assert_response :success
53 58 assert_template 'new'
54 assert_select 'select[name=?]', 'time_entry[project_id]'
55 assert_select 'input[name=?]', 'time_entry[project_id]', 0
59 assert_select 'input[name=?][type=hidden]', 'project_id', 0
60 assert_select 'input[name=?][type=hidden]', 'issue_id'
61 assert_select 'select[name=?]', 'time_entry[project_id]', 0
56 62 end
57 63
58 64 def test_new_without_project_should_prefill_the_form
59 65 @request.session[:user_id] = 3
60 66 get :new, :time_entry => {:project_id => '1'}
61 67 assert_response :success
62 68 assert_template 'new'
63 69 assert_select 'select[name=?]', 'time_entry[project_id]' do
64 70 assert_select 'option[value=1][selected=selected]'
65 71 end
66 assert_select 'input[name=?]', 'time_entry[project_id]', 0
67 72 end
68 73
69 74 def test_new_without_project_should_deny_without_permission
70 75 Role.all.each {|role| role.remove_permission! :log_time}
71 76 @request.session[:user_id] = 3
72 77
73 78 get :new
74 79 assert_response 403
75 80 end
76 81
77 82 def test_new_should_select_default_activity
78 83 @request.session[:user_id] = 3
79 84 get :new, :project_id => 1
80 85 assert_response :success
81 86 assert_select 'select[name=?]', 'time_entry[activity_id]' do
82 87 assert_select 'option[selected=selected]', :text => 'Development'
83 88 end
84 89 end
85 90
86 91 def test_new_should_only_show_active_time_entry_activities
87 92 @request.session[:user_id] = 3
88 93 get :new, :project_id => 1
89 94 assert_response :success
90 95 assert_no_tag 'option', :content => 'Inactive Activity'
91 96 end
92 97
93 98 def test_get_edit_existing_time
94 99 @request.session[:user_id] = 2
95 100 get :edit, :id => 2, :project_id => nil
96 101 assert_response :success
97 102 assert_template 'edit'
98 103 # Default activity selected
99 104 assert_tag :tag => 'form', :attributes => { :action => '/projects/ecookbook/time_entries/2' }
100 105 end
101 106
102 107 def test_get_edit_with_an_existing_time_entry_with_inactive_activity
103 108 te = TimeEntry.find(1)
104 109 te.activity = TimeEntryActivity.find_by_name("Inactive Activity")
105 110 te.save!
106 111
107 112 @request.session[:user_id] = 1
108 113 get :edit, :project_id => 1, :id => 1
109 114 assert_response :success
110 115 assert_template 'edit'
111 116 # Blank option since nothing is pre-selected
112 117 assert_tag :tag => 'option', :content => '--- Please select ---'
113 118 end
114 119
115 120 def test_post_create
116 # TODO: should POST to issues’ time log instead of project. change form
117 # and routing
118 121 @request.session[:user_id] = 3
119 post :create, :project_id => 1,
122 assert_difference 'TimeEntry.count' do
123 post :create, :project_id => 1,
120 124 :time_entry => {:comments => 'Some work on TimelogControllerTest',
121 125 # Not the default activity
122 126 :activity_id => '11',
123 127 :spent_on => '2008-03-14',
124 128 :issue_id => '1',
125 129 :hours => '7.3'}
126 assert_redirected_to :action => 'index', :project_id => 'ecookbook'
130 assert_redirected_to '/projects/ecookbook/time_entries'
131 end
127 132
128 i = Issue.find(1)
129 t = TimeEntry.find_by_comments('Some work on TimelogControllerTest')
133 t = TimeEntry.order('id DESC').first
130 134 assert_not_nil t
135 assert_equal 'Some work on TimelogControllerTest', t.comments
136 assert_equal 1, t.project_id
137 assert_equal 1, t.issue_id
131 138 assert_equal 11, t.activity_id
132 139 assert_equal 7.3, t.hours
133 140 assert_equal 3, t.user_id
134 assert_equal i, t.issue
135 assert_equal i.project, t.project
136 141 end
137 142
138 143 def test_post_create_with_blank_issue
139 # TODO: should POST to issues’ time log instead of project. change form
140 # and routing
141 144 @request.session[:user_id] = 3
142 post :create, :project_id => 1,
145 assert_difference 'TimeEntry.count' do
146 post :create, :project_id => 1,
143 147 :time_entry => {:comments => 'Some work on TimelogControllerTest',
144 148 # Not the default activity
145 149 :activity_id => '11',
146 150 :issue_id => '',
147 151 :spent_on => '2008-03-14',
148 152 :hours => '7.3'}
149 assert_redirected_to :action => 'index', :project_id => 'ecookbook'
153 assert_redirected_to '/projects/ecookbook/time_entries'
154 end
150 155
151 t = TimeEntry.find_by_comments('Some work on TimelogControllerTest')
156 t = TimeEntry.order('id DESC').first
152 157 assert_not_nil t
158 assert_equal 'Some work on TimelogControllerTest', t.comments
159 assert_equal 1, t.project_id
160 assert_nil t.issue_id
153 161 assert_equal 11, t.activity_id
154 162 assert_equal 7.3, t.hours
155 163 assert_equal 3, t.user_id
156 164 end
157 165
158 def test_create_and_continue
166 def test_create_and_continue_at_project_level
159 167 @request.session[:user_id] = 2
160 post :create, :project_id => 1,
161 :time_entry => {:activity_id => '11',
162 :issue_id => '',
163 :spent_on => '2008-03-14',
164 :hours => '7.3'},
165 :continue => '1'
166 assert_redirected_to '/projects/ecookbook/time_entries/new?time_entry%5Bactivity_id%5D=11&time_entry%5Bissue_id%5D='
168 assert_difference 'TimeEntry.count' do
169 post :create, :time_entry => {:project_id => '1',
170 :activity_id => '11',
171 :issue_id => '',
172 :spent_on => '2008-03-14',
173 :hours => '7.3'},
174 :continue => '1'
175 assert_redirected_to '/time_entries/new?time_entry%5Bactivity_id%5D=11&time_entry%5Bissue_id%5D=&time_entry%5Bproject_id%5D=1'
176 end
167 177 end
168 178
169 def test_create_and_continue_with_issue_id
179 def test_create_and_continue_at_issue_level
170 180 @request.session[:user_id] = 2
171 post :create, :project_id => 1,
172 :time_entry => {:activity_id => '11',
173 :issue_id => '1',
174 :spent_on => '2008-03-14',
175 :hours => '7.3'},
176 :continue => '1'
177 assert_redirected_to '/projects/ecookbook/issues/1/time_entries/new?time_entry%5Bactivity_id%5D=11&time_entry%5Bissue_id%5D=1'
181 assert_difference 'TimeEntry.count' do
182 post :create, :time_entry => {:project_id => '',
183 :activity_id => '11',
184 :issue_id => '1',
185 :spent_on => '2008-03-14',
186 :hours => '7.3'},
187 :continue => '1'
188 assert_redirected_to '/time_entries/new?time_entry%5Bactivity_id%5D=11&time_entry%5Bissue_id%5D=1&time_entry%5Bproject_id%5D='
189 end
178 190 end
179 191
180 def test_create_and_continue_without_project
192 def test_create_and_continue_with_project_id
181 193 @request.session[:user_id] = 2
182 post :create, :time_entry => {:project_id => '1',
183 :activity_id => '11',
184 :issue_id => '',
185 :spent_on => '2008-03-14',
186 :hours => '7.3'},
187 :continue => '1'
194 assert_difference 'TimeEntry.count' do
195 post :create, :project_id => 1,
196 :time_entry => {:activity_id => '11',
197 :issue_id => '',
198 :spent_on => '2008-03-14',
199 :hours => '7.3'},
200 :continue => '1'
201 assert_redirected_to '/projects/ecookbook/time_entries/new?time_entry%5Bactivity_id%5D=11&time_entry%5Bissue_id%5D=&time_entry%5Bproject_id%5D='
202 end
203 end
188 204
189 assert_redirected_to '/time_entries/new?time_entry%5Bactivity_id%5D=11&time_entry%5Bissue_id%5D=&time_entry%5Bproject_id%5D=1'
205 def test_create_and_continue_with_issue_id
206 @request.session[:user_id] = 2
207 assert_difference 'TimeEntry.count' do
208 post :create, :issue_id => 1,
209 :time_entry => {:activity_id => '11',
210 :issue_id => '1',
211 :spent_on => '2008-03-14',
212 :hours => '7.3'},
213 :continue => '1'
214 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='
215 end
190 216 end
191 217
192 218 def test_create_without_log_time_permission_should_be_denied
193 219 @request.session[:user_id] = 2
194 220 Role.find_by_name('Manager').remove_permission! :log_time
195 221 post :create, :project_id => 1,
196 222 :time_entry => {:activity_id => '11',
197 223 :issue_id => '',
198 224 :spent_on => '2008-03-14',
199 225 :hours => '7.3'}
200 226
201 227 assert_response 403
202 228 end
203 229
230 def test_create_without_project_and_issue_should_fail
231 @request.session[:user_id] = 2
232 post :create, :time_entry => {:issue_id => ''}
233
234 assert_response :success
235 assert_template 'new'
236 end
237
204 238 def test_create_with_failure
205 239 @request.session[:user_id] = 2
206 240 post :create, :project_id => 1,
207 241 :time_entry => {:activity_id => '',
208 242 :issue_id => '',
209 243 :spent_on => '2008-03-14',
210 244 :hours => '7.3'}
211 245
212 246 assert_response :success
213 247 assert_template 'new'
214 248 end
215 249
216 250 def test_create_without_project
217 251 @request.session[:user_id] = 2
218 252 assert_difference 'TimeEntry.count' do
219 253 post :create, :time_entry => {:project_id => '1',
220 254 :activity_id => '11',
221 255 :issue_id => '',
222 256 :spent_on => '2008-03-14',
223 257 :hours => '7.3'}
224 258 end
225 259
226 260 assert_redirected_to '/projects/ecookbook/time_entries'
227 261 time_entry = TimeEntry.order('id DESC').first
228 262 assert_equal 1, time_entry.project_id
229 263 end
230 264
231 265 def test_create_without_project_should_fail_with_issue_not_inside_project
232 266 @request.session[:user_id] = 2
233 267 assert_no_difference 'TimeEntry.count' do
234 268 post :create, :time_entry => {:project_id => '1',
235 269 :activity_id => '11',
236 270 :issue_id => '5',
237 271 :spent_on => '2008-03-14',
238 272 :hours => '7.3'}
239 273 end
240 274
241 275 assert_response :success
242 276 assert assigns(:time_entry).errors[:issue_id].present?
243 277 end
244 278
245 279 def test_create_without_project_should_deny_without_permission
246 280 @request.session[:user_id] = 2
247 281 Project.find(3).disable_module!(:time_tracking)
248 282
249 283 assert_no_difference 'TimeEntry.count' do
250 284 post :create, :time_entry => {:project_id => '3',
251 285 :activity_id => '11',
252 286 :issue_id => '',
253 287 :spent_on => '2008-03-14',
254 288 :hours => '7.3'}
255 289 end
256 290
257 291 assert_response 403
258 292 end
259 293
260 294 def test_create_without_project_with_failure
261 295 @request.session[:user_id] = 2
262 296 assert_no_difference 'TimeEntry.count' do
263 297 post :create, :time_entry => {:project_id => '1',
264 298 :activity_id => '11',
265 299 :issue_id => '',
266 300 :spent_on => '2008-03-14',
267 301 :hours => ''}
268 302 end
269 303
270 304 assert_response :success
271 305 assert_tag 'select', :attributes => {:name => 'time_entry[project_id]'},
272 306 :child => {:tag => 'option', :attributes => {:value => '1', :selected => 'selected'}}
273 307 end
274 308
275 309 def test_update
276 310 entry = TimeEntry.find(1)
277 311 assert_equal 1, entry.issue_id
278 312 assert_equal 2, entry.user_id
279 313
280 314 @request.session[:user_id] = 1
281 315 put :update, :id => 1,
282 316 :time_entry => {:issue_id => '2',
283 317 :hours => '8'}
284 318 assert_redirected_to :action => 'index', :project_id => 'ecookbook'
285 319 entry.reload
286 320
287 321 assert_equal 8, entry.hours
288 322 assert_equal 2, entry.issue_id
289 323 assert_equal 2, entry.user_id
290 324 end
291 325
292 326 def test_update_should_allow_to_change_issue_to_another_project
293 327 entry = TimeEntry.generate!(:issue_id => 1)
294 328
295 329 @request.session[:user_id] = 1
296 330 put :update, :id => entry.id, :time_entry => {:issue_id => '5'}
297 331 assert_response 302
298 332 entry.reload
299 333
300 334 assert_equal 5, entry.issue_id
301 335 assert_equal 3, entry.project_id
302 336 end
303 337
304 338 def test_update_should_not_allow_to_change_issue_to_an_invalid_project
305 339 entry = TimeEntry.generate!(:issue_id => 1)
306 340 Project.find(3).disable_module!(:time_tracking)
307 341
308 342 @request.session[:user_id] = 1
309 343 put :update, :id => entry.id, :time_entry => {:issue_id => '5'}
310 344 assert_response 200
311 345 assert_include "Issue is invalid", assigns(:time_entry).errors.full_messages
312 346 end
313 347
314 348 def test_get_bulk_edit
315 349 @request.session[:user_id] = 2
316 350 get :bulk_edit, :ids => [1, 2]
317 351 assert_response :success
318 352 assert_template 'bulk_edit'
319 353
320 354 assert_select 'ul#bulk-selection' do
321 355 assert_select 'li', 2
322 356 assert_select 'li a', :text => '03/23/2007 - eCookbook: 4.25 hours'
323 357 end
324 358
325 359 assert_select 'form#bulk_edit_form[action=?]', '/time_entries/bulk_update' do
326 360 # System wide custom field
327 361 assert_select 'select[name=?]', 'time_entry[custom_field_values][10]'
328 362
329 363 # Activities
330 364 assert_select 'select[name=?]', 'time_entry[activity_id]' do
331 365 assert_select 'option[value=]', :text => '(No change)'
332 366 assert_select 'option[value=9]', :text => 'Design'
333 367 end
334 368 end
335 369 end
336 370
337 371 def test_get_bulk_edit_on_different_projects
338 372 @request.session[:user_id] = 2
339 373 get :bulk_edit, :ids => [1, 2, 6]
340 374 assert_response :success
341 375 assert_template 'bulk_edit'
342 376 end
343 377
344 378 def test_bulk_update
345 379 @request.session[:user_id] = 2
346 380 # update time entry activity
347 381 post :bulk_update, :ids => [1, 2], :time_entry => { :activity_id => 9}
348 382
349 383 assert_response 302
350 384 # check that the issues were updated
351 385 assert_equal [9, 9], TimeEntry.where(:id => [1, 2]).collect {|i| i.activity_id}
352 386 end
353 387
354 388 def test_bulk_update_with_failure
355 389 @request.session[:user_id] = 2
356 390 post :bulk_update, :ids => [1, 2], :time_entry => { :hours => 'A'}
357 391
358 392 assert_response 302
359 393 assert_match /Failed to save 2 time entrie/, flash[:error]
360 394 end
361 395
362 396 def test_bulk_update_on_different_projects
363 397 @request.session[:user_id] = 2
364 398 # makes user a manager on the other project
365 399 Member.create!(:user_id => 2, :project_id => 3, :role_ids => [1])
366 400
367 401 # update time entry activity
368 402 post :bulk_update, :ids => [1, 2, 4], :time_entry => { :activity_id => 9 }
369 403
370 404 assert_response 302
371 405 # check that the issues were updated
372 406 assert_equal [9, 9, 9], TimeEntry.where(:id => [1, 2, 4]).collect {|i| i.activity_id}
373 407 end
374 408
375 409 def test_bulk_update_on_different_projects_without_rights
376 410 @request.session[:user_id] = 3
377 411 user = User.find(3)
378 412 action = { :controller => "timelog", :action => "bulk_update" }
379 413 assert user.allowed_to?(action, TimeEntry.find(1).project)
380 414 assert ! user.allowed_to?(action, TimeEntry.find(5).project)
381 415 post :bulk_update, :ids => [1, 5], :time_entry => { :activity_id => 9 }
382 416 assert_response 403
383 417 end
384 418
385 419 def test_bulk_update_custom_field
386 420 @request.session[:user_id] = 2
387 421 post :bulk_update, :ids => [1, 2], :time_entry => { :custom_field_values => {'10' => '0'} }
388 422
389 423 assert_response 302
390 424 assert_equal ["0", "0"], TimeEntry.where(:id => [1, 2]).collect {|i| i.custom_value_for(10).value}
391 425 end
392 426
393 427 def test_post_bulk_update_should_redirect_back_using_the_back_url_parameter
394 428 @request.session[:user_id] = 2
395 429 post :bulk_update, :ids => [1,2], :back_url => '/time_entries'
396 430
397 431 assert_response :redirect
398 432 assert_redirected_to '/time_entries'
399 433 end
400 434
401 435 def test_post_bulk_update_should_not_redirect_back_using_the_back_url_parameter_off_the_host
402 436 @request.session[:user_id] = 2
403 437 post :bulk_update, :ids => [1,2], :back_url => 'http://google.com'
404 438
405 439 assert_response :redirect
406 440 assert_redirected_to :controller => 'timelog', :action => 'index', :project_id => Project.find(1).identifier
407 441 end
408 442
409 443 def test_post_bulk_update_without_edit_permission_should_be_denied
410 444 @request.session[:user_id] = 2
411 445 Role.find_by_name('Manager').remove_permission! :edit_time_entries
412 446 post :bulk_update, :ids => [1,2]
413 447
414 448 assert_response 403
415 449 end
416 450
417 451 def test_destroy
418 452 @request.session[:user_id] = 2
419 453 delete :destroy, :id => 1
420 454 assert_redirected_to :action => 'index', :project_id => 'ecookbook'
421 455 assert_equal I18n.t(:notice_successful_delete), flash[:notice]
422 456 assert_nil TimeEntry.find_by_id(1)
423 457 end
424 458
425 459 def test_destroy_should_fail
426 460 # simulate that this fails (e.g. due to a plugin), see #5700
427 461 TimeEntry.any_instance.expects(:destroy).returns(false)
428 462
429 463 @request.session[:user_id] = 2
430 464 delete :destroy, :id => 1
431 465 assert_redirected_to :action => 'index', :project_id => 'ecookbook'
432 466 assert_equal I18n.t(:notice_unable_delete_time_entry), flash[:error]
433 467 assert_not_nil TimeEntry.find_by_id(1)
434 468 end
435 469
436 470 def test_index_all_projects
437 471 get :index
438 472 assert_response :success
439 473 assert_template 'index'
440 474 assert_not_nil assigns(:total_hours)
441 475 assert_equal "162.90", "%.2f" % assigns(:total_hours)
442 476 assert_tag :form,
443 477 :attributes => {:action => "/time_entries", :id => 'query_form'}
444 478 end
445 479
446 480 def test_index_all_projects_should_show_log_time_link
447 481 @request.session[:user_id] = 2
448 482 get :index
449 483 assert_response :success
450 484 assert_template 'index'
451 485 assert_tag 'a', :attributes => {:href => '/time_entries/new'}, :content => /Log time/
452 486 end
453 487
454 488 def test_index_my_spent_time
455 489 @request.session[:user_id] = 2
456 490 get :index, :user_id => 'me'
457 491 assert_response :success
458 492 assert_template 'index'
459 493 assert assigns(:entries).all? {|entry| entry.user_id == 2}
460 494 end
461 495
462 496 def test_index_at_project_level
463 497 get :index, :project_id => 'ecookbook'
464 498 assert_response :success
465 499 assert_template 'index'
466 500 assert_not_nil assigns(:entries)
467 501 assert_equal 4, assigns(:entries).size
468 502 # project and subproject
469 503 assert_equal [1, 3], assigns(:entries).collect(&:project_id).uniq.sort
470 504 assert_not_nil assigns(:total_hours)
471 505 assert_equal "162.90", "%.2f" % assigns(:total_hours)
472 506 assert_tag :form,
473 507 :attributes => {:action => "/projects/ecookbook/time_entries", :id => 'query_form'}
474 508 end
475 509
476 510 def test_index_with_display_subprojects_issues_to_false_should_not_include_subproject_entries
477 511 entry = TimeEntry.generate!(:project => Project.find(3))
478 512
479 513 with_settings :display_subprojects_issues => '0' do
480 514 get :index, :project_id => 'ecookbook'
481 515 assert_response :success
482 516 assert_template 'index'
483 517 assert_not_include entry, assigns(:entries)
484 518 end
485 519 end
486 520
487 521 def test_index_with_display_subprojects_issues_to_false_and_subproject_filter_should_include_subproject_entries
488 522 entry = TimeEntry.generate!(:project => Project.find(3))
489 523
490 524 with_settings :display_subprojects_issues => '0' do
491 525 get :index, :project_id => 'ecookbook', :subproject_id => 3
492 526 assert_response :success
493 527 assert_template 'index'
494 528 assert_include entry, assigns(:entries)
495 529 end
496 530 end
497 531
498 532 def test_index_at_project_level_with_date_range
499 533 get :index, :project_id => 'ecookbook',
500 534 :f => ['spent_on'],
501 535 :op => {'spent_on' => '><'},
502 536 :v => {'spent_on' => ['2007-03-20', '2007-04-30']}
503 537 assert_response :success
504 538 assert_template 'index'
505 539 assert_not_nil assigns(:entries)
506 540 assert_equal 3, assigns(:entries).size
507 541 assert_not_nil assigns(:total_hours)
508 542 assert_equal "12.90", "%.2f" % assigns(:total_hours)
509 543 assert_tag :form,
510 544 :attributes => {:action => "/projects/ecookbook/time_entries", :id => 'query_form'}
511 545 end
512 546
513 547 def test_index_at_project_level_with_date_range_using_from_and_to_params
514 548 get :index, :project_id => 'ecookbook', :from => '2007-03-20', :to => '2007-04-30'
515 549 assert_response :success
516 550 assert_template 'index'
517 551 assert_not_nil assigns(:entries)
518 552 assert_equal 3, assigns(:entries).size
519 553 assert_not_nil assigns(:total_hours)
520 554 assert_equal "12.90", "%.2f" % assigns(:total_hours)
521 555 assert_tag :form,
522 556 :attributes => {:action => "/projects/ecookbook/time_entries", :id => 'query_form'}
523 557 end
524 558
525 559 def test_index_at_project_level_with_period
526 560 get :index, :project_id => 'ecookbook',
527 561 :f => ['spent_on'],
528 562 :op => {'spent_on' => '>t-'},
529 563 :v => {'spent_on' => ['7']}
530 564 assert_response :success
531 565 assert_template 'index'
532 566 assert_not_nil assigns(:entries)
533 567 assert_not_nil assigns(:total_hours)
534 568 assert_tag :form,
535 569 :attributes => {:action => "/projects/ecookbook/time_entries", :id => 'query_form'}
536 570 end
537 571
538 572 def test_index_at_issue_level
539 573 get :index, :issue_id => 1
540 574 assert_response :success
541 575 assert_template 'index'
542 576 assert_not_nil assigns(:entries)
543 577 assert_equal 2, assigns(:entries).size
544 578 assert_not_nil assigns(:total_hours)
545 579 assert_equal 154.25, assigns(:total_hours)
546 580 # display all time
547 581 assert_nil assigns(:from)
548 582 assert_nil assigns(:to)
549 # TODO: remove /projects/:project_id/issues/:issue_id/time_entries routes
550 # to use /issues/:issue_id/time_entries
551 583 assert_tag :form,
552 584 :attributes => {:action => "/projects/ecookbook/issues/1/time_entries", :id => 'query_form'}
553 585 end
554 586
555 587 def test_index_should_sort_by_spent_on_and_created_on
556 588 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)
557 589 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)
558 590 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)
559 591
560 592 get :index, :project_id => 1,
561 593 :f => ['spent_on'],
562 594 :op => {'spent_on' => '><'},
563 595 :v => {'spent_on' => ['2012-06-15', '2012-06-16']}
564 596 assert_response :success
565 597 assert_equal [t2, t1, t3], assigns(:entries)
566 598
567 599 get :index, :project_id => 1,
568 600 :f => ['spent_on'],
569 601 :op => {'spent_on' => '><'},
570 602 :v => {'spent_on' => ['2012-06-15', '2012-06-16']},
571 603 :sort => 'spent_on'
572 604 assert_response :success
573 605 assert_equal [t3, t1, t2], assigns(:entries)
574 606 end
575 607
576 608 def test_index_with_filter_on_issue_custom_field
577 609 issue = Issue.generate!(:project_id => 1, :tracker_id => 1, :custom_field_values => {2 => 'filter_on_issue_custom_field'})
578 610 entry = TimeEntry.generate!(:issue => issue, :hours => 2.5)
579 611
580 612 get :index, :f => ['issue.cf_2'], :op => {'issue.cf_2' => '='}, :v => {'issue.cf_2' => ['filter_on_issue_custom_field']}
581 613 assert_response :success
582 614 assert_equal [entry], assigns(:entries)
583 615 end
584 616
585 617 def test_index_with_issue_custom_field_column
586 618 issue = Issue.generate!(:project_id => 1, :tracker_id => 1, :custom_field_values => {2 => 'filter_on_issue_custom_field'})
587 619 entry = TimeEntry.generate!(:issue => issue, :hours => 2.5)
588 620
589 621 get :index, :c => %w(project spent_on issue comments hours issue.cf_2)
590 622 assert_response :success
591 623 assert_include :'issue.cf_2', assigns(:query).column_names
592 624 assert_select 'td.issue_cf_2', :text => 'filter_on_issue_custom_field'
593 625 end
594 626
595 627 def test_index_with_time_entry_custom_field_column
596 628 field = TimeEntryCustomField.generate!(:field_format => 'string')
597 629 entry = TimeEntry.generate!(:hours => 2.5, :custom_field_values => {field.id => 'CF Value'})
598 630 field_name = "cf_#{field.id}"
599 631
600 632 get :index, :c => ["hours", field_name]
601 633 assert_response :success
602 634 assert_include field_name.to_sym, assigns(:query).column_names
603 635 assert_select "td.#{field_name}", :text => 'CF Value'
604 636 end
605 637
606 638 def test_index_with_time_entry_custom_field_sorting
607 639 field = TimeEntryCustomField.generate!(:field_format => 'string', :name => 'String Field')
608 640 TimeEntry.generate!(:hours => 2.5, :custom_field_values => {field.id => 'CF Value 1'})
609 641 TimeEntry.generate!(:hours => 2.5, :custom_field_values => {field.id => 'CF Value 3'})
610 642 TimeEntry.generate!(:hours => 2.5, :custom_field_values => {field.id => 'CF Value 2'})
611 643 field_name = "cf_#{field.id}"
612 644
613 645 get :index, :c => ["hours", field_name], :sort => field_name
614 646 assert_response :success
615 647 assert_include field_name.to_sym, assigns(:query).column_names
616 648 assert_select "th a.sort", :text => 'String Field'
617 649
618 650 # Make sure that values are properly sorted
619 651 values = assigns(:entries).map {|e| e.custom_field_value(field)}.compact
620 652 assert_equal 3, values.size
621 653 assert_equal values.sort, values
622 654 end
623 655
624 656 def test_index_atom_feed
625 657 get :index, :project_id => 1, :format => 'atom'
626 658 assert_response :success
627 659 assert_equal 'application/atom+xml', @response.content_type
628 660 assert_not_nil assigns(:items)
629 661 assert assigns(:items).first.is_a?(TimeEntry)
630 662 end
631 663
632 664 def test_index_at_project_level_should_include_csv_export_dialog
633 665 get :index, :project_id => 'ecookbook',
634 666 :f => ['spent_on'],
635 667 :op => {'spent_on' => '>='},
636 668 :v => {'spent_on' => ['2007-04-01']},
637 669 :c => ['spent_on', 'user']
638 670 assert_response :success
639 671
640 672 assert_select '#csv-export-options' do
641 673 assert_select 'form[action=?][method=get]', '/projects/ecookbook/time_entries.csv' do
642 674 # filter
643 675 assert_select 'input[name=?][value=?]', 'f[]', 'spent_on'
644 676 assert_select 'input[name=?][value=?]', 'op[spent_on]', '&gt;='
645 677 assert_select 'input[name=?][value=?]', 'v[spent_on][]', '2007-04-01'
646 678 # columns
647 679 assert_select 'input[name=?][value=?]', 'c[]', 'spent_on'
648 680 assert_select 'input[name=?][value=?]', 'c[]', 'user'
649 681 assert_select 'input[name=?]', 'c[]', 2
650 682 end
651 683 end
652 684 end
653 685
654 686 def test_index_cross_project_should_include_csv_export_dialog
655 687 get :index
656 688 assert_response :success
657 689
658 690 assert_select '#csv-export-options' do
659 691 assert_select 'form[action=?][method=get]', '/time_entries.csv'
660 692 end
661 693 end
662 694
663 695 def test_index_at_issue_level_should_include_csv_export_dialog
664 696 get :index, :project_id => 'ecookbook', :issue_id => 3
665 697 assert_response :success
666 698
667 699 assert_select '#csv-export-options' do
668 700 assert_select 'form[action=?][method=get]', '/projects/ecookbook/issues/3/time_entries.csv'
669 701 end
670 702 end
671 703
672 704 def test_index_csv_all_projects
673 705 Setting.date_format = '%m/%d/%Y'
674 706 get :index, :format => 'csv'
675 707 assert_response :success
676 708 assert_equal 'text/csv; header=present', response.content_type
677 709 end
678 710
679 711 def test_index_csv
680 712 Setting.date_format = '%m/%d/%Y'
681 713 get :index, :project_id => 1, :format => 'csv'
682 714 assert_response :success
683 715 assert_equal 'text/csv; header=present', response.content_type
684 716 end
685 717 end
General Comments 0
You need to be logged in to leave comments. Login now