Add form submission export [SCI-11389]

This commit is contained in:
Andrej 2024-12-31 08:49:53 +01:00
parent b0463b06b6
commit ee4886bd67
14 changed files with 194 additions and 6 deletions

View file

@ -3,7 +3,7 @@
class FormsController < ApplicationController
include UserRolesHelper
before_action :load_form, only: %i(show update publish unpublish)
before_action :load_form, only: %i(show update publish unpublish export_form_responses)
before_action :set_breadcrumbs_items, only: %i(index show)
before_action :check_manage_permissions, only: %i(update publish unpublish)
before_action :check_create_permissions, only: :create
@ -142,6 +142,27 @@ class FormsController < ApplicationController
end
end
def export_form_responses
FormResponsesZipExportJob.perform_later(
user_id: current_user.id,
params: {
form_id: @form.id
}
)
Activities::CreateActivityService.call(
activity_type: :export_form_responses,
owner: current_user,
subject: @form,
team: @form.team,
message_items: {
form: @form.id
}
)
render json: { message: t('zip_export.export_request_success') }
end
def actions_toolbar
render json: {
actions:

View file

@ -14,10 +14,18 @@
@archive="archive"
@restore="restore"
@access="access"
@export="exportFormResponse"
/>
</div>
<AccessModal v-if="accessModalParams" :params="accessModalParams"
@close="accessModalParams = null" @refresh="this.reloadingTable = true" />
<ConfirmationModal
:title="i18n.t('forms.export.title')"
:description="i18n.t('forms.export.description')"
confirmClass="btn btn-primary"
:confirmText="i18n.t('forms.export.export_button')"
ref="exportModal"
></ConfirmationModal>
</template>
<script>
@ -27,6 +35,7 @@ import axios from '../../packs/custom_axios.js';
import DataTable from '../shared/datatable/table.vue';
import DeleteModal from '../shared/confirmation_modal.vue';
import ConfirmationModal from '../shared/confirmation_modal.vue';
import NameRenderer from './renderers/name.vue';
import UsersRenderer from '../projects/renderers/users.vue';
@ -40,7 +49,8 @@ export default {
DeleteModal,
NameRenderer,
UsersRenderer,
AccessModal
AccessModal,
ConfirmationModal
},
props: {
dataSource: {
@ -153,6 +163,17 @@ export default {
}).catch((error) => {
HelperModule.flashAlertMsg(error.response.data.error, 'danger');
});
},
async exportFormResponse(event) {
const ok = await this.$refs.exportModal.show();
if (ok) {
axios.post(event.path).then((response) => {
this.reloadingTable = true;
HelperModule.flashAlertMsg(response.data.message, 'success');
}).catch((error) => {
HelperModule.flashAlertMsg(error.response.data.error, 'danger');
});
}
}
}
};

View file

@ -0,0 +1,73 @@
# frozen_string_literal: true
require 'caxlsx'
class FormResponsesZipExportJob < ZipExportJob
private
# Override
def fill_content(dir, params)
form = Form.find_by(id: params[:form_id])
exported_data = to_xlsx(form)
File.binwrite("#{dir}/#{form.name}.xlsx", exported_data)
end
def failed_notification_title
I18n.t('activejob.failure_notifiable_job.item_notification_title',
item: I18n.t('activejob.failure_notifiable_job.items.form'))
end
def to_xlsx(form)
package = Axlsx::Package.new
workbook = package.workbook
warning_bg_style = workbook.styles.add_style bg_color: 'ffbf00'
workbook.add_worksheet(name: 'Form submissions') do |sheet|
sheet.add_row build_header(form)
form.form_responses.where(status: 'submitted').find_each do |form_response|
sheet.add_row do |row|
row.add_cell breadcrumbs(form_response)
row.add_cell form_response.submitted_at.to_s
row.add_cell form_response.submitted_by.full_name
form_response.form_field_values.joins(:form_field).where(latest: true).order(:position).each do |form_field_value|
if form_field_value.value_in_range?
row.add_cell form_field_value.formatted
else
row.add_cell form_field_value.formatted, style: warning_bg_style
end
row.add_cell form_field_value.submitted_at.to_s
row.add_cell form_field_value.submitted_by.full_name
end
end
end
end
package.to_stream.read
end
def build_header(form)
header = [I18n.t('forms.export.columns.form_path'), I18n.t('forms.export.columns.form_timestamp'), I18n.t('forms.export.columns.submitted_by')]
form.form_fields.order(:position).select(:name).each do |form_field|
header << form_field.name
header << I18n.t('forms.export.columns.form_field_timestamp', name: form_field.name)
header << I18n.t('forms.export.columns.form_field_submitted_by', name: form_field.name)
end
header
end
def breadcrumbs(form_response)
return '' unless (my_module = form_response&.step&.my_module)
[
"#{my_module.project.name} (#{my_module.project.code})",
"#{my_module.experiment.name} (#{my_module.experiment.code})",
"#{my_module.name} (#{my_module.code})",
my_module.protocol.name || I18n.t('search.index.untitled_protocol'),
form_response.step.name || I18n.t('protocols.steps.default_name')
].join('/ ')
end
end

View file

@ -17,4 +17,8 @@ class FormDatetimeFieldValue < FormFieldValue
def range?
datetime_to.present?
end
def formatted
range? ? [datetime, datetime_to].join(' - ') : datetime.to_s
end
end

View file

@ -15,4 +15,12 @@ class FormFieldValue < ApplicationRecord
def value
raise NotImplementedError
end
def formatted
value
end
def value_in_range?
true
end
end

View file

@ -8,4 +8,8 @@ class FormMultipleChoiceFieldValue < FormFieldValue
def value
selection
end
def formatted
value.join('|')
end
end

View file

@ -19,4 +19,22 @@ class FormNumberFieldValue < FormFieldValue
def range?
number_to.present?
end
def formatted
number_with_unit = "#{number} #{unit}"
range? ? "#{number_with_unit} - #{number_to} #{unit}" : number_with_unit
end
def value_in_range?
return true if number.nil?
validation_params = form_field.data.dig('validations', 'response_validation')
return true unless validation_params && validation_params['enabled']
min_value = validation_params['min']
max_value = validation_params['max']
!((min_value.present? && min_value > number) || (max_value.present? && max_value < number))
end
end

View file

@ -37,6 +37,7 @@ class FormResponse < ApplicationRecord
# these can change if the form_response is reset, as submitted_by will be kept the same, but created_by will change
created_by: created_by,
submitted_by: created_by,
submitted_at: DateTime.current,
value: value
)
end
@ -62,10 +63,11 @@ class FormResponse < ApplicationRecord
end
# if attached to step, reattach new form response
self&.step_orderable_element&.update!(orderable: new_form_response)
discard
self&.step_orderable_element&.update!(orderable: new_form_response)
new_form_response
end
end

View file

@ -17,5 +17,7 @@ module Lists
end
def filter_records; end
def sort_records; end
end
end

View file

@ -20,7 +20,8 @@ module Toolbars
[
access_action,
archive_action,
restore_action
restore_action,
export_action
].compact
end
@ -60,5 +61,21 @@ module Toolbars
type: :emit
}
end
def export_action
return unless @single
return unless @forms.first.published?
return unless can_read_form?(@forms.first)
{
name: 'export',
label: I18n.t('protocols.index.toolbar.export'),
icon: 'sn-icon sn-icon-export',
path: export_form_responses_form_path(@forms.first),
type: :emit
}
end
end
end

View file

@ -543,7 +543,8 @@ class Extends
form_block_added: 342,
form_block_edited: 343,
form_block_deleted: 344,
form_block_rearranged: 345
form_block_rearranged: 345,
export_form_responses: 346
}
ACTIVITY_GROUPS = {
@ -567,7 +568,7 @@ class Extends
storage_locations: [*309..315],
container_storage_locations: [*316..322, 326],
storage_location_repository_rows: [*323..325],
forms: [331, 332, 333, 334, 335, 336, *337..345]
forms: [331, 332, 333, 334, 335, 336, *337..346]
}
TOP_LEVEL_ASSIGNABLES = %w(Project Team Protocol Repository Form).freeze

View file

@ -289,6 +289,7 @@ en:
repository: "Inventories"
repository_item: "Items"
stock_consumption: "Stock consumption"
form: "Form"
head:
title: "SciNote | %{title}"
@ -1062,6 +1063,19 @@ en:
archived:
success_flash: "<strong>%{number}</strong> form(s) successfully archived."
error_flash: "Failed to archive form(s)."
export:
title: "Export form data"
description: "You are about to export data from the selected form template. Only completed form submissions will be included in a .xlsx file. You will receive an <strong>email and a notification</strong> with the download link when the file is ready. For security reasons, the link will expire in 7 days."
export_button: "Export"
columns:
form_path: "Form path"
form_timestamp: "Timestamp of form submission (UTC)"
submitted_by: "Submitted by user"
form_field_timestamp: "Timestamp for %{name} (UTC)"
form_field_submitted_by: "User for %{name}"
values:
not_applicable: 'N/A'
fields:
mark_as_na: "Mark as N/A"
add_text: "Add text"

View file

@ -359,6 +359,7 @@ en:
form_block_edited_html: "%{user} edited form block %{block_name} in form %{form} in Form templates."
form_block_deleted_html: "%{user} deleted form block %{block_name} in form %{form} in Form templates."
form_block_rearranged_html: "%{user} rearranged form blocks in form %{form} in Form templates."
export_form_responses_html: "%{user} exported data from form %{form} in Form templates."
activity_name:
create_project: "Project created"
rename_project: "Project renamed"
@ -669,6 +670,7 @@ en:
form_block_edited: "Form block edited"
form_block_deleted: "Form block deleted"
form_block_rearranged: "Form block rearranged"
export_form_responses: "Form data exported"
activity_group:
projects: "Projects"
task_results: "Task results"

View file

@ -858,6 +858,7 @@ Rails.application.routes.draw do
member do
post :publish
post :unpublish
post :export_form_responses
end
collection do