##// END OF EJS Templates
Restores object count and adds offset/limit attributes to API responses for paginated collections (#6140)....
Jean-Philippe Lang -
r4375:00d50157d3d6
parent child
Show More
@@ -1,449 +1,466
1 1 # redMine - project management software
2 2 # Copyright (C) 2006-2007 Jean-Philippe Lang
3 3 #
4 4 # This program is free software; you can redistribute it and/or
5 5 # modify it under the terms of the GNU General Public License
6 6 # as published by the Free Software Foundation; either version 2
7 7 # of the License, or (at your option) any later version.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU General Public License
15 15 # along with this program; if not, write to the Free Software
16 16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17 17
18 18 require 'uri'
19 19 require 'cgi'
20 20
21 21 class ApplicationController < ActionController::Base
22 22 include Redmine::I18n
23 23
24 24 layout 'base'
25 25 exempt_from_layout 'builder', 'rsb'
26 26
27 27 # Remove broken cookie after upgrade from 0.8.x (#4292)
28 28 # See https://rails.lighthouseapp.com/projects/8994/tickets/3360
29 29 # TODO: remove it when Rails is fixed
30 30 before_filter :delete_broken_cookies
31 31 def delete_broken_cookies
32 32 if cookies['_redmine_session'] && cookies['_redmine_session'] !~ /--/
33 33 cookies.delete '_redmine_session'
34 34 redirect_to home_path
35 35 return false
36 36 end
37 37 end
38 38
39 39 before_filter :user_setup, :check_if_login_required, :set_localization
40 40 filter_parameter_logging :password
41 41 protect_from_forgery
42 42
43 43 rescue_from ActionController::InvalidAuthenticityToken, :with => :invalid_authenticity_token
44 44
45 45 include Redmine::Search::Controller
46 46 include Redmine::MenuManager::MenuController
47 47 helper Redmine::MenuManager::MenuHelper
48 48
49 49 Redmine::Scm::Base.all.each do |scm|
50 50 require_dependency "repository/#{scm.underscore}"
51 51 end
52 52
53 53 def user_setup
54 54 # Check the settings cache for each request
55 55 Setting.check_cache
56 56 # Find the current user
57 57 User.current = find_current_user
58 58 end
59 59
60 60 # Returns the current user or nil if no user is logged in
61 61 # and starts a session if needed
62 62 def find_current_user
63 63 if session[:user_id]
64 64 # existing session
65 65 (User.active.find(session[:user_id]) rescue nil)
66 66 elsif cookies[:autologin] && Setting.autologin?
67 67 # auto-login feature starts a new session
68 68 user = User.try_to_autologin(cookies[:autologin])
69 69 session[:user_id] = user.id if user
70 70 user
71 71 elsif params[:format] == 'atom' && params[:key] && accept_key_auth_actions.include?(params[:action])
72 72 # RSS key authentication does not start a session
73 73 User.find_by_rss_key(params[:key])
74 74 elsif Setting.rest_api_enabled? && ['xml', 'json'].include?(params[:format])
75 75 if params[:key].present? && accept_key_auth_actions.include?(params[:action])
76 76 # Use API key
77 77 User.find_by_api_key(params[:key])
78 78 else
79 79 # HTTP Basic, either username/password or API key/random
80 80 authenticate_with_http_basic do |username, password|
81 81 User.try_to_login(username, password) || User.find_by_api_key(username)
82 82 end
83 83 end
84 84 end
85 85 end
86 86
87 87 # Sets the logged in user
88 88 def logged_user=(user)
89 89 reset_session
90 90 if user && user.is_a?(User)
91 91 User.current = user
92 92 session[:user_id] = user.id
93 93 else
94 94 User.current = User.anonymous
95 95 end
96 96 end
97 97
98 98 # check if login is globally required to access the application
99 99 def check_if_login_required
100 100 # no check needed if user is already logged in
101 101 return true if User.current.logged?
102 102 require_login if Setting.login_required?
103 103 end
104 104
105 105 def set_localization
106 106 lang = nil
107 107 if User.current.logged?
108 108 lang = find_language(User.current.language)
109 109 end
110 110 if lang.nil? && request.env['HTTP_ACCEPT_LANGUAGE']
111 111 accept_lang = parse_qvalues(request.env['HTTP_ACCEPT_LANGUAGE']).first
112 112 if !accept_lang.blank?
113 113 accept_lang = accept_lang.downcase
114 114 lang = find_language(accept_lang) || find_language(accept_lang.split('-').first)
115 115 end
116 116 end
117 117 lang ||= Setting.default_language
118 118 set_language_if_valid(lang)
119 119 end
120 120
121 121 def require_login
122 122 if !User.current.logged?
123 123 # Extract only the basic url parameters on non-GET requests
124 124 if request.get?
125 125 url = url_for(params)
126 126 else
127 127 url = url_for(:controller => params[:controller], :action => params[:action], :id => params[:id], :project_id => params[:project_id])
128 128 end
129 129 respond_to do |format|
130 130 format.html { redirect_to :controller => "account", :action => "login", :back_url => url }
131 131 format.atom { redirect_to :controller => "account", :action => "login", :back_url => url }
132 132 format.xml { head :unauthorized, 'WWW-Authenticate' => 'Basic realm="Redmine API"' }
133 133 format.js { head :unauthorized, 'WWW-Authenticate' => 'Basic realm="Redmine API"' }
134 134 format.json { head :unauthorized, 'WWW-Authenticate' => 'Basic realm="Redmine API"' }
135 135 end
136 136 return false
137 137 end
138 138 true
139 139 end
140 140
141 141 def require_admin
142 142 return unless require_login
143 143 if !User.current.admin?
144 144 render_403
145 145 return false
146 146 end
147 147 true
148 148 end
149 149
150 150 def deny_access
151 151 User.current.logged? ? render_403 : require_login
152 152 end
153 153
154 154 # Authorize the user for the requested action
155 155 def authorize(ctrl = params[:controller], action = params[:action], global = false)
156 156 allowed = User.current.allowed_to?({:controller => ctrl, :action => action}, @project || @projects, :global => global)
157 157 if allowed
158 158 true
159 159 else
160 160 if @project && @project.archived?
161 161 render_403 :message => :notice_not_authorized_archived_project
162 162 else
163 163 deny_access
164 164 end
165 165 end
166 166 end
167 167
168 168 # Authorize the user for the requested action outside a project
169 169 def authorize_global(ctrl = params[:controller], action = params[:action], global = true)
170 170 authorize(ctrl, action, global)
171 171 end
172 172
173 173 # Find project of id params[:id]
174 174 def find_project
175 175 @project = Project.find(params[:id])
176 176 rescue ActiveRecord::RecordNotFound
177 177 render_404
178 178 end
179 179
180 180 # Find project of id params[:project_id]
181 181 def find_project_by_project_id
182 182 @project = Project.find(params[:project_id])
183 183 rescue ActiveRecord::RecordNotFound
184 184 render_404
185 185 end
186 186
187 187 # Find a project based on params[:project_id]
188 188 # TODO: some subclasses override this, see about merging their logic
189 189 def find_optional_project
190 190 @project = Project.find(params[:project_id]) unless params[:project_id].blank?
191 191 allowed = User.current.allowed_to?({:controller => params[:controller], :action => params[:action]}, @project, :global => true)
192 192 allowed ? true : deny_access
193 193 rescue ActiveRecord::RecordNotFound
194 194 render_404
195 195 end
196 196
197 197 # Finds and sets @project based on @object.project
198 198 def find_project_from_association
199 199 render_404 unless @object.present?
200 200
201 201 @project = @object.project
202 202 rescue ActiveRecord::RecordNotFound
203 203 render_404
204 204 end
205 205
206 206 def find_model_object
207 207 model = self.class.read_inheritable_attribute('model_object')
208 208 if model
209 209 @object = model.find(params[:id])
210 210 self.instance_variable_set('@' + controller_name.singularize, @object) if @object
211 211 end
212 212 rescue ActiveRecord::RecordNotFound
213 213 render_404
214 214 end
215 215
216 216 def self.model_object(model)
217 217 write_inheritable_attribute('model_object', model)
218 218 end
219 219
220 220 # Filter for bulk issue operations
221 221 def find_issues
222 222 @issues = Issue.find_all_by_id(params[:id] || params[:ids])
223 223 raise ActiveRecord::RecordNotFound if @issues.empty?
224 224 @projects = @issues.collect(&:project).compact.uniq
225 225 @project = @projects.first if @projects.size == 1
226 226 rescue ActiveRecord::RecordNotFound
227 227 render_404
228 228 end
229 229
230 230 # Check if project is unique before bulk operations
231 231 def check_project_uniqueness
232 232 unless @project
233 233 # TODO: let users bulk edit/move/destroy issues from different projects
234 234 render_error 'Can not bulk edit/move/destroy issues from different projects'
235 235 return false
236 236 end
237 237 end
238 238
239 239 # make sure that the user is a member of the project (or admin) if project is private
240 240 # used as a before_filter for actions that do not require any particular permission on the project
241 241 def check_project_privacy
242 242 if @project && @project.active?
243 243 if @project.is_public? || User.current.member_of?(@project) || User.current.admin?
244 244 true
245 245 else
246 246 User.current.logged? ? render_403 : require_login
247 247 end
248 248 else
249 249 @project = nil
250 250 render_404
251 251 false
252 252 end
253 253 end
254 254
255 255 def back_url
256 256 params[:back_url] || request.env['HTTP_REFERER']
257 257 end
258 258
259 259 def redirect_back_or_default(default)
260 260 back_url = CGI.unescape(params[:back_url].to_s)
261 261 if !back_url.blank?
262 262 begin
263 263 uri = URI.parse(back_url)
264 264 # do not redirect user to another host or to the login or register page
265 265 if (uri.relative? || (uri.host == request.host)) && !uri.path.match(%r{/(login|account/register)})
266 266 redirect_to(back_url)
267 267 return
268 268 end
269 269 rescue URI::InvalidURIError
270 270 # redirect to default
271 271 end
272 272 end
273 273 redirect_to default
274 274 end
275 275
276 276 def render_403(options={})
277 277 @project = nil
278 278 render_error({:message => :notice_not_authorized, :status => 403}.merge(options))
279 279 return false
280 280 end
281 281
282 282 def render_404(options={})
283 283 render_error({:message => :notice_file_not_found, :status => 404}.merge(options))
284 284 return false
285 285 end
286 286
287 287 # Renders an error response
288 288 def render_error(arg)
289 289 arg = {:message => arg} unless arg.is_a?(Hash)
290 290
291 291 @message = arg[:message]
292 292 @message = l(@message) if @message.is_a?(Symbol)
293 293 @status = arg[:status] || 500
294 294
295 295 respond_to do |format|
296 296 format.html {
297 297 render :template => 'common/error', :layout => use_layout, :status => @status
298 298 }
299 299 format.atom { head @status }
300 300 format.xml { head @status }
301 301 format.js { head @status }
302 302 format.json { head @status }
303 303 end
304 304 end
305 305
306 306 # Picks which layout to use based on the request
307 307 #
308 308 # @return [boolean, string] name of the layout to use or false for no layout
309 309 def use_layout
310 310 request.xhr? ? false : 'base'
311 311 end
312 312
313 313 def invalid_authenticity_token
314 314 if api_request?
315 315 logger.error "Form authenticity token is missing or is invalid. API calls must include a proper Content-type header (text/xml or text/json)."
316 316 end
317 317 render_error "Invalid form authenticity token."
318 318 end
319 319
320 320 def render_feed(items, options={})
321 321 @items = items || []
322 322 @items.sort! {|x,y| y.event_datetime <=> x.event_datetime }
323 323 @items = @items.slice(0, Setting.feeds_limit.to_i)
324 324 @title = options[:title] || Setting.app_title
325 325 render :template => "common/feed.atom.rxml", :layout => false, :content_type => 'application/atom+xml'
326 326 end
327 327
328 328 def self.accept_key_auth(*actions)
329 329 actions = actions.flatten.map(&:to_s)
330 330 write_inheritable_attribute('accept_key_auth_actions', actions)
331 331 end
332 332
333 333 def accept_key_auth_actions
334 334 self.class.read_inheritable_attribute('accept_key_auth_actions') || []
335 335 end
336 336
337 337 # Returns the number of objects that should be displayed
338 338 # on the paginated list
339 339 def per_page_option
340 340 per_page = nil
341 341 if params[:per_page] && Setting.per_page_options_array.include?(params[:per_page].to_s.to_i)
342 342 per_page = params[:per_page].to_s.to_i
343 343 session[:per_page] = per_page
344 344 elsif session[:per_page]
345 345 per_page = session[:per_page]
346 346 else
347 347 per_page = Setting.per_page_options_array.first || 25
348 348 end
349 349 per_page
350 350 end
351 351
352 def api_offset_and_limit
353 offset = nil
354 if params[:offset].present?
355 offset = params[:offset].to_i
356 if offset < 0
357 offset = 0
358 end
359 end
360 limit = params[:limit].to_i
361 if limit < 1
362 limit = 25
363 elsif limit > 100
364 limit = 100
365 end
366 [offset, limit]
367 end
368
352 369 # qvalues http header parser
353 370 # code taken from webrick
354 371 def parse_qvalues(value)
355 372 tmp = []
356 373 if value
357 374 parts = value.split(/,\s*/)
358 375 parts.each {|part|
359 376 if m = %r{^([^\s,]+?)(?:;\s*q=(\d+(?:\.\d+)?))?$}.match(part)
360 377 val = m[1]
361 378 q = (m[2] or 1).to_f
362 379 tmp.push([val, q])
363 380 end
364 381 }
365 382 tmp = tmp.sort_by{|val, q| -q}
366 383 tmp.collect!{|val, q| val}
367 384 end
368 385 return tmp
369 386 rescue
370 387 nil
371 388 end
372 389
373 390 # Returns a string that can be used as filename value in Content-Disposition header
374 391 def filename_for_content_disposition(name)
375 392 request.env['HTTP_USER_AGENT'] =~ %r{MSIE} ? ERB::Util.url_encode(name) : name
376 393 end
377 394
378 395 def api_request?
379 396 %w(xml json).include? params[:format]
380 397 end
381 398
382 399 # Renders a warning flash if obj has unsaved attachments
383 400 def render_attachment_warning_if_needed(obj)
384 401 flash[:warning] = l(:warning_attachments_not_saved, obj.unsaved_attachments.size) if obj.unsaved_attachments.present?
385 402 end
386 403
387 404 # Sets the `flash` notice or error based the number of issues that did not save
388 405 #
389 406 # @param [Array, Issue] issues all of the saved and unsaved Issues
390 407 # @param [Array, Integer] unsaved_issue_ids the issue ids that were not saved
391 408 def set_flash_from_bulk_issue_save(issues, unsaved_issue_ids)
392 409 if unsaved_issue_ids.empty?
393 410 flash[:notice] = l(:notice_successful_update) unless issues.empty?
394 411 else
395 412 flash[:error] = l(:notice_failed_to_save_issues,
396 413 :count => unsaved_issue_ids.size,
397 414 :total => issues.size,
398 415 :ids => '#' + unsaved_issue_ids.join(', #'))
399 416 end
400 417 end
401 418
402 419 # Rescues an invalid query statement. Just in case...
403 420 def query_statement_invalid(exception)
404 421 logger.error "Query::StatementInvalid: #{exception.message}" if logger
405 422 session.delete(:query)
406 423 sort_clear if respond_to?(:sort_clear)
407 424 render_error "An error occurred while executing the query and has been logged. Please report this error to your Redmine administrator."
408 425 end
409 426
410 427 # Converts the errors on an ActiveRecord object into a common JSON format
411 428 def object_errors_to_json(object)
412 429 object.errors.collect do |attribute, error|
413 430 { attribute => error }
414 431 end.to_json
415 432 end
416 433
417 434 # Renders API response on validation failure
418 435 def render_validation_errors(object)
419 436 options = { :status => :unprocessable_entity, :layout => false }
420 437 options.merge!(case params[:format]
421 438 when 'xml'; { :xml => object.errors }
422 439 when 'json'; { :json => {'errors' => object.errors} } # ActiveResource client compliance
423 440 else
424 441 raise "Unknown format #{params[:format]} in #render_validation_errors"
425 442 end
426 443 )
427 444 render options
428 445 end
429 446
430 447 # Overrides #default_template so that the api template
431 448 # is used automatically if it exists
432 449 def default_template(action_name = self.action_name)
433 450 if api_request?
434 451 begin
435 452 return self.view_paths.find_template(default_template_name(action_name), 'api')
436 453 rescue ::ActionView::MissingTemplate
437 454 # the api template was not found
438 455 # fallback to the default behaviour
439 456 end
440 457 end
441 458 super
442 459 end
443 460
444 461 # Overrides #pick_layout so that #render with no arguments
445 462 # doesn't use the layout for api requests
446 463 def pick_layout(*args)
447 464 api_request? ? nil : super
448 465 end
449 466 end
@@ -1,313 +1,316
1 1 # Redmine - project management software
2 2 # Copyright (C) 2006-2008 Jean-Philippe Lang
3 3 #
4 4 # This program is free software; you can redistribute it and/or
5 5 # modify it under the terms of the GNU General Public License
6 6 # as published by the Free Software Foundation; either version 2
7 7 # of the License, or (at your option) any later version.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU General Public License
15 15 # along with this program; if not, write to the Free Software
16 16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17 17
18 18 class IssuesController < ApplicationController
19 19 menu_item :new_issue, :only => [:new, :create]
20 20 default_search_scope :issues
21 21
22 22 before_filter :find_issue, :only => [:show, :edit, :update]
23 23 before_filter :find_issues, :only => [:bulk_edit, :bulk_update, :move, :perform_move, :destroy]
24 24 before_filter :check_project_uniqueness, :only => [:move, :perform_move]
25 25 before_filter :find_project, :only => [:new, :create]
26 26 before_filter :authorize, :except => [:index]
27 27 before_filter :find_optional_project, :only => [:index]
28 28 before_filter :check_for_default_issue_status, :only => [:new, :create]
29 29 before_filter :build_new_issue_from_params, :only => [:new, :create]
30 30 accept_key_auth :index, :show, :create, :update, :destroy
31 31
32 32 rescue_from Query::StatementInvalid, :with => :query_statement_invalid
33 33
34 34 helper :journals
35 35 helper :projects
36 36 include ProjectsHelper
37 37 helper :custom_fields
38 38 include CustomFieldsHelper
39 39 helper :issue_relations
40 40 include IssueRelationsHelper
41 41 helper :watchers
42 42 include WatchersHelper
43 43 helper :attachments
44 44 include AttachmentsHelper
45 45 helper :queries
46 46 include QueriesHelper
47 47 helper :sort
48 48 include SortHelper
49 49 include IssuesHelper
50 50 helper :timelog
51 51 helper :gantt
52 52 include Redmine::Export::PDF
53 53
54 54 verify :method => [:post, :delete],
55 55 :only => :destroy,
56 56 :render => { :nothing => true, :status => :method_not_allowed }
57 57
58 58 verify :method => :post, :only => :create, :render => {:nothing => true, :status => :method_not_allowed }
59 59 verify :method => :post, :only => :bulk_update, :render => {:nothing => true, :status => :method_not_allowed }
60 60 verify :method => :put, :only => :update, :render => {:nothing => true, :status => :method_not_allowed }
61 61
62 62 def index
63 63 retrieve_query
64 64 sort_init(@query.sort_criteria.empty? ? [['id', 'desc']] : @query.sort_criteria)
65 65 sort_update(@query.sortable_columns)
66 66
67 67 if @query.valid?
68 limit = case params[:format]
68 case params[:format]
69 69 when 'csv', 'pdf'
70 Setting.issues_export_limit.to_i
70 @limit = Setting.issues_export_limit.to_i
71 71 when 'atom'
72 Setting.feeds_limit.to_i
72 @limit = Setting.feeds_limit.to_i
73 when 'xml', 'json'
74 @offset, @limit = api_offset_and_limit
73 75 else
74 per_page_option
76 @limit = per_page_option
75 77 end
76 78
77 79 @issue_count = @query.issue_count
78 @issue_pages = Paginator.new self, @issue_count, limit, params['page']
80 @issue_pages = Paginator.new self, @issue_count, @limit, params['page']
81 @offset ||= @issue_pages.current.offset
79 82 @issues = @query.issues(:include => [:assigned_to, :tracker, :priority, :category, :fixed_version],
80 83 :order => sort_clause,
81 :offset => @issue_pages.current.offset,
82 :limit => limit)
84 :offset => @offset,
85 :limit => @limit)
83 86 @issue_count_by_group = @query.issue_count_by_group
84 87
85 88 respond_to do |format|
86 89 format.html { render :template => 'issues/index.rhtml', :layout => !request.xhr? }
87 90 format.api
88 91 format.atom { render_feed(@issues, :title => "#{@project || Setting.app_title}: #{l(:label_issue_plural)}") }
89 92 format.csv { send_data(issues_to_csv(@issues, @project), :type => 'text/csv; header=present', :filename => 'export.csv') }
90 93 format.pdf { send_data(issues_to_pdf(@issues, @project, @query), :type => 'application/pdf', :filename => 'export.pdf') }
91 94 end
92 95 else
93 96 # Send html if the query is not valid
94 97 render(:template => 'issues/index.rhtml', :layout => !request.xhr?)
95 98 end
96 99 rescue ActiveRecord::RecordNotFound
97 100 render_404
98 101 end
99 102
100 103 def show
101 104 @journals = @issue.journals.find(:all, :include => [:user, :details], :order => "#{Journal.table_name}.created_on ASC")
102 105 @journals.each_with_index {|j,i| j.indice = i+1}
103 106 @journals.reverse! if User.current.wants_comments_in_reverse_order?
104 107 @changesets = @issue.changesets.visible.all
105 108 @changesets.reverse! if User.current.wants_comments_in_reverse_order?
106 109 @allowed_statuses = @issue.new_statuses_allowed_to(User.current)
107 110 @edit_allowed = User.current.allowed_to?(:edit_issues, @project)
108 111 @priorities = IssuePriority.all
109 112 @time_entry = TimeEntry.new
110 113 respond_to do |format|
111 114 format.html { render :template => 'issues/show.rhtml' }
112 115 format.api
113 116 format.atom { render :template => 'journals/index', :layout => false, :content_type => 'application/atom+xml' }
114 117 format.pdf { send_data(issue_to_pdf(@issue), :type => 'application/pdf', :filename => "#{@project.identifier}-#{@issue.id}.pdf") }
115 118 end
116 119 end
117 120
118 121 # Add a new issue
119 122 # The new issue will be created from an existing one if copy_from parameter is given
120 123 def new
121 124 respond_to do |format|
122 125 format.html { render :action => 'new', :layout => !request.xhr? }
123 126 format.js { render :partial => 'attributes' }
124 127 end
125 128 end
126 129
127 130 def create
128 131 call_hook(:controller_issues_new_before_save, { :params => params, :issue => @issue })
129 132 if @issue.save
130 133 attachments = Attachment.attach_files(@issue, params[:attachments])
131 134 render_attachment_warning_if_needed(@issue)
132 135 flash[:notice] = l(:notice_successful_create)
133 136 call_hook(:controller_issues_new_after_save, { :params => params, :issue => @issue})
134 137 respond_to do |format|
135 138 format.html {
136 139 redirect_to(params[:continue] ? { :action => 'new', :project_id => @project, :issue => {:tracker_id => @issue.tracker, :parent_issue_id => @issue.parent_issue_id}.reject {|k,v| v.nil?} } :
137 140 { :action => 'show', :id => @issue })
138 141 }
139 142 format.api { render :action => 'show', :status => :created, :location => issue_url(@issue) }
140 143 end
141 144 return
142 145 else
143 146 respond_to do |format|
144 147 format.html { render :action => 'new' }
145 148 format.api { render_validation_errors(@issue) }
146 149 end
147 150 end
148 151 end
149 152
150 153 def edit
151 154 update_issue_from_params
152 155
153 156 @journal = @issue.current_journal
154 157
155 158 respond_to do |format|
156 159 format.html { }
157 160 format.xml { }
158 161 end
159 162 end
160 163
161 164 def update
162 165 update_issue_from_params
163 166
164 167 if @issue.save_issue_with_child_records(params, @time_entry)
165 168 render_attachment_warning_if_needed(@issue)
166 169 flash[:notice] = l(:notice_successful_update) unless @issue.current_journal.new_record?
167 170
168 171 respond_to do |format|
169 172 format.html { redirect_back_or_default({:action => 'show', :id => @issue}) }
170 173 format.api { head :ok }
171 174 end
172 175 else
173 176 render_attachment_warning_if_needed(@issue)
174 177 flash[:notice] = l(:notice_successful_update) unless @issue.current_journal.new_record?
175 178 @journal = @issue.current_journal
176 179
177 180 respond_to do |format|
178 181 format.html { render :action => 'edit' }
179 182 format.api { render_validation_errors(@issue) }
180 183 end
181 184 end
182 185 end
183 186
184 187 # Bulk edit a set of issues
185 188 def bulk_edit
186 189 @issues.sort!
187 190 @available_statuses = @projects.map{|p|Workflow.available_statuses(p)}.inject{|memo,w|memo & w}
188 191 @custom_fields = @projects.map{|p|p.all_issue_custom_fields}.inject{|memo,c|memo & c}
189 192 @assignables = @projects.map(&:assignable_users).inject{|memo,a| memo & a}
190 193 @trackers = @projects.map(&:trackers).inject{|memo,t| memo & t}
191 194 end
192 195
193 196 def bulk_update
194 197 @issues.sort!
195 198 attributes = parse_params_for_bulk_issue_attributes(params)
196 199
197 200 unsaved_issue_ids = []
198 201 @issues.each do |issue|
199 202 issue.reload
200 203 journal = issue.init_journal(User.current, params[:notes])
201 204 issue.safe_attributes = attributes
202 205 call_hook(:controller_issues_bulk_edit_before_save, { :params => params, :issue => issue })
203 206 unless issue.save
204 207 # Keep unsaved issue ids to display them in flash error
205 208 unsaved_issue_ids << issue.id
206 209 end
207 210 end
208 211 set_flash_from_bulk_issue_save(@issues, unsaved_issue_ids)
209 212 redirect_back_or_default({:controller => 'issues', :action => 'index', :project_id => @project})
210 213 end
211 214
212 215 def destroy
213 216 @hours = TimeEntry.sum(:hours, :conditions => ['issue_id IN (?)', @issues]).to_f
214 217 if @hours > 0
215 218 case params[:todo]
216 219 when 'destroy'
217 220 # nothing to do
218 221 when 'nullify'
219 222 TimeEntry.update_all('issue_id = NULL', ['issue_id IN (?)', @issues])
220 223 when 'reassign'
221 224 reassign_to = @project.issues.find_by_id(params[:reassign_to_id])
222 225 if reassign_to.nil?
223 226 flash.now[:error] = l(:error_issue_not_found_in_project)
224 227 return
225 228 else
226 229 TimeEntry.update_all("issue_id = #{reassign_to.id}", ['issue_id IN (?)', @issues])
227 230 end
228 231 else
229 232 # display the destroy form if it's a user request
230 233 return unless api_request?
231 234 end
232 235 end
233 236 @issues.each(&:destroy)
234 237 respond_to do |format|
235 238 format.html { redirect_back_or_default(:action => 'index', :project_id => @project) }
236 239 format.api { head :ok }
237 240 end
238 241 end
239 242
240 243 private
241 244 def find_issue
242 245 @issue = Issue.find(params[:id], :include => [:project, :tracker, :status, :author, :priority, :category])
243 246 @project = @issue.project
244 247 rescue ActiveRecord::RecordNotFound
245 248 render_404
246 249 end
247 250
248 251 def find_project
249 252 project_id = (params[:issue] && params[:issue][:project_id]) || params[:project_id]
250 253 @project = Project.find(project_id)
251 254 rescue ActiveRecord::RecordNotFound
252 255 render_404
253 256 end
254 257
255 258 # Used by #edit and #update to set some common instance variables
256 259 # from the params
257 260 # TODO: Refactor, not everything in here is needed by #edit
258 261 def update_issue_from_params
259 262 @allowed_statuses = @issue.new_statuses_allowed_to(User.current)
260 263 @priorities = IssuePriority.all
261 264 @edit_allowed = User.current.allowed_to?(:edit_issues, @project)
262 265 @time_entry = TimeEntry.new
263 266 @time_entry.attributes = params[:time_entry]
264 267
265 268 @notes = params[:notes] || (params[:issue].present? ? params[:issue][:notes] : nil)
266 269 @issue.init_journal(User.current, @notes)
267 270 @issue.safe_attributes = params[:issue]
268 271 end
269 272
270 273 # TODO: Refactor, lots of extra code in here
271 274 # TODO: Changing tracker on an existing issue should not trigger this
272 275 def build_new_issue_from_params
273 276 if params[:id].blank?
274 277 @issue = Issue.new
275 278 @issue.copy_from(params[:copy_from]) if params[:copy_from]
276 279 @issue.project = @project
277 280 else
278 281 @issue = @project.issues.visible.find(params[:id])
279 282 end
280 283
281 284 @issue.project = @project
282 285 # Tracker must be set before custom field values
283 286 @issue.tracker ||= @project.trackers.find((params[:issue] && params[:issue][:tracker_id]) || params[:tracker_id] || :first)
284 287 if @issue.tracker.nil?
285 288 render_error l(:error_no_tracker_in_project)
286 289 return false
287 290 end
288 291 @issue.start_date ||= Date.today
289 292 if params[:issue].is_a?(Hash)
290 293 @issue.safe_attributes = params[:issue]
291 294 if User.current.allowed_to?(:add_issue_watchers, @project) && @issue.new_record?
292 295 @issue.watcher_user_ids = params[:issue]['watcher_user_ids']
293 296 end
294 297 end
295 298 @issue.author = User.current
296 299 @priorities = IssuePriority.all
297 300 @allowed_statuses = @issue.new_statuses_allowed_to(User.current, true)
298 301 end
299 302
300 303 def check_for_default_issue_status
301 304 if IssueStatus.default.nil?
302 305 render_error l(:error_no_default_issue_status)
303 306 return false
304 307 end
305 308 end
306 309
307 310 def parse_params_for_bulk_issue_attributes(params)
308 311 attributes = (params[:issue] || {}).reject {|k,v| v.blank?}
309 312 attributes.keys.each {|k| attributes[k] = '' if attributes[k] == 'none'}
310 313 attributes[:custom_field_values].reject! {|k,v| v.blank?} if attributes[:custom_field_values]
311 314 attributes
312 315 end
313 316 end
@@ -1,223 +1,230
1 1 # Redmine - project management software
2 2 # Copyright (C) 2006-2010 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 UsersController < ApplicationController
19 19 layout 'admin'
20 20
21 21 before_filter :require_admin, :except => :show
22 22 accept_key_auth :index, :show, :create, :update
23 23
24 24 helper :sort
25 25 include SortHelper
26 26 helper :custom_fields
27 27 include CustomFieldsHelper
28 28
29 29 def index
30 30 sort_init 'login', 'asc'
31 31 sort_update %w(login firstname lastname mail admin created_on last_login_on)
32 32
33 case params[:format]
34 when 'xml', 'json'
35 @offset, @limit = api_offset_and_limit
36 else
37 @limit = per_page_option
38 end
39
33 40 @status = params[:status] ? params[:status].to_i : 1
34 41 c = ARCondition.new(@status == 0 ? "status <> 0" : ["status = ?", @status])
35 42
36 43 unless params[:name].blank?
37 44 name = "%#{params[:name].strip.downcase}%"
38 45 c << ["LOWER(login) LIKE ? OR LOWER(firstname) LIKE ? OR LOWER(lastname) LIKE ? OR LOWER(mail) LIKE ?", name, name, name, name]
39 46 end
40 47
41 48 @user_count = User.count(:conditions => c.conditions)
42 @user_pages = Paginator.new self, @user_count,
43 per_page_option,
44 params['page']
45 @users = User.find :all,:order => sort_clause,
49 @user_pages = Paginator.new self, @user_count, @limit, params['page']
50 @offset ||= @user_pages.current.offset
51 @users = User.find :all,
52 :order => sort_clause,
46 53 :conditions => c.conditions,
47 :limit => @user_pages.items_per_page,
48 :offset => @user_pages.current.offset
54 :limit => @limit,
55 :offset => @offset
49 56
50 57 respond_to do |format|
51 58 format.html { render :layout => !request.xhr? }
52 59 format.api
53 60 end
54 61 end
55 62
56 63 def show
57 64 @user = User.find(params[:id])
58 65
59 66 # show projects based on current user visibility
60 67 @memberships = @user.memberships.all(:conditions => Project.visible_by(User.current))
61 68
62 69 events = Redmine::Activity::Fetcher.new(User.current, :author => @user).events(nil, nil, :limit => 10)
63 70 @events_by_day = events.group_by(&:event_date)
64 71
65 72 unless User.current.admin?
66 73 if !@user.active? || (@user != User.current && @memberships.empty? && events.empty?)
67 74 render_404
68 75 return
69 76 end
70 77 end
71 78
72 79 respond_to do |format|
73 80 format.html { render :layout => 'base' }
74 81 format.api
75 82 end
76 83 rescue ActiveRecord::RecordNotFound
77 84 render_404
78 85 end
79 86
80 87 def new
81 88 @notification_options = User::MAIL_NOTIFICATION_OPTIONS
82 89 @notification_option = Setting.default_notification_option
83 90
84 91 @user = User.new(:language => Setting.default_language)
85 92 @auth_sources = AuthSource.find(:all)
86 93 end
87 94
88 95 verify :method => :post, :only => :create, :render => {:nothing => true, :status => :method_not_allowed }
89 96 def create
90 97 @notification_options = User::MAIL_NOTIFICATION_OPTIONS
91 98 @notification_option = Setting.default_notification_option
92 99
93 100 @user = User.new(params[:user])
94 101 @user.admin = params[:user][:admin] || false
95 102 @user.login = params[:user][:login]
96 103 @user.password, @user.password_confirmation = params[:password], params[:password_confirmation] unless @user.auth_source_id
97 104
98 105 # TODO: Similar to My#account
99 106 @user.mail_notification = params[:notification_option] || 'only_my_events'
100 107 @user.pref.attributes = params[:pref]
101 108 @user.pref[:no_self_notified] = (params[:no_self_notified] == '1')
102 109
103 110 if @user.save
104 111 @user.pref.save
105 112 @user.notified_project_ids = (params[:notification_option] == 'selected' ? params[:notified_project_ids] : [])
106 113
107 114 Mailer.deliver_account_information(@user, params[:password]) if params[:send_information]
108 115
109 116 respond_to do |format|
110 117 format.html {
111 118 flash[:notice] = l(:notice_successful_create)
112 119 redirect_to(params[:continue] ?
113 120 {:controller => 'users', :action => 'new'} :
114 121 {:controller => 'users', :action => 'edit', :id => @user}
115 122 )
116 123 }
117 124 format.api { render :action => 'show', :status => :created, :location => user_url(@user) }
118 125 end
119 126 else
120 127 @auth_sources = AuthSource.find(:all)
121 128 @notification_option = @user.mail_notification
122 129
123 130 respond_to do |format|
124 131 format.html { render :action => 'new' }
125 132 format.api { render_validation_errors(@user) }
126 133 end
127 134 end
128 135 end
129 136
130 137 def edit
131 138 @user = User.find(params[:id])
132 139 @notification_options = @user.valid_notification_options
133 140 @notification_option = @user.mail_notification
134 141
135 142 @auth_sources = AuthSource.find(:all)
136 143 @membership ||= Member.new
137 144 end
138 145
139 146 verify :method => :put, :only => :update, :render => {:nothing => true, :status => :method_not_allowed }
140 147 def update
141 148 @user = User.find(params[:id])
142 149 @notification_options = @user.valid_notification_options
143 150 @notification_option = @user.mail_notification
144 151
145 152 @user.admin = params[:user][:admin] if params[:user][:admin]
146 153 @user.login = params[:user][:login] if params[:user][:login]
147 154 if params[:password].present? && (@user.auth_source_id.nil? || params[:user][:auth_source_id].blank?)
148 155 @user.password, @user.password_confirmation = params[:password], params[:password_confirmation]
149 156 end
150 157 @user.group_ids = params[:user][:group_ids] if params[:user][:group_ids]
151 158 @user.attributes = params[:user]
152 159 # Was the account actived ? (do it before User#save clears the change)
153 160 was_activated = (@user.status_change == [User::STATUS_REGISTERED, User::STATUS_ACTIVE])
154 161 # TODO: Similar to My#account
155 162 @user.mail_notification = params[:notification_option] || 'only_my_events'
156 163 @user.pref.attributes = params[:pref]
157 164 @user.pref[:no_self_notified] = (params[:no_self_notified] == '1')
158 165
159 166 if @user.save
160 167 @user.pref.save
161 168 @user.notified_project_ids = (params[:notification_option] == 'selected' ? params[:notified_project_ids] : [])
162 169
163 170 if was_activated
164 171 Mailer.deliver_account_activated(@user)
165 172 elsif @user.active? && params[:send_information] && !params[:password].blank? && @user.auth_source_id.nil?
166 173 Mailer.deliver_account_information(@user, params[:password])
167 174 end
168 175
169 176 respond_to do |format|
170 177 format.html {
171 178 flash[:notice] = l(:notice_successful_update)
172 179 redirect_to :back
173 180 }
174 181 format.api { head :ok }
175 182 end
176 183 else
177 184 @auth_sources = AuthSource.find(:all)
178 185 @membership ||= Member.new
179 186
180 187 respond_to do |format|
181 188 format.html { render :action => :edit }
182 189 format.api { render_validation_errors(@user) }
183 190 end
184 191 end
185 192 rescue ::ActionController::RedirectBackError
186 193 redirect_to :controller => 'users', :action => 'edit', :id => @user
187 194 end
188 195
189 196 def edit_membership
190 197 @user = User.find(params[:id])
191 198 @membership = Member.edit_membership(params[:membership_id], params[:membership], @user)
192 199 @membership.save if request.post?
193 200 respond_to do |format|
194 201 if @membership.valid?
195 202 format.html { redirect_to :controller => 'users', :action => 'edit', :id => @user, :tab => 'memberships' }
196 203 format.js {
197 204 render(:update) {|page|
198 205 page.replace_html "tab-content-memberships", :partial => 'users/memberships'
199 206 page.visual_effect(:highlight, "member-#{@membership.id}")
200 207 }
201 208 }
202 209 else
203 210 format.js {
204 211 render(:update) {|page|
205 212 page.alert(l(:notice_failed_to_save_members, :errors => @membership.errors.full_messages.join(', ')))
206 213 }
207 214 }
208 215 end
209 216 end
210 217 end
211 218
212 219 def destroy_membership
213 220 @user = User.find(params[:id])
214 221 @membership = Member.find(params[:membership_id])
215 222 if request.post? && @membership.deletable?
216 223 @membership.destroy
217 224 end
218 225 respond_to do |format|
219 226 format.html { redirect_to :controller => 'users', :action => 'edit', :id => @user, :tab => 'memberships' }
220 227 format.js { render(:update) {|page| page.replace_html "tab-content-memberships", :partial => 'users/memberships'} }
221 228 end
222 229 end
223 230 end
@@ -1,895 +1,907
1 1 # Redmine - project management software
2 2 # Copyright (C) 2006-2010 Jean-Philippe Lang
3 3 #
4 4 # This program is free software; you can redistribute it and/or
5 5 # modify it under the terms of the GNU General Public License
6 6 # as published by the Free Software Foundation; either version 2
7 7 # of the License, or (at your option) any later version.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU General Public License
15 15 # along with this program; if not, write to the Free Software
16 16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17 17
18 18 require 'forwardable'
19 19 require 'cgi'
20 20
21 21 module ApplicationHelper
22 22 include Redmine::WikiFormatting::Macros::Definitions
23 23 include Redmine::I18n
24 24 include GravatarHelper::PublicMethods
25 25
26 26 extend Forwardable
27 27 def_delegators :wiki_helper, :wikitoolbar_for, :heads_for_wiki_formatter
28 28
29 29 # Return true if user is authorized for controller/action, otherwise false
30 30 def authorize_for(controller, action)
31 31 User.current.allowed_to?({:controller => controller, :action => action}, @project)
32 32 end
33 33
34 34 # Display a link if user is authorized
35 35 #
36 36 # @param [String] name Anchor text (passed to link_to)
37 37 # @param [Hash] options Hash params. This will checked by authorize_for to see if the user is authorized
38 38 # @param [optional, Hash] html_options Options passed to link_to
39 39 # @param [optional, Hash] parameters_for_method_reference Extra parameters for link_to
40 40 def link_to_if_authorized(name, options = {}, html_options = nil, *parameters_for_method_reference)
41 41 link_to(name, options, html_options, *parameters_for_method_reference) if authorize_for(options[:controller] || params[:controller], options[:action])
42 42 end
43 43
44 44 # Display a link to remote if user is authorized
45 45 def link_to_remote_if_authorized(name, options = {}, html_options = nil)
46 46 url = options[:url] || {}
47 47 link_to_remote(name, options, html_options) if authorize_for(url[:controller] || params[:controller], url[:action])
48 48 end
49 49
50 50 # Displays a link to user's account page if active
51 51 def link_to_user(user, options={})
52 52 if user.is_a?(User)
53 53 name = h(user.name(options[:format]))
54 54 if user.active?
55 55 link_to name, :controller => 'users', :action => 'show', :id => user
56 56 else
57 57 name
58 58 end
59 59 else
60 60 h(user.to_s)
61 61 end
62 62 end
63 63
64 64 # Displays a link to +issue+ with its subject.
65 65 # Examples:
66 66 #
67 67 # link_to_issue(issue) # => Defect #6: This is the subject
68 68 # link_to_issue(issue, :truncate => 6) # => Defect #6: This i...
69 69 # link_to_issue(issue, :subject => false) # => Defect #6
70 70 # link_to_issue(issue, :project => true) # => Foo - Defect #6
71 71 #
72 72 def link_to_issue(issue, options={})
73 73 title = nil
74 74 subject = nil
75 75 if options[:subject] == false
76 76 title = truncate(issue.subject, :length => 60)
77 77 else
78 78 subject = issue.subject
79 79 if options[:truncate]
80 80 subject = truncate(subject, :length => options[:truncate])
81 81 end
82 82 end
83 83 s = link_to "#{issue.tracker} ##{issue.id}", {:controller => "issues", :action => "show", :id => issue},
84 84 :class => issue.css_classes,
85 85 :title => title
86 86 s << ": #{h subject}" if subject
87 87 s = "#{h issue.project} - " + s if options[:project]
88 88 s
89 89 end
90 90
91 91 # Generates a link to an attachment.
92 92 # Options:
93 93 # * :text - Link text (default to attachment filename)
94 94 # * :download - Force download (default: false)
95 95 def link_to_attachment(attachment, options={})
96 96 text = options.delete(:text) || attachment.filename
97 97 action = options.delete(:download) ? 'download' : 'show'
98 98
99 99 link_to(h(text), {:controller => 'attachments', :action => action, :id => attachment, :filename => attachment.filename }, 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, project, options={})
106 106 text = options.delete(:text) || format_revision(revision)
107 107
108 108 link_to(text, {:controller => 'repositories', :action => 'revision', :id => project, :rev => revision}, :title => l(:label_revision_id, revision))
109 109 end
110 110
111 111 # Generates a link to a project if active
112 112 # Examples:
113 113 #
114 114 # link_to_project(project) # => link to the specified project overview
115 115 # link_to_project(project, :action=>'settings') # => link to project settings
116 116 # link_to_project(project, {:only_path => false}, :class => "project") # => 3rd arg adds html options
117 117 # link_to_project(project, {}, :class => "project") # => html options with default url (project overview)
118 118 #
119 119 def link_to_project(project, options={}, html_options = nil)
120 120 if project.active?
121 121 url = {:controller => 'projects', :action => 'show', :id => project}.merge(options)
122 122 link_to(h(project), url, html_options)
123 123 else
124 124 h(project)
125 125 end
126 126 end
127 127
128 128 def toggle_link(name, id, options={})
129 129 onclick = "Element.toggle('#{id}'); "
130 130 onclick << (options[:focus] ? "Form.Element.focus('#{options[:focus]}'); " : "this.blur(); ")
131 131 onclick << "return false;"
132 132 link_to(name, "#", :onclick => onclick)
133 133 end
134 134
135 135 def image_to_function(name, function, html_options = {})
136 136 html_options.symbolize_keys!
137 137 tag(:input, html_options.merge({
138 138 :type => "image", :src => image_path(name),
139 139 :onclick => (html_options[:onclick] ? "#{html_options[:onclick]}; " : "") + "#{function};"
140 140 }))
141 141 end
142 142
143 143 def prompt_to_remote(name, text, param, url, html_options = {})
144 144 html_options[:onclick] = "promptToRemote('#{text}', '#{param}', '#{url_for(url)}'); return false;"
145 145 link_to name, {}, html_options
146 146 end
147 147
148 148 def format_activity_title(text)
149 149 h(truncate_single_line(text, :length => 100))
150 150 end
151 151
152 152 def format_activity_day(date)
153 153 date == Date.today ? l(:label_today).titleize : format_date(date)
154 154 end
155 155
156 156 def format_activity_description(text)
157 157 h(truncate(text.to_s, :length => 120).gsub(%r{[\r\n]*<(pre|code)>.*$}m, '...')).gsub(/[\r\n]+/, "<br />")
158 158 end
159 159
160 160 def format_version_name(version)
161 161 if version.project == @project
162 162 h(version)
163 163 else
164 164 h("#{version.project} - #{version}")
165 165 end
166 166 end
167 167
168 168 def due_date_distance_in_words(date)
169 169 if date
170 170 l((date < Date.today ? :label_roadmap_overdue : :label_roadmap_due_in), distance_of_date_in_words(Date.today, date))
171 171 end
172 172 end
173 173
174 174 def render_page_hierarchy(pages, node=nil)
175 175 content = ''
176 176 if pages[node]
177 177 content << "<ul class=\"pages-hierarchy\">\n"
178 178 pages[node].each do |page|
179 179 content << "<li>"
180 180 content << link_to(h(page.pretty_title), {:controller => 'wiki', :action => 'show', :project_id => page.project, :id => page.title},
181 181 :title => (page.respond_to?(:updated_on) ? l(:label_updated_time, distance_of_time_in_words(Time.now, page.updated_on)) : nil))
182 182 content << "\n" + render_page_hierarchy(pages, page.id) if pages[page.id]
183 183 content << "</li>\n"
184 184 end
185 185 content << "</ul>\n"
186 186 end
187 187 content
188 188 end
189 189
190 190 # Renders flash messages
191 191 def render_flash_messages
192 192 s = ''
193 193 flash.each do |k,v|
194 194 s << content_tag('div', v, :class => "flash #{k}")
195 195 end
196 196 s
197 197 end
198 198
199 199 # Renders tabs and their content
200 200 def render_tabs(tabs)
201 201 if tabs.any?
202 202 render :partial => 'common/tabs', :locals => {:tabs => tabs}
203 203 else
204 204 content_tag 'p', l(:label_no_data), :class => "nodata"
205 205 end
206 206 end
207 207
208 208 # Renders the project quick-jump box
209 209 def render_project_jump_box
210 210 # Retrieve them now to avoid a COUNT query
211 211 projects = User.current.projects.all
212 212 if projects.any?
213 213 s = '<select onchange="if (this.value != \'\') { window.location = this.value; }">' +
214 214 "<option value=''>#{ l(:label_jump_to_a_project) }</option>" +
215 215 '<option value="" disabled="disabled">---</option>'
216 216 s << project_tree_options_for_select(projects, :selected => @project) do |p|
217 217 { :value => url_for(:controller => 'projects', :action => 'show', :id => p, :jump => current_menu_item) }
218 218 end
219 219 s << '</select>'
220 220 s
221 221 end
222 222 end
223 223
224 224 def project_tree_options_for_select(projects, options = {})
225 225 s = ''
226 226 project_tree(projects) do |project, level|
227 227 name_prefix = (level > 0 ? ('&nbsp;' * 2 * level + '&#187; ') : '')
228 228 tag_options = {:value => project.id}
229 229 if project == options[:selected] || (options[:selected].respond_to?(:include?) && options[:selected].include?(project))
230 230 tag_options[:selected] = 'selected'
231 231 else
232 232 tag_options[:selected] = nil
233 233 end
234 234 tag_options.merge!(yield(project)) if block_given?
235 235 s << content_tag('option', name_prefix + h(project), tag_options)
236 236 end
237 237 s
238 238 end
239 239
240 240 # Yields the given block for each project with its level in the tree
241 241 #
242 242 # Wrapper for Project#project_tree
243 243 def project_tree(projects, &block)
244 244 Project.project_tree(projects, &block)
245 245 end
246 246
247 247 def project_nested_ul(projects, &block)
248 248 s = ''
249 249 if projects.any?
250 250 ancestors = []
251 251 projects.sort_by(&:lft).each do |project|
252 252 if (ancestors.empty? || project.is_descendant_of?(ancestors.last))
253 253 s << "<ul>\n"
254 254 else
255 255 ancestors.pop
256 256 s << "</li>"
257 257 while (ancestors.any? && !project.is_descendant_of?(ancestors.last))
258 258 ancestors.pop
259 259 s << "</ul></li>\n"
260 260 end
261 261 end
262 262 s << "<li>"
263 263 s << yield(project).to_s
264 264 ancestors << project
265 265 end
266 266 s << ("</li></ul>\n" * ancestors.size)
267 267 end
268 268 s
269 269 end
270 270
271 271 def principals_check_box_tags(name, principals)
272 272 s = ''
273 273 principals.sort.each do |principal|
274 274 s << "<label>#{ check_box_tag name, principal.id, false } #{h principal}</label>\n"
275 275 end
276 276 s
277 277 end
278 278
279 279 # Truncates and returns the string as a single line
280 280 def truncate_single_line(string, *args)
281 281 truncate(string.to_s, *args).gsub(%r{[\r\n]+}m, ' ')
282 282 end
283 283
284 284 # Truncates at line break after 250 characters or options[:length]
285 285 def truncate_lines(string, options={})
286 286 length = options[:length] || 250
287 287 if string.to_s =~ /\A(.{#{length}}.*?)$/m
288 288 "#{$1}..."
289 289 else
290 290 string
291 291 end
292 292 end
293 293
294 294 def html_hours(text)
295 295 text.gsub(%r{(\d+)\.(\d+)}, '<span class="hours hours-int">\1</span><span class="hours hours-dec">.\2</span>')
296 296 end
297 297
298 298 def authoring(created, author, options={})
299 299 l(options[:label] || :label_added_time_by, :author => link_to_user(author), :age => time_tag(created))
300 300 end
301 301
302 302 def time_tag(time)
303 303 text = distance_of_time_in_words(Time.now, time)
304 304 if @project
305 305 link_to(text, {:controller => 'activities', :action => 'index', :id => @project, :from => time.to_date}, :title => format_time(time))
306 306 else
307 307 content_tag('acronym', text, :title => format_time(time))
308 308 end
309 309 end
310 310
311 311 def syntax_highlight(name, content)
312 312 Redmine::SyntaxHighlighting.highlight_by_filename(content, name)
313 313 end
314 314
315 315 def to_path_param(path)
316 316 path.to_s.split(%r{[/\\]}).select {|p| !p.blank?}
317 317 end
318 318
319 319 def pagination_links_full(paginator, count=nil, options={})
320 320 page_param = options.delete(:page_param) || :page
321 321 per_page_links = options.delete(:per_page_links)
322 322 url_param = params.dup
323 323 # don't reuse query params if filters are present
324 324 url_param.merge!(:fields => nil, :values => nil, :operators => nil) if url_param.delete(:set_filter)
325 325
326 326 html = ''
327 327 if paginator.current.previous
328 328 html << link_to_remote_content_update('&#171; ' + l(:label_previous), url_param.merge(page_param => paginator.current.previous)) + ' '
329 329 end
330 330
331 331 html << (pagination_links_each(paginator, options) do |n|
332 332 link_to_remote_content_update(n.to_s, url_param.merge(page_param => n))
333 333 end || '')
334 334
335 335 if paginator.current.next
336 336 html << ' ' + link_to_remote_content_update((l(:label_next) + ' &#187;'), url_param.merge(page_param => paginator.current.next))
337 337 end
338 338
339 339 unless count.nil?
340 340 html << " (#{paginator.current.first_item}-#{paginator.current.last_item}/#{count})"
341 341 if per_page_links != false && links = per_page_links(paginator.items_per_page)
342 342 html << " | #{links}"
343 343 end
344 344 end
345 345
346 346 html
347 347 end
348 348
349 349 def per_page_links(selected=nil)
350 350 url_param = params.dup
351 351 url_param.clear if url_param.has_key?(:set_filter)
352 352
353 353 links = Setting.per_page_options_array.collect do |n|
354 354 n == selected ? n : link_to_remote(n, {:update => "content",
355 355 :url => params.dup.merge(:per_page => n),
356 356 :method => :get},
357 357 {:href => url_for(url_param.merge(:per_page => n))})
358 358 end
359 359 links.size > 1 ? l(:label_display_per_page, links.join(', ')) : nil
360 360 end
361 361
362 362 def reorder_links(name, url)
363 363 link_to(image_tag('2uparrow.png', :alt => l(:label_sort_highest)), url.merge({"#{name}[move_to]" => 'highest'}), :method => :post, :title => l(:label_sort_highest)) +
364 364 link_to(image_tag('1uparrow.png', :alt => l(:label_sort_higher)), url.merge({"#{name}[move_to]" => 'higher'}), :method => :post, :title => l(:label_sort_higher)) +
365 365 link_to(image_tag('1downarrow.png', :alt => l(:label_sort_lower)), url.merge({"#{name}[move_to]" => 'lower'}), :method => :post, :title => l(:label_sort_lower)) +
366 366 link_to(image_tag('2downarrow.png', :alt => l(:label_sort_lowest)), url.merge({"#{name}[move_to]" => 'lowest'}), :method => :post, :title => l(:label_sort_lowest))
367 367 end
368 368
369 369 def breadcrumb(*args)
370 370 elements = args.flatten
371 371 elements.any? ? content_tag('p', args.join(' &#187; ') + ' &#187; ', :class => 'breadcrumb') : nil
372 372 end
373 373
374 374 def other_formats_links(&block)
375 375 concat('<p class="other-formats">' + l(:label_export_to))
376 376 yield Redmine::Views::OtherFormatsBuilder.new(self)
377 377 concat('</p>')
378 378 end
379 379
380 380 def page_header_title
381 381 if @project.nil? || @project.new_record?
382 382 h(Setting.app_title)
383 383 else
384 384 b = []
385 385 ancestors = (@project.root? ? [] : @project.ancestors.visible)
386 386 if ancestors.any?
387 387 root = ancestors.shift
388 388 b << link_to_project(root, {:jump => current_menu_item}, :class => 'root')
389 389 if ancestors.size > 2
390 390 b << '&#8230;'
391 391 ancestors = ancestors[-2, 2]
392 392 end
393 393 b += ancestors.collect {|p| link_to_project(p, {:jump => current_menu_item}, :class => 'ancestor') }
394 394 end
395 395 b << h(@project)
396 396 b.join(' &#187; ')
397 397 end
398 398 end
399 399
400 400 def html_title(*args)
401 401 if args.empty?
402 402 title = []
403 403 title << @project.name if @project
404 404 title += @html_title if @html_title
405 405 title << Setting.app_title
406 406 title.select {|t| !t.blank? }.join(' - ')
407 407 else
408 408 @html_title ||= []
409 409 @html_title += args
410 410 end
411 411 end
412 412
413 413 # Returns the theme, controller name, and action as css classes for the
414 414 # HTML body.
415 415 def body_css_classes
416 416 css = []
417 417 if theme = Redmine::Themes.theme(Setting.ui_theme)
418 418 css << 'theme-' + theme.name
419 419 end
420 420
421 421 css << 'controller-' + params[:controller]
422 422 css << 'action-' + params[:action]
423 423 css.join(' ')
424 424 end
425 425
426 426 def accesskey(s)
427 427 Redmine::AccessKeys.key_for s
428 428 end
429 429
430 430 # Formats text according to system settings.
431 431 # 2 ways to call this method:
432 432 # * with a String: textilizable(text, options)
433 433 # * with an object and one of its attribute: textilizable(issue, :description, options)
434 434 def textilizable(*args)
435 435 options = args.last.is_a?(Hash) ? args.pop : {}
436 436 case args.size
437 437 when 1
438 438 obj = options[:object]
439 439 text = args.shift
440 440 when 2
441 441 obj = args.shift
442 442 attr = args.shift
443 443 text = obj.send(attr).to_s
444 444 else
445 445 raise ArgumentError, 'invalid arguments to textilizable'
446 446 end
447 447 return '' if text.blank?
448 448 project = options[:project] || @project || (obj && obj.respond_to?(:project) ? obj.project : nil)
449 449 only_path = options.delete(:only_path) == false ? false : true
450 450
451 451 text = Redmine::WikiFormatting.to_html(Setting.text_formatting, text, :object => obj, :attribute => attr) { |macro, args| exec_macro(macro, obj, args) }
452 452
453 453 parse_non_pre_blocks(text) do |text|
454 454 [:parse_inline_attachments, :parse_wiki_links, :parse_redmine_links, :parse_headings].each do |method_name|
455 455 send method_name, text, project, obj, attr, only_path, options
456 456 end
457 457 end
458 458 end
459 459
460 460 def parse_non_pre_blocks(text)
461 461 s = StringScanner.new(text)
462 462 tags = []
463 463 parsed = ''
464 464 while !s.eos?
465 465 s.scan(/(.*?)(<(\/)?(pre|code)(.*?)>|\z)/im)
466 466 text, full_tag, closing, tag = s[1], s[2], s[3], s[4]
467 467 if tags.empty?
468 468 yield text
469 469 end
470 470 parsed << text
471 471 if tag
472 472 if closing
473 473 if tags.last == tag.downcase
474 474 tags.pop
475 475 end
476 476 else
477 477 tags << tag.downcase
478 478 end
479 479 parsed << full_tag
480 480 end
481 481 end
482 482 # Close any non closing tags
483 483 while tag = tags.pop
484 484 parsed << "</#{tag}>"
485 485 end
486 486 parsed
487 487 end
488 488
489 489 def parse_inline_attachments(text, project, obj, attr, only_path, options)
490 490 # when using an image link, try to use an attachment, if possible
491 491 if options[:attachments] || (obj && obj.respond_to?(:attachments))
492 492 attachments = nil
493 493 text.gsub!(/src="([^\/"]+\.(bmp|gif|jpg|jpeg|png))"(\s+alt="([^"]*)")?/i) do |m|
494 494 filename, ext, alt, alttext = $1.downcase, $2, $3, $4
495 495 attachments ||= (options[:attachments] || obj.attachments).sort_by(&:created_on).reverse
496 496 # search for the picture in attachments
497 497 if found = attachments.detect { |att| att.filename.downcase == filename }
498 498 image_url = url_for :only_path => only_path, :controller => 'attachments', :action => 'download', :id => found
499 499 desc = found.description.to_s.gsub('"', '')
500 500 if !desc.blank? && alttext.blank?
501 501 alt = " title=\"#{desc}\" alt=\"#{desc}\""
502 502 end
503 503 "src=\"#{image_url}\"#{alt}"
504 504 else
505 505 m
506 506 end
507 507 end
508 508 end
509 509 end
510 510
511 511 # Wiki links
512 512 #
513 513 # Examples:
514 514 # [[mypage]]
515 515 # [[mypage|mytext]]
516 516 # wiki links can refer other project wikis, using project name or identifier:
517 517 # [[project:]] -> wiki starting page
518 518 # [[project:|mytext]]
519 519 # [[project:mypage]]
520 520 # [[project:mypage|mytext]]
521 521 def parse_wiki_links(text, project, obj, attr, only_path, options)
522 522 text.gsub!(/(!)?(\[\[([^\]\n\|]+)(\|([^\]\n\|]+))?\]\])/) do |m|
523 523 link_project = project
524 524 esc, all, page, title = $1, $2, $3, $5
525 525 if esc.nil?
526 526 if page =~ /^([^\:]+)\:(.*)$/
527 527 link_project = Project.find_by_identifier($1) || Project.find_by_name($1)
528 528 page = $2
529 529 title ||= $1 if page.blank?
530 530 end
531 531
532 532 if link_project && link_project.wiki
533 533 # extract anchor
534 534 anchor = nil
535 535 if page =~ /^(.+?)\#(.+)$/
536 536 page, anchor = $1, $2
537 537 end
538 538 # check if page exists
539 539 wiki_page = link_project.wiki.find_page(page)
540 540 url = case options[:wiki_links]
541 541 when :local; "#{title}.html"
542 542 when :anchor; "##{title}" # used for single-file wiki export
543 543 else
544 544 wiki_page_id = page.present? ? Wiki.titleize(page) : nil
545 545 url_for(:only_path => only_path, :controller => 'wiki', :action => 'show', :project_id => link_project, :id => wiki_page_id, :anchor => anchor)
546 546 end
547 547 link_to((title || page), url, :class => ('wiki-page' + (wiki_page ? '' : ' new')))
548 548 else
549 549 # project or wiki doesn't exist
550 550 all
551 551 end
552 552 else
553 553 all
554 554 end
555 555 end
556 556 end
557 557
558 558 # Redmine links
559 559 #
560 560 # Examples:
561 561 # Issues:
562 562 # #52 -> Link to issue #52
563 563 # Changesets:
564 564 # r52 -> Link to revision 52
565 565 # commit:a85130f -> Link to scmid starting with a85130f
566 566 # Documents:
567 567 # document#17 -> Link to document with id 17
568 568 # document:Greetings -> Link to the document with title "Greetings"
569 569 # document:"Some document" -> Link to the document with title "Some document"
570 570 # Versions:
571 571 # version#3 -> Link to version with id 3
572 572 # version:1.0.0 -> Link to version named "1.0.0"
573 573 # version:"1.0 beta 2" -> Link to version named "1.0 beta 2"
574 574 # Attachments:
575 575 # attachment:file.zip -> Link to the attachment of the current object named file.zip
576 576 # Source files:
577 577 # source:some/file -> Link to the file located at /some/file in the project's repository
578 578 # source:some/file@52 -> Link to the file's revision 52
579 579 # source:some/file#L120 -> Link to line 120 of the file
580 580 # source:some/file@52#L120 -> Link to line 120 of the file's revision 52
581 581 # export:some/file -> Force the download of the file
582 582 # Forum messages:
583 583 # message#1218 -> Link to message with id 1218
584 584 def parse_redmine_links(text, project, obj, attr, only_path, options)
585 585 text.gsub!(%r{([\s\(,\-\[\>]|^)(!)?(attachment|document|version|commit|source|export|message|project)?((#|r)(\d+)|(:)([^"\s<>][^\s<>]*?|"[^"]+?"))(?=(?=[[:punct:]]\W)|,|\s|\]|<|$)}) do |m|
586 586 leading, esc, prefix, sep, identifier = $1, $2, $3, $5 || $7, $6 || $8
587 587 link = nil
588 588 if esc.nil?
589 589 if prefix.nil? && sep == 'r'
590 590 if project && (changeset = project.changesets.find_by_revision(identifier))
591 591 link = link_to("r#{identifier}", {:only_path => only_path, :controller => 'repositories', :action => 'revision', :id => project, :rev => changeset.revision},
592 592 :class => 'changeset',
593 593 :title => truncate_single_line(changeset.comments, :length => 100))
594 594 end
595 595 elsif sep == '#'
596 596 oid = identifier.to_i
597 597 case prefix
598 598 when nil
599 599 if issue = Issue.visible.find_by_id(oid, :include => :status)
600 600 link = link_to("##{oid}", {:only_path => only_path, :controller => 'issues', :action => 'show', :id => oid},
601 601 :class => issue.css_classes,
602 602 :title => "#{truncate(issue.subject, :length => 100)} (#{issue.status.name})")
603 603 end
604 604 when 'document'
605 605 if document = Document.find_by_id(oid, :include => [:project], :conditions => Project.visible_by(User.current))
606 606 link = link_to h(document.title), {:only_path => only_path, :controller => 'documents', :action => 'show', :id => document},
607 607 :class => 'document'
608 608 end
609 609 when 'version'
610 610 if version = Version.find_by_id(oid, :include => [:project], :conditions => Project.visible_by(User.current))
611 611 link = link_to h(version.name), {:only_path => only_path, :controller => 'versions', :action => 'show', :id => version},
612 612 :class => 'version'
613 613 end
614 614 when 'message'
615 615 if message = Message.find_by_id(oid, :include => [:parent, {:board => :project}], :conditions => Project.visible_by(User.current))
616 616 link = link_to h(truncate(message.subject, :length => 60)), {:only_path => only_path,
617 617 :controller => 'messages',
618 618 :action => 'show',
619 619 :board_id => message.board,
620 620 :id => message.root,
621 621 :anchor => (message.parent ? "message-#{message.id}" : nil)},
622 622 :class => 'message'
623 623 end
624 624 when 'project'
625 625 if p = Project.visible.find_by_id(oid)
626 626 link = link_to_project(p, {:only_path => only_path}, :class => 'project')
627 627 end
628 628 end
629 629 elsif sep == ':'
630 630 # removes the double quotes if any
631 631 name = identifier.gsub(%r{^"(.*)"$}, "\\1")
632 632 case prefix
633 633 when 'document'
634 634 if project && document = project.documents.find_by_title(name)
635 635 link = link_to h(document.title), {:only_path => only_path, :controller => 'documents', :action => 'show', :id => document},
636 636 :class => 'document'
637 637 end
638 638 when 'version'
639 639 if project && version = project.versions.find_by_name(name)
640 640 link = link_to h(version.name), {:only_path => only_path, :controller => 'versions', :action => 'show', :id => version},
641 641 :class => 'version'
642 642 end
643 643 when 'commit'
644 644 if project && (changeset = project.changesets.find(:first, :conditions => ["scmid LIKE ?", "#{name}%"]))
645 645 link = link_to h("#{name}"), {:only_path => only_path, :controller => 'repositories', :action => 'revision', :id => project, :rev => changeset.revision},
646 646 :class => 'changeset',
647 647 :title => truncate_single_line(changeset.comments, :length => 100)
648 648 end
649 649 when 'source', 'export'
650 650 if project && project.repository
651 651 name =~ %r{^[/\\]*(.*?)(@([0-9a-f]+))?(#(L\d+))?$}
652 652 path, rev, anchor = $1, $3, $5
653 653 link = link_to h("#{prefix}:#{name}"), {:controller => 'repositories', :action => 'entry', :id => project,
654 654 :path => to_path_param(path),
655 655 :rev => rev,
656 656 :anchor => anchor,
657 657 :format => (prefix == 'export' ? 'raw' : nil)},
658 658 :class => (prefix == 'export' ? 'source download' : 'source')
659 659 end
660 660 when 'attachment'
661 661 attachments = options[:attachments] || (obj && obj.respond_to?(:attachments) ? obj.attachments : nil)
662 662 if attachments && attachment = attachments.detect {|a| a.filename == name }
663 663 link = link_to h(attachment.filename), {:only_path => only_path, :controller => 'attachments', :action => 'download', :id => attachment},
664 664 :class => 'attachment'
665 665 end
666 666 when 'project'
667 667 if p = Project.visible.find(:first, :conditions => ["identifier = :s OR LOWER(name) = :s", {:s => name.downcase}])
668 668 link = link_to_project(p, {:only_path => only_path}, :class => 'project')
669 669 end
670 670 end
671 671 end
672 672 end
673 673 leading + (link || "#{prefix}#{sep}#{identifier}")
674 674 end
675 675 end
676 676
677 677 TOC_RE = /<p>\{\{([<>]?)toc\}\}<\/p>/i unless const_defined?(:TOC_RE)
678 678 HEADING_RE = /<h(1|2|3|4)( [^>]+)?>(.+?)<\/h(1|2|3|4)>/i unless const_defined?(:HEADING_RE)
679 679
680 680 # Headings and TOC
681 681 # Adds ids and links to headings and renders the TOC if needed unless options[:headings] is set to false
682 682 def parse_headings(text, project, obj, attr, only_path, options)
683 683 headings = []
684 684 text.gsub!(HEADING_RE) do
685 685 level, attrs, content = $1.to_i, $2, $3
686 686 item = strip_tags(content).strip
687 687 anchor = item.gsub(%r{[^\w\s\-]}, '').gsub(%r{\s+(\-+\s*)?}, '-')
688 688 headings << [level, anchor, item]
689 689 "<h#{level} #{attrs} id=\"#{anchor}\">#{content}<a href=\"##{anchor}\" class=\"wiki-anchor\">&para;</a></h#{level}>"
690 690 end unless options[:headings] == false
691 691
692 692 text.gsub!(TOC_RE) do
693 693 if headings.empty?
694 694 ''
695 695 else
696 696 div_class = 'toc'
697 697 div_class << ' right' if $1 == '>'
698 698 div_class << ' left' if $1 == '<'
699 699 out = "<ul class=\"#{div_class}\"><li>"
700 700 root = headings.map(&:first).min
701 701 current = root
702 702 started = false
703 703 headings.each do |level, anchor, item|
704 704 if level > current
705 705 out << '<ul><li>' * (level - current)
706 706 elsif level < current
707 707 out << "</li></ul>\n" * (current - level) + "</li><li>"
708 708 elsif started
709 709 out << '</li><li>'
710 710 end
711 711 out << "<a href=\"##{anchor}\">#{item}</a>"
712 712 current = level
713 713 started = true
714 714 end
715 715 out << '</li></ul>' * (current - root)
716 716 out << '</li></ul>'
717 717 end
718 718 end
719 719 end
720 720
721 721 # Same as Rails' simple_format helper without using paragraphs
722 722 def simple_format_without_paragraph(text)
723 723 text.to_s.
724 724 gsub(/\r\n?/, "\n"). # \r\n and \r -> \n
725 725 gsub(/\n\n+/, "<br /><br />"). # 2+ newline -> 2 br
726 726 gsub(/([^\n]\n)(?=[^\n])/, '\1<br />') # 1 newline -> br
727 727 end
728 728
729 729 def lang_options_for_select(blank=true)
730 730 (blank ? [["(auto)", ""]] : []) +
731 731 valid_languages.collect{|lang| [ ll(lang.to_s, :general_lang_name), lang.to_s]}.sort{|x,y| x.last <=> y.last }
732 732 end
733 733
734 734 def label_tag_for(name, option_tags = nil, options = {})
735 735 label_text = l(("field_"+field.to_s.gsub(/\_id$/, "")).to_sym) + (options.delete(:required) ? @template.content_tag("span", " *", :class => "required"): "")
736 736 content_tag("label", label_text)
737 737 end
738 738
739 739 def labelled_tabular_form_for(name, object, options, &proc)
740 740 options[:html] ||= {}
741 741 options[:html][:class] = 'tabular' unless options[:html].has_key?(:class)
742 742 form_for(name, object, options.merge({ :builder => TabularFormBuilder, :lang => current_language}), &proc)
743 743 end
744 744
745 745 def back_url_hidden_field_tag
746 746 back_url = params[:back_url] || request.env['HTTP_REFERER']
747 747 back_url = CGI.unescape(back_url.to_s)
748 748 hidden_field_tag('back_url', CGI.escape(back_url)) unless back_url.blank?
749 749 end
750 750
751 751 def check_all_links(form_name)
752 752 link_to_function(l(:button_check_all), "checkAll('#{form_name}', true)") +
753 753 " | " +
754 754 link_to_function(l(:button_uncheck_all), "checkAll('#{form_name}', false)")
755 755 end
756 756
757 757 def progress_bar(pcts, options={})
758 758 pcts = [pcts, pcts] unless pcts.is_a?(Array)
759 759 pcts = pcts.collect(&:round)
760 760 pcts[1] = pcts[1] - pcts[0]
761 761 pcts << (100 - pcts[1] - pcts[0])
762 762 width = options[:width] || '100px;'
763 763 legend = options[:legend] || ''
764 764 content_tag('table',
765 765 content_tag('tr',
766 766 (pcts[0] > 0 ? content_tag('td', '', :style => "width: #{pcts[0]}%;", :class => 'closed') : '') +
767 767 (pcts[1] > 0 ? content_tag('td', '', :style => "width: #{pcts[1]}%;", :class => 'done') : '') +
768 768 (pcts[2] > 0 ? content_tag('td', '', :style => "width: #{pcts[2]}%;", :class => 'todo') : '')
769 769 ), :class => 'progress', :style => "width: #{width};") +
770 770 content_tag('p', legend, :class => 'pourcent')
771 771 end
772 772
773 773 def checked_image(checked=true)
774 774 if checked
775 775 image_tag 'toggle_check.png'
776 776 end
777 777 end
778 778
779 779 def context_menu(url)
780 780 unless @context_menu_included
781 781 content_for :header_tags do
782 782 javascript_include_tag('context_menu') +
783 783 stylesheet_link_tag('context_menu')
784 784 end
785 785 if l(:direction) == 'rtl'
786 786 content_for :header_tags do
787 787 stylesheet_link_tag('context_menu_rtl')
788 788 end
789 789 end
790 790 @context_menu_included = true
791 791 end
792 792 javascript_tag "new ContextMenu('#{ url_for(url) }')"
793 793 end
794 794
795 795 def context_menu_link(name, url, options={})
796 796 options[:class] ||= ''
797 797 if options.delete(:selected)
798 798 options[:class] << ' icon-checked disabled'
799 799 options[:disabled] = true
800 800 end
801 801 if options.delete(:disabled)
802 802 options.delete(:method)
803 803 options.delete(:confirm)
804 804 options.delete(:onclick)
805 805 options[:class] << ' disabled'
806 806 url = '#'
807 807 end
808 808 link_to name, url, options
809 809 end
810 810
811 811 def calendar_for(field_id)
812 812 include_calendar_headers_tags
813 813 image_tag("calendar.png", {:id => "#{field_id}_trigger",:class => "calendar-trigger"}) +
814 814 javascript_tag("Calendar.setup({inputField : '#{field_id}', ifFormat : '%Y-%m-%d', button : '#{field_id}_trigger' });")
815 815 end
816 816
817 817 def include_calendar_headers_tags
818 818 unless @calendar_headers_tags_included
819 819 @calendar_headers_tags_included = true
820 820 content_for :header_tags do
821 821 start_of_week = case Setting.start_of_week.to_i
822 822 when 1
823 823 'Calendar._FD = 1;' # Monday
824 824 when 7
825 825 'Calendar._FD = 0;' # Sunday
826 826 else
827 827 '' # use language
828 828 end
829 829
830 830 javascript_include_tag('calendar/calendar') +
831 831 javascript_include_tag("calendar/lang/calendar-#{current_language.to_s.downcase}.js") +
832 832 javascript_tag(start_of_week) +
833 833 javascript_include_tag('calendar/calendar-setup') +
834 834 stylesheet_link_tag('calendar')
835 835 end
836 836 end
837 837 end
838 838
839 839 def content_for(name, content = nil, &block)
840 840 @has_content ||= {}
841 841 @has_content[name] = true
842 842 super(name, content, &block)
843 843 end
844 844
845 845 def has_content?(name)
846 846 (@has_content && @has_content[name]) || false
847 847 end
848 848
849 849 # Returns the avatar image tag for the given +user+ if avatars are enabled
850 850 # +user+ can be a User or a string that will be scanned for an email address (eg. 'joe <joe@foo.bar>')
851 851 def avatar(user, options = { })
852 852 if Setting.gravatar_enabled?
853 853 options.merge!({:ssl => (defined?(request) && request.ssl?), :default => Setting.gravatar_default})
854 854 email = nil
855 855 if user.respond_to?(:mail)
856 856 email = user.mail
857 857 elsif user.to_s =~ %r{<(.+?)>}
858 858 email = $1
859 859 end
860 860 return gravatar(email.to_s.downcase, options) unless email.blank? rescue nil
861 861 else
862 862 ''
863 863 end
864 864 end
865 865
866 866 def favicon
867 867 "<link rel='shortcut icon' href='#{image_path('/favicon.ico')}' />"
868 868 end
869 869
870 870 # Returns true if arg is expected in the API response
871 871 def include_in_api_response?(arg)
872 872 unless @included_in_api_response
873 873 param = params[:include]
874 874 @included_in_api_response = param.is_a?(Array) ? param.collect(&:to_s) : param.to_s.split(',')
875 875 @included_in_api_response.collect!(&:strip)
876 876 end
877 877 @included_in_api_response.include?(arg.to_s)
878 878 end
879 879
880 # Returns options or nil if nometa param or X-Redmine-Nometa header
881 # was set in the request
882 def api_meta(options)
883 if params[:nometa].present? || request.headers['X-Redmine-Nometa']
884 # compatibility mode for activeresource clients that raise
885 # an error when unserializing an array with attributes
886 nil
887 else
888 options
889 end
890 end
891
880 892 private
881 893
882 894 def wiki_helper
883 895 helper = Redmine::WikiFormatting.helper_for(Setting.text_formatting)
884 896 extend helper
885 897 return self
886 898 end
887 899
888 900 def link_to_remote_content_update(text, url_params)
889 901 link_to_remote(text,
890 902 {:url => url_params, :method => :get, :update => 'content', :complete => 'window.scrollTo(0,0)'},
891 903 {:href => url_for(:params => url_params)}
892 904 )
893 905 end
894 906
895 907 end
@@ -1,28 +1,28
1 api.array :issues do
1 api.array :issues, api_meta(:total_count => @issue_count, :offset => @offset, :limit => @limit) do
2 2 @issues.each do |issue|
3 3 api.issue do
4 4 api.id issue.id
5 5 api.project(:id => issue.project_id, :name => issue.project.name) unless issue.project.nil?
6 6 api.tracker(:id => issue.tracker_id, :name => issue.tracker.name) unless issue.tracker.nil?
7 7 api.status(:id => issue.status_id, :name => issue.status.name) unless issue.status.nil?
8 8 api.priority(:id => issue.priority_id, :name => issue.priority.name) unless issue.priority.nil?
9 9 api.author(:id => issue.author_id, :name => issue.author.name) unless issue.author.nil?
10 10 api.assigned_to(:id => issue.assigned_to_id, :name => issue.assigned_to.name) unless issue.assigned_to.nil?
11 11 api.category(:id => issue.category_id, :name => issue.category.name) unless issue.category.nil?
12 12 api.fixed_version(:id => issue.fixed_version_id, :name => issue.fixed_version.name) unless issue.fixed_version.nil?
13 13 api.parent(:id => issue.parent_id) unless issue.parent.nil?
14 14
15 15 api.subject issue.subject
16 16 api.description issue.description
17 17 api.start_date issue.start_date
18 18 api.due_date issue.due_date
19 19 api.done_ratio issue.done_ratio
20 20 api.estimated_hours issue.estimated_hours
21 21
22 22 render_api_custom_values issue.custom_field_values, api
23 23
24 24 api.created_on issue.created_on
25 25 api.updated_on issue.updated_on
26 26 end
27 27 end
28 28 end
@@ -1,15 +1,15
1 api.array :users do
1 api.array :users, api_meta(:total_count => @user_count, :offset => @offset, :limit => @limit) do
2 2 @users.each do |user|
3 3 api.user do
4 4 api.id user.id
5 5 api.login user.login
6 6 api.firstname user.firstname
7 7 api.lastname user.lastname
8 8 api.mail user.mail
9 9 api.created_on user.created_on
10 10 api.last_login_on user.last_login_on
11 11
12 12 render_api_custom_values user.visible_custom_field_values, api
13 13 end
14 14 end
15 15 end
@@ -1,74 +1,75
1 1 # Redmine - project management software
2 2 # Copyright (C) 2006-2010 Jean-Philippe Lang
3 3 #
4 4 # This program is free software; you can redistribute it and/or
5 5 # modify it under the terms of the GNU General Public License
6 6 # as published by the Free Software Foundation; either version 2
7 7 # of the License, or (at your option) any later version.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU General Public License
15 15 # along with this program; if not, write to the Free Software
16 16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17 17
18 18 require 'blankslate'
19 19
20 20 module Redmine
21 21 module Views
22 22 module Builders
23 23 class Structure < BlankSlate
24 24 def initialize
25 25 @struct = [{}]
26 26 end
27 27
28 def array(tag, &block)
28 def array(tag, options={}, &block)
29 29 @struct << []
30 30 block.call(self)
31 31 ret = @struct.pop
32 32 @struct.last[tag] = ret
33 @struct.last.merge!(options) if options
33 34 end
34 35
35 36 def method_missing(sym, *args, &block)
36 37 if args.any?
37 38 if args.first.is_a?(Hash)
38 39 if @struct.last.is_a?(Array)
39 40 @struct.last << args.first unless block
40 41 else
41 42 @struct.last[sym] = args.first
42 43 end
43 44 else
44 45 if @struct.last.is_a?(Array)
45 46 @struct.last << (args.last || {}).merge(:value => args.first)
46 47 else
47 48 @struct.last[sym] = args.first
48 49 end
49 50 end
50 51 end
51 52
52 53 if block
53 54 @struct << (args.first.is_a?(Hash) ? args.first : {})
54 55 block.call(self)
55 56 ret = @struct.pop
56 57 if @struct.last.is_a?(Array)
57 58 @struct.last << ret
58 59 else
59 60 if @struct.last.has_key?(sym) && @struct.last[sym].is_a?(Hash)
60 61 @struct.last[sym].merge! ret
61 62 else
62 63 @struct.last[sym] = ret
63 64 end
64 65 end
65 66 end
66 67 end
67 68
68 69 def output
69 70 raise "Need to implement #{self.class.name}#output"
70 71 end
71 72 end
72 73 end
73 74 end
74 75 end
@@ -1,45 +1,45
1 1 # Redmine - project management software
2 2 # Copyright (C) 2006-2010 Jean-Philippe Lang
3 3 #
4 4 # This program is free software; you can redistribute it and/or
5 5 # modify it under the terms of the GNU General Public License
6 6 # as published by the Free Software Foundation; either version 2
7 7 # of the License, or (at your option) any later version.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU General Public License
15 15 # along with this program; if not, write to the Free Software
16 16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17 17
18 18 module Redmine
19 19 module Views
20 20 module Builders
21 21 class Xml < ::Builder::XmlMarkup
22 22 def initialize
23 23 super
24 24 instruct!
25 25 end
26 26
27 27 def output
28 28 target!
29 29 end
30 30
31 31 def method_missing(sym, *args, &block)
32 32 if args.size == 1 && args.first.is_a?(Time)
33 33 __send__ sym, args.first.xmlschema, &block
34 34 else
35 35 super
36 36 end
37 37 end
38 38
39 39 def array(name, options={}, &block)
40 __send__ name, options.merge(:type => 'array'), &block
40 __send__ name, (options || {}).merge(:type => 'array'), &block
41 41 end
42 42 end
43 43 end
44 44 end
45 45 end
@@ -1,471 +1,521
1 1 # Redmine - project management software
2 2 # Copyright (C) 2006-2010 Jean-Philippe Lang
3 3 #
4 4 # This program is free software; you can redistribute it and/or
5 5 # modify it under the terms of the GNU General Public License
6 6 # as published by the Free Software Foundation; either version 2
7 7 # of the License, or (at your option) any later version.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU General Public License
15 15 # along with this program; if not, write to the Free Software
16 16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17 17
18 18 require "#{File.dirname(__FILE__)}/../../test_helper"
19 19
20 20 class ApiTest::IssuesTest < ActionController::IntegrationTest
21 21 fixtures :projects,
22 22 :users,
23 23 :roles,
24 24 :members,
25 25 :member_roles,
26 26 :issues,
27 27 :issue_statuses,
28 28 :versions,
29 29 :trackers,
30 30 :projects_trackers,
31 31 :issue_categories,
32 32 :enabled_modules,
33 33 :enumerations,
34 34 :attachments,
35 35 :workflows,
36 36 :custom_fields,
37 37 :custom_values,
38 38 :custom_fields_projects,
39 39 :custom_fields_trackers,
40 40 :time_entries,
41 41 :journals,
42 42 :journal_details,
43 43 :queries
44 44
45 45 def setup
46 46 Setting.rest_api_enabled = '1'
47 47 end
48 48
49 context "/index.xml" do
49 50 # Use a private project to make sure auth is really working and not just
50 51 # only showing public issues.
51 context "/index.xml" do
52 52 should_allow_api_authentication(:get, "/projects/private-child/issues.xml")
53
54 should "contain metadata" do
55 get '/issues.xml'
56
57 assert_tag :tag => 'issues',
58 :attributes => {
59 :type => 'array',
60 :total_count => assigns(:issue_count),
61 :limit => 25,
62 :offset => 0
63 }
64 end
65
66 context "with offset and limit" do
67 should "use the params" do
68 get '/issues.xml?offset=2&limit=3'
69
70 assert_equal 3, assigns(:limit)
71 assert_equal 2, assigns(:offset)
72 assert_tag :tag => 'issues', :children => {:count => 3, :only => {:tag => 'issue'}}
73 end
74 end
75
76 context "with nometa param" do
77 should "not contain metadata" do
78 get '/issues.xml?nometa=1'
79
80 assert_tag :tag => 'issues',
81 :attributes => {
82 :type => 'array',
83 :total_count => nil,
84 :limit => nil,
85 :offset => nil
86 }
87 end
88 end
89
90 context "with nometa header" do
91 should "not contain metadata" do
92 get '/issues.xml', {}, {'X-Redmine-Nometa' => '1'}
93
94 assert_tag :tag => 'issues',
95 :attributes => {
96 :type => 'array',
97 :total_count => nil,
98 :limit => nil,
99 :offset => nil
100 }
101 end
102 end
53 103 end
54 104
55 105 context "/index.json" do
56 106 should_allow_api_authentication(:get, "/projects/private-child/issues.json")
57 107 end
58 108
59 109 context "/index.xml with filter" do
60 110 should_allow_api_authentication(:get, "/projects/private-child/issues.xml?status_id=5")
61 111
62 112 should "show only issues with the status_id" do
63 113 get '/issues.xml?status_id=5'
64 114 assert_tag :tag => 'issues',
65 115 :children => { :count => Issue.visible.count(:conditions => {:status_id => 5}),
66 116 :only => { :tag => 'issue' } }
67 117 end
68 118 end
69 119
70 120 context "/index.json with filter" do
71 121 should_allow_api_authentication(:get, "/projects/private-child/issues.json?status_id=5")
72 122
73 123 should "show only issues with the status_id" do
74 124 get '/issues.json?status_id=5'
75 125
76 126 json = ActiveSupport::JSON.decode(response.body)
77 127 status_ids_used = json['issues'].collect {|j| j['status']['id'] }
78 128 assert_equal 3, status_ids_used.length
79 129 assert status_ids_used.all? {|id| id == 5 }
80 130 end
81 131
82 132 end
83 133
84 134 # Issue 6 is on a private project
85 135 context "/issues/6.xml" do
86 136 should_allow_api_authentication(:get, "/issues/6.xml")
87 137 end
88 138
89 139 context "/issues/6.json" do
90 140 should_allow_api_authentication(:get, "/issues/6.json")
91 141 end
92 142
93 143 context "GET /issues/:id" do
94 144 context "with journals" do
95 145 context ".xml" do
96 146 should "display journals" do
97 147 get '/issues/1.xml?include=journals'
98 148
99 149 assert_tag :tag => 'issue',
100 150 :child => {
101 151 :tag => 'journals',
102 152 :attributes => { :type => 'array' },
103 153 :child => {
104 154 :tag => 'journal',
105 155 :attributes => { :id => '1'},
106 156 :child => {
107 157 :tag => 'details',
108 158 :attributes => { :type => 'array' },
109 159 :child => {
110 160 :tag => 'detail',
111 161 :attributes => { :name => 'status_id' },
112 162 :child => {
113 163 :tag => 'old_value',
114 164 :content => '1',
115 165 :sibling => {
116 166 :tag => 'new_value',
117 167 :content => '2'
118 168 }
119 169 }
120 170 }
121 171 }
122 172 }
123 173 }
124 174 end
125 175 end
126 176 end
127 177
128 178 context "with custom fields" do
129 179 context ".xml" do
130 180 should "display custom fields" do
131 181 get '/issues/3.xml'
132 182
133 183 assert_tag :tag => 'issue',
134 184 :child => {
135 185 :tag => 'custom_fields',
136 186 :attributes => { :type => 'array' },
137 187 :child => {
138 188 :tag => 'custom_field',
139 189 :attributes => { :id => '1'},
140 190 :child => {
141 191 :tag => 'value',
142 192 :content => 'MySQL'
143 193 }
144 194 }
145 195 }
146 196
147 197 assert_nothing_raised do
148 198 Hash.from_xml(response.body).to_xml
149 199 end
150 200 end
151 201 end
152 202 end
153 203
154 204 context "with subtasks" do
155 205 setup do
156 206 @c1 = Issue.generate!(:status_id => 1, :subject => "child c1", :tracker_id => 1, :project_id => 1, :parent_issue_id => 1)
157 207 @c2 = Issue.generate!(:status_id => 1, :subject => "child c2", :tracker_id => 1, :project_id => 1, :parent_issue_id => 1)
158 208 @c3 = Issue.generate!(:status_id => 1, :subject => "child c3", :tracker_id => 1, :project_id => 1, :parent_issue_id => @c1.id)
159 209 end
160 210
161 211 context ".xml" do
162 212 should "display children" do
163 213 get '/issues/1.xml?include=children'
164 214
165 215 assert_tag :tag => 'issue',
166 216 :child => {
167 217 :tag => 'children',
168 218 :children => {:count => 2},
169 219 :child => {
170 220 :tag => 'issue',
171 221 :attributes => {:id => @c1.id.to_s},
172 222 :child => {
173 223 :tag => 'subject',
174 224 :content => 'child c1',
175 225 :sibling => {
176 226 :tag => 'children',
177 227 :children => {:count => 1},
178 228 :child => {
179 229 :tag => 'issue',
180 230 :attributes => {:id => @c3.id.to_s}
181 231 }
182 232 }
183 233 }
184 234 }
185 235 }
186 236 end
187 237
188 238 context ".json" do
189 239 should "display children" do
190 240 get '/issues/1.json?include=children'
191 241
192 242 json = ActiveSupport::JSON.decode(response.body)
193 243 assert_equal([
194 244 {
195 245 'id' => @c1.id, 'subject' => 'child c1', 'tracker' => {'id' => 1, 'name' => 'Bug'},
196 246 'children' => [{ 'id' => @c3.id, 'subject' => 'child c3', 'tracker' => {'id' => 1, 'name' => 'Bug'} }]
197 247 },
198 248 { 'id' => @c2.id, 'subject' => 'child c2', 'tracker' => {'id' => 1, 'name' => 'Bug'} }
199 249 ],
200 250 json['issue']['children'])
201 251 end
202 252 end
203 253 end
204 254 end
205 255 end
206 256
207 257 context "POST /issues.xml" do
208 258 should_allow_api_authentication(:post,
209 259 '/issues.xml',
210 260 {:issue => {:project_id => 1, :subject => 'API test', :tracker_id => 2, :status_id => 3}},
211 261 {:success_code => :created})
212 262
213 263 should "create an issue with the attributes" do
214 264 assert_difference('Issue.count') do
215 265 post '/issues.xml', {:issue => {:project_id => 1, :subject => 'API test', :tracker_id => 2, :status_id => 3}}, :authorization => credentials('jsmith')
216 266 end
217 267
218 268 issue = Issue.first(:order => 'id DESC')
219 269 assert_equal 1, issue.project_id
220 270 assert_equal 2, issue.tracker_id
221 271 assert_equal 3, issue.status_id
222 272 assert_equal 'API test', issue.subject
223 273
224 274 assert_response :created
225 275 assert_equal 'application/xml', @response.content_type
226 276 assert_tag 'issue', :child => {:tag => 'id', :content => issue.id.to_s}
227 277 end
228 278 end
229 279
230 280 context "POST /issues.xml with failure" do
231 281 should_allow_api_authentication(:post,
232 282 '/issues.xml',
233 283 {:issue => {:project_id => 1}},
234 284 {:success_code => :unprocessable_entity})
235 285
236 286 should "have an errors tag" do
237 287 assert_no_difference('Issue.count') do
238 288 post '/issues.xml', {:issue => {:project_id => 1}}, :authorization => credentials('jsmith')
239 289 end
240 290
241 291 assert_tag :errors, :child => {:tag => 'error', :content => "Subject can't be blank"}
242 292 end
243 293 end
244 294
245 295 context "POST /issues.json" do
246 296 should_allow_api_authentication(:post,
247 297 '/issues.json',
248 298 {:issue => {:project_id => 1, :subject => 'API test', :tracker_id => 2, :status_id => 3}},
249 299 {:success_code => :created})
250 300
251 301 should "create an issue with the attributes" do
252 302 assert_difference('Issue.count') do
253 303 post '/issues.json', {:issue => {:project_id => 1, :subject => 'API test', :tracker_id => 2, :status_id => 3}}, :authorization => credentials('jsmith')
254 304 end
255 305
256 306 issue = Issue.first(:order => 'id DESC')
257 307 assert_equal 1, issue.project_id
258 308 assert_equal 2, issue.tracker_id
259 309 assert_equal 3, issue.status_id
260 310 assert_equal 'API test', issue.subject
261 311 end
262 312
263 313 end
264 314
265 315 context "POST /issues.json with failure" do
266 316 should_allow_api_authentication(:post,
267 317 '/issues.json',
268 318 {:issue => {:project_id => 1}},
269 319 {:success_code => :unprocessable_entity})
270 320
271 321 should "have an errors element" do
272 322 assert_no_difference('Issue.count') do
273 323 post '/issues.json', {:issue => {:project_id => 1}}, :authorization => credentials('jsmith')
274 324 end
275 325
276 326 json = ActiveSupport::JSON.decode(response.body)
277 327 assert json['errors'].include?(['subject', "can't be blank"])
278 328 end
279 329 end
280 330
281 331 # Issue 6 is on a private project
282 332 context "PUT /issues/6.xml" do
283 333 setup do
284 334 @parameters = {:issue => {:subject => 'API update', :notes => 'A new note'}}
285 335 @headers = { :authorization => credentials('jsmith') }
286 336 end
287 337
288 338 should_allow_api_authentication(:put,
289 339 '/issues/6.xml',
290 340 {:issue => {:subject => 'API update', :notes => 'A new note'}},
291 341 {:success_code => :ok})
292 342
293 343 should "not create a new issue" do
294 344 assert_no_difference('Issue.count') do
295 345 put '/issues/6.xml', @parameters, @headers
296 346 end
297 347 end
298 348
299 349 should "create a new journal" do
300 350 assert_difference('Journal.count') do
301 351 put '/issues/6.xml', @parameters, @headers
302 352 end
303 353 end
304 354
305 355 should "add the note to the journal" do
306 356 put '/issues/6.xml', @parameters, @headers
307 357
308 358 journal = Journal.last
309 359 assert_equal "A new note", journal.notes
310 360 end
311 361
312 362 should "update the issue" do
313 363 put '/issues/6.xml', @parameters, @headers
314 364
315 365 issue = Issue.find(6)
316 366 assert_equal "API update", issue.subject
317 367 end
318 368
319 369 end
320 370
321 371 context "PUT /issues/3.xml with custom fields" do
322 372 setup do
323 373 @parameters = {:issue => {:custom_fields => [{'id' => '1', 'value' => 'PostgreSQL' }, {'id' => '2', 'value' => '150'}]}}
324 374 @headers = { :authorization => credentials('jsmith') }
325 375 end
326 376
327 377 should "update custom fields" do
328 378 assert_no_difference('Issue.count') do
329 379 put '/issues/3.xml', @parameters, @headers
330 380 end
331 381
332 382 issue = Issue.find(3)
333 383 assert_equal '150', issue.custom_value_for(2).value
334 384 assert_equal 'PostgreSQL', issue.custom_value_for(1).value
335 385 end
336 386 end
337 387
338 388 context "PUT /issues/6.xml with failed update" do
339 389 setup do
340 390 @parameters = {:issue => {:subject => ''}}
341 391 @headers = { :authorization => credentials('jsmith') }
342 392 end
343 393
344 394 should_allow_api_authentication(:put,
345 395 '/issues/6.xml',
346 396 {:issue => {:subject => ''}}, # Missing subject should fail
347 397 {:success_code => :unprocessable_entity})
348 398
349 399 should "not create a new issue" do
350 400 assert_no_difference('Issue.count') do
351 401 put '/issues/6.xml', @parameters, @headers
352 402 end
353 403 end
354 404
355 405 should "not create a new journal" do
356 406 assert_no_difference('Journal.count') do
357 407 put '/issues/6.xml', @parameters, @headers
358 408 end
359 409 end
360 410
361 411 should "have an errors tag" do
362 412 put '/issues/6.xml', @parameters, @headers
363 413
364 414 assert_tag :errors, :child => {:tag => 'error', :content => "Subject can't be blank"}
365 415 end
366 416 end
367 417
368 418 context "PUT /issues/6.json" do
369 419 setup do
370 420 @parameters = {:issue => {:subject => 'API update', :notes => 'A new note'}}
371 421 @headers = { :authorization => credentials('jsmith') }
372 422 end
373 423
374 424 should_allow_api_authentication(:put,
375 425 '/issues/6.json',
376 426 {:issue => {:subject => 'API update', :notes => 'A new note'}},
377 427 {:success_code => :ok})
378 428
379 429 should "not create a new issue" do
380 430 assert_no_difference('Issue.count') do
381 431 put '/issues/6.json', @parameters, @headers
382 432 end
383 433 end
384 434
385 435 should "create a new journal" do
386 436 assert_difference('Journal.count') do
387 437 put '/issues/6.json', @parameters, @headers
388 438 end
389 439 end
390 440
391 441 should "add the note to the journal" do
392 442 put '/issues/6.json', @parameters, @headers
393 443
394 444 journal = Journal.last
395 445 assert_equal "A new note", journal.notes
396 446 end
397 447
398 448 should "update the issue" do
399 449 put '/issues/6.json', @parameters, @headers
400 450
401 451 issue = Issue.find(6)
402 452 assert_equal "API update", issue.subject
403 453 end
404 454
405 455 end
406 456
407 457 context "PUT /issues/6.json with failed update" do
408 458 setup do
409 459 @parameters = {:issue => {:subject => ''}}
410 460 @headers = { :authorization => credentials('jsmith') }
411 461 end
412 462
413 463 should_allow_api_authentication(:put,
414 464 '/issues/6.json',
415 465 {:issue => {:subject => ''}}, # Missing subject should fail
416 466 {:success_code => :unprocessable_entity})
417 467
418 468 should "not create a new issue" do
419 469 assert_no_difference('Issue.count') do
420 470 put '/issues/6.json', @parameters, @headers
421 471 end
422 472 end
423 473
424 474 should "not create a new journal" do
425 475 assert_no_difference('Journal.count') do
426 476 put '/issues/6.json', @parameters, @headers
427 477 end
428 478 end
429 479
430 480 should "have an errors attribute" do
431 481 put '/issues/6.json', @parameters, @headers
432 482
433 483 json = ActiveSupport::JSON.decode(response.body)
434 484 assert json['errors'].include?(['subject', "can't be blank"])
435 485 end
436 486 end
437 487
438 488 context "DELETE /issues/1.xml" do
439 489 should_allow_api_authentication(:delete,
440 490 '/issues/6.xml',
441 491 {},
442 492 {:success_code => :ok})
443 493
444 494 should "delete the issue" do
445 495 assert_difference('Issue.count',-1) do
446 496 delete '/issues/6.xml', {}, :authorization => credentials('jsmith')
447 497 end
448 498
449 499 assert_nil Issue.find_by_id(6)
450 500 end
451 501 end
452 502
453 503 context "DELETE /issues/1.json" do
454 504 should_allow_api_authentication(:delete,
455 505 '/issues/6.json',
456 506 {},
457 507 {:success_code => :ok})
458 508
459 509 should "delete the issue" do
460 510 assert_difference('Issue.count',-1) do
461 511 delete '/issues/6.json', {}, :authorization => credentials('jsmith')
462 512 end
463 513
464 514 assert_nil Issue.find_by_id(6)
465 515 end
466 516 end
467 517
468 518 def credentials(user, password=nil)
469 519 ActionController::HttpAuthentication::Basic.encode_credentials(user, password || user)
470 520 end
471 521 end
General Comments 0
You need to be logged in to leave comments. Login now