mirror of
https://github.com/scinote-eln/scinote-web.git
synced 2025-09-06 13:14:29 +08:00
Add form submission export [SCI-11389]
This commit is contained in:
parent
b0463b06b6
commit
ee4886bd67
14 changed files with 194 additions and 6 deletions
|
@ -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:
|
||||
|
|
|
@ -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');
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
|
73
app/jobs/form_responses_zip_export_job.rb
Normal file
73
app/jobs/form_responses_zip_export_job.rb
Normal 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
|
|
@ -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
|
||||
|
|
|
@ -15,4 +15,12 @@ class FormFieldValue < ApplicationRecord
|
|||
def value
|
||||
raise NotImplementedError
|
||||
end
|
||||
|
||||
def formatted
|
||||
value
|
||||
end
|
||||
|
||||
def value_in_range?
|
||||
true
|
||||
end
|
||||
end
|
||||
|
|
|
@ -8,4 +8,8 @@ class FormMultipleChoiceFieldValue < FormFieldValue
|
|||
def value
|
||||
selection
|
||||
end
|
||||
|
||||
def formatted
|
||||
value.join('|')
|
||||
end
|
||||
end
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -17,5 +17,7 @@ module Lists
|
|||
end
|
||||
|
||||
def filter_records; end
|
||||
|
||||
def sort_records; end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -858,6 +858,7 @@ Rails.application.routes.draw do
|
|||
member do
|
||||
post :publish
|
||||
post :unpublish
|
||||
post :export_form_responses
|
||||
end
|
||||
|
||||
collection do
|
||||
|
|
Loading…
Add table
Reference in a new issue