Project

General

Profile

Feature #18245 ยป 0001-Add-REST-API-for-documents.patch

Mizuki ISHIKAWA, 2026-08-25 06:19

View differences:

app/controllers/documents_controller.rb
25 25
  before_action :find_model_object, :except => [:index, :new, :create]
26 26
  before_action :find_project_from_association, :except => [:index, :new, :create]
27 27
  before_action :authorize
28
  accept_api_auth :index, :show, :create, :update, :destroy
28 29

  
29 30
  helper :attachments
30 31
  helper :custom_fields
31 32

  
32 33
  def index
33
    @sort_by = %w(category date title author).include?(params[:sort_by]) ? params[:sort_by] : 'category'
34
    documents = @project.documents.includes(:attachments, :category).to_a
35
    case @sort_by
36
    when 'date'
37
      documents.sort!{|a, b| b.updated_on <=> a.updated_on}
38
      @grouped = documents.group_by {|d| d.updated_on.to_date}
39
    when 'title'
40
      @grouped = documents.group_by {|d| d.title.first.upcase}
41
    when 'author'
42
      @grouped = documents.select{|d| d.attachments.any?}.group_by {|d| d.attachments.last.author}
43
    else
44
      @grouped = documents.group_by(&:category)
34
    respond_to do |format|
35
      format.html do
36
        @sort_by = %w(category date title author).include?(params[:sort_by]) ? params[:sort_by] : 'category'
37
        documents = @project.documents.includes(:attachments, :category).to_a
38
        case @sort_by
39
        when 'date'
40
          documents.sort!{|a, b| b.updated_on <=> a.updated_on}
41
          @grouped = documents.group_by {|d| d.updated_on.to_date}
42
        when 'title'
43
          @grouped = documents.group_by {|d| d.title.first.upcase}
44
        when 'author'
45
          @grouped = documents.select{|d| d.attachments.any?}.group_by {|d| d.attachments.last.author}
46
        else
47
          @grouped = documents.group_by(&:category)
48
        end
49
        @document = @project.documents.build
50
        render :layout => false if request.xhr?
51
      end
52
      format.api do
53
        @offset, @limit = api_offset_and_limit
54
        scope = @project.documents
55
        @document_count = scope.count
56
        @documents = scope.includes(:project, :category, :attachments).
57
                        preload(:custom_values => :custom_field).
58
                        order("#{Document.table_name}.created_on DESC, #{Document.table_name}.id DESC").
59
                        limit(@limit).
60
                        offset(@offset).
61
                        to_a
62
      end
45 63
    end
46
    @document = @project.documents.build
47
    render :layout => false if request.xhr?
48 64
  end
49 65

  
50 66
  def show
51 67
    @attachments = @document.attachments.to_a
68
    respond_to do |format|
69
      format.html
70
      format.api
71
    end
52 72
  end
53 73

  
54 74
  def new
......
59 79
  def create
60 80
    @document = @project.documents.build
61 81
    @document.safe_attributes = params[:document]
62
    @document.save_attachments(params[:attachments])
82
    attachments = params[:attachments]
83
    attachments ||= params[:document] && params[:document][:uploads] if api_request?
84
    @document.save_attachments(attachments)
63 85
    if @document.save
64
      render_attachment_warning_if_needed(@document)
65
      flash[:notice] = l(:notice_successful_create)
66
      redirect_to project_documents_path(@project)
86
      respond_to do |format|
87
        format.html do
88
          render_attachment_warning_if_needed(@document)
89
          flash[:notice] = l(:notice_successful_create)
90
          redirect_to project_documents_path(@project)
91
        end
92
        format.api do
93
          @attachments = @document.attachments.to_a
94
          render :action => 'show', :status => :created, :location => document_url(@document)
95
        end
96
      end
67 97
    else
68
      render :action => 'new'
98
      respond_to do |format|
99
        format.html {render :action => 'new'}
100
        format.api  {render_validation_errors(@document)}
101
      end
69 102
    end
70 103
  end
71 104

  
......
74 107

  
75 108
  def update
76 109
    @document.safe_attributes = params[:document]
110
    if api_request?
111
      @document.save_attachments(params[:attachments] || (params[:document] && params[:document][:uploads]))
112
    end
77 113
    if @document.save
78
      flash[:notice] = l(:notice_successful_update)
79
      redirect_to document_path(@document)
114
      respond_to do |format|
115
        format.html do
116
          flash[:notice] = l(:notice_successful_update)
117
          redirect_to document_path(@document)
118
        end
119
        format.api  {render_api_ok}
120
      end
80 121
    else
81
      render :action => 'edit'
122
      respond_to do |format|
123
        format.html {render :action => 'edit'}
124
        format.api  {render_validation_errors(@document)}
125
      end
82 126
    end
83 127
  end
84 128

  
85 129
  def destroy
86 130
    @document.destroy if request.delete?
87
    flash[:notice] = l(:notice_successful_delete)
88
    redirect_to project_documents_path(@project)
131
    respond_to do |format|
132
      format.html do
133
        flash[:notice] = l(:notice_successful_delete)
134
        redirect_to project_documents_path(@project)
135
      end
136
      format.api  {render_api_ok}
137
    end
89 138
  end
90 139

  
91 140
  def add_attachment
app/views/documents/index.api.rsb
1
api.array :documents, api_meta(:total_count => @document_count, :offset => @offset, :limit => @limit) do
2
  @documents.each do |document|
3
    api.document do
4
      api.id document.id
5
      api.project(:id => document.project_id, :name => document.project.name) unless document.project.nil?
6
      api.category(:id => document.category_id, :name => document.category.name) unless document.category.nil?
7
      api.title document.title
8
      api.description document.description
9
      api.created_on document.created_on
10
      api.updated_on document.updated_on
11

  
12
      render_api_custom_values document.visible_custom_field_values, api
13
    end
14
  end
15
end
app/views/documents/show.api.rsb
1
api.document do
2
  api.id @document.id
3
  api.project(:id => @document.project_id, :name => @document.project.name) unless @document.project.nil?
4
  api.category(:id => @document.category_id, :name => @document.category.name) unless @document.category.nil?
5
  api.title @document.title
6
  api.description @document.description
7
  api.created_on @document.created_on
8
  api.updated_on @document.updated_on
9

  
10
  render_api_custom_values @document.visible_custom_field_values, api
11

  
12
  api.array :attachments do
13
    @attachments.each do |attachment|
14
      render_api_attachment(attachment, api)
15
    end
16
  end if include_in_api_response?('attachments')
17
end
test/functional/documents_controller_test.rb
237 237
    assert_equal 'test_update', document.title
238 238
  end
239 239

  
240
  def test_update_should_not_add_attachments
241
    @request.session[:user_id] = 2
242
    set_tmp_attachments_directory
243

  
244
    assert_no_difference 'Attachment.count' do
245
      put(
246
        :update,
247
        :params => {
248
          :id => 1,
249
          :document => {:title => 'test_update'},
250
          :attachments => {
251
            '1' => {
252
              'file' => uploaded_test_file('testfile.txt', 'text/plain')
253
            }
254
          }
255
        }
256
      )
257
    end
258
    assert_redirected_to '/documents/1'
259
  end
260

  
240 261
  def test_update_with_failure
241 262
    @request.session[:user_id] = 2
242 263
    put(
test/integration/api_test/documents_test.rb
1
# frozen_string_literal: true
2

  
3
# Redmine - project management software
4
# Copyright (C) 2006-  Jean-Philippe Lang
5
#
6
# This program is free software; you can redistribute it and/or
7
# modify it under the terms of the GNU General Public License
8
# as published by the Free Software Foundation; either version 2
9
# of the License, or (at your option) any later version.
10
#
11
# This program is distributed in the hope that it will be useful,
12
# but WITHOUT ANY WARRANTY; without even the implied warranty of
13
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
14
# GNU General Public License for more details.
15
#
16
# You should have received a copy of the GNU General Public License
17
# along with this program; if not, write to the Free Software
18
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
19

  
20
require_relative '../../test_helper'
21

  
22
class Redmine::ApiTest::DocumentsTest < Redmine::ApiTest::Base
23
  test "GET /projects/:project_id/documents.xml should return documents" do
24
    get '/projects/ecookbook/documents.xml'
25

  
26
    assert_response :success
27
    assert_equal 'application/xml', response.media_type
28
    assert_select 'documents[type=array][total_count=?]', Project.find(1).documents.count.to_s do
29
      assert_select 'document', 3
30
      # documents are sorted by descending creation date
31
      assert_select 'document:first-of-type' do
32
        assert_select 'id', :text => '3'
33
        assert_select 'project[id=1][name=eCookbook]'
34
        assert_select 'category[id=3][name="Technical documentation"]'
35
        assert_select 'title', :text => 'An other document 2'
36
        assert_select 'created_on', :text => Document.find(3).created_on.iso8601
37
        assert_select 'updated_on', :text => Document.find(3).updated_on.iso8601
38
      end
39
    end
40
  end
41

  
42
  test "GET /projects/:project_id/documents.json should return documents" do
43
    get '/projects/ecookbook/documents.json'
44

  
45
    assert_response :success
46
    json = ActiveSupport::JSON.decode(response.body)
47
    assert_kind_of Hash, json
48
    assert_kind_of Array, json['documents']
49
    assert_equal Project.find(1).documents.count, json['total_count']
50
    # documents are sorted by descending creation date
51
    assert_equal 3, json['documents'].first['id']
52
    assert_equal({'id' => 3, 'name' => 'Technical documentation'}, json['documents'].first['category'])
53
  end
54

  
55
  test "GET /projects/:project_id/documents.xml should paginate" do
56
    get '/projects/ecookbook/documents.xml', :params => {:limit => 2, :offset => 1}
57

  
58
    assert_response :success
59
    assert_select 'documents[type=array][total_count="3"][offset="1"][limit="2"]' do
60
      assert_select 'document', 2
61
      assert_select 'document id', :text => '2'
62
      assert_select 'document id', :text => '1'
63
    end
64
  end
65

  
66
  test "GET /projects/:project_id/documents.xml should deny access without permission" do
67
    Role.anonymous.remove_permission! :view_documents
68

  
69
    get '/projects/ecookbook/documents.xml'
70
    assert_response :unauthorized
71
  end
72

  
73
  test "GET /projects/:project_id/documents.xml without permission should return 403" do
74
    Role.find(1).remove_permission! :view_documents
75

  
76
    get '/projects/ecookbook/documents.xml', :headers => credentials('jsmith')
77

  
78
    assert_response :forbidden
79
  end
80

  
81
  test "GET /documents/:id.xml should return the document" do
82
    get '/documents/1.xml'
83

  
84
    assert_response :success
85
    assert_equal 'application/xml', response.media_type
86
    assert_select 'document' do
87
      assert_select 'id', :text => '1'
88
      assert_select 'project[id=1][name=eCookbook]'
89
      assert_select 'category[id=1][name=Uncategorized]'
90
      assert_select 'title', :text => 'Test document'
91
      assert_select 'description', :text => 'Document description'
92
      assert_select 'created_on', :text => Document.find(1).created_on.iso8601
93
      assert_select 'updated_on', :text => Document.find(1).updated_on.iso8601
94
      assert_select 'attachments', 0
95
    end
96
  end
97

  
98
  test "GET /documents/:id.json should return the document" do
99
    get '/documents/1.json'
100

  
101
    assert_response :success
102
    json = ActiveSupport::JSON.decode(response.body)
103
    assert_kind_of Hash, json['document']
104
    assert_equal 1, json['document']['id']
105
    assert_equal 'Test document', json['document']['title']
106
  end
107

  
108
  test "GET /documents/:id.xml with include=attachments should include attachments" do
109
    get '/documents/1.xml?include=attachments'
110

  
111
    assert_select 'document attachments[type=array]' do
112
      assert_select 'attachment', 2
113
      assert_select 'attachment id', :text => '2' do
114
        assert_select '~ filename', :text => 'document.txt'
115
        assert_select '~ content_url',
116
                      :text => 'http://www.example.com/attachments/download/2/document.txt'
117
      end
118
    end
119
  end
120

  
121
  test "GET /documents/:id.xml should include custom fields" do
122
    field = DocumentCustomField.generate!(:name => 'Author', :field_format => 'string')
123
    document = Document.find(1)
124
    document.custom_field_values = {field.id => 'John Smith'}
125
    document.save!
126

  
127
    get '/documents/1.xml'
128

  
129
    assert_response :success
130
    assert_select 'document custom_fields[type=array]' do
131
      assert_select "custom_field[id=\"#{field.id}\"][name=Author]" do
132
        assert_select 'value', :text => 'John Smith'
133
      end
134
    end
135
  end
136

  
137
  test "GET /documents/:id.xml on a private project without credentials should return 401" do
138
    Document.find(1).project.update_column :is_public, false
139

  
140
    get '/documents/1.xml'
141

  
142
    assert_response :unauthorized
143
  end
144

  
145
  test "POST /projects/:project_id/documents.xml should create a document with the attributes" do
146
    payload = <<~XML
147
      <?xml version="1.0" encoding="UTF-8" ?>
148
      <document>
149
        <title>API document</title>
150
        <description>This is a document created by the API</description>
151
        <category_id>2</category_id>
152
      </document>
153
    XML
154
    assert_difference('Document.count') do
155
      post(
156
        '/projects/1/documents.xml',
157
        :params => payload,
158
        :headers => {"CONTENT_TYPE" => 'application/xml'}.merge(credentials('jsmith')))
159
    end
160
    assert_response :created
161
    assert_equal 'application/xml', response.media_type
162

  
163
    document = Document.order(:id => :desc).first
164
    assert_equal 'API document', document.title
165
    assert_equal 'This is a document created by the API', document.description
166
    assert_equal DocumentCategory.find(2), document.category
167
    assert_equal Project.find(1), document.project
168
    assert_equal document_url(document), response.headers['Location']
169
    assert_select 'document id', :text => document.id.to_s
170
  end
171

  
172
  test "POST /projects/:project_id/documents.json should create a document with the attributes" do
173
    payload = <<~JSON
174
      {
175
        "document": {
176
          "title": "API document",
177
          "description": "This is a document created by the API",
178
          "category_id": 2
179
        }
180
      }
181
    JSON
182
    assert_difference('Document.count') do
183
      post(
184
        '/projects/1/documents.json',
185
        :params => payload,
186
        :headers => {"CONTENT_TYPE" => 'application/json'}.merge(credentials('jsmith')))
187
    end
188
    assert_response :created
189

  
190
    document = Document.order(:id => :desc).first
191
    assert_equal 'API document', document.title
192
    assert_equal 'This is a document created by the API', document.description
193

  
194
    json = ActiveSupport::JSON.decode(response.body)
195
    assert_equal document.id, json['document']['id']
196
  end
197

  
198
  test "POST /projects/:project_id/documents.json with failure should return errors" do
199
    assert_no_difference('Document.count') do
200
      post(
201
        '/projects/1/documents.json',
202
        :params => {:document => {:title => '', :category_id => 1}},
203
        :headers => credentials('jsmith'))
204
    end
205
    assert_response :unprocessable_content
206
    json = ActiveSupport::JSON.decode(response.body)
207
    assert json['errors'].include?("Title cannot be blank")
208
  end
209

  
210
  test "POST /projects/:project_id/documents.json with attachment should create a document with attachment" do
211
    token = json_upload('test_create_with_attachment', credentials('jsmith'))
212
    attachment = Attachment.find_by_token(token)
213
    assert_difference 'Document.count' do
214
      post(
215
        '/projects/1/documents.json',
216
        :params => {:document => {:title => 'API document with attachment',
217
                                  :category_id => 1,
218
                                  :uploads => [{:token => token, :filename => 'test.txt',
219
                                                :content_type => 'text/plain'}]}},
220
        :headers => credentials('jsmith'))
221
      assert_response :created
222
    end
223
    document = Document.order(:id => :desc).first
224
    assert_equal 'API document with attachment', document.title
225
    assert_equal attachment, document.attachments.first
226

  
227
    attachment.reload
228
    assert_equal 'test.txt', attachment.filename
229
    assert_equal 'text/plain', attachment.content_type
230
    assert_equal 'test_create_with_attachment'.size, attachment.filesize
231
    assert_equal 2, attachment.author_id
232
  end
233

  
234
  test "POST /projects/:project_id/documents.xml with attachment should create a document with attachment" do
235
    token = xml_upload('test_create_with_attachment', credentials('jsmith'))
236
    payload = <<~XML
237
      <?xml version="1.0" encoding="UTF-8" ?>
238
      <document>
239
        <title>API document with attachment</title>
240
        <category_id>1</category_id>
241
        <uploads type="array">
242
          <upload>
243
            <token>#{token}</token>
244
            <filename>test.txt</filename>
245
          </upload>
246
        </uploads>
247
      </document>
248
    XML
249
    assert_difference 'Document.count' do
250
      post(
251
        '/projects/1/documents.xml',
252
        :params => payload,
253
        :headers => {"CONTENT_TYPE" => 'application/xml'}.merge(credentials('jsmith')))
254
      assert_response :created
255
    end
256
    document = Document.order(:id => :desc).first
257
    assert_equal ['test.txt'], document.attachments.map(&:filename)
258
  end
259

  
260
  test "POST /projects/:project_id/documents.xml without permission should return 403" do
261
    Role.find(1).remove_permission! :add_documents
262

  
263
    assert_no_difference 'Document.count' do
264
      post(
265
        '/projects/1/documents.xml',
266
        :params => {:document => {:title => 'API document'}},
267
        :headers => credentials('jsmith'))
268
    end
269
    assert_response :forbidden
270
  end
271

  
272
  test "PUT /documents/:id.xml should update the document" do
273
    payload = <<~XML
274
      <?xml version="1.0" encoding="UTF-8" ?>
275
      <document>
276
        <title>Updated title</title>
277
        <description>Updated description</description>
278
        <category_id>2</category_id>
279
      </document>
280
    XML
281
    put(
282
      '/documents/1.xml',
283
      :params => payload,
284
      :headers => {"CONTENT_TYPE" => 'application/xml'}.merge(credentials('jsmith')))
285

  
286
    assert_response :no_content
287
    assert_equal '', response.body
288
    document = Document.find(1)
289
    assert_equal 'Updated title', document.title
290
    assert_equal 'Updated description', document.description
291
    assert_equal DocumentCategory.find(2), document.category
292
  end
293

  
294
  test "PUT /documents/:id.xml with failure should return errors" do
295
    put(
296
      '/documents/1.xml',
297
      :params => {:document => {:title => ''}},
298
      :headers => credentials('jsmith'))
299

  
300
    assert_response :unprocessable_content
301
    assert_select 'errors error', :text => "Title cannot be blank"
302
  end
303

  
304
  test "PUT /documents/:id.json should update the document" do
305
    put(
306
      '/documents/1.json',
307
      :params => {:document => {:title => 'Updated title'}},
308
      :headers => credentials('jsmith'))
309

  
310
    assert_response :no_content
311
    assert_equal 'Updated title', Document.find(1).title
312
  end
313

  
314
  test "PUT /documents/:id.json with failure should return errors" do
315
    put(
316
      '/documents/1.json',
317
      :params => {:document => {:title => ''}},
318
      :headers => credentials('jsmith'))
319

  
320
    assert_response :unprocessable_content
321
    json = ActiveSupport::JSON.decode(response.body)
322
    assert json['errors'].include?("Title cannot be blank")
323
  end
324

  
325
  test "PUT /documents/:id.json with attachment should add the attachment" do
326
    token = json_upload('test_update_with_attachment', credentials('jsmith'))
327

  
328
    put(
329
      '/documents/2.json',
330
      :params => {:document => {:uploads => [{:token => token, :filename => 'test.txt',
331
                                              :content_type => 'text/plain'}]}},
332
      :headers => credentials('jsmith'))
333

  
334
    assert_response :no_content
335
    assert_equal ['test.txt'], Document.find(2).attachments.map(&:filename)
336
  end
337

  
338
  test "PUT /documents/:id.xml with attachment should add the attachment" do
339
    token = xml_upload('test_update_with_attachment', credentials('jsmith'))
340
    payload = <<~XML
341
      <?xml version="1.0" encoding="UTF-8" ?>
342
      <document>
343
        <uploads type="array">
344
          <upload>
345
            <token>#{token}</token>
346
            <filename>test.txt</filename>
347
          </upload>
348
        </uploads>
349
      </document>
350
    XML
351
    put(
352
      '/documents/2.xml',
353
      :params => payload,
354
      :headers => {"CONTENT_TYPE" => 'application/xml'}.merge(credentials('jsmith')))
355

  
356
    assert_response :no_content
357
    assert_equal ['test.txt'], Document.find(2).attachments.map(&:filename)
358
  end
359

  
360
  test "PUT /documents/:id.xml without permission should return 403" do
361
    Role.find(1).remove_permission! :edit_documents
362

  
363
    put(
364
      '/documents/1.xml',
365
      :params => {:document => {:title => 'Updated title'}},
366
      :headers => credentials('jsmith'))
367

  
368
    assert_response :forbidden
369
  end
370

  
371
  test "DELETE /documents/:id.xml should delete the document" do
372
    assert_difference('Document.count', -1) do
373
      delete '/documents/1.xml', :headers => credentials('jsmith')
374
    end
375

  
376
    assert_response :no_content
377
    assert_equal '', response.body
378
    assert_nil Document.find_by_id(1)
379
  end
380

  
381
  test "DELETE /documents/:id.json should delete the document" do
382
    assert_difference('Document.count', -1) do
383
      delete '/documents/1.json', :headers => credentials('jsmith')
384
    end
385

  
386
    assert_response :no_content
387
    assert_nil Document.find_by_id(1)
388
  end
389

  
390
  test "DELETE /documents/:id.xml without permission should return 403" do
391
    Role.find(1).remove_permission! :delete_documents
392

  
393
    assert_no_difference 'Document.count' do
394
      delete '/documents/1.xml', :headers => credentials('jsmith')
395
    end
396

  
397
    assert_response :forbidden
398
  end
399
end
    (1-1/1)