mirror of
https://github.com/scinote-eln/scinote-web.git
synced 2025-11-12 01:11:24 +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
|
class FormsController < ApplicationController
|
||||||
include UserRolesHelper
|
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 :set_breadcrumbs_items, only: %i(index show)
|
||||||
before_action :check_manage_permissions, only: %i(update publish unpublish)
|
before_action :check_manage_permissions, only: %i(update publish unpublish)
|
||||||
before_action :check_create_permissions, only: :create
|
before_action :check_create_permissions, only: :create
|
||||||
|
|
@ -142,6 +142,27 @@ class FormsController < ApplicationController
|
||||||
end
|
end
|
||||||
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
|
def actions_toolbar
|
||||||
render json: {
|
render json: {
|
||||||
actions:
|
actions:
|
||||||
|
|
|
||||||
|
|
@ -14,10 +14,18 @@
|
||||||
@archive="archive"
|
@archive="archive"
|
||||||
@restore="restore"
|
@restore="restore"
|
||||||
@access="access"
|
@access="access"
|
||||||
|
@export="exportFormResponse"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<AccessModal v-if="accessModalParams" :params="accessModalParams"
|
<AccessModal v-if="accessModalParams" :params="accessModalParams"
|
||||||
@close="accessModalParams = null" @refresh="this.reloadingTable = true" />
|
@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>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
|
@ -27,6 +35,7 @@ import axios from '../../packs/custom_axios.js';
|
||||||
|
|
||||||
import DataTable from '../shared/datatable/table.vue';
|
import DataTable from '../shared/datatable/table.vue';
|
||||||
import DeleteModal from '../shared/confirmation_modal.vue';
|
import DeleteModal from '../shared/confirmation_modal.vue';
|
||||||
|
import ConfirmationModal from '../shared/confirmation_modal.vue';
|
||||||
import NameRenderer from './renderers/name.vue';
|
import NameRenderer from './renderers/name.vue';
|
||||||
import UsersRenderer from '../projects/renderers/users.vue';
|
import UsersRenderer from '../projects/renderers/users.vue';
|
||||||
|
|
||||||
|
|
@ -40,7 +49,8 @@ export default {
|
||||||
DeleteModal,
|
DeleteModal,
|
||||||
NameRenderer,
|
NameRenderer,
|
||||||
UsersRenderer,
|
UsersRenderer,
|
||||||
AccessModal
|
AccessModal,
|
||||||
|
ConfirmationModal
|
||||||
},
|
},
|
||||||
props: {
|
props: {
|
||||||
dataSource: {
|
dataSource: {
|
||||||
|
|
@ -153,6 +163,17 @@ export default {
|
||||||
}).catch((error) => {
|
}).catch((error) => {
|
||||||
HelperModule.flashAlertMsg(error.response.data.error, 'danger');
|
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?
|
def range?
|
||||||
datetime_to.present?
|
datetime_to.present?
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def formatted
|
||||||
|
range? ? [datetime, datetime_to].join(' - ') : datetime.to_s
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -15,4 +15,12 @@ class FormFieldValue < ApplicationRecord
|
||||||
def value
|
def value
|
||||||
raise NotImplementedError
|
raise NotImplementedError
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def formatted
|
||||||
|
value
|
||||||
|
end
|
||||||
|
|
||||||
|
def value_in_range?
|
||||||
|
true
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -8,4 +8,8 @@ class FormMultipleChoiceFieldValue < FormFieldValue
|
||||||
def value
|
def value
|
||||||
selection
|
selection
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def formatted
|
||||||
|
value.join('|')
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -19,4 +19,22 @@ class FormNumberFieldValue < FormFieldValue
|
||||||
def range?
|
def range?
|
||||||
number_to.present?
|
number_to.present?
|
||||||
end
|
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
|
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
|
# 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,
|
created_by: created_by,
|
||||||
submitted_by: created_by,
|
submitted_by: created_by,
|
||||||
|
submitted_at: DateTime.current,
|
||||||
value: value
|
value: value
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
@ -62,10 +63,11 @@ class FormResponse < ApplicationRecord
|
||||||
end
|
end
|
||||||
|
|
||||||
# if attached to step, reattach new form response
|
# if attached to step, reattach new form response
|
||||||
self&.step_orderable_element&.update!(orderable: new_form_response)
|
|
||||||
|
|
||||||
discard
|
discard
|
||||||
|
|
||||||
|
self&.step_orderable_element&.update!(orderable: new_form_response)
|
||||||
|
|
||||||
new_form_response
|
new_form_response
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -17,5 +17,7 @@ module Lists
|
||||||
end
|
end
|
||||||
|
|
||||||
def filter_records; end
|
def filter_records; end
|
||||||
|
|
||||||
|
def sort_records; end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,8 @@ module Toolbars
|
||||||
[
|
[
|
||||||
access_action,
|
access_action,
|
||||||
archive_action,
|
archive_action,
|
||||||
restore_action
|
restore_action,
|
||||||
|
export_action
|
||||||
].compact
|
].compact
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -60,5 +61,21 @@ module Toolbars
|
||||||
type: :emit
|
type: :emit
|
||||||
}
|
}
|
||||||
end
|
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
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -543,7 +543,8 @@ class Extends
|
||||||
form_block_added: 342,
|
form_block_added: 342,
|
||||||
form_block_edited: 343,
|
form_block_edited: 343,
|
||||||
form_block_deleted: 344,
|
form_block_deleted: 344,
|
||||||
form_block_rearranged: 345
|
form_block_rearranged: 345,
|
||||||
|
export_form_responses: 346
|
||||||
}
|
}
|
||||||
|
|
||||||
ACTIVITY_GROUPS = {
|
ACTIVITY_GROUPS = {
|
||||||
|
|
@ -567,7 +568,7 @@ class Extends
|
||||||
storage_locations: [*309..315],
|
storage_locations: [*309..315],
|
||||||
container_storage_locations: [*316..322, 326],
|
container_storage_locations: [*316..322, 326],
|
||||||
storage_location_repository_rows: [*323..325],
|
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
|
TOP_LEVEL_ASSIGNABLES = %w(Project Team Protocol Repository Form).freeze
|
||||||
|
|
|
||||||
|
|
@ -289,6 +289,7 @@ en:
|
||||||
repository: "Inventories"
|
repository: "Inventories"
|
||||||
repository_item: "Items"
|
repository_item: "Items"
|
||||||
stock_consumption: "Stock consumption"
|
stock_consumption: "Stock consumption"
|
||||||
|
form: "Form"
|
||||||
|
|
||||||
head:
|
head:
|
||||||
title: "SciNote | %{title}"
|
title: "SciNote | %{title}"
|
||||||
|
|
@ -1062,6 +1063,19 @@ en:
|
||||||
archived:
|
archived:
|
||||||
success_flash: "<strong>%{number}</strong> form(s) successfully archived."
|
success_flash: "<strong>%{number}</strong> form(s) successfully archived."
|
||||||
error_flash: "Failed to archive form(s)."
|
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:
|
fields:
|
||||||
mark_as_na: "Mark as N/A"
|
mark_as_na: "Mark as N/A"
|
||||||
add_text: "Add text"
|
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_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_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."
|
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:
|
activity_name:
|
||||||
create_project: "Project created"
|
create_project: "Project created"
|
||||||
rename_project: "Project renamed"
|
rename_project: "Project renamed"
|
||||||
|
|
@ -669,6 +670,7 @@ en:
|
||||||
form_block_edited: "Form block edited"
|
form_block_edited: "Form block edited"
|
||||||
form_block_deleted: "Form block deleted"
|
form_block_deleted: "Form block deleted"
|
||||||
form_block_rearranged: "Form block rearranged"
|
form_block_rearranged: "Form block rearranged"
|
||||||
|
export_form_responses: "Form data exported"
|
||||||
activity_group:
|
activity_group:
|
||||||
projects: "Projects"
|
projects: "Projects"
|
||||||
task_results: "Task results"
|
task_results: "Task results"
|
||||||
|
|
|
||||||
|
|
@ -858,6 +858,7 @@ Rails.application.routes.draw do
|
||||||
member do
|
member do
|
||||||
post :publish
|
post :publish
|
||||||
post :unpublish
|
post :unpublish
|
||||||
|
post :export_form_responses
|
||||||
end
|
end
|
||||||
|
|
||||||
collection do
|
collection do
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue