• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

pulibrary / pdc_describe / 0d468505-0f3c-4da9-b4ec-c410618b1ec4

12 Jan 2026 08:59PM UTC coverage: 94.491% (-1.0%) from 95.463%
0d468505-0f3c-4da9-b4ec-c410618b1ec4

Pull #2194

circleci

web-flow
Merge branch 'main' into title_expansion
Pull Request #2194: adjust title field length

3499 of 3703 relevant lines covered (94.49%)

187.36 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

82.05
/app/controllers/works_controller.rb
1
# frozen_string_literal: true
2

3
require "nokogiri"
1✔
4
require "open-uri"
1✔
5

6
# Currently this controller supports Multiple ways to create a work, wizard mode, create dataset, and migrate
7
# The goal is to eventually break some of these workflows into separate contorllers.
8
# For the moment I'm documenting which methods get called by each workflow below.
9
# Note: new, edit and update get called by both the migrate and Non wizard workflows
10
#
11
# Normal mode
12
#  new & file_list -> create -> show & file_list
13
#
14
#  Clicking Edit puts you in wizard mode for some reason :(
15
#
16
# migrate
17
#
18
#  new & file_list -> create -> show & file_list
19
#
20
#  Clicking edit
21
#   edit & file_list -> update -> show & file_list
22
#
23

24
# rubocop:disable Metrics/ClassLength
25
class WorksController < ApplicationController
1✔
26
  include ERB::Util
1✔
27
  around_action :rescue_aasm_error, only: [:approve, :withdraw, :resubmit, :validate, :create]
1✔
28

29
  skip_before_action :authenticate_user!
1✔
30
  before_action :authenticate_user!, unless: :public_request?
1✔
31

32
  def index
1✔
33
    if rss_index_request?
6✔
34
      rss_index
2✔
35
    elsif current_user.super_admin?
4✔
36
      @works = Work.all
2✔
37
      respond_to do |format|
2✔
38
        format.html
2✔
39
      end
40
    else
41
      flash[:notice] = "You do not have access to this page."
2✔
42
      redirect_to root_path
2✔
43
    end
44
  end
45

46
  # only non wizard mode
47
  def new
1✔
48
    group = Group.find_by(code: params[:group_code]) || current_user.default_group
17✔
49
    @work = Work.new(created_by_user_id: current_user.id, group:)
17✔
50
    @work_decorator = WorkDecorator.new(@work, current_user)
17✔
51
    @form_resource_decorator = FormResourceDecorator.new(@work, current_user)
17✔
52
  end
53

54
  # only non wizard mode
55
  def create
1✔
56
    @work = Work.new(created_by_user_id: current_user.id, group_id: params_group_id, user_entered_doi: params["doi"].present?)
4✔
57
    @work.resource = FormToResourceService.convert(params, @work)
4✔
58
    @work.resource.migrated = migrated?
4✔
59
    if @work.valid?
4✔
60
      @work.draft!(current_user)
4✔
61
      upload_service = WorkUploadsEditService.new(@work, current_user)
4✔
62
      upload_service.update_precurated_file_list(added_files_param, deleted_files_param)
4✔
63
      redirect_to work_url(@work), notice: "Work was successfully created."
4✔
64
    else
65
      @work_decorator = WorkDecorator.new(@work, current_user)
×
66
      @form_resource_decorator = FormResourceDecorator.new(@work, current_user)
×
67
      # return 200 so the loadbalancer doesn't capture the error
68
      render :new
×
69
    end
70
  end
71

72
  ##
73
  # Show the information for the dataset with the given id
74
  # When requested as .json, return the internal json resource
75
  def show
1✔
76
    @work = Work.find(params[:id])
75✔
77
    UpdateSnapshotJob.perform_later(work_id: @work.id, last_snapshot_id: work.upload_snapshots.first&.id)
74✔
78
    @work_decorator = WorkDecorator.new(@work, current_user)
74✔
79

80
    respond_to do |format|
74✔
81
      format.html do
74✔
82
        # Ensure that the Work belongs to a Group
83
        group = @work_decorator.group
71✔
84
        raise(Work::InvalidGroupError, "The Work #{@work.id} does not belong to any Group") unless group
71✔
85

86
        @can_curate = current_user.can_admin?(group)
70✔
87
        @work.mark_new_notifications_as_read(current_user.id)
70✔
88
      end
89
      format.json { render json: @work.to_json }
77✔
90
    end
91
  end
92

93
  # only non wizard mode
94
  def file_list
1✔
95
    if params[:id] == "NONE"
156✔
96
      # This is a special case when we render the file list for a work being created
97
      # (i.e. it does not have an id just yet)
98
      render json: file_list_ajax_response(nil)
16✔
99
    else
100
      work = Work.find(params[:id])
140✔
101
      render json: file_list_ajax_response(work)
140✔
102
    end
103
  end
104

105
  def resolve_doi
1✔
106
    @work = Work.find_by_doi(params[:doi])
3✔
107
    redirect_to @work
2✔
108
  end
109

110
  def resolve_ark
1✔
111
    @work = Work.find_by_ark(params[:ark])
3✔
112
    redirect_to @work
2✔
113
  end
114

115
  # GET /works/1/edit
116
  # only non wizard mode
117
  def edit
1✔
118
    @work = Work.find(params[:id])
85✔
119
    @work_decorator = WorkDecorator.new(@work, current_user)
85✔
120
    if validate_modification_permissions(work: @work,
85✔
121
                                         uneditable_message: "Can not update work: #{@work.id} is not editable by #{current_user.uid}",
122
                                         current_state_message: "Can not update work: #{@work.id} is not editable in current state by #{current_user.uid}")
123
      @uploads = @work.uploads
81✔
124
      @form_resource_decorator = FormResourceDecorator.new(@work, current_user)
81✔
125
    end
126
  end
127

128
  # PATCH /works/1
129
  # only non wizard mode
130
  def update
1✔
131
    @work = Work.find(params[:id])
26✔
132
    if validate_modification_permissions(work: @work, uneditable_message: "Can not update work: #{@work.id} is not editable by #{current_user.uid}",
26✔
133
                                         current_state_message: "Can not update work: #{@work.id} is not editable in current state by #{current_user.uid}")
134
      update_work
24✔
135
    end
136
  end
137

138
  def approve
1✔
139
    @work = Work.find(params[:id])
14✔
140
    @work.approve!(current_user)
14✔
141
    flash[:notice] = "Your files are being moved to the #{@work.files_mode_human} bucket in the background. Depending on the file sizes this may take some time."
6✔
142
    redirect_to work_path(@work)
6✔
143
  end
144

145
  def withdraw
1✔
146
    @work = Work.find(params[:id])
2✔
147
    @work.withdraw!(current_user)
2✔
148
    redirect_to work_path(@work)
1✔
149
  end
150

151
  def resubmit
1✔
152
    @work = Work.find(params[:id])
2✔
153
    @work.resubmit!(current_user)
2✔
154
    redirect_to work_path(@work)
1✔
155
  end
156

157
  def revert_to_draft
1✔
158
    @work = Work.find(params[:id])
3✔
159
    @work.revert_to_draft!(current_user)
3✔
160

161
    redirect_to work_path(@work)
3✔
162
  end
163

164
  def assign_curator
1✔
165
    work = Work.find(params[:id])
5✔
166
    work.change_curator(params[:uid], current_user)
5✔
167
    if work.errors.count > 0
4✔
168
      render json: { errors: work.errors.map(&:type) }, status: :bad_request
1✔
169
    else
170
      render json: {}
3✔
171
    end
172
  rescue => ex
173
    # This is necessary for JSON responses
174
    Rails.logger.error("Error changing curator for work: #{work.id}. Exception: #{ex.message}")
1✔
175
    Honeybadger.notify("Error changing curator for work: #{work.id}. Exception: #{ex.message}")
1✔
176
    render(json: { errors: ["Cannot save dataset"] }, status: :bad_request)
1✔
177
  end
178

179
  def add_message
1✔
180
    work = Work.find(params[:id])
6✔
181
    if params["new-message"].present?
6✔
182
      new_message_param = params["new-message"]
6✔
183
      sanitized_new_message = html_escape(new_message_param)
6✔
184

185
      work.add_message(sanitized_new_message, current_user.id)
6✔
186
    end
187
    redirect_to work_path(id: params[:id])
6✔
188
  end
189

190
  def add_provenance_note
1✔
191
    work = Work.find(params[:id])
2✔
192
    if params["new-provenance-note"].present?
2✔
193
      new_date = params["new-provenance-date"]
2✔
194
      new_label = params["change_label"]
2✔
195
      new_note = html_escape(params["new-provenance-note"])
2✔
196

197
      work.add_provenance_note(new_date, new_note, current_user.id, new_label)
2✔
198
    end
199
    redirect_to work_path(id: params[:id])
2✔
200
  end
201

202
  # Outputs the Datacite XML representation of the work
203
  def datacite
1✔
204
    work = Work.find(params[:id])
2✔
205
    render xml: work.to_xml
2✔
206
  end
207

208
  def datacite_validate
1✔
209
    @errors = []
3✔
210
    @work = Work.find(params[:id])
3✔
211
    validator = WorkValidator.new(@work)
3✔
212
    unless validator.valid_datacite?
3✔
213
      @errors = @work.errors.full_messages
2✔
214
    end
215
  end
216

217
  def migrating?
1✔
218
    return @work.resource.migrated if @work&.resource && !params.key?(:migrate)
32✔
219

220
    params[:migrate]
12✔
221
  end
222
  helper_method :migrating?
1✔
223

224
  # Returns the raw BibTex citation information
225
  def bibtex
1✔
226
    work = Work.find(params[:id])
1✔
227
    creators = work.resource.creators.map { |creator| "#{creator.family_name}, #{creator.given_name}" }
15✔
228
    citation = DatasetCitation.new(creators, [work.resource.publication_year], work.resource.titles.first.title, work.resource.resource_type, work.resource.publisher, work.resource.doi)
1✔
229
    bibtex = citation.bibtex
1✔
230
    send_data bibtex, filename: "#{citation.bibtex_id}.bibtex", type: "text/plain", disposition: "attachment"
1✔
231
  end
232

233
  # POST /works/1/upload-files (called via Uppy)
234
  def upload_files
1✔
235
    @work = Work.find(params[:id])
×
236
    upload_service = WorkUploadsEditService.new(@work, current_user)
×
237
    upload_service.update_precurated_file_list(params["files"], [])
×
238
    render plain: params["files"].map(&:original_filename).join(",")
×
239
  end
240

241
  # Validates that the work is ready to be approved
242
  # GET /works/1/validate
243
  def validate
1✔
244
    @work = Work.find(params[:id])
3✔
245
    @work.complete_submission!(current_user)
3✔
246
    redirect_to user_path(current_user)
1✔
247
  end
248

249
  private
1✔
250

251
    # Extract the Work ID parameter
252
    # @return [String]
253
    def work_id_param
1✔
254
      params[:id]
78✔
255
    end
256

257
    # Find the Work requested by ID
258
    # @return [Work]
259
    def work
1✔
260
      Work.find(work_id_param)
78✔
261
    end
262

263
    # Determine whether or not the request is for the :index action in the RSS
264
    # response format
265
    # This is to enable PDC Discovery to index approved content via the RSS feed
266
    def rss_index_request?
1✔
267
      action_name == "index" && request.format.symbol == :rss
430✔
268
    end
269

270
    # Determine whether or not the request is for the :show action in the JSON
271
    # response format
272
    # @return [Boolean]
273
    def json_show_request?
1✔
274
      action_name == "show" && request.format.symbol == :json
422✔
275
    end
276

277
    # Determine whether or not the requested Work has been approved
278
    # @return [Boolean]
279
    def work_approved?
1✔
280
      work&.state == "approved"
4✔
281
    end
282

283
    ##
284
    # Public requests are requests that do not require authentication.
285
    # This is to enable PDC Discovery to index approved content via the RSS feed
286
    # and .json calls to individual works without needing to log in as a user.
287
    # Note that only approved works can be fetched for indexing.
288
    def public_request?
1✔
289
      return true if rss_index_request?
424✔
290
      return true if json_show_request? && work_approved?
422✔
291
      false
420✔
292
    end
293

294
    def work_params
1✔
295
      params[:work] || {}
58✔
296
    end
297

298
    # @note No testing coverage but not a route, not called
299
    def patch_params
1✔
300
      return {} unless params.key?(:patch)
×
301

302
      params[:patch]
×
303
    end
304

305
    # @note No testing coverage but not a route, not called
306
    def pre_curation_uploads_param
1✔
307
      return if patch_params.nil?
×
308

309
      patch_params[:pre_curation_uploads]
×
310
    end
311

312
    # @note No testing coverage but not a route, not called
313
    def readme_file_param
1✔
314
      return if patch_params.nil?
×
315

316
      patch_params[:readme_file]
×
317
    end
318

319
    # @note No testing coverage but not a route, not called
320
    def rescue_aasm_error
1✔
321
      super
25✔
322
    rescue StandardError => generic_error
323
      if action_name == "create"
×
324
        handle_error_for_create(generic_error)
×
325
      else
326
        redirect_to error_url, notice: "We apologize, an error was encountered: #{generic_error.message}. Please contact the PDC Describe administrators."
×
327
      end
328
    end
329

330
    rescue_from StandardError do |generic_error|
1✔
331
      Honeybadger.notify("We apologize, an error was encountered: #{generic_error.message}.")
4✔
332
      redirect_to error_url, notice: "We apologize, an error was encountered: #{generic_error.message}. Please contact the PDC Describe administrators."
4✔
333
    end
334

335
    # @note No testing coverage but not a route, not called
336
    def handle_error_for_create(generic_error)
1✔
337
      if @work.persisted?
×
338
        Honeybadger.notify("Failed to create the new Dataset #{@work.id}: #{generic_error.message}")
×
339
        @form_resource_decorator = FormResourceDecorator.new(@work, current_user)
×
340
        redirect_to edit_work_url(id: @work.id), notice: "Failed to create the new Dataset #{@work.id}: #{generic_error.message}", params:
×
341
      else
342
        Honeybadger.notify("Failed to create a new Dataset #{@work.id}: #{generic_error.message}")
×
343
        new_params = {}
×
344
        new_params[:wizard] = wizard_mode? if wizard_mode?
×
345
        new_params[:migrate] = migrating? if migrating?
×
346
        @form_resource_decorator = FormResourceDecorator.new(@work, current_user)
×
347
        redirect_to new_work_url(params: new_params), notice: "Failed to create a new Dataset: #{generic_error.message}", params: new_params
×
348
      end
349
    end
350

351
    # @note No testing coverage but not a route, not called
352
    def redirect_aasm_error(transition_error_message)
1✔
353
      if @work.persisted?
12✔
354
        redirect_to edit_work_url(id: @work.id), notice: transition_error_message, params:
12✔
355
      else
356
        new_params = {}
×
357
        new_params[:wizard] = wizard_mode? if wizard_mode?
×
358
        new_params[:migrate] = migrating? if migrating?
×
359
        @form_resource_decorator = FormResourceDecorator.new(@work, current_user)
×
360
        redirect_to new_work_url(params: new_params), notice: transition_error_message, params: new_params
×
361
      end
362
    end
363

364
    # @note No testing coverage but not a route, not called
365
    def error_action
1✔
366
      @form_resource_decorator = FormResourceDecorator.new(@work, current_user)
×
367
      if action_name == "create"
×
368
        :new
×
369
      elsif action_name == "validate"
×
370
        :edit
×
371
      elsif action_name == "new_submission"
×
372
        :new_submission
×
373
      else
374
        @work_decorator = WorkDecorator.new(@work, current_user)
×
375
        :show
×
376
      end
377
    end
378

379
    def wizard_mode?
1✔
380
      params[:wizard] == "true"
×
381
    end
382

383
    def update_work
1✔
384
      check_for_stale_update(@work, params)
24✔
385
      upload_service = WorkUploadsEditService.new(@work, current_user)
24✔
386
      if @work.approved?
24✔
387
        upload_keys = deleted_files_param || []
5✔
388
        deleted_uploads = upload_service.find_post_curation_uploads(upload_keys:)
5✔
389

390
        return head(:forbidden) unless deleted_uploads.empty?
5✔
391
      else
392
        @work = upload_service.update_precurated_file_list(added_files_param, deleted_files_param)
19✔
393
      end
394

395
      process_updates
23✔
396
    end
397

398
    def added_files_param
1✔
399
      Array(work_params[:pre_curation_uploads_added])
23✔
400
    end
401

402
    def deleted_files_param
1✔
403
      deleted_count = (work_params["deleted_files_count"] || "0").to_i
28✔
404
      (1..deleted_count).map { |i| work_params["deleted_file_#{i}"] }.select(&:present?)
35✔
405
    end
406

407
    def process_updates
1✔
408
      if WorkCompareService.update_work(work: @work, update_params:, current_user:)
23✔
409
        redirect_to work_url(@work), notice: "Work was successfully updated."
21✔
410
      else
411
        # This is needed for rendering HTML views with validation errors
412
        @uploads = @work.uploads
2✔
413
        @form_resource_decorator = FormResourceDecorator.new(@work, current_user)
2✔
414
        @work_decorator = WorkDecorator.new(@work, current_user)
2✔
415

416
        # return 200 so the loadbalancer doesn't capture the error
417
        render :edit
2✔
418
      end
419
    end
420

421
    def migrated?
1✔
422
      return false unless params.key?(:submit)
4✔
423

424
      params[:submit] == "Migrate"
×
425
    end
426

427
    # Returns a hash object that can be serialized into something that DataTables
428
    # can consume. The `data` elements includes the work's file list all other
429
    # properties are used for displaying different data elements related but not
430
    # directly on the DataTable object (e.g. the total file size)
431
    def file_list_ajax_response(work)
1✔
432
      files = []
156✔
433
      total_size = 0
156✔
434
      unless work.nil?
156✔
435
        files = work.file_list
140✔
436
        total_size = work.total_file_size_from_list(files)
140✔
437
      end
438
      {
439
        data: files,
156✔
440
        total_size:,
441
        total_size_display: ActiveSupport::NumberHelper.number_to_human_size(total_size),
442
        total_file_count: files.count
443
      }
444
    end
445

446
    def rss_index
1✔
447
      # Only include approved works in the RSS feed
448
      @approved_works = Work.all.select(&:approved?)
2✔
449
      respond_to do |format|
2✔
450
        format.rss { render layout: false }
4✔
451
      end
452
    end
453
end
454
# rubocop:enable Metrics/ClassLength
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc