##// END OF EJS Templates
Adds support for adding attachments to issues through the REST API (#8171)....
Jean-Philippe Lang -
r8808:77626ef6fbf2
parent child
Show More
@@ -0,0 +1,3
1 api.upload do
2 api.token @attachment.token
3 end
@@ -1,100 +1,124
1 1 # Redmine - project management software
2 2 # Copyright (C) 2006-2011 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 AttachmentsController < ApplicationController
19 before_filter :find_project
20 before_filter :file_readable, :read_authorize, :except => :destroy
19 before_filter :find_project, :except => :upload
20 before_filter :file_readable, :read_authorize, :only => [:show, :download]
21 21 before_filter :delete_authorize, :only => :destroy
22 before_filter :authorize_global, :only => :upload
22 23
23 accept_api_auth :show, :download
24 accept_api_auth :show, :download, :upload
24 25
25 26 def show
26 27 respond_to do |format|
27 28 format.html {
28 29 if @attachment.is_diff?
29 30 @diff = File.new(@attachment.diskfile, "rb").read
30 31 @diff_type = params[:type] || User.current.pref[:diff_type] || 'inline'
31 32 @diff_type = 'inline' unless %w(inline sbs).include?(@diff_type)
32 33 # Save diff type as user preference
33 34 if User.current.logged? && @diff_type != User.current.pref[:diff_type]
34 35 User.current.pref[:diff_type] = @diff_type
35 36 User.current.preference.save
36 37 end
37 38 render :action => 'diff'
38 39 elsif @attachment.is_text? && @attachment.filesize <= Setting.file_max_size_displayed.to_i.kilobyte
39 40 @content = File.new(@attachment.diskfile, "rb").read
40 41 render :action => 'file'
41 42 else
42 43 download
43 44 end
44 45 }
45 46 format.api
46 47 end
47 48 end
48 49
49 50 def download
50 51 if @attachment.container.is_a?(Version) || @attachment.container.is_a?(Project)
51 52 @attachment.increment_download
52 53 end
53 54
54 55 # images are sent inline
55 56 send_file @attachment.diskfile, :filename => filename_for_content_disposition(@attachment.filename),
56 57 :type => detect_content_type(@attachment),
57 58 :disposition => (@attachment.image? ? 'inline' : 'attachment')
58 59
59 60 end
60 61
62 def upload
63 # Make sure that API users get used to set this content type
64 # as it won't trigger Rails' automatic parsing of the request body for parameters
65 unless request.content_type == 'application/octet-stream'
66 render :nothing => true, :status => 406
67 return
68 end
69
70 @attachment = Attachment.new(:file => request.body)
71 @attachment.author = User.current
72 @attachment.filename = "test" #ActiveSupport::SecureRandom.hex(16)
73
74 if @attachment.save
75 respond_to do |format|
76 format.api { render :action => 'upload', :status => :created }
77 end
78 else
79 respond_to do |format|
80 format.api { render_validation_errors(@attachment) }
81 end
82 end
83 end
84
61 85 verify :method => :delete, :only => :destroy
62 86 def destroy
63 87 # Make sure association callbacks are called
64 88 @attachment.container.attachments.delete(@attachment)
65 89 redirect_to :back
66 90 rescue ::ActionController::RedirectBackError
67 91 redirect_to :controller => 'projects', :action => 'show', :id => @project
68 92 end
69 93
70 94 private
71 95 def find_project
72 96 @attachment = Attachment.find(params[:id])
73 97 # Show 404 if the filename in the url is wrong
74 98 raise ActiveRecord::RecordNotFound if params[:filename] && params[:filename] != @attachment.filename
75 99 @project = @attachment.project
76 100 rescue ActiveRecord::RecordNotFound
77 101 render_404
78 102 end
79 103
80 104 # Checks that the file exists and is readable
81 105 def file_readable
82 106 @attachment.readable? ? true : render_404
83 107 end
84 108
85 109 def read_authorize
86 110 @attachment.visible? ? true : deny_access
87 111 end
88 112
89 113 def delete_authorize
90 114 @attachment.deletable? ? true : deny_access
91 115 end
92 116
93 117 def detect_content_type(attachment)
94 118 content_type = attachment.content_type
95 119 if content_type.blank?
96 120 content_type = Redmine::MimeType.of(attachment.filename)
97 121 end
98 122 content_type.to_s
99 123 end
100 124 end
@@ -1,429 +1,429
1 1 # Redmine - project management software
2 2 # Copyright (C) 2006-2011 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_rss_auth :index, :show
31 31 accept_api_auth :index, :show, :create, :update, :destroy
32 32
33 33 rescue_from Query::StatementInvalid, :with => :query_statement_invalid
34 34
35 35 helper :journals
36 36 helper :projects
37 37 include ProjectsHelper
38 38 helper :custom_fields
39 39 include CustomFieldsHelper
40 40 helper :issue_relations
41 41 include IssueRelationsHelper
42 42 helper :watchers
43 43 include WatchersHelper
44 44 helper :attachments
45 45 include AttachmentsHelper
46 46 helper :queries
47 47 include QueriesHelper
48 48 helper :repositories
49 49 include RepositoriesHelper
50 50 helper :sort
51 51 include SortHelper
52 52 include IssuesHelper
53 53 helper :timelog
54 54 helper :gantt
55 55 include Redmine::Export::PDF
56 56
57 57 verify :method => :post, :only => :create, :render => {:nothing => true, :status => :method_not_allowed }
58 58 verify :method => :post, :only => :bulk_update, :render => {:nothing => true, :status => :method_not_allowed }
59 59 verify :method => :put, :only => :update, :render => {:nothing => true, :status => :method_not_allowed }
60 60
61 61 def index
62 62 retrieve_query
63 63 sort_init(@query.sort_criteria.empty? ? [['id', 'desc']] : @query.sort_criteria)
64 64 sort_update(@query.sortable_columns)
65 65
66 66 if @query.valid?
67 67 case params[:format]
68 68 when 'csv', 'pdf'
69 69 @limit = Setting.issues_export_limit.to_i
70 70 when 'atom'
71 71 @limit = Setting.feeds_limit.to_i
72 72 when 'xml', 'json'
73 73 @offset, @limit = api_offset_and_limit
74 74 else
75 75 @limit = per_page_option
76 76 end
77 77
78 78 @issue_count = @query.issue_count
79 79 @issue_pages = Paginator.new self, @issue_count, @limit, params['page']
80 80 @offset ||= @issue_pages.current.offset
81 81 @issues = @query.issues(:include => [:assigned_to, :tracker, :priority, :category, :fixed_version],
82 82 :order => sort_clause,
83 83 :offset => @offset,
84 84 :limit => @limit)
85 85 @issue_count_by_group = @query.issue_count_by_group
86 86
87 87 respond_to do |format|
88 88 format.html { render :template => 'issues/index', :layout => !request.xhr? }
89 89 format.api {
90 90 Issue.load_relations(@issues) if include_in_api_response?('relations')
91 91 }
92 92 format.atom { render_feed(@issues, :title => "#{@project || Setting.app_title}: #{l(:label_issue_plural)}") }
93 93 format.csv { send_data(issues_to_csv(@issues, @project, @query, params), :type => 'text/csv; header=present', :filename => 'export.csv') }
94 94 format.pdf { send_data(issues_to_pdf(@issues, @project, @query), :type => 'application/pdf', :filename => 'export.pdf') }
95 95 end
96 96 else
97 97 respond_to do |format|
98 98 format.html { render(:template => 'issues/index', :layout => !request.xhr?) }
99 99 format.any(:atom, :csv, :pdf) { render(:nothing => true) }
100 100 format.api { render_validation_errors(@query) }
101 101 end
102 102 end
103 103 rescue ActiveRecord::RecordNotFound
104 104 render_404
105 105 end
106 106
107 107 def show
108 108 @journals = @issue.journals.find(:all, :include => [:user, :details], :order => "#{Journal.table_name}.created_on ASC")
109 109 @journals.each_with_index {|j,i| j.indice = i+1}
110 110 @journals.reverse! if User.current.wants_comments_in_reverse_order?
111 111
112 112 @changesets = @issue.changesets.visible.all
113 113 @changesets.reverse! if User.current.wants_comments_in_reverse_order?
114 114
115 115 @relations = @issue.relations.select {|r| r.other_issue(@issue) && r.other_issue(@issue).visible? }
116 116 @allowed_statuses = @issue.new_statuses_allowed_to(User.current)
117 117 @edit_allowed = User.current.allowed_to?(:edit_issues, @project)
118 118 @priorities = IssuePriority.active
119 119 @time_entry = TimeEntry.new(:issue => @issue, :project => @issue.project)
120 120 respond_to do |format|
121 121 format.html {
122 122 retrieve_previous_and_next_issue_ids
123 123 render :template => 'issues/show'
124 124 }
125 125 format.api
126 126 format.atom { render :template => 'journals/index', :layout => false, :content_type => 'application/atom+xml' }
127 127 format.pdf { send_data(issue_to_pdf(@issue), :type => 'application/pdf', :filename => "#{@project.identifier}-#{@issue.id}.pdf") }
128 128 end
129 129 end
130 130
131 131 # Add a new issue
132 132 # The new issue will be created from an existing one if copy_from parameter is given
133 133 def new
134 134 respond_to do |format|
135 135 format.html { render :action => 'new', :layout => !request.xhr? }
136 136 format.js {
137 137 render(:update) { |page|
138 138 if params[:project_change]
139 139 page.replace_html 'all_attributes', :partial => 'form'
140 140 else
141 141 page.replace_html 'attributes', :partial => 'attributes'
142 142 end
143 143 m = User.current.allowed_to?(:log_time, @issue.project) ? 'show' : 'hide'
144 144 page << "if ($('log_time')) {Element.#{m}('log_time');}"
145 145 }
146 146 }
147 147 end
148 148 end
149 149
150 150 def create
151 151 call_hook(:controller_issues_new_before_save, { :params => params, :issue => @issue })
152 @issue.save_attachments(params[:attachments])
152 @issue.save_attachments(params[:attachments] || (params[:issue] && params[:issue][:uploads]))
153 153 if @issue.save
154 154 call_hook(:controller_issues_new_after_save, { :params => params, :issue => @issue})
155 155 respond_to do |format|
156 156 format.html {
157 157 render_attachment_warning_if_needed(@issue)
158 158 flash[:notice] = l(:notice_issue_successful_create, :id => "<a href='#{issue_path(@issue)}'>##{@issue.id}</a>")
159 159 redirect_to(params[:continue] ? { :action => 'new', :project_id => @issue.project, :issue => {:tracker_id => @issue.tracker, :parent_issue_id => @issue.parent_issue_id}.reject {|k,v| v.nil?} } :
160 160 { :action => 'show', :id => @issue })
161 161 }
162 162 format.api { render :action => 'show', :status => :created, :location => issue_url(@issue) }
163 163 end
164 164 return
165 165 else
166 166 respond_to do |format|
167 167 format.html { render :action => 'new' }
168 168 format.api { render_validation_errors(@issue) }
169 169 end
170 170 end
171 171 end
172 172
173 173 def edit
174 174 return unless update_issue_from_params
175 175
176 176 respond_to do |format|
177 177 format.html { }
178 178 format.xml { }
179 179 end
180 180 end
181 181
182 182 def update
183 183 return unless update_issue_from_params
184 @issue.save_attachments(params[:attachments])
184 @issue.save_attachments(params[:attachments] || (params[:issue] && params[:issue][:uploads]))
185 185 saved = false
186 186 begin
187 187 saved = @issue.save_issue_with_child_records(params, @time_entry)
188 188 rescue ActiveRecord::StaleObjectError
189 189 @conflict = true
190 190 if params[:last_journal_id]
191 191 if params[:last_journal_id].present?
192 192 last_journal_id = params[:last_journal_id].to_i
193 193 @conflict_journals = @issue.journals.all(:conditions => ["#{Journal.table_name}.id > ?", last_journal_id])
194 194 else
195 195 @conflict_journals = @issue.journals.all
196 196 end
197 197 end
198 198 end
199 199
200 200 if saved
201 201 render_attachment_warning_if_needed(@issue)
202 202 flash[:notice] = l(:notice_successful_update) unless @issue.current_journal.new_record?
203 203
204 204 respond_to do |format|
205 205 format.html { redirect_back_or_default({:action => 'show', :id => @issue}) }
206 206 format.api { head :ok }
207 207 end
208 208 else
209 209 respond_to do |format|
210 210 format.html { render :action => 'edit' }
211 211 format.api { render_validation_errors(@issue) }
212 212 end
213 213 end
214 214 end
215 215
216 216 # Bulk edit/copy a set of issues
217 217 def bulk_edit
218 218 @issues.sort!
219 219 @copy = params[:copy].present?
220 220 @notes = params[:notes]
221 221
222 222 if User.current.allowed_to?(:move_issues, @projects)
223 223 @allowed_projects = Issue.allowed_target_projects_on_move
224 224 if params[:issue]
225 225 @target_project = @allowed_projects.detect {|p| p.id.to_s == params[:issue][:project_id]}
226 226 if @target_project
227 227 target_projects = [@target_project]
228 228 end
229 229 end
230 230 end
231 231 target_projects ||= @projects
232 232
233 233 @available_statuses = @issues.map(&:new_statuses_allowed_to).reduce(:&)
234 234 @custom_fields = target_projects.map{|p|p.all_issue_custom_fields}.reduce(:&)
235 235 @assignables = target_projects.map(&:assignable_users).reduce(:&)
236 236 @trackers = target_projects.map(&:trackers).reduce(:&)
237 237
238 238 @safe_attributes = @issues.map(&:safe_attribute_names).reduce(:&)
239 239 render :layout => false if request.xhr?
240 240 end
241 241
242 242 def bulk_update
243 243 @issues.sort!
244 244 @copy = params[:copy].present?
245 245 attributes = parse_params_for_bulk_issue_attributes(params)
246 246
247 247 unsaved_issue_ids = []
248 248 moved_issues = []
249 249 @issues.each do |issue|
250 250 issue.reload
251 251 if @copy
252 252 issue = issue.copy
253 253 end
254 254 journal = issue.init_journal(User.current, params[:notes])
255 255 issue.safe_attributes = attributes
256 256 call_hook(:controller_issues_bulk_edit_before_save, { :params => params, :issue => issue })
257 257 if issue.save
258 258 moved_issues << issue
259 259 else
260 260 # Keep unsaved issue ids to display them in flash error
261 261 unsaved_issue_ids << issue.id
262 262 end
263 263 end
264 264 set_flash_from_bulk_issue_save(@issues, unsaved_issue_ids)
265 265
266 266 if params[:follow]
267 267 if @issues.size == 1 && moved_issues.size == 1
268 268 redirect_to :controller => 'issues', :action => 'show', :id => moved_issues.first
269 269 elsif moved_issues.map(&:project).uniq.size == 1
270 270 redirect_to :controller => 'issues', :action => 'index', :project_id => moved_issues.map(&:project).first
271 271 end
272 272 else
273 273 redirect_back_or_default({:controller => 'issues', :action => 'index', :project_id => @project})
274 274 end
275 275 end
276 276
277 277 verify :method => :delete, :only => :destroy, :render => { :nothing => true, :status => :method_not_allowed }
278 278 def destroy
279 279 @hours = TimeEntry.sum(:hours, :conditions => ['issue_id IN (?)', @issues]).to_f
280 280 if @hours > 0
281 281 case params[:todo]
282 282 when 'destroy'
283 283 # nothing to do
284 284 when 'nullify'
285 285 TimeEntry.update_all('issue_id = NULL', ['issue_id IN (?)', @issues])
286 286 when 'reassign'
287 287 reassign_to = @project.issues.find_by_id(params[:reassign_to_id])
288 288 if reassign_to.nil?
289 289 flash.now[:error] = l(:error_issue_not_found_in_project)
290 290 return
291 291 else
292 292 TimeEntry.update_all("issue_id = #{reassign_to.id}", ['issue_id IN (?)', @issues])
293 293 end
294 294 else
295 295 # display the destroy form if it's a user request
296 296 return unless api_request?
297 297 end
298 298 end
299 299 @issues.each do |issue|
300 300 begin
301 301 issue.reload.destroy
302 302 rescue ::ActiveRecord::RecordNotFound # raised by #reload if issue no longer exists
303 303 # nothing to do, issue was already deleted (eg. by a parent)
304 304 end
305 305 end
306 306 respond_to do |format|
307 307 format.html { redirect_back_or_default(:action => 'index', :project_id => @project) }
308 308 format.api { head :ok }
309 309 end
310 310 end
311 311
312 312 private
313 313 def find_issue
314 314 # Issue.visible.find(...) can not be used to redirect user to the login form
315 315 # if the issue actually exists but requires authentication
316 316 @issue = Issue.find(params[:id], :include => [:project, :tracker, :status, :author, :priority, :category])
317 317 unless @issue.visible?
318 318 deny_access
319 319 return
320 320 end
321 321 @project = @issue.project
322 322 rescue ActiveRecord::RecordNotFound
323 323 render_404
324 324 end
325 325
326 326 def find_project
327 327 project_id = params[:project_id] || (params[:issue] && params[:issue][:project_id])
328 328 @project = Project.find(project_id)
329 329 rescue ActiveRecord::RecordNotFound
330 330 render_404
331 331 end
332 332
333 333 def retrieve_previous_and_next_issue_ids
334 334 retrieve_query_from_session
335 335 if @query
336 336 sort_init(@query.sort_criteria.empty? ? [['id', 'desc']] : @query.sort_criteria)
337 337 sort_update(@query.sortable_columns, 'issues_index_sort')
338 338 limit = 500
339 339 issue_ids = @query.issue_ids(:order => sort_clause, :limit => (limit + 1), :include => [:assigned_to, :tracker, :priority, :category, :fixed_version])
340 340 if (idx = issue_ids.index(@issue.id)) && idx < limit
341 341 if issue_ids.size < 500
342 342 @issue_position = idx + 1
343 343 @issue_count = issue_ids.size
344 344 end
345 345 @prev_issue_id = issue_ids[idx - 1] if idx > 0
346 346 @next_issue_id = issue_ids[idx + 1] if idx < (issue_ids.size - 1)
347 347 end
348 348 end
349 349 end
350 350
351 351 # Used by #edit and #update to set some common instance variables
352 352 # from the params
353 353 # TODO: Refactor, not everything in here is needed by #edit
354 354 def update_issue_from_params
355 355 @allowed_statuses = @issue.new_statuses_allowed_to(User.current)
356 356 @priorities = IssuePriority.active
357 357 @edit_allowed = User.current.allowed_to?(:edit_issues, @project)
358 358 @time_entry = TimeEntry.new(:issue => @issue, :project => @issue.project)
359 359 @time_entry.attributes = params[:time_entry]
360 360
361 361 @notes = params[:notes] || (params[:issue].present? ? params[:issue][:notes] : nil)
362 362 @issue.init_journal(User.current, @notes)
363 363
364 364 issue_attributes = params[:issue]
365 365 if issue_attributes && params[:conflict_resolution]
366 366 case params[:conflict_resolution]
367 367 when 'overwrite'
368 368 issue_attributes = issue_attributes.dup
369 369 issue_attributes.delete(:lock_version)
370 370 when 'add_notes'
371 371 issue_attributes = {}
372 372 when 'cancel'
373 373 redirect_to issue_path(@issue)
374 374 return false
375 375 end
376 376 end
377 377 @issue.safe_attributes = issue_attributes
378 378 true
379 379 end
380 380
381 381 # TODO: Refactor, lots of extra code in here
382 382 # TODO: Changing tracker on an existing issue should not trigger this
383 383 def build_new_issue_from_params
384 384 if params[:id].blank?
385 385 @issue = Issue.new
386 386 if params[:copy_from]
387 387 begin
388 388 @copy_from = Issue.visible.find(params[:copy_from])
389 389 @copy_attachments = params[:copy_attachments].present? || request.get?
390 390 @issue.copy_from(@copy_from, :attachments => @copy_attachments)
391 391 rescue ActiveRecord::RecordNotFound
392 392 render_404
393 393 return
394 394 end
395 395 end
396 396 @issue.project = @project
397 397 else
398 398 @issue = @project.issues.visible.find(params[:id])
399 399 end
400 400
401 401 @issue.project = @project
402 402 @issue.author = User.current
403 403 # Tracker must be set before custom field values
404 404 @issue.tracker ||= @project.trackers.find((params[:issue] && params[:issue][:tracker_id]) || params[:tracker_id] || :first)
405 405 if @issue.tracker.nil?
406 406 render_error l(:error_no_tracker_in_project)
407 407 return false
408 408 end
409 409 @issue.start_date ||= Date.today if Setting.default_issue_start_date_to_creation_date?
410 410 @issue.safe_attributes = params[:issue]
411 411
412 412 @priorities = IssuePriority.active
413 413 @allowed_statuses = @issue.new_statuses_allowed_to(User.current, true)
414 414 end
415 415
416 416 def check_for_default_issue_status
417 417 if IssueStatus.default.nil?
418 418 render_error l(:error_no_default_issue_status)
419 419 return false
420 420 end
421 421 end
422 422
423 423 def parse_params_for_bulk_issue_attributes(params)
424 424 attributes = (params[:issue] || {}).reject {|k,v| v.blank?}
425 425 attributes.keys.each {|k| attributes[k] = '' if attributes[k] == 'none'}
426 426 attributes[:custom_field_values].reject! {|k,v| v.blank?} if attributes[:custom_field_values]
427 427 attributes
428 428 end
429 429 end
@@ -1,232 +1,243
1 1 # Redmine - project management software
2 2 # Copyright (C) 2006-2011 Jean-Philippe Lang
3 3 #
4 4 # This program is free software; you can redistribute it and/or
5 5 # modify it under the terms of the GNU General Public License
6 6 # as published by the Free Software Foundation; either version 2
7 7 # of the License, or (at your option) any later version.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU General Public License
15 15 # along with this program; if not, write to the Free Software
16 16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17 17
18 18 require "digest/md5"
19 19
20 20 class Attachment < ActiveRecord::Base
21 21 belongs_to :container, :polymorphic => true
22 22 belongs_to :author, :class_name => "User", :foreign_key => "author_id"
23 23
24 24 validates_presence_of :filename, :author
25 25 validates_length_of :filename, :maximum => 255
26 26 validates_length_of :disk_filename, :maximum => 255
27 27 validate :validate_max_file_size
28 28
29 29 acts_as_event :title => :filename,
30 30 :url => Proc.new {|o| {:controller => 'attachments', :action => 'download', :id => o.id, :filename => o.filename}}
31 31
32 32 acts_as_activity_provider :type => 'files',
33 33 :permission => :view_files,
34 34 :author_key => :author_id,
35 35 :find_options => {:select => "#{Attachment.table_name}.*",
36 36 :joins => "LEFT JOIN #{Version.table_name} ON #{Attachment.table_name}.container_type='Version' AND #{Version.table_name}.id = #{Attachment.table_name}.container_id " +
37 37 "LEFT JOIN #{Project.table_name} ON #{Version.table_name}.project_id = #{Project.table_name}.id OR ( #{Attachment.table_name}.container_type='Project' AND #{Attachment.table_name}.container_id = #{Project.table_name}.id )"}
38 38
39 39 acts_as_activity_provider :type => 'documents',
40 40 :permission => :view_documents,
41 41 :author_key => :author_id,
42 42 :find_options => {:select => "#{Attachment.table_name}.*",
43 43 :joins => "LEFT JOIN #{Document.table_name} ON #{Attachment.table_name}.container_type='Document' AND #{Document.table_name}.id = #{Attachment.table_name}.container_id " +
44 44 "LEFT JOIN #{Project.table_name} ON #{Document.table_name}.project_id = #{Project.table_name}.id"}
45 45
46 46 cattr_accessor :storage_path
47 47 @@storage_path = Redmine::Configuration['attachments_storage_path'] || "#{Rails.root}/files"
48 48
49 49 before_save :files_to_final_location
50 50 after_destroy :delete_from_disk
51 51
52 52 def container_with_blank_type_check
53 53 if container_type.blank?
54 54 nil
55 55 else
56 56 container_without_blank_type_check
57 57 end
58 58 end
59 59 alias_method_chain :container, :blank_type_check unless method_defined?(:container_without_blank_type_check)
60 60
61 61 # Returns an unsaved copy of the attachment
62 62 def copy(attributes=nil)
63 63 copy = self.class.new
64 64 copy.attributes = self.attributes.dup.except("id", "downloads")
65 65 copy.attributes = attributes if attributes
66 66 copy
67 67 end
68 68
69 69 def validate_max_file_size
70 70 if @temp_file && self.filesize > Setting.attachment_max_size.to_i.kilobytes
71 71 errors.add(:base, :too_long, :count => Setting.attachment_max_size.to_i.kilobytes)
72 72 end
73 73 end
74 74
75 75 def file=(incoming_file)
76 76 unless incoming_file.nil?
77 77 @temp_file = incoming_file
78 78 if @temp_file.size > 0
79 self.filename = sanitize_filename(@temp_file.original_filename)
80 self.disk_filename = Attachment.disk_filename(filename)
81 self.content_type = @temp_file.content_type.to_s.chomp
82 if content_type.blank?
79 if @temp_file.respond_to?(:original_filename)
80 self.filename = @temp_file.original_filename
81 end
82 if @temp_file.respond_to?(:content_type)
83 self.content_type = @temp_file.content_type.to_s.chomp
84 end
85 if content_type.blank? && filename.present?
83 86 self.content_type = Redmine::MimeType.of(filename)
84 87 end
85 88 self.filesize = @temp_file.size
86 89 end
87 90 end
88 91 end
89
92
90 93 def file
91 94 nil
92 95 end
93 96
97 def filename=(arg)
98 write_attribute :filename, sanitize_filename(arg.to_s)
99 if new_record? && disk_filename.blank?
100 self.disk_filename = Attachment.disk_filename(filename)
101 end
102 filename
103 end
104
94 105 # Copies the temporary file to its final location
95 106 # and computes its MD5 hash
96 107 def files_to_final_location
97 108 if @temp_file && (@temp_file.size > 0)
98 109 logger.info("Saving attachment '#{self.diskfile}' (#{@temp_file.size} bytes)")
99 110 md5 = Digest::MD5.new
100 111 File.open(diskfile, "wb") do |f|
101 112 buffer = ""
102 113 while (buffer = @temp_file.read(8192))
103 114 f.write(buffer)
104 115 md5.update(buffer)
105 116 end
106 117 end
107 118 self.digest = md5.hexdigest
108 119 end
109 120 @temp_file = nil
110 121 # Don't save the content type if it's longer than the authorized length
111 122 if self.content_type && self.content_type.length > 255
112 123 self.content_type = nil
113 124 end
114 125 end
115 126
116 127 # Deletes the file from the file system if it's not referenced by other attachments
117 128 def delete_from_disk
118 129 if Attachment.first(:conditions => ["disk_filename = ? AND id <> ?", disk_filename, id]).nil?
119 130 delete_from_disk!
120 131 end
121 132 end
122 133
123 134 # Returns file's location on disk
124 135 def diskfile
125 136 "#{@@storage_path}/#{self.disk_filename}"
126 137 end
127 138
128 139 def increment_download
129 140 increment!(:downloads)
130 141 end
131 142
132 143 def project
133 144 container.try(:project)
134 145 end
135 146
136 147 def visible?(user=User.current)
137 148 container && container.attachments_visible?(user)
138 149 end
139 150
140 151 def deletable?(user=User.current)
141 152 container && container.attachments_deletable?(user)
142 153 end
143 154
144 155 def image?
145 156 self.filename =~ /\.(bmp|gif|jpg|jpe|jpeg|png)$/i
146 157 end
147 158
148 159 def is_text?
149 160 Redmine::MimeType.is_type?('text', filename)
150 161 end
151 162
152 163 def is_diff?
153 164 self.filename =~ /\.(patch|diff)$/i
154 165 end
155 166
156 167 # Returns true if the file is readable
157 168 def readable?
158 169 File.readable?(diskfile)
159 170 end
160 171
161 172 # Returns the attachment token
162 173 def token
163 174 "#{id}.#{digest}"
164 175 end
165 176
166 177 # Finds an attachment that matches the given token and that has no container
167 178 def self.find_by_token(token)
168 179 if token.to_s =~ /^(\d+)\.([0-9a-f]+)$/
169 180 attachment_id, attachment_digest = $1, $2
170 181 attachment = Attachment.first(:conditions => {:id => attachment_id, :digest => attachment_digest})
171 182 if attachment && attachment.container.nil?
172 183 attachment
173 184 end
174 185 end
175 186 end
176 187
177 188 # Bulk attaches a set of files to an object
178 189 #
179 190 # Returns a Hash of the results:
180 191 # :files => array of the attached files
181 192 # :unsaved => array of the files that could not be attached
182 193 def self.attach_files(obj, attachments)
183 194 result = obj.save_attachments(attachments, User.current)
184 195 obj.attach_saved_attachments
185 196 result
186 197 end
187 198
188 199 def self.latest_attach(attachments, filename)
189 200 attachments.sort_by(&:created_on).reverse.detect {
190 201 |att| att.filename.downcase == filename.downcase
191 202 }
192 203 end
193 204
194 205 def self.prune(age=1.day)
195 206 attachments = Attachment.all(:conditions => ["created_on < ? AND (container_type IS NULL OR container_type = '')", Time.now - age])
196 207 attachments.each(&:destroy)
197 208 end
198 209
199 210 private
200 211
201 212 # Physically deletes the file from the file system
202 213 def delete_from_disk!
203 214 if disk_filename.present? && File.exist?(diskfile)
204 215 File.delete(diskfile)
205 216 end
206 217 end
207 218
208 219 def sanitize_filename(value)
209 220 # get only the filename, not the whole path
210 221 just_filename = value.gsub(/^.*(\\|\/)/, '')
211 222
212 223 # Finally, replace invalid characters with underscore
213 224 @filename = just_filename.gsub(/[\/\?\%\*\:\|\"\'<>]+/, '_')
214 225 end
215 226
216 227 # Returns an ASCII or hashed filename
217 228 def self.disk_filename(filename)
218 229 timestamp = DateTime.now.strftime("%y%m%d%H%M%S")
219 230 ascii = ''
220 231 if filename =~ %r{^[a-zA-Z0-9_\.\-]*$}
221 232 ascii = filename
222 233 else
223 234 ascii = Digest::MD5.hexdigest(filename)
224 235 # keep the extension if any
225 236 ascii << $1 if filename =~ %r{(\.[a-zA-Z0-9]+)$}
226 237 end
227 238 while File.exist?(File.join(@@storage_path, "#{timestamp}_#{ascii}"))
228 239 timestamp.succ!
229 240 end
230 241 "#{timestamp}_#{ascii}"
231 242 end
232 243 end
@@ -1,417 +1,419
1 1 ActionController::Routing::Routes.draw do |map|
2 2 # Add your own custom routes here.
3 3 # The priority is based upon order of creation: first created -> highest priority.
4 4
5 5 # Here's a sample route:
6 6 # map.connect 'products/:id', :controller => 'catalog', :action => 'view'
7 7 # Keep in mind you can assign values other than :controller and :action
8 8
9 9 map.home '', :controller => 'welcome', :conditions => {:method => :get}
10 10
11 11 map.signin 'login', :controller => 'account', :action => 'login',
12 12 :conditions => {:method => [:get, :post]}
13 13 map.signout 'logout', :controller => 'account', :action => 'logout',
14 14 :conditions => {:method => :get}
15 15 map.connect 'account/register', :controller => 'account', :action => 'register',
16 16 :conditions => {:method => [:get, :post]}
17 17 map.connect 'account/lost_password', :controller => 'account', :action => 'lost_password',
18 18 :conditions => {:method => [:get, :post]}
19 19 map.connect 'account/activate', :controller => 'account', :action => 'activate',
20 20 :conditions => {:method => :get}
21 21
22 22 map.connect 'projects/:id/wiki', :controller => 'wikis',
23 23 :action => 'edit', :conditions => {:method => :post}
24 24 map.connect 'projects/:id/wiki/destroy', :controller => 'wikis',
25 25 :action => 'destroy', :conditions => {:method => [:get, :post]}
26 26
27 27 map.with_options :controller => 'messages' do |messages_routes|
28 28 messages_routes.with_options :conditions => {:method => :get} do |messages_views|
29 29 messages_views.connect 'boards/:board_id/topics/new', :action => 'new'
30 30 messages_views.connect 'boards/:board_id/topics/:id', :action => 'show'
31 31 messages_views.connect 'boards/:board_id/topics/:id/edit', :action => 'edit'
32 32 end
33 33 messages_routes.with_options :conditions => {:method => :post} do |messages_actions|
34 34 messages_actions.connect 'boards/:board_id/topics/new', :action => 'new'
35 35 messages_actions.connect 'boards/:board_id/topics/preview', :action => 'preview'
36 36 messages_actions.connect 'boards/:board_id/topics/quote/:id', :action => 'quote'
37 37 messages_actions.connect 'boards/:board_id/topics/:id/replies', :action => 'reply'
38 38 messages_actions.connect 'boards/:board_id/topics/:id/edit', :action => 'edit'
39 39 messages_actions.connect 'boards/:board_id/topics/:id/destroy', :action => 'destroy'
40 40 end
41 41 end
42 42
43 43 # Misc issue routes. TODO: move into resources
44 44 map.auto_complete_issues '/issues/auto_complete', :controller => 'auto_completes',
45 45 :action => 'issues', :conditions => { :method => :get }
46 46 # TODO: would look nicer as /issues/:id/preview
47 47 map.preview_new_issue '/issues/preview/new/:project_id', :controller => 'previews',
48 48 :action => 'issue'
49 49 map.preview_edit_issue '/issues/preview/edit/:id', :controller => 'previews',
50 50 :action => 'issue'
51 51 map.issues_context_menu '/issues/context_menu',
52 52 :controller => 'context_menus', :action => 'issues'
53 53
54 54 map.issue_changes '/issues/changes', :controller => 'journals', :action => 'index'
55 55 map.quoted_issue '/issues/:id/quoted', :controller => 'journals', :action => 'new',
56 56 :id => /\d+/, :conditions => { :method => :post }
57 57
58 58 map.connect '/journals/diff/:id', :controller => 'journals', :action => 'diff',
59 59 :id => /\d+/, :conditions => { :method => :get }
60 60 map.connect '/journals/edit/:id', :controller => 'journals', :action => 'edit',
61 61 :id => /\d+/, :conditions => { :method => [:get, :post] }
62 62
63 63 map.with_options :controller => 'gantts', :action => 'show' do |gantts_routes|
64 64 gantts_routes.connect '/projects/:project_id/issues/gantt'
65 65 gantts_routes.connect '/projects/:project_id/issues/gantt.:format'
66 66 gantts_routes.connect '/issues/gantt.:format'
67 67 end
68 68
69 69 map.with_options :controller => 'calendars', :action => 'show' do |calendars_routes|
70 70 calendars_routes.connect '/projects/:project_id/issues/calendar'
71 71 calendars_routes.connect '/issues/calendar'
72 72 end
73 73
74 74 map.with_options :controller => 'reports', :conditions => {:method => :get} do |reports|
75 75 reports.connect 'projects/:id/issues/report', :action => 'issue_report'
76 76 reports.connect 'projects/:id/issues/report/:detail', :action => 'issue_report_details'
77 77 end
78 78
79 79 map.connect 'my/account', :controller => 'my', :action => 'account',
80 80 :conditions => {:method => [:get, :post]}
81 81 map.connect 'my/page', :controller => 'my', :action => 'page',
82 82 :conditions => {:method => :get}
83 83 # Redirects to my/page
84 84 map.connect 'my', :controller => 'my', :action => 'index',
85 85 :conditions => {:method => :get}
86 86 map.connect 'my/reset_rss_key', :controller => 'my', :action => 'reset_rss_key',
87 87 :conditions => {:method => :post}
88 88 map.connect 'my/reset_api_key', :controller => 'my', :action => 'reset_api_key',
89 89 :conditions => {:method => :post}
90 90 map.connect 'my/password', :controller => 'my', :action => 'password',
91 91 :conditions => {:method => [:get, :post]}
92 92 map.connect 'my/page_layout', :controller => 'my', :action => 'page_layout',
93 93 :conditions => {:method => :get}
94 94 map.connect 'my/add_block', :controller => 'my', :action => 'add_block',
95 95 :conditions => {:method => :post}
96 96 map.connect 'my/remove_block', :controller => 'my', :action => 'remove_block',
97 97 :conditions => {:method => :post}
98 98 map.connect 'my/order_blocks', :controller => 'my', :action => 'order_blocks',
99 99 :conditions => {:method => :post}
100 100
101 101 map.with_options :controller => 'users' do |users|
102 102 users.user_membership 'users/:id/memberships/:membership_id',
103 103 :action => 'edit_membership',
104 104 :conditions => {:method => :put}
105 105 users.connect 'users/:id/memberships/:membership_id',
106 106 :action => 'destroy_membership',
107 107 :conditions => {:method => :delete}
108 108 users.user_memberships 'users/:id/memberships',
109 109 :action => 'edit_membership',
110 110 :conditions => {:method => :post}
111 111 end
112 112 map.resources :users
113 113
114 114 # For nice "roadmap" in the url for the index action
115 115 map.connect 'projects/:project_id/roadmap', :controller => 'versions', :action => 'index'
116 116
117 117 map.preview_news '/news/preview', :controller => 'previews', :action => 'news'
118 118 map.connect 'news/:id/comments', :controller => 'comments',
119 119 :action => 'create', :conditions => {:method => :post}
120 120 map.connect 'news/:id/comments/:comment_id', :controller => 'comments',
121 121 :action => 'destroy', :conditions => {:method => :delete}
122 122
123 123 map.connect 'watchers/new', :controller=> 'watchers', :action => 'new',
124 124 :conditions => {:method => :get}
125 125 map.connect 'watchers', :controller=> 'watchers', :action => 'create',
126 126 :conditions => {:method => :post}
127 127 map.connect 'watchers/destroy', :controller=> 'watchers', :action => 'destroy',
128 128 :conditions => {:method => :post}
129 129 map.connect 'watchers/watch', :controller=> 'watchers', :action => 'watch',
130 130 :conditions => {:method => :post}
131 131 map.connect 'watchers/unwatch', :controller=> 'watchers', :action => 'unwatch',
132 132 :conditions => {:method => :post}
133 133 map.connect 'watchers/autocomplete_for_user', :controller=> 'watchers', :action => 'autocomplete_for_user',
134 134 :conditions => {:method => :get}
135 135
136 136 # TODO: port to be part of the resources route(s)
137 137 map.with_options :conditions => {:method => :get} do |project_views|
138 138 project_views.connect 'projects/:id/settings/:tab',
139 139 :controller => 'projects', :action => 'settings'
140 140 project_views.connect 'projects/:project_id/issues/:copy_from/copy',
141 141 :controller => 'issues', :action => 'new'
142 142 end
143 143
144 144 map.resources :projects, :member => {
145 145 :copy => [:get, :post],
146 146 :settings => :get,
147 147 :modules => :post,
148 148 :archive => :post,
149 149 :unarchive => :post
150 150 } do |project|
151 151 project.resource :enumerations, :controller => 'project_enumerations',
152 152 :only => [:update, :destroy]
153 153 # issue form update
154 154 project.issue_form 'issues/new', :controller => 'issues',
155 155 :action => 'new', :conditions => {:method => [:post, :put]}
156 156 project.resources :issues, :only => [:index, :new, :create] do |issues|
157 157 issues.resources :time_entries, :controller => 'timelog',
158 158 :collection => {:report => :get}
159 159 end
160 160
161 161 project.resources :files, :only => [:index, :new, :create]
162 162 project.resources :versions, :shallow => true,
163 163 :collection => {:close_completed => :put},
164 164 :member => {:status_by => :post}
165 165 project.resources :news, :shallow => true
166 166 project.resources :time_entries, :controller => 'timelog',
167 167 :collection => {:report => :get}
168 168 project.resources :queries, :only => [:new, :create]
169 169 project.resources :issue_categories, :shallow => true
170 170 project.resources :documents, :shallow => true, :member => {:add_attachment => :post}
171 171 project.resources :boards
172 172 project.resources :repositories, :shallow => true, :except => [:index, :show],
173 173 :member => {:committers => [:get, :post]}
174 174 project.resources :memberships, :shallow => true, :controller => 'members',
175 175 :only => [:index, :show, :create, :update, :destroy],
176 176 :collection => {:autocomplete => :get}
177 177
178 178 project.wiki_start_page 'wiki', :controller => 'wiki', :action => 'show', :conditions => {:method => :get}
179 179 project.wiki_index 'wiki/index', :controller => 'wiki', :action => 'index', :conditions => {:method => :get}
180 180 project.wiki_diff 'wiki/:id/diff/:version', :controller => 'wiki', :action => 'diff', :version => nil
181 181 project.wiki_diff 'wiki/:id/diff/:version/vs/:version_from', :controller => 'wiki', :action => 'diff'
182 182 project.wiki_annotate 'wiki/:id/annotate/:version', :controller => 'wiki', :action => 'annotate'
183 183 project.resources :wiki, :except => [:new, :create], :member => {
184 184 :rename => [:get, :post],
185 185 :history => :get,
186 186 :preview => :any,
187 187 :protect => :post,
188 188 :add_attachment => :post
189 189 }, :collection => {
190 190 :export => :get,
191 191 :date_index => :get
192 192 }
193 193 end
194 194
195 195 map.connect 'news', :controller => 'news', :action => 'index'
196 196 map.connect 'news.:format', :controller => 'news', :action => 'index'
197 197
198 198 map.resources :queries, :except => [:show]
199 199 map.resources :issues,
200 200 :collection => {:bulk_edit => [:get, :post], :bulk_update => :post} do |issues|
201 201 issues.resources :time_entries, :controller => 'timelog',
202 202 :collection => {:report => :get}
203 203 issues.resources :relations, :shallow => true,
204 204 :controller => 'issue_relations',
205 205 :only => [:index, :show, :create, :destroy]
206 206 end
207 207 # Bulk deletion
208 208 map.connect '/issues', :controller => 'issues', :action => 'destroy',
209 209 :conditions => {:method => :delete}
210 210
211 211 map.connect '/time_entries/destroy',
212 212 :controller => 'timelog', :action => 'destroy',
213 213 :conditions => { :method => :delete }
214 214 map.time_entries_context_menu '/time_entries/context_menu',
215 215 :controller => 'context_menus', :action => 'time_entries'
216 216
217 217 map.resources :time_entries, :controller => 'timelog',
218 218 :collection => {:report => :get, :bulk_edit => :get, :bulk_update => :post}
219 219
220 220 map.with_options :controller => 'activities', :action => 'index',
221 221 :conditions => {:method => :get} do |activity|
222 222 activity.connect 'projects/:id/activity'
223 223 activity.connect 'projects/:id/activity.:format'
224 224 activity.connect 'activity', :id => nil
225 225 activity.connect 'activity.:format', :id => nil
226 226 end
227 227
228 228 map.with_options :controller => 'repositories' do |repositories|
229 229 repositories.with_options :conditions => {:method => :get} do |repository_views|
230 230 repository_views.connect 'projects/:id/repository',
231 231 :action => 'show'
232 232
233 233 repository_views.connect 'projects/:id/repository/:repository_id/statistics',
234 234 :action => 'stats'
235 235 repository_views.connect 'projects/:id/repository/:repository_id/graph',
236 236 :action => 'graph'
237 237
238 238 repository_views.connect 'projects/:id/repository/statistics',
239 239 :action => 'stats'
240 240 repository_views.connect 'projects/:id/repository/graph',
241 241 :action => 'graph'
242 242
243 243 repository_views.connect 'projects/:id/repository/:repository_id/revisions',
244 244 :action => 'revisions'
245 245 repository_views.connect 'projects/:id/repository/:repository_id/revisions.:format',
246 246 :action => 'revisions'
247 247 repository_views.connect 'projects/:id/repository/:repository_id/revisions/:rev',
248 248 :action => 'revision'
249 249 repository_views.connect 'projects/:id/repository/:repository_id/revisions/:rev/issues',
250 250 :action => 'add_related_issue', :conditions => {:method => :post}
251 251 repository_views.connect 'projects/:id/repository/:repository_id/revisions/:rev/issues/:issue_id',
252 252 :action => 'remove_related_issue', :conditions => {:method => :delete}
253 253 repository_views.connect 'projects/:id/repository/:repository_id/revisions/:rev/diff',
254 254 :action => 'diff'
255 255 repository_views.connect 'projects/:id/repository/:repository_id/revisions/:rev/diff.:format',
256 256 :action => 'diff'
257 257 repository_views.connect 'projects/:id/repository/:repository_id/revisions/:rev/raw/*path',
258 258 :action => 'entry', :format => 'raw'
259 259 repository_views.connect 'projects/:id/repository/:repository_id/revisions/:rev/:action/*path',
260 260 :requirements => {
261 261 :action => /(browse|show|entry|changes|annotate|diff)/,
262 262 :rev => /[a-z0-9\.\-_]+/
263 263 }
264 264 repository_views.connect 'projects/:id/repository/:repository_id/raw/*path',
265 265 :action => 'entry', :format => 'raw'
266 266 repository_views.connect 'projects/:id/repository/:repository_id/:action/*path',
267 267 :requirements => { :action => /(browse|show|entry|changes|annotate|diff)/ }
268 268
269 269 repository_views.connect 'projects/:id/repository/revisions',
270 270 :action => 'revisions'
271 271 repository_views.connect 'projects/:id/repository/revisions.:format',
272 272 :action => 'revisions'
273 273 repository_views.connect 'projects/:id/repository/revisions/:rev',
274 274 :action => 'revision'
275 275 repository_views.connect 'projects/:id/repository/revisions/:rev/issues',
276 276 :action => 'add_related_issue', :conditions => {:method => :post}
277 277 repository_views.connect 'projects/:id/repository/revisions/:rev/issues/:issue_id',
278 278 :action => 'remove_related_issue', :conditions => {:method => :delete}
279 279 repository_views.connect 'projects/:id/repository/revisions/:rev/diff',
280 280 :action => 'diff'
281 281 repository_views.connect 'projects/:id/repository/revisions/:rev/diff.:format',
282 282 :action => 'diff'
283 283 repository_views.connect 'projects/:id/repository/revisions/:rev/raw/*path',
284 284 :action => 'entry', :format => 'raw'
285 285 repository_views.connect 'projects/:id/repository/revisions/:rev/:action/*path',
286 286 :requirements => {
287 287 :action => /(browse|show|entry|changes|annotate|diff)/,
288 288 :rev => /[a-z0-9\.\-_]+/
289 289 }
290 290 repository_views.connect 'projects/:id/repository/raw/*path',
291 291 :action => 'entry', :format => 'raw'
292 292 repository_views.connect 'projects/:id/repository/:action/*path',
293 293 :requirements => { :action => /(browse|show|entry|changes|annotate|diff)/ }
294 294
295 295 repository_views.connect 'projects/:id/repository/:repository_id',
296 296 :action => 'show'
297 297 end
298 298
299 299 repositories.connect 'projects/:id/repository/revision',
300 300 :action => 'revision',
301 301 :conditions => {:method => [:get, :post]}
302 302 end
303 303
304 304 # additional routes for having the file name at the end of url
305 305 map.connect 'attachments/:id/:filename', :controller => 'attachments',
306 306 :action => 'show', :id => /\d+/, :filename => /.*/,
307 307 :conditions => {:method => :get}
308 308 map.connect 'attachments/download/:id/:filename', :controller => 'attachments',
309 309 :action => 'download', :id => /\d+/, :filename => /.*/,
310 310 :conditions => {:method => :get}
311 311 map.connect 'attachments/download/:id', :controller => 'attachments',
312 312 :action => 'download', :id => /\d+/,
313 313 :conditions => {:method => :get}
314 314 map.resources :attachments, :only => [:show, :destroy]
315 315
316 316 map.resources :groups, :member => {:autocomplete_for_user => :get}
317 317 map.group_users 'groups/:id/users', :controller => 'groups',
318 318 :action => 'add_users', :id => /\d+/,
319 319 :conditions => {:method => :post}
320 320 map.group_user 'groups/:id/users/:user_id', :controller => 'groups',
321 321 :action => 'remove_user', :id => /\d+/,
322 322 :conditions => {:method => :delete}
323 323 map.connect 'groups/destroy_membership/:id', :controller => 'groups',
324 324 :action => 'destroy_membership', :id => /\d+/,
325 325 :conditions => {:method => :post}
326 326 map.connect 'groups/edit_membership/:id', :controller => 'groups',
327 327 :action => 'edit_membership', :id => /\d+/,
328 328 :conditions => {:method => :post}
329 329
330 330 map.resources :trackers, :except => :show
331 331 map.resources :issue_statuses, :except => :show, :collection => {:update_issue_done_ratio => :post}
332 332 map.resources :custom_fields, :except => :show
333 333 map.resources :roles, :except => :show, :collection => {:permissions => [:get, :post]}
334 334 map.resources :enumerations, :except => :show
335 335
336 336 map.connect 'search', :controller => 'search', :action => 'index', :conditions => {:method => :get}
337 337
338 338 map.connect 'mail_handler', :controller => 'mail_handler',
339 339 :action => 'index', :conditions => {:method => :post}
340 340
341 341 map.connect 'admin', :controller => 'admin', :action => 'index',
342 342 :conditions => {:method => :get}
343 343 map.connect 'admin/projects', :controller => 'admin', :action => 'projects',
344 344 :conditions => {:method => :get}
345 345 map.connect 'admin/plugins', :controller => 'admin', :action => 'plugins',
346 346 :conditions => {:method => :get}
347 347 map.connect 'admin/info', :controller => 'admin', :action => 'info',
348 348 :conditions => {:method => :get}
349 349 map.connect 'admin/test_email', :controller => 'admin', :action => 'test_email',
350 350 :conditions => {:method => :get}
351 351 map.connect 'admin/default_configuration', :controller => 'admin',
352 352 :action => 'default_configuration', :conditions => {:method => :post}
353 353
354 354 # Used by AuthSourcesControllerTest
355 355 # TODO : refactor *AuthSourcesController to remove these routes
356 356 map.connect 'auth_sources', :controller => 'auth_sources',
357 357 :action => 'index', :conditions => {:method => :get}
358 358 map.connect 'auth_sources/new', :controller => 'auth_sources',
359 359 :action => 'new', :conditions => {:method => :get}
360 360 map.connect 'auth_sources/create', :controller => 'auth_sources',
361 361 :action => 'create', :conditions => {:method => :post}
362 362 map.connect 'auth_sources/destroy/:id', :controller => 'auth_sources',
363 363 :action => 'destroy', :id => /\d+/, :conditions => {:method => :post}
364 364 map.connect 'auth_sources/test_connection/:id', :controller => 'auth_sources',
365 365 :action => 'test_connection', :conditions => {:method => :get}
366 366 map.connect 'auth_sources/edit/:id', :controller => 'auth_sources',
367 367 :action => 'edit', :id => /\d+/, :conditions => {:method => :get}
368 368 map.connect 'auth_sources/update/:id', :controller => 'auth_sources',
369 369 :action => 'update', :id => /\d+/, :conditions => {:method => :post}
370 370
371 371 map.connect 'ldap_auth_sources', :controller => 'ldap_auth_sources',
372 372 :action => 'index', :conditions => {:method => :get}
373 373 map.connect 'ldap_auth_sources/new', :controller => 'ldap_auth_sources',
374 374 :action => 'new', :conditions => {:method => :get}
375 375 map.connect 'ldap_auth_sources/create', :controller => 'ldap_auth_sources',
376 376 :action => 'create', :conditions => {:method => :post}
377 377 map.connect 'ldap_auth_sources/destroy/:id', :controller => 'ldap_auth_sources',
378 378 :action => 'destroy', :id => /\d+/, :conditions => {:method => :post}
379 379 map.connect 'ldap_auth_sources/test_connection/:id', :controller => 'ldap_auth_sources',
380 380 :action => 'test_connection', :conditions => {:method => :get}
381 381 map.connect 'ldap_auth_sources/edit/:id', :controller => 'ldap_auth_sources',
382 382 :action => 'edit', :id => /\d+/, :conditions => {:method => :get}
383 383 map.connect 'ldap_auth_sources/update/:id', :controller => 'ldap_auth_sources',
384 384 :action => 'update', :id => /\d+/, :conditions => {:method => :post}
385 385
386 386 map.connect 'workflows', :controller => 'workflows',
387 387 :action => 'index', :conditions => {:method => :get}
388 388 map.connect 'workflows/edit', :controller => 'workflows',
389 389 :action => 'edit', :conditions => {:method => [:get, :post]}
390 390 map.connect 'workflows/copy', :controller => 'workflows',
391 391 :action => 'copy', :conditions => {:method => [:get, :post]}
392 392
393 393 map.connect 'settings', :controller => 'settings',
394 394 :action => 'index', :conditions => {:method => :get}
395 395 map.connect 'settings/edit', :controller => 'settings',
396 396 :action => 'edit', :conditions => {:method => [:get, :post]}
397 397 map.connect 'settings/plugin/:id', :controller => 'settings',
398 398 :action => 'plugin', :conditions => {:method => [:get, :post]}
399 399
400 400 map.with_options :controller => 'sys' do |sys|
401 401 sys.connect 'sys/projects.:format',
402 402 :action => 'projects',
403 403 :conditions => {:method => :get}
404 404 sys.connect 'sys/projects/:id/repository.:format',
405 405 :action => 'create_project_repository',
406 406 :conditions => {:method => :post}
407 407 sys.connect 'sys/fetch_changesets',
408 408 :action => 'fetch_changesets',
409 409 :conditions => {:method => :get}
410 410 end
411 411
412 map.connect 'uploads.:format', :controller => 'attachments', :action => 'upload', :conditions => {:method => :post}
413
412 414 map.connect 'robots.txt', :controller => 'welcome',
413 415 :action => 'robots', :conditions => {:method => :get}
414 416
415 417 # Used for OpenID
416 418 map.root :controller => 'account', :action => 'login'
417 419 end
@@ -1,238 +1,238
1 1 require 'redmine/access_control'
2 2 require 'redmine/menu_manager'
3 3 require 'redmine/activity'
4 4 require 'redmine/search'
5 5 require 'redmine/custom_field_format'
6 6 require 'redmine/mime_type'
7 7 require 'redmine/core_ext'
8 8 require 'redmine/themes'
9 9 require 'redmine/hook'
10 10 require 'redmine/plugin'
11 11 require 'redmine/notifiable'
12 12 require 'redmine/wiki_formatting'
13 13 require 'redmine/scm/base'
14 14
15 15 begin
16 16 require_library_or_gem 'RMagick' unless Object.const_defined?(:Magick)
17 17 rescue LoadError
18 18 # RMagick is not available
19 19 end
20 20
21 21 if RUBY_VERSION < '1.9'
22 22 require 'fastercsv'
23 23 else
24 24 require 'csv'
25 25 FCSV = CSV
26 26 end
27 27
28 28 Redmine::Scm::Base.add "Subversion"
29 29 Redmine::Scm::Base.add "Darcs"
30 30 Redmine::Scm::Base.add "Mercurial"
31 31 Redmine::Scm::Base.add "Cvs"
32 32 Redmine::Scm::Base.add "Bazaar"
33 33 Redmine::Scm::Base.add "Git"
34 34 Redmine::Scm::Base.add "Filesystem"
35 35
36 36 Redmine::CustomFieldFormat.map do |fields|
37 37 fields.register Redmine::CustomFieldFormat.new('string', :label => :label_string, :order => 1)
38 38 fields.register Redmine::CustomFieldFormat.new('text', :label => :label_text, :order => 2)
39 39 fields.register Redmine::CustomFieldFormat.new('int', :label => :label_integer, :order => 3)
40 40 fields.register Redmine::CustomFieldFormat.new('float', :label => :label_float, :order => 4)
41 41 fields.register Redmine::CustomFieldFormat.new('list', :label => :label_list, :order => 5)
42 42 fields.register Redmine::CustomFieldFormat.new('date', :label => :label_date, :order => 6)
43 43 fields.register Redmine::CustomFieldFormat.new('bool', :label => :label_boolean, :order => 7)
44 44 fields.register Redmine::CustomFieldFormat.new('user', :label => :label_user, :only => %w(Issue TimeEntry Version Project), :edit_as => 'list', :order => 8)
45 45 fields.register Redmine::CustomFieldFormat.new('version', :label => :label_version, :only => %w(Issue TimeEntry Version Project), :edit_as => 'list', :order => 9)
46 46 end
47 47
48 48 # Permissions
49 49 Redmine::AccessControl.map do |map|
50 50 map.permission :view_project, {:projects => [:show], :activities => [:index]}, :public => true
51 51 map.permission :search_project, {:search => :index}, :public => true
52 52 map.permission :add_project, {:projects => [:new, :create]}, :require => :loggedin
53 53 map.permission :edit_project, {:projects => [:settings, :edit, :update]}, :require => :member
54 54 map.permission :select_project_modules, {:projects => :modules}, :require => :member
55 55 map.permission :manage_members, {:projects => :settings, :members => [:index, :show, :create, :update, :destroy, :autocomplete]}, :require => :member
56 56 map.permission :manage_versions, {:projects => :settings, :versions => [:new, :create, :edit, :update, :close_completed, :destroy]}, :require => :member
57 57 map.permission :add_subprojects, {:projects => [:new, :create]}, :require => :member
58 58
59 59 map.project_module :issue_tracking do |map|
60 60 # Issue categories
61 61 map.permission :manage_categories, {:projects => :settings, :issue_categories => [:index, :show, :new, :create, :edit, :update, :destroy]}, :require => :member
62 62 # Issues
63 63 map.permission :view_issues, {:issues => [:index, :show],
64 64 :auto_complete => [:issues],
65 65 :context_menus => [:issues],
66 66 :versions => [:index, :show, :status_by],
67 67 :journals => [:index, :diff],
68 68 :queries => :index,
69 69 :reports => [:issue_report, :issue_report_details]}
70 map.permission :add_issues, {:issues => [:new, :create, :update_form]}
71 map.permission :edit_issues, {:issues => [:edit, :update, :bulk_edit, :bulk_update, :update_form], :journals => [:new]}
70 map.permission :add_issues, {:issues => [:new, :create, :update_form], :attachments => :upload}
71 map.permission :edit_issues, {:issues => [:edit, :update, :bulk_edit, :bulk_update, :update_form], :journals => [:new], :attachments => :upload}
72 72 map.permission :manage_issue_relations, {:issue_relations => [:index, :show, :create, :destroy]}
73 73 map.permission :manage_subtasks, {}
74 74 map.permission :set_issues_private, {}
75 75 map.permission :set_own_issues_private, {}, :require => :loggedin
76 map.permission :add_issue_notes, {:issues => [:edit, :update], :journals => [:new]}
76 map.permission :add_issue_notes, {:issues => [:edit, :update], :journals => [:new], :attachments => :upload}
77 77 map.permission :edit_issue_notes, {:journals => :edit}, :require => :loggedin
78 78 map.permission :edit_own_issue_notes, {:journals => :edit}, :require => :loggedin
79 79 map.permission :move_issues, {:issues => [:bulk_edit, :bulk_update]}, :require => :loggedin
80 80 map.permission :delete_issues, {:issues => :destroy}, :require => :member
81 81 # Queries
82 82 map.permission :manage_public_queries, {:queries => [:new, :create, :edit, :update, :destroy]}, :require => :member
83 83 map.permission :save_queries, {:queries => [:new, :create, :edit, :update, :destroy]}, :require => :loggedin
84 84 # Watchers
85 85 map.permission :view_issue_watchers, {}
86 86 map.permission :add_issue_watchers, {:watchers => :new}
87 87 map.permission :delete_issue_watchers, {:watchers => :destroy}
88 88 end
89 89
90 90 map.project_module :time_tracking do |map|
91 91 map.permission :log_time, {:timelog => [:new, :create]}, :require => :loggedin
92 92 map.permission :view_time_entries, :timelog => [:index, :report, :show]
93 93 map.permission :edit_time_entries, {:timelog => [:edit, :update, :destroy, :bulk_edit, :bulk_update]}, :require => :member
94 94 map.permission :edit_own_time_entries, {:timelog => [:edit, :update, :destroy,:bulk_edit, :bulk_update]}, :require => :loggedin
95 95 map.permission :manage_project_activities, {:project_enumerations => [:update, :destroy]}, :require => :member
96 96 end
97 97
98 98 map.project_module :news do |map|
99 99 map.permission :manage_news, {:news => [:new, :create, :edit, :update, :destroy], :comments => [:destroy]}, :require => :member
100 100 map.permission :view_news, {:news => [:index, :show]}, :public => true
101 101 map.permission :comment_news, {:comments => :create}
102 102 end
103 103
104 104 map.project_module :documents do |map|
105 105 map.permission :manage_documents, {:documents => [:new, :create, :edit, :update, :destroy, :add_attachment]}, :require => :loggedin
106 106 map.permission :view_documents, :documents => [:index, :show, :download]
107 107 end
108 108
109 109 map.project_module :files do |map|
110 110 map.permission :manage_files, {:files => [:new, :create]}, :require => :loggedin
111 111 map.permission :view_files, :files => :index, :versions => :download
112 112 end
113 113
114 114 map.project_module :wiki do |map|
115 115 map.permission :manage_wiki, {:wikis => [:edit, :destroy]}, :require => :member
116 116 map.permission :rename_wiki_pages, {:wiki => :rename}, :require => :member
117 117 map.permission :delete_wiki_pages, {:wiki => :destroy}, :require => :member
118 118 map.permission :view_wiki_pages, :wiki => [:index, :show, :special, :date_index]
119 119 map.permission :export_wiki_pages, :wiki => [:export]
120 120 map.permission :view_wiki_edits, :wiki => [:history, :diff, :annotate]
121 121 map.permission :edit_wiki_pages, :wiki => [:edit, :update, :preview, :add_attachment]
122 122 map.permission :delete_wiki_pages_attachments, {}
123 123 map.permission :protect_wiki_pages, {:wiki => :protect}, :require => :member
124 124 end
125 125
126 126 map.project_module :repository do |map|
127 127 map.permission :manage_repository, {:repositories => [:new, :create, :edit, :update, :committers, :destroy]}, :require => :member
128 128 map.permission :browse_repository, :repositories => [:show, :browse, :entry, :annotate, :changes, :diff, :stats, :graph]
129 129 map.permission :view_changesets, :repositories => [:show, :revisions, :revision]
130 130 map.permission :commit_access, {}
131 131 map.permission :manage_related_issues, {:repositories => [:add_related_issue, :remove_related_issue]}
132 132 end
133 133
134 134 map.project_module :boards do |map|
135 135 map.permission :manage_boards, {:boards => [:new, :create, :edit, :update, :destroy]}, :require => :member
136 136 map.permission :view_messages, {:boards => [:index, :show], :messages => [:show]}, :public => true
137 137 map.permission :add_messages, {:messages => [:new, :reply, :quote]}
138 138 map.permission :edit_messages, {:messages => :edit}, :require => :member
139 139 map.permission :edit_own_messages, {:messages => :edit}, :require => :loggedin
140 140 map.permission :delete_messages, {:messages => :destroy}, :require => :member
141 141 map.permission :delete_own_messages, {:messages => :destroy}, :require => :loggedin
142 142 end
143 143
144 144 map.project_module :calendar do |map|
145 145 map.permission :view_calendar, :calendars => [:show, :update]
146 146 end
147 147
148 148 map.project_module :gantt do |map|
149 149 map.permission :view_gantt, :gantts => [:show, :update]
150 150 end
151 151 end
152 152
153 153 Redmine::MenuManager.map :top_menu do |menu|
154 154 menu.push :home, :home_path
155 155 menu.push :my_page, { :controller => 'my', :action => 'page' }, :if => Proc.new { User.current.logged? }
156 156 menu.push :projects, { :controller => 'projects', :action => 'index' }, :caption => :label_project_plural
157 157 menu.push :administration, { :controller => 'admin', :action => 'index' }, :if => Proc.new { User.current.admin? }, :last => true
158 158 menu.push :help, Redmine::Info.help_url, :last => true
159 159 end
160 160
161 161 Redmine::MenuManager.map :account_menu do |menu|
162 162 menu.push :login, :signin_path, :if => Proc.new { !User.current.logged? }
163 163 menu.push :register, { :controller => 'account', :action => 'register' }, :if => Proc.new { !User.current.logged? && Setting.self_registration? }
164 164 menu.push :my_account, { :controller => 'my', :action => 'account' }, :if => Proc.new { User.current.logged? }
165 165 menu.push :logout, :signout_path, :if => Proc.new { User.current.logged? }
166 166 end
167 167
168 168 Redmine::MenuManager.map :application_menu do |menu|
169 169 # Empty
170 170 end
171 171
172 172 Redmine::MenuManager.map :admin_menu do |menu|
173 173 menu.push :projects, {:controller => 'admin', :action => 'projects'}, :caption => :label_project_plural
174 174 menu.push :users, {:controller => 'users'}, :caption => :label_user_plural
175 175 menu.push :groups, {:controller => 'groups'}, :caption => :label_group_plural
176 176 menu.push :roles, {:controller => 'roles'}, :caption => :label_role_and_permissions
177 177 menu.push :trackers, {:controller => 'trackers'}, :caption => :label_tracker_plural
178 178 menu.push :issue_statuses, {:controller => 'issue_statuses'}, :caption => :label_issue_status_plural,
179 179 :html => {:class => 'issue_statuses'}
180 180 menu.push :workflows, {:controller => 'workflows', :action => 'edit'}, :caption => :label_workflow
181 181 menu.push :custom_fields, {:controller => 'custom_fields'}, :caption => :label_custom_field_plural,
182 182 :html => {:class => 'custom_fields'}
183 183 menu.push :enumerations, {:controller => 'enumerations'}
184 184 menu.push :settings, {:controller => 'settings'}
185 185 menu.push :ldap_authentication, {:controller => 'ldap_auth_sources', :action => 'index'},
186 186 :html => {:class => 'server_authentication'}
187 187 menu.push :plugins, {:controller => 'admin', :action => 'plugins'}, :last => true
188 188 menu.push :info, {:controller => 'admin', :action => 'info'}, :caption => :label_information_plural, :last => true
189 189 end
190 190
191 191 Redmine::MenuManager.map :project_menu do |menu|
192 192 menu.push :overview, { :controller => 'projects', :action => 'show' }
193 193 menu.push :activity, { :controller => 'activities', :action => 'index' }
194 194 menu.push :roadmap, { :controller => 'versions', :action => 'index' }, :param => :project_id,
195 195 :if => Proc.new { |p| p.shared_versions.any? }
196 196 menu.push :issues, { :controller => 'issues', :action => 'index' }, :param => :project_id, :caption => :label_issue_plural
197 197 menu.push :new_issue, { :controller => 'issues', :action => 'new' }, :param => :project_id, :caption => :label_issue_new,
198 198 :html => { :accesskey => Redmine::AccessKeys.key_for(:new_issue) }
199 199 menu.push :gantt, { :controller => 'gantts', :action => 'show' }, :param => :project_id, :caption => :label_gantt
200 200 menu.push :calendar, { :controller => 'calendars', :action => 'show' }, :param => :project_id, :caption => :label_calendar
201 201 menu.push :news, { :controller => 'news', :action => 'index' }, :param => :project_id, :caption => :label_news_plural
202 202 menu.push :documents, { :controller => 'documents', :action => 'index' }, :param => :project_id, :caption => :label_document_plural
203 203 menu.push :wiki, { :controller => 'wiki', :action => 'show', :id => nil }, :param => :project_id,
204 204 :if => Proc.new { |p| p.wiki && !p.wiki.new_record? }
205 205 menu.push :boards, { :controller => 'boards', :action => 'index', :id => nil }, :param => :project_id,
206 206 :if => Proc.new { |p| p.boards.any? }, :caption => :label_board_plural
207 207 menu.push :files, { :controller => 'files', :action => 'index' }, :caption => :label_file_plural, :param => :project_id
208 208 menu.push :repository, { :controller => 'repositories', :action => 'show' },
209 209 :if => Proc.new { |p| p.repository && !p.repository.new_record? }
210 210 menu.push :settings, { :controller => 'projects', :action => 'settings' }, :last => true
211 211 end
212 212
213 213 Redmine::Activity.map do |activity|
214 214 activity.register :issues, :class_name => %w(Issue Journal)
215 215 activity.register :changesets
216 216 activity.register :news
217 217 activity.register :documents, :class_name => %w(Document Attachment)
218 218 activity.register :files, :class_name => 'Attachment'
219 219 activity.register :wiki_edits, :class_name => 'WikiContent::Version', :default => false
220 220 activity.register :messages, :default => false
221 221 activity.register :time_entries, :default => false
222 222 end
223 223
224 224 Redmine::Search.map do |search|
225 225 search.register :issues
226 226 search.register :news
227 227 search.register :documents
228 228 search.register :changesets
229 229 search.register :wiki_pages
230 230 search.register :messages
231 231 search.register :projects
232 232 end
233 233
234 234 Redmine::WikiFormatting.map do |format|
235 235 format.register :textile, Redmine::WikiFormatting::Textile::Formatter, Redmine::WikiFormatting::Textile::Helper
236 236 end
237 237
238 238 ActionView::Template.register_template_handler :rsb, Redmine::Views::ApiTemplateHandler
@@ -1,85 +1,120
1 1 # Redmine - project management software
2 2 # Copyright (C) 2006-2011 Jean-Philippe Lang
3 3 #
4 4 # This program is free software; you can redistribute it and/or
5 5 # modify it under the terms of the GNU General Public License
6 6 # as published by the Free Software Foundation; either version 2
7 7 # of the License, or (at your option) any later version.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU General Public License
15 15 # along with this program; if not, write to the Free Software
16 16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17 17
18 18 require File.expand_path('../../../test_helper', __FILE__)
19 19
20 20 class ApiTest::AttachmentsTest < ActionController::IntegrationTest
21 21 fixtures :projects, :trackers, :issue_statuses, :issues,
22 22 :enumerations, :users, :issue_categories,
23 23 :projects_trackers,
24 24 :roles,
25 25 :member_roles,
26 26 :members,
27 27 :enabled_modules,
28 28 :workflows,
29 29 :attachments
30 30
31 31 def setup
32 32 Setting.rest_api_enabled = '1'
33 33 set_fixtures_attachments_directory
34 34 end
35 35
36 36 def teardown
37 37 set_tmp_attachments_directory
38 38 end
39 39
40 40 context "/attachments/:id" do
41 41 context "GET" do
42 42 should "return the attachment" do
43 43 get '/attachments/7.xml', {}, credentials('jsmith')
44 44 assert_response :success
45 45 assert_equal 'application/xml', @response.content_type
46 46 assert_tag :tag => 'attachment',
47 47 :child => {
48 48 :tag => 'id',
49 49 :content => '7',
50 50 :sibling => {
51 51 :tag => 'filename',
52 52 :content => 'archive.zip',
53 53 :sibling => {
54 54 :tag => 'content_url',
55 55 :content => 'http://www.example.com/attachments/download/7/archive.zip'
56 56 }
57 57 }
58 58 }
59 59 end
60 60
61 61 should "deny access without credentials" do
62 62 get '/attachments/7.xml'
63 63 assert_response 401
64 64 set_tmp_attachments_directory
65 65 end
66 66 end
67 67 end
68 68
69 69 context "/attachments/download/:id/:filename" do
70 70 context "GET" do
71 71 should "return the attachment content" do
72 72 get '/attachments/download/7/archive.zip', {}, credentials('jsmith')
73 73 assert_response :success
74 74 assert_equal 'application/octet-stream', @response.content_type
75 75 set_tmp_attachments_directory
76 76 end
77 77
78 78 should "deny access without credentials" do
79 79 get '/attachments/download/7/archive.zip'
80 80 assert_response 302
81 81 set_tmp_attachments_directory
82 82 end
83 83 end
84 84 end
85
86 context "POST /uploads" do
87 should "return the token" do
88 set_tmp_attachments_directory
89 assert_difference 'Attachment.count' do
90 post '/uploads.xml', 'File content', {'Content-Type' => 'application/octet-stream'}.merge(credentials('jsmith'))
91 assert_response :created
92 assert_equal 'application/xml', response.content_type
93
94 xml = Hash.from_xml(response.body)
95 assert_kind_of Hash, xml['upload']
96 token = xml['upload']['token']
97 assert_not_nil token
98
99 attachment = Attachment.first(:order => 'id DESC')
100 assert_equal token, attachment.token
101 assert_nil attachment.container
102 assert_equal 2, attachment.author_id
103 assert_equal 'File content'.size, attachment.filesize
104 assert attachment.content_type.blank?
105 assert attachment.filename.present?
106 assert_match /\d+_[0-9a-z]+/, attachment.diskfile
107 assert File.exist?(attachment.diskfile)
108 assert_equal 'File content', File.read(attachment.diskfile)
109 end
110 end
111
112 should "not accept other content types" do
113 set_tmp_attachments_directory
114 assert_no_difference 'Attachment.count' do
115 post '/uploads.xml', 'PNG DATA', {'Content-Type' => 'image/png'}.merge(credentials('jsmith'))
116 assert_response 406
117 end
118 end
119 end
85 120 end
@@ -1,710 +1,778
1 1 # Redmine - project management software
2 2 # Copyright (C) 2006-2011 Jean-Philippe Lang
3 3 #
4 4 # This program is free software; you can redistribute it and/or
5 5 # modify it under the terms of the GNU General Public License
6 6 # as published by the Free Software Foundation; either version 2
7 7 # of the License, or (at your option) any later version.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU General Public License
15 15 # along with this program; if not, write to the Free Software
16 16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17 17
18 18 require File.expand_path('../../../test_helper', __FILE__)
19 19
20 20 class 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 :attachments
45 45
46 46 def setup
47 47 Setting.rest_api_enabled = '1'
48 48 end
49 49
50 50 context "/issues" do
51 51 # Use a private project to make sure auth is really working and not just
52 52 # only showing public issues.
53 53 should_allow_api_authentication(:get, "/projects/private-child/issues.xml")
54 54
55 55 should "contain metadata" do
56 56 get '/issues.xml'
57 57
58 58 assert_tag :tag => 'issues',
59 59 :attributes => {
60 60 :type => 'array',
61 61 :total_count => assigns(:issue_count),
62 62 :limit => 25,
63 63 :offset => 0
64 64 }
65 65 end
66 66
67 67 context "with offset and limit" do
68 68 should "use the params" do
69 69 get '/issues.xml?offset=2&limit=3'
70 70
71 71 assert_equal 3, assigns(:limit)
72 72 assert_equal 2, assigns(:offset)
73 73 assert_tag :tag => 'issues', :children => {:count => 3, :only => {:tag => 'issue'}}
74 74 end
75 75 end
76 76
77 77 context "with nometa param" do
78 78 should "not contain metadata" do
79 79 get '/issues.xml?nometa=1'
80 80
81 81 assert_tag :tag => 'issues',
82 82 :attributes => {
83 83 :type => 'array',
84 84 :total_count => nil,
85 85 :limit => nil,
86 86 :offset => nil
87 87 }
88 88 end
89 89 end
90 90
91 91 context "with nometa header" do
92 92 should "not contain metadata" do
93 93 get '/issues.xml', {}, {'X-Redmine-Nometa' => '1'}
94 94
95 95 assert_tag :tag => 'issues',
96 96 :attributes => {
97 97 :type => 'array',
98 98 :total_count => nil,
99 99 :limit => nil,
100 100 :offset => nil
101 101 }
102 102 end
103 103 end
104 104
105 105 context "with relations" do
106 106 should "display relations" do
107 107 get '/issues.xml?include=relations'
108 108
109 109 assert_response :success
110 110 assert_equal 'application/xml', @response.content_type
111 111 assert_tag 'relations',
112 112 :parent => {:tag => 'issue', :child => {:tag => 'id', :content => '3'}},
113 113 :children => {:count => 1},
114 114 :child => {
115 115 :tag => 'relation',
116 116 :attributes => {:id => '2', :issue_id => '2', :issue_to_id => '3', :relation_type => 'relates'}
117 117 }
118 118 assert_tag 'relations',
119 119 :parent => {:tag => 'issue', :child => {:tag => 'id', :content => '1'}},
120 120 :children => {:count => 0}
121 121 end
122 122 end
123 123
124 124 context "with invalid query params" do
125 125 should "return errors" do
126 126 get '/issues.xml', {:f => ['start_date'], :op => {:start_date => '='}}
127 127
128 128 assert_response :unprocessable_entity
129 129 assert_equal 'application/xml', @response.content_type
130 130 assert_tag 'errors', :child => {:tag => 'error', :content => "Start date can't be blank"}
131 131 end
132 132 end
133 133
134 134 context "with custom field filter" do
135 135 should "show only issues with the custom field value" do
136 136 get '/issues.xml', { :set_filter => 1, :f => ['cf_1'], :op => {:cf_1 => '='}, :v => {:cf_1 => ['MySQL']}}
137 137
138 138 expected_ids = Issue.visible.all(
139 139 :include => :custom_values,
140 140 :conditions => {:custom_values => {:custom_field_id => 1, :value => 'MySQL'}}).map(&:id)
141 141
142 142 assert_select 'issues > issue > id', :count => expected_ids.count do |ids|
143 143 ids.each { |id| assert expected_ids.delete(id.children.first.content.to_i) }
144 144 end
145 145 end
146 146 end
147 147
148 148 context "with custom field filter (shorthand method)" do
149 149 should "show only issues with the custom field value" do
150 150 get '/issues.xml', { :cf_1 => 'MySQL' }
151 151
152 152 expected_ids = Issue.visible.all(
153 153 :include => :custom_values,
154 154 :conditions => {:custom_values => {:custom_field_id => 1, :value => 'MySQL'}}).map(&:id)
155 155
156 156 assert_select 'issues > issue > id', :count => expected_ids.count do |ids|
157 157 ids.each { |id| assert expected_ids.delete(id.children.first.content.to_i) }
158 158 end
159 159 end
160 160 end
161 161 end
162 162
163 163 context "/index.json" do
164 164 should_allow_api_authentication(:get, "/projects/private-child/issues.json")
165 165 end
166 166
167 167 context "/index.xml with filter" do
168 168 should "show only issues with the status_id" do
169 169 get '/issues.xml?status_id=5'
170 170
171 171 expected_ids = Issue.visible.all(:conditions => {:status_id => 5}).map(&:id)
172 172
173 173 assert_select 'issues > issue > id', :count => expected_ids.count do |ids|
174 174 ids.each { |id| assert expected_ids.delete(id.children.first.content.to_i) }
175 175 end
176 176 end
177 177 end
178 178
179 179 context "/index.json with filter" do
180 180 should "show only issues with the status_id" do
181 181 get '/issues.json?status_id=5'
182 182
183 183 json = ActiveSupport::JSON.decode(response.body)
184 184 status_ids_used = json['issues'].collect {|j| j['status']['id'] }
185 185 assert_equal 3, status_ids_used.length
186 186 assert status_ids_used.all? {|id| id == 5 }
187 187 end
188 188
189 189 end
190 190
191 191 # Issue 6 is on a private project
192 192 context "/issues/6.xml" do
193 193 should_allow_api_authentication(:get, "/issues/6.xml")
194 194 end
195 195
196 196 context "/issues/6.json" do
197 197 should_allow_api_authentication(:get, "/issues/6.json")
198 198 end
199 199
200 200 context "GET /issues/:id" do
201 201 context "with journals" do
202 202 context ".xml" do
203 203 should "display journals" do
204 204 get '/issues/1.xml?include=journals'
205 205
206 206 assert_tag :tag => 'issue',
207 207 :child => {
208 208 :tag => 'journals',
209 209 :attributes => { :type => 'array' },
210 210 :child => {
211 211 :tag => 'journal',
212 212 :attributes => { :id => '1'},
213 213 :child => {
214 214 :tag => 'details',
215 215 :attributes => { :type => 'array' },
216 216 :child => {
217 217 :tag => 'detail',
218 218 :attributes => { :name => 'status_id' },
219 219 :child => {
220 220 :tag => 'old_value',
221 221 :content => '1',
222 222 :sibling => {
223 223 :tag => 'new_value',
224 224 :content => '2'
225 225 }
226 226 }
227 227 }
228 228 }
229 229 }
230 230 }
231 231 end
232 232 end
233 233 end
234 234
235 235 context "with custom fields" do
236 236 context ".xml" do
237 237 should "display custom fields" do
238 238 get '/issues/3.xml'
239 239
240 240 assert_tag :tag => 'issue',
241 241 :child => {
242 242 :tag => 'custom_fields',
243 243 :attributes => { :type => 'array' },
244 244 :child => {
245 245 :tag => 'custom_field',
246 246 :attributes => { :id => '1'},
247 247 :child => {
248 248 :tag => 'value',
249 249 :content => 'MySQL'
250 250 }
251 251 }
252 252 }
253 253
254 254 assert_nothing_raised do
255 255 Hash.from_xml(response.body).to_xml
256 256 end
257 257 end
258 258 end
259 259 end
260 260
261 261 context "with multi custom fields" do
262 262 setup do
263 263 field = CustomField.find(1)
264 264 field.update_attribute :multiple, true
265 265 issue = Issue.find(3)
266 266 issue.custom_field_values = {1 => ['MySQL', 'Oracle']}
267 267 issue.save!
268 268 end
269 269
270 270 context ".xml" do
271 271 should "display custom fields" do
272 272 get '/issues/3.xml'
273 273 assert_response :success
274 274 assert_tag :tag => 'issue',
275 275 :child => {
276 276 :tag => 'custom_fields',
277 277 :attributes => { :type => 'array' },
278 278 :child => {
279 279 :tag => 'custom_field',
280 280 :attributes => { :id => '1'},
281 281 :child => {
282 282 :tag => 'value',
283 283 :attributes => { :type => 'array' },
284 284 :children => { :count => 2 }
285 285 }
286 286 }
287 287 }
288 288
289 289 xml = Hash.from_xml(response.body)
290 290 custom_fields = xml['issue']['custom_fields']
291 291 assert_kind_of Array, custom_fields
292 292 field = custom_fields.detect {|f| f['id'] == '1'}
293 293 assert_kind_of Hash, field
294 294 assert_equal ['MySQL', 'Oracle'], field['value'].sort
295 295 end
296 296 end
297 297
298 298 context ".json" do
299 299 should "display custom fields" do
300 300 get '/issues/3.json'
301 301 assert_response :success
302 302 json = ActiveSupport::JSON.decode(response.body)
303 303 custom_fields = json['issue']['custom_fields']
304 304 assert_kind_of Array, custom_fields
305 305 field = custom_fields.detect {|f| f['id'] == 1}
306 306 assert_kind_of Hash, field
307 307 assert_equal ['MySQL', 'Oracle'], field['value'].sort
308 308 end
309 309 end
310 310 end
311 311
312 312 context "with empty value for multi custom field" do
313 313 setup do
314 314 field = CustomField.find(1)
315 315 field.update_attribute :multiple, true
316 316 issue = Issue.find(3)
317 317 issue.custom_field_values = {1 => ['']}
318 318 issue.save!
319 319 end
320 320
321 321 context ".xml" do
322 322 should "display custom fields" do
323 323 get '/issues/3.xml'
324 324 assert_response :success
325 325 assert_tag :tag => 'issue',
326 326 :child => {
327 327 :tag => 'custom_fields',
328 328 :attributes => { :type => 'array' },
329 329 :child => {
330 330 :tag => 'custom_field',
331 331 :attributes => { :id => '1'},
332 332 :child => {
333 333 :tag => 'value',
334 334 :attributes => { :type => 'array' },
335 335 :children => { :count => 0 }
336 336 }
337 337 }
338 338 }
339 339
340 340 xml = Hash.from_xml(response.body)
341 341 custom_fields = xml['issue']['custom_fields']
342 342 assert_kind_of Array, custom_fields
343 343 field = custom_fields.detect {|f| f['id'] == '1'}
344 344 assert_kind_of Hash, field
345 345 assert_equal [], field['value']
346 346 end
347 347 end
348 348
349 349 context ".json" do
350 350 should "display custom fields" do
351 351 get '/issues/3.json'
352 352 assert_response :success
353 353 json = ActiveSupport::JSON.decode(response.body)
354 354 custom_fields = json['issue']['custom_fields']
355 355 assert_kind_of Array, custom_fields
356 356 field = custom_fields.detect {|f| f['id'] == 1}
357 357 assert_kind_of Hash, field
358 358 assert_equal [], field['value'].sort
359 359 end
360 360 end
361 361 end
362 362
363 363 context "with attachments" do
364 364 context ".xml" do
365 365 should "display attachments" do
366 366 get '/issues/3.xml?include=attachments'
367 367
368 368 assert_tag :tag => 'issue',
369 369 :child => {
370 370 :tag => 'attachments',
371 371 :children => {:count => 5},
372 372 :child => {
373 373 :tag => 'attachment',
374 374 :child => {
375 375 :tag => 'filename',
376 376 :content => 'source.rb',
377 377 :sibling => {
378 378 :tag => 'content_url',
379 379 :content => 'http://www.example.com/attachments/download/4/source.rb'
380 380 }
381 381 }
382 382 }
383 383 }
384 384 end
385 385 end
386 386 end
387 387
388 388 context "with subtasks" do
389 389 setup do
390 390 @c1 = Issue.generate!(:status_id => 1, :subject => "child c1", :tracker_id => 1, :project_id => 1, :parent_issue_id => 1)
391 391 @c2 = Issue.generate!(:status_id => 1, :subject => "child c2", :tracker_id => 1, :project_id => 1, :parent_issue_id => 1)
392 392 @c3 = Issue.generate!(:status_id => 1, :subject => "child c3", :tracker_id => 1, :project_id => 1, :parent_issue_id => @c1.id)
393 393 end
394 394
395 395 context ".xml" do
396 396 should "display children" do
397 397 get '/issues/1.xml?include=children'
398 398
399 399 assert_tag :tag => 'issue',
400 400 :child => {
401 401 :tag => 'children',
402 402 :children => {:count => 2},
403 403 :child => {
404 404 :tag => 'issue',
405 405 :attributes => {:id => @c1.id.to_s},
406 406 :child => {
407 407 :tag => 'subject',
408 408 :content => 'child c1',
409 409 :sibling => {
410 410 :tag => 'children',
411 411 :children => {:count => 1},
412 412 :child => {
413 413 :tag => 'issue',
414 414 :attributes => {:id => @c3.id.to_s}
415 415 }
416 416 }
417 417 }
418 418 }
419 419 }
420 420 end
421 421
422 422 context ".json" do
423 423 should "display children" do
424 424 get '/issues/1.json?include=children'
425 425
426 426 json = ActiveSupport::JSON.decode(response.body)
427 427 assert_equal([
428 428 {
429 429 'id' => @c1.id, 'subject' => 'child c1', 'tracker' => {'id' => 1, 'name' => 'Bug'},
430 430 'children' => [{ 'id' => @c3.id, 'subject' => 'child c3', 'tracker' => {'id' => 1, 'name' => 'Bug'} }]
431 431 },
432 432 { 'id' => @c2.id, 'subject' => 'child c2', 'tracker' => {'id' => 1, 'name' => 'Bug'} }
433 433 ],
434 434 json['issue']['children'])
435 435 end
436 436 end
437 437 end
438 438 end
439 439 end
440 440
441 441 context "POST /issues.xml" do
442 442 should_allow_api_authentication(:post,
443 443 '/issues.xml',
444 444 {:issue => {:project_id => 1, :subject => 'API test', :tracker_id => 2, :status_id => 3}},
445 445 {:success_code => :created})
446 446
447 447 should "create an issue with the attributes" do
448 448 assert_difference('Issue.count') do
449 449 post '/issues.xml', {:issue => {:project_id => 1, :subject => 'API test', :tracker_id => 2, :status_id => 3}}, credentials('jsmith')
450 450 end
451 451
452 452 issue = Issue.first(:order => 'id DESC')
453 453 assert_equal 1, issue.project_id
454 454 assert_equal 2, issue.tracker_id
455 455 assert_equal 3, issue.status_id
456 456 assert_equal 'API test', issue.subject
457 457
458 458 assert_response :created
459 459 assert_equal 'application/xml', @response.content_type
460 460 assert_tag 'issue', :child => {:tag => 'id', :content => issue.id.to_s}
461 461 end
462 462 end
463 463
464 464 context "POST /issues.xml with failure" do
465 465 should "have an errors tag" do
466 466 assert_no_difference('Issue.count') do
467 467 post '/issues.xml', {:issue => {:project_id => 1}}, credentials('jsmith')
468 468 end
469 469
470 470 assert_tag :errors, :child => {:tag => 'error', :content => "Subject can't be blank"}
471 471 end
472 472 end
473 473
474 474 context "POST /issues.json" do
475 475 should_allow_api_authentication(:post,
476 476 '/issues.json',
477 477 {:issue => {:project_id => 1, :subject => 'API test', :tracker_id => 2, :status_id => 3}},
478 478 {:success_code => :created})
479 479
480 480 should "create an issue with the attributes" do
481 481 assert_difference('Issue.count') do
482 482 post '/issues.json', {:issue => {:project_id => 1, :subject => 'API test', :tracker_id => 2, :status_id => 3}}, credentials('jsmith')
483 483 end
484 484
485 485 issue = Issue.first(:order => 'id DESC')
486 486 assert_equal 1, issue.project_id
487 487 assert_equal 2, issue.tracker_id
488 488 assert_equal 3, issue.status_id
489 489 assert_equal 'API test', issue.subject
490 490 end
491 491
492 492 end
493 493
494 494 context "POST /issues.json with failure" do
495 495 should "have an errors element" do
496 496 assert_no_difference('Issue.count') do
497 497 post '/issues.json', {:issue => {:project_id => 1}}, credentials('jsmith')
498 498 end
499 499
500 500 json = ActiveSupport::JSON.decode(response.body)
501 501 assert json['errors'].include?(['subject', "can't be blank"])
502 502 end
503 503 end
504 504
505 505 # Issue 6 is on a private project
506 506 context "PUT /issues/6.xml" do
507 507 setup do
508 508 @parameters = {:issue => {:subject => 'API update', :notes => 'A new note'}}
509 509 end
510 510
511 511 should_allow_api_authentication(:put,
512 512 '/issues/6.xml',
513 513 {:issue => {:subject => 'API update', :notes => 'A new note'}},
514 514 {:success_code => :ok})
515 515
516 516 should "not create a new issue" do
517 517 assert_no_difference('Issue.count') do
518 518 put '/issues/6.xml', @parameters, credentials('jsmith')
519 519 end
520 520 end
521 521
522 522 should "create a new journal" do
523 523 assert_difference('Journal.count') do
524 524 put '/issues/6.xml', @parameters, credentials('jsmith')
525 525 end
526 526 end
527 527
528 528 should "add the note to the journal" do
529 529 put '/issues/6.xml', @parameters, credentials('jsmith')
530 530
531 531 journal = Journal.last
532 532 assert_equal "A new note", journal.notes
533 533 end
534 534
535 535 should "update the issue" do
536 536 put '/issues/6.xml', @parameters, credentials('jsmith')
537 537
538 538 issue = Issue.find(6)
539 539 assert_equal "API update", issue.subject
540 540 end
541 541
542 542 end
543 543
544 544 context "PUT /issues/3.xml with custom fields" do
545 545 setup do
546 546 @parameters = {:issue => {:custom_fields => [{'id' => '1', 'value' => 'PostgreSQL' }, {'id' => '2', 'value' => '150'}]}}
547 547 end
548 548
549 549 should "update custom fields" do
550 550 assert_no_difference('Issue.count') do
551 551 put '/issues/3.xml', @parameters, credentials('jsmith')
552 552 end
553 553
554 554 issue = Issue.find(3)
555 555 assert_equal '150', issue.custom_value_for(2).value
556 556 assert_equal 'PostgreSQL', issue.custom_value_for(1).value
557 557 end
558 558 end
559 559
560 560 context "PUT /issues/3.xml with multi custom fields" do
561 561 setup do
562 562 field = CustomField.find(1)
563 563 field.update_attribute :multiple, true
564 564 @parameters = {:issue => {:custom_fields => [{'id' => '1', 'value' => ['MySQL', 'PostgreSQL'] }, {'id' => '2', 'value' => '150'}]}}
565 565 end
566 566
567 567 should "update custom fields" do
568 568 assert_no_difference('Issue.count') do
569 569 put '/issues/3.xml', @parameters, credentials('jsmith')
570 570 end
571 571
572 572 issue = Issue.find(3)
573 573 assert_equal '150', issue.custom_value_for(2).value
574 574 assert_equal ['MySQL', 'PostgreSQL'], issue.custom_field_value(1).sort
575 575 end
576 576 end
577 577
578 578 context "PUT /issues/3.xml with project change" do
579 579 setup do
580 580 @parameters = {:issue => {:project_id => 2, :subject => 'Project changed'}}
581 581 end
582 582
583 583 should "update project" do
584 584 assert_no_difference('Issue.count') do
585 585 put '/issues/3.xml', @parameters, credentials('jsmith')
586 586 end
587 587
588 588 issue = Issue.find(3)
589 589 assert_equal 2, issue.project_id
590 590 assert_equal 'Project changed', issue.subject
591 591 end
592 592 end
593 593
594 594 context "PUT /issues/6.xml with failed update" do
595 595 setup do
596 596 @parameters = {:issue => {:subject => ''}}
597 597 end
598 598
599 599 should "not create a new issue" do
600 600 assert_no_difference('Issue.count') do
601 601 put '/issues/6.xml', @parameters, credentials('jsmith')
602 602 end
603 603 end
604 604
605 605 should "not create a new journal" do
606 606 assert_no_difference('Journal.count') do
607 607 put '/issues/6.xml', @parameters, credentials('jsmith')
608 608 end
609 609 end
610 610
611 611 should "have an errors tag" do
612 612 put '/issues/6.xml', @parameters, credentials('jsmith')
613 613
614 614 assert_tag :errors, :child => {:tag => 'error', :content => "Subject can't be blank"}
615 615 end
616 616 end
617 617
618 618 context "PUT /issues/6.json" do
619 619 setup do
620 620 @parameters = {:issue => {:subject => 'API update', :notes => 'A new note'}}
621 621 end
622 622
623 623 should_allow_api_authentication(:put,
624 624 '/issues/6.json',
625 625 {:issue => {:subject => 'API update', :notes => 'A new note'}},
626 626 {:success_code => :ok})
627 627
628 628 should "not create a new issue" do
629 629 assert_no_difference('Issue.count') do
630 630 put '/issues/6.json', @parameters, credentials('jsmith')
631 631 end
632 632 end
633 633
634 634 should "create a new journal" do
635 635 assert_difference('Journal.count') do
636 636 put '/issues/6.json', @parameters, credentials('jsmith')
637 637 end
638 638 end
639 639
640 640 should "add the note to the journal" do
641 641 put '/issues/6.json', @parameters, credentials('jsmith')
642 642
643 643 journal = Journal.last
644 644 assert_equal "A new note", journal.notes
645 645 end
646 646
647 647 should "update the issue" do
648 648 put '/issues/6.json', @parameters, credentials('jsmith')
649 649
650 650 issue = Issue.find(6)
651 651 assert_equal "API update", issue.subject
652 652 end
653 653
654 654 end
655 655
656 656 context "PUT /issues/6.json with failed update" do
657 657 setup do
658 658 @parameters = {:issue => {:subject => ''}}
659 659 end
660 660
661 661 should "not create a new issue" do
662 662 assert_no_difference('Issue.count') do
663 663 put '/issues/6.json', @parameters, credentials('jsmith')
664 664 end
665 665 end
666 666
667 667 should "not create a new journal" do
668 668 assert_no_difference('Journal.count') do
669 669 put '/issues/6.json', @parameters, credentials('jsmith')
670 670 end
671 671 end
672 672
673 673 should "have an errors attribute" do
674 674 put '/issues/6.json', @parameters, credentials('jsmith')
675 675
676 676 json = ActiveSupport::JSON.decode(response.body)
677 677 assert json['errors'].include?(['subject', "can't be blank"])
678 678 end
679 679 end
680 680
681 681 context "DELETE /issues/1.xml" do
682 682 should_allow_api_authentication(:delete,
683 683 '/issues/6.xml',
684 684 {},
685 685 {:success_code => :ok})
686 686
687 687 should "delete the issue" do
688 688 assert_difference('Issue.count',-1) do
689 689 delete '/issues/6.xml', {}, credentials('jsmith')
690 690 end
691 691
692 692 assert_nil Issue.find_by_id(6)
693 693 end
694 694 end
695 695
696 696 context "DELETE /issues/1.json" do
697 697 should_allow_api_authentication(:delete,
698 698 '/issues/6.json',
699 699 {},
700 700 {:success_code => :ok})
701 701
702 702 should "delete the issue" do
703 703 assert_difference('Issue.count',-1) do
704 704 delete '/issues/6.json', {}, credentials('jsmith')
705 705 end
706 706
707 707 assert_nil Issue.find_by_id(6)
708 708 end
709 709 end
710
711 def test_create_issue_with_uploaded_file
712 set_tmp_attachments_directory
713
714 # upload the file
715 assert_difference 'Attachment.count' do
716 post '/uploads.xml', 'test_create_with_upload', {'Content-Type' => 'application/octet-stream'}.merge(credentials('jsmith'))
717 assert_response :created
718 end
719 xml = Hash.from_xml(response.body)
720 token = xml['upload']['token']
721 attachment = Attachment.first(:order => 'id DESC')
722
723 # create the issue with the upload's token
724 assert_difference 'Issue.count' do
725 post '/issues.xml',
726 {:issue => {:project_id => 1, :subject => 'Uploaded file', :uploads => [{:token => token, :filename => 'test.txt', :content_type => 'text/plain'}]}},
727 credentials('jsmith')
728 assert_response :created
729 end
730 issue = Issue.first(:order => 'id DESC')
731 assert_equal 1, issue.attachments.count
732 assert_equal attachment, issue.attachments.first
733
734 attachment.reload
735 assert_equal 'test.txt', attachment.filename
736 assert_equal 'text/plain', attachment.content_type
737 assert_equal 'test_create_with_upload'.size, attachment.filesize
738 assert_equal 2, attachment.author_id
739
740 # get the issue with its attachments
741 get "/issues/#{issue.id}.xml", :include => 'attachments'
742 assert_response :success
743 xml = Hash.from_xml(response.body)
744 attachments = xml['issue']['attachments']
745 assert_kind_of Array, attachments
746 assert_equal 1, attachments.size
747 url = attachments.first['content_url']
748 assert_not_nil url
749
750 # download the attachment
751 get url
752 assert_response :success
753 end
754
755 def test_update_issue_with_uploaded_file
756 set_tmp_attachments_directory
757
758 # upload the file
759 assert_difference 'Attachment.count' do
760 post '/uploads.xml', 'test_upload_with_upload', {'Content-Type' => 'application/octet-stream'}.merge(credentials('jsmith'))
761 assert_response :created
762 end
763 xml = Hash.from_xml(response.body)
764 token = xml['upload']['token']
765 attachment = Attachment.first(:order => 'id DESC')
766
767 # update the issue with the upload's token
768 assert_difference 'Journal.count' do
769 put '/issues/1.xml',
770 {:issue => {:notes => 'Attachment added', :uploads => [{:token => token, :filename => 'test.txt', :content_type => 'text/plain'}]}},
771 credentials('jsmith')
772 assert_response :ok
773 end
774
775 issue = Issue.find(1)
776 assert_include attachment, issue.attachments
777 end
710 778 end
@@ -1,53 +1,61
1 1 # Redmine - project management software
2 2 # Copyright (C) 2006-2011 Jean-Philippe Lang
3 3 #
4 4 # This program is free software; you can redistribute it and/or
5 5 # modify it under the terms of the GNU General Public License
6 6 # as published by the Free Software Foundation; either version 2
7 7 # of the License, or (at your option) any later version.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU General Public License
15 15 # along with this program; if not, write to the Free Software
16 16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17 17
18 18 require File.expand_path('../../../test_helper', __FILE__)
19 19
20 20 class RoutingAttachmentsTest < ActionController::IntegrationTest
21 21 def test_attachments
22 22 assert_routing(
23 23 { :method => 'get', :path => "/attachments/1" },
24 24 { :controller => 'attachments', :action => 'show', :id => '1' }
25 25 )
26 26 assert_routing(
27 27 { :method => 'get', :path => "/attachments/1.xml" },
28 28 { :controller => 'attachments', :action => 'show', :id => '1', :format => 'xml' }
29 29 )
30 30 assert_routing(
31 31 { :method => 'get', :path => "/attachments/1.json" },
32 32 { :controller => 'attachments', :action => 'show', :id => '1', :format => 'json' }
33 33 )
34 34 assert_routing(
35 35 { :method => 'get', :path => "/attachments/1/filename.ext" },
36 36 { :controller => 'attachments', :action => 'show', :id => '1',
37 37 :filename => 'filename.ext' }
38 38 )
39 39 assert_routing(
40 40 { :method => 'get', :path => "/attachments/download/1" },
41 41 { :controller => 'attachments', :action => 'download', :id => '1' }
42 42 )
43 43 assert_routing(
44 44 { :method => 'get', :path => "/attachments/download/1/filename.ext" },
45 45 { :controller => 'attachments', :action => 'download', :id => '1',
46 46 :filename => 'filename.ext' }
47 47 )
48 48 assert_routing(
49 49 { :method => 'delete', :path => "/attachments/1" },
50 50 { :controller => 'attachments', :action => 'destroy', :id => '1' }
51 51 )
52 assert_routing(
53 { :method => 'post', :path => '/uploads.xml' },
54 { :controller => 'attachments', :action => 'upload', :format => 'xml' }
55 )
56 assert_routing(
57 { :method => 'post', :path => '/uploads.json' },
58 { :controller => 'attachments', :action => 'upload', :format => 'json' }
59 )
52 60 end
53 61 end
@@ -1,97 +1,102
1 1 # Redmine - project management software
2 2 # Copyright (C) 2006-2011 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 Acts
20 20 module Attachable
21 21 def self.included(base)
22 22 base.extend ClassMethods
23 23 end
24 24
25 25 module ClassMethods
26 26 def acts_as_attachable(options = {})
27 27 cattr_accessor :attachable_options
28 28 self.attachable_options = {}
29 29 attachable_options[:view_permission] = options.delete(:view_permission) || "view_#{self.name.pluralize.underscore}".to_sym
30 30 attachable_options[:delete_permission] = options.delete(:delete_permission) || "edit_#{self.name.pluralize.underscore}".to_sym
31 31
32 32 has_many :attachments, options.merge(:as => :container,
33 33 :order => "#{Attachment.table_name}.created_on",
34 34 :dependent => :destroy)
35 35 send :include, Redmine::Acts::Attachable::InstanceMethods
36 36 before_save :attach_saved_attachments
37 37 end
38 38 end
39 39
40 40 module InstanceMethods
41 41 def self.included(base)
42 42 base.extend ClassMethods
43 43 end
44 44
45 45 def attachments_visible?(user=User.current)
46 46 (respond_to?(:visible?) ? visible?(user) : true) &&
47 47 user.allowed_to?(self.class.attachable_options[:view_permission], self.project)
48 48 end
49 49
50 50 def attachments_deletable?(user=User.current)
51 51 (respond_to?(:visible?) ? visible?(user) : true) &&
52 52 user.allowed_to?(self.class.attachable_options[:delete_permission], self.project)
53 53 end
54 54
55 55 def saved_attachments
56 56 @saved_attachments ||= []
57 57 end
58 58
59 59 def unsaved_attachments
60 60 @unsaved_attachments ||= []
61 61 end
62 62
63 63 def save_attachments(attachments, author=User.current)
64 if attachments && attachments.is_a?(Hash)
65 attachments.each_value do |attachment|
64 if attachments.is_a?(Hash)
65 attachments = attachments.values
66 end
67 if attachments.is_a?(Array)
68 attachments.each do |attachment|
66 69 a = nil
67 70 if file = attachment['file']
68 next unless file && file.size > 0
69 a = Attachment.create(:file => file,
70 :description => attachment['description'].to_s.strip,
71 :author => author)
71 next unless file.size > 0
72 a = Attachment.create(:file => file, :author => author)
72 73 elsif token = attachment['token']
73 74 a = Attachment.find_by_token(token)
75 next unless a
76 a.filename = attachment['filename'] unless attachment['filename'].blank?
77 a.content_type = attachment['content_type']
74 78 end
75 79 next unless a
80 a.description = attachment['description'].to_s.strip
76 81 if a.new_record?
77 82 unsaved_attachments << a
78 83 else
79 84 saved_attachments << a
80 85 end
81 86 end
82 87 end
83 88 {:files => saved_attachments, :unsaved => unsaved_attachments}
84 89 end
85 90
86 91 def attach_saved_attachments
87 92 saved_attachments.each do |attachment|
88 93 self.attachments << attachment
89 94 end
90 95 end
91 96
92 97 module ClassMethods
93 98 end
94 99 end
95 100 end
96 101 end
97 102 end
General Comments 0
You need to be logged in to leave comments. Login now