Add form response models and controllers [SCI-11356]

This commit is contained in:
Martin Artnik 2024-12-18 13:24:35 +01:00
parent 6339c71d8f
commit 52e2c50b37
27 changed files with 787 additions and 7 deletions

View file

@ -0,0 +1,39 @@
# frozen_string_literal: true
class FormFieldValuesController < ApplicationController
before_action :load_form_response
before_action :load_form_field
before_action :check_create_permissions
def create
@form_field_value = @form_response.create_value!(
current_user,
@form_field,
form_field_value_params[:value]
)
render json: @form_field_value, serializer: FormFieldValueSerializer, user: current_user
end
private
def form_field_value_params
params.require(:form_field_value).permit(:form_field_id, :value)
end
def load_form_response
@form_response = FormResponse.find_by(id: params[:form_response_id])
render_404 unless @form_response
end
def load_form_field
@form_field = @form_response.form.form_fields.find_by(id: form_field_value_params[:form_field_id])
render_404 unless @form_field
end
def check_create_permissions
render_403 unless can_submit_form_response?(@form_response)
end
end

View file

@ -0,0 +1,68 @@
# frozen_string_literal: true
class FormResponsesController < ApplicationController
before_action :load_form, only: :create
before_action :load_parent, only: :create
before_action :load_form_response, except: :create
def create
case @parent
when Step
render_403 and return unless can_create_protocol_form_responses?(@parent.protocol)
ActiveRecord::Base.transaction do
@form_response = FormResponse.create!(form: @form, created_by: current_user)
@parent.step_orderable_elements.create!(orderable: @form_response)
end
else
render_422
end
render json: @form_response, serializer: FormResponseSerializer, user: current_user
end
def submit
render_403 and return unless can_submit_form_response?(@form_response)
@form_response.submit!(current_user)
render json: @form_response, serializer: FormResponseSerializer, user: current_user
end
def reset
render_403 and return unless can_reset_form_response?(@form_response)
@form_response.reset!(current_user)
render json: @form_response, serializer: FormResponseSerializer, user: current_user
end
private
def form_response_params
params.require(:form_response).permit(:form_id, :parent_id, :parent_type)
end
def load_form
@form = Form.find_by(id: form_response_params[:form_id])
render_404 unless @form && can_read_form?(@form)
end
def load_parent
case form_response_params[:parent_type]
when 'Step'
@parent = Step.find_by(id: form_response_params[:parent_id])
else
return render_422
end
render_404 unless @parent
end
def load_form_response
@form_response = FormResponse.find_by(id: params[:id])
render_404 unless @form_response
end
end

View file

@ -17,6 +17,7 @@ class Form < ApplicationRecord
belongs_to :default_public_user_role, class_name: 'UserRole', optional: true
has_many :form_fields, inverse_of: :form, dependent: :destroy
has_many :form_responses, dependent: :destroy
has_many :users, through: :user_assignments
validates :name, length: { minimum: Constants::NAME_MIN_LENGTH, maximum: Constants::NAME_MAX_LENGTH }
@ -27,7 +28,7 @@ class Form < ApplicationRecord
after_update :update_automatic_user_assignments,
if: -> { saved_change_to_default_public_user_role_id? }
enum visibility: { hidden: 0, visible: 1 }
enum :visibility, { hidden: 0, visible: 1 }
def permission_parent
nil

View file

@ -0,0 +1,20 @@
# frozen_string_literal: true
class FormDatetimeFieldValue < FormFieldValue
def value=(val)
if val.is_a?(Array)
self.datetime = val[0]
self.datetime_to = val[1]
else
self.datetime = val
end
end
def value
range? ? [datetime, datetime_to] : datetime
end
def range?
datetime_to.present?
end
end

View file

@ -1,15 +1,14 @@
# frozen_string_literal: true
class FormField < ApplicationRecord
belongs_to :form
belongs_to :created_by, class_name: 'User'
belongs_to :last_modified_by, class_name: 'User'
has_many :form_field_values, dependent: :destroy
validates :name, length: { minimum: Constants::NAME_MIN_LENGTH, maximum: Constants::NAME_MAX_LENGTH }
validates :description, length: { maximum: Constants::NAME_MAX_LENGTH }
validates :position, presence: true, uniqueness: { scope: :form }
acts_as_list scope: :form, top_of_list: 0, sequential_updates: true
end

View file

@ -0,0 +1,18 @@
# frozen_string_literal: true
class FormFieldValue < ApplicationRecord
belongs_to :form_response
belongs_to :form_field
belongs_to :created_by, class_name: 'User'
belongs_to :submitted_by, class_name: 'User'
scope :latest, -> { where(latest: true) }
def value=(_)
raise NotImplementedError
end
def value
raise NotImplementedError
end
end

View file

@ -0,0 +1,11 @@
# frozen_string_literal: true
class FormMultipleChoiceFieldValue < FormFieldValue
def value=(val)
self.selection = val
end
def value
selection
end
end

View file

@ -0,0 +1,22 @@
# frozen_string_literal: true
class FormNumberFieldValue < FormFieldValue
def value=(val)
self.unit = form_field.data['unit']
if val.is_a?(Array)
self.number = val[0]
self.number_to = val[1]
else
self.number = val
end
end
def value
range? ? [number, number_to] : number
end
def range?
number_to.present?
end
end

View file

@ -0,0 +1,72 @@
# frozen_string_literal: true
class InvalidStatusError < StandardError; end
class FormResponse < ApplicationRecord
include Discard::Model
default_scope -> { kept }
belongs_to :form
belongs_to :created_by, class_name: 'User'
belongs_to :submitted_by, class_name: 'User', optional: true
has_one :step_orderable_element, as: :orderable, dependent: :destroy
enum :status, { pending: 0, submitted: 1, locked: 2 }
has_many :form_field_values, dependent: :destroy
def step
step_orderable_element&.step
end
def parent
step_orderable_element&.step
end
def create_value!(created_by, form_field, value)
ActiveRecord::Base.transaction(requires_new: true) do
form_field_values.where(form_field: form_field).find_each do |form_field_value|
form_field_value.update!(latest: false)
end
"Form#{form_field.data['type']}Value".constantize.create!(
form_field: form_field,
form_response: self,
# 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,
value: value
)
end
end
def submit!(user)
update!(
status: :submitted,
submitted_by: user,
submitted_at: DateTime.current
)
end
def reset!(user)
raise InvalidStatusError, 'Cannot reset form that has not been submitted yet!' if status != 'submitted'
ActiveRecord::Base.transaction(requires_new: true) do
new_form_response = dup
new_form_response.update!(status: 'pending', created_by: user)
form_field_values.latest.find_each do |form_field_value|
form_field_value.dup.update!(form_response: new_form_response, created_by: user)
end
# if attached to step, reattach new form response
self&.step_orderable_element&.update!(orderable: new_form_response)
discard
new_form_response
end
end
end

View file

@ -0,0 +1,11 @@
# frozen_string_literal: true
class FormSingleChoiceFieldValue < FormFieldValue
def value=(val)
self.text = val
end
def value
text
end
end

View file

@ -0,0 +1,11 @@
# frozen_string_literal: true
class FormTextFieldValue < FormFieldValue
def value=(val)
self.text = val
end
def value
text
end
end

View file

@ -61,6 +61,10 @@ class UserRole < ApplicationRecord
predefined.find_by(name: UserRole.public_send('viewer_role').name)
end
def self.find_predefined_technician_role
predefined.find_by(name: UserRole.public_send('technician_role').name)
end
def has_permission?(permission)
permissions.include?(permission)
end

View file

@ -0,0 +1,38 @@
# frozen_string_literal: true
Canaid::Permissions.register_for(FormResponse) do
%i(
submit_form_response
reset_form_response
).each do |perm|
can perm do |_, form_response|
!form_response.locked?
end
end
can :submit_form_response do |user, form_response|
parent = form_response.parent
case parent
when Step
next false unless parent.protocol.my_module # protocol template forms can't be submitted
parent.protocol.my_module.permission_granted?(user, FormResponsePermissions::SUBMIT)
end
end
can :reset_form_response do |user, form_response|
parent = form_response.parent
case parent
when Step
next false unless parent.protocol.my_module # protocol template forms can't be reset
parent.protocol.my_module.permission_granted?(user, FormResponsePermissions::SUBMIT)
end
end
end
Canaid::Permissions.register_for(Protocol) do
can :create_protocol_form_responses do |user, protocol|
(protocol.my_module || protocol).permission_granted?(user, FormResponsePermissions::CREATE)
end
end

View file

@ -0,0 +1,11 @@
# frozen_string_literal: true
class FormFieldValueSerializer < ActiveModel::Serializer
include Canaid::Helpers::PermissionsHelper
attributes :form_field_id, :type, :value, :submitted_at, :submitted_by_full_name, :unit
def submitted_by_full_name
object.submitted_by.full_name
end
end

View file

@ -0,0 +1,15 @@
# frozen_string_literal: true
class FormResponseSerializer < ActiveModel::Serializer
include Canaid::Helpers::PermissionsHelper
attributes :id, :created_at, :form_id
has_many :form_field_values do
object.form_field_values.latest
end
def submitted_by_full_name
object.submitted_by.full_name
end
end

View file

@ -139,7 +139,15 @@ class Extends
RepositoryStockValue
)
STI_PRELOAD_CLASSES = %w(LinkedRepository SoftLockedRepository)
STI_PRELOAD_CLASSES = %w(
LinkedRepository
SoftLockedRepository
FormTextFieldValue
FormNumberFieldValue
FormDatetimeFieldValue
FormMultipleChoiceFieldValue
FormSingleChoiceFieldValue
)
# Array of preload relations used in search query for repository rows
REPOSITORY_ROWS_PRELOAD_RELATIONS = []

View file

@ -44,6 +44,15 @@ module PermissionExtends
).each { |permission| const_set(permission, "form_#{permission.parameterize}") }
end
module FormResponsePermissions
%w(
NONE
CREATE
SUBMIT
RESET
).each { |permission| const_set(permission, "form_response_#{permission.parameterize}") }
end
module ReportPermissions
%w(
NONE
@ -156,8 +165,9 @@ module PermissionExtends
ProjectPermissions.constants.map { |const| ProjectPermissions.const_get(const) } +
ExperimentPermissions.constants.map { |const| ExperimentPermissions.const_get(const) } +
MyModulePermissions.constants.map { |const| MyModulePermissions.const_get(const) } +
RepositoryPermissions.constants.map { |const| RepositoryPermissions.const_get(const) }
).reject { |p| p.end_with?("_none") }
RepositoryPermissions.constants.map { |const| RepositoryPermissions.const_get(const) } +
FormResponsePermissions.constants.map { |const| FormResponsePermissions.const_get(const) }
).reject { |p| p.end_with?('_none') }
NORMAL_USER_PERMISSIONS = [
TeamPermissions::PROJECTS_CREATE,
@ -177,6 +187,9 @@ module PermissionExtends
ProtocolPermissions::MANAGE_DRAFT,
FormPermissions::READ,
FormPermissions::READ_ARCHIVED,
FormResponsePermissions::CREATE,
FormResponsePermissions::SUBMIT,
FormResponsePermissions::RESET,
ReportPermissions::READ,
ReportPermissions::MANAGE,
ProjectPermissions::READ,
@ -265,7 +278,8 @@ module PermissionExtends
MyModulePermissions::REPOSITORY_ROWS_ASSIGN,
MyModulePermissions::REPOSITORY_ROWS_MANAGE,
MyModulePermissions::USERS_READ,
MyModulePermissions::STOCK_CONSUMPTION_UPDATE
MyModulePermissions::STOCK_CONSUMPTION_UPDATE,
FormResponsePermissions::SUBMIT
]
VIEWER_PERMISSIONS = [

View file

@ -874,6 +874,15 @@ Rails.application.routes.draw do
end
end
resources :form_responses, only: %i(create) do
member do
post :submit
post :reset
end
resources :form_field_values, only: %i(create)
end
get 'search' => 'search#index'
get 'search/new' => 'search#new', as: :new_search
resource :search, only: [], controller: :search do

View file

@ -0,0 +1,16 @@
# frozen_string_literal: true
class CreateFormResponses < ActiveRecord::Migration[7.0]
def change
create_table :form_responses do |t|
t.references :form, null: false, foreign_key: true
t.references :created_by, null: false, foreign_key: { to_table: :users }
t.references :submitted_by, null: true, foreign_key: { to_table: :users }
t.integer :status, default: 0
t.datetime :submitted_at
t.datetime :discarded_at
t.timestamps
end
end
end

View file

@ -0,0 +1,33 @@
# frozen_string_literal: true
class CreateFormFieldValues < ActiveRecord::Migration[7.0]
def change
create_table :form_field_values do |t|
t.string :type, index: true
t.references :form_response, null: false, foreign_key: true
t.references :form_field, null: false, foreign_key: true
t.references :created_by, null: false, foreign_key: { to_table: :users }
t.references :submitted_by, null: true, foreign_key: { to_table: :users }
t.timestamp :submitted_at
t.boolean :latest, null: false, default: true
t.boolean :not_applicable, null: false, default: false
# FormFieldDateTimeValue
t.datetime :datetime
t.datetime :datetime_to
# FormFieldNumberValue
t.decimal :number
t.decimal :number_to
t.text :unit
# FormFieldTextValue, FormFieldSingleChoiceValue
t.text :text
# FormFieldMultipleCohiceValue
t.text :selection, array: true
t.timestamps
end
end
end

View file

@ -0,0 +1,40 @@
# frozen_string_literal: true
class AddFormResponsePermissions < ActiveRecord::Migration[7.0]
FORM_RESPONSE_MANAGE_PERMISSION = [
FormResponsePermissions::RESET,
FormResponsePermissions::CREATE
].freeze
FORM_RESPONSE_SUBMIT_PERMISSION = [
FormResponsePermissions::SUBMIT
].freeze
def up
@owner_role = UserRole.find_predefined_owner_role
@normal_user_role = UserRole.find_predefined_normal_user_role
@technician_user_role = UserRole.find_predefined_technician_role
@owner_role.permissions = @owner_role.permissions | (FORM_RESPONSE_SUBMIT_PERMISSION + FORM_RESPONSE_MANAGE_PERMISSION)
@normal_user_role.permissions = @normal_user_role.permissions | (FORM_RESPONSE_SUBMIT_PERMISSION + FORM_RESPONSE_MANAGE_PERMISSION)
@technician_user_role.permissions = @technician_user_role.permissions | FORM_RESPONSE_SUBMIT_PERMISSION
@owner_role.save(validate: false)
@normal_user_role.save(validate: false)
@technician_user_role.save(validate: false)
end
def down
@owner_role = UserRole.find_predefined_owner_role
@normal_user_role = UserRole.find_predefined_normal_user_role
@technician_user_role = UserRole.find_predefined_technician_role
@owner_role.permissions = @owner_role.permissions - (FORM_RESPONSE_SUBMIT_PERMISSION + FORM_RESPONSE_MANAGE_PERMISSION)
@normal_user_role.permissions = @normal_user_role.permissions - (FORM_RESPONSE_SUBMIT_PERMISSION + FORM_RESPONSE_MANAGE_PERMISSION)
@technician_user_role.permissions = @technician_user_role.permissions - FORM_RESPONSE_SUBMIT_PERMISSION
@owner_role.save(validate: false)
@normal_user_role.save(validate: false)
@technician_user_role.save(validate: false)
end
end

View file

@ -0,0 +1,94 @@
# frozen_string_literal: true
require 'rails_helper'
describe FormFieldValuesController, type: :controller do
login_user
include_context 'reference_project_structure'
let!(:form) { create(:form, team: team, created_by: user) }
let!(:form_response) { create(:form_response, form: form, created_by: user) }
let!(:form_field) { create(:form_field, form: form, created_by: user, data: { type: 'TextField' }) }
describe 'POST create' do
let(:action) { post :create, params: params, format: :json }
let(:params) do
{
form_response_id: form_response.id,
form_field_value: {
form_field_id: form_field.id,
value: 'Test value'
}
}
end
context 'when user has permissions' do
before { allow(controller).to receive(:can_submit_form_response?).and_return(true) }
it 'creates a form field value successfully' do
expect { action }.to change(FormFieldValue, :count).by(1)
expect(response).to have_http_status(:success)
response_body = JSON.parse(response.body)
expect(response_body['data']['attributes']['value']).to eq 'Test value'
expect(response_body['data']['attributes']['form_field_id']).to eq form_field.id
end
end
context 'when user lacks permissions' do
before { allow(controller).to receive(:can_submit_form_response?).and_return(false) }
it 'returns a forbidden response' do
action
expect(response).to have_http_status(:forbidden)
end
end
context 'when invalid form_field_id is provided' do
let(:params) do
{
form_response_id: form_response.id,
form_field_value: {
form_field_id: -1,
value: 'Test value'
}
}
end
it 'returns a not found response' do
action
expect(response).to have_http_status(:not_found)
end
end
context 'when invalid form_response_id is provided' do
let(:params) do
{
form_response_id: 0,
form_field_value: {
form_field_id: form_field.id,
value: 'Test value'
}
}
end
it 'returns a not found response' do
action
expect(response).to have_http_status(:not_found)
end
end
context 'when missing value parameter' do
let(:params) do
{
form_response_id: form_response.id,
nothing: {}
}
end
it 'raises a parameter missing error' do
expect { action }.to raise_error(ActionController::ParameterMissing)
end
end
end
end

View file

@ -0,0 +1,128 @@
# frozen_string_literal: true
require 'rails_helper'
describe FormResponsesController, type: :controller do
login_user
include_context 'reference_project_structure'
let!(:form) { create(:form, team: team, created_by: user) }
let!(:step) { create(:step, protocol: protocol) }
let!(:protocol) { create(:protocol, added_by: user) }
let!(:form_response) { create(:form_response, form: form, created_by: user) }
describe 'POST create' do
let(:action) { post :create, params: params, format: :json }
let(:params) do
{
form_response: {
form_id: form.id,
parent_id: step.id,
parent_type: 'Step'
}
}
end
context 'when user has permissions' do
before { allow(controller).to receive(:can_create_protocol_form_responses?).and_return(true) }
it 'creates a form response successfully' do
expect { action }.to change(FormResponse, :count).by(1)
expect(response).to have_http_status(:success)
response_body = JSON.parse(response.body)
expect(response_body['data']['attributes']['form_id']).to eq form.id
end
end
context 'when user lacks permissions' do
before { allow(controller).to receive(:can_create_protocol_form_responses?).and_return(false) }
it 'returns a forbidden response' do
action
expect(response).to have_http_status(:forbidden)
end
end
context 'when invalid parent type' do
let(:params) do
{
form_response: {
form_id: form.id,
parent_id: step.id,
parent_type: 'InvalidType'
}
}
end
it 'returns an unprocessable entity response' do
action
expect(response).to have_http_status(:unprocessable_entity)
end
end
end
describe 'PUT submit' do
let(:action) { put :submit, params: { id: form_response.id }, format: :json }
context 'when user has permissions' do
before { allow(controller).to receive(:can_submit_form_response?).and_return(true) }
it 'submits the form response successfully' do
expect(form_response.status).to eq 'pending'
action
expect(response).to have_http_status(:success)
form_response.reload
expect(form_response.status).to eq 'submitted'
expect(form_response.submitted_by).to eq user
end
end
context 'when user lacks permissions' do
before { allow(controller).to receive(:can_submit_form_response?).and_return(false) }
it 'returns a forbidden response' do
action
expect(response).to have_http_status(:forbidden)
end
end
end
describe 'PUT reset' do
let(:action) { put :reset, params: { id: form_response.id }, format: :json }
context 'when user has permissions and form is submitted' do
before do
allow(controller).to receive(:can_reset_form_response?).and_return(true)
form_response.update!(status: 'submitted')
end
it 'resets the form response successfully' do
expect { action }.to change(FormResponse.unscoped, :count).by(1)
expect(response).to have_http_status(:success)
form_response.reload
expect(form_response.discarded?).to be true
end
end
context 'when form is not submitted' do
before do
allow(controller).to receive(:can_reset_form_response?).and_return(true)
form_response.update!(status: 'pending')
end
it 'raises an error' do
expect { action }.to raise_error(InvalidStatusError)
end
end
context 'when user lacks permissions' do
before { allow(controller).to receive(:can_reset_form_response?).and_return(false) }
it 'returns a forbidden response' do
action
expect(response).to have_http_status(:forbidden)
end
end
end
end

View file

@ -0,0 +1,12 @@
# frozen_string_literal: true
FactoryBot.define do
factory :form_field_value do
type { 'FormFieldTextValue' }
text { 'hello' }
association :form_response
association :form_field
association :created_by, factory: :user
association :submitted_by, factory: :user
end
end

View file

@ -0,0 +1,15 @@
# frozen_string_literal: true
FactoryBot.define do
factory :form_response do
association :form
association :created_by, factory: :user
status { :pending }
trait :submitted do
status { :submitted }
association :submitted_by, factory: :user
submitted_at { DateTime.current }
end
end
end

View file

@ -0,0 +1,39 @@
# frozen_string_literal: true
require 'rails_helper'
describe FormFieldValue, type: :model do
let(:form_response) { build :form_field_value }
it 'is valid' do
expect(form_response).to be_valid
end
it 'should be of class form field value' do
expect(subject.class).to eq FormFieldValue
end
describe 'Database table' do
it { should have_db_column :form_response_id }
it { should have_db_column :created_by_id }
it { should have_db_column :submitted_by_id }
it { should have_db_column :submitted_at }
it { should have_db_column :created_at }
it { should have_db_column :updated_at }
it { should have_db_column :latest }
it { should have_db_column :not_applicable }
it { should have_db_column :datetime }
it { should have_db_column :datetime_to }
it { should have_db_column :number }
it { should have_db_column :number_to }
it { should have_db_column :text }
it { should have_db_column :selection }
end
describe 'Relations' do
it { should belong_to(:form_response) }
it { should belong_to(:form_field) }
it { should belong_to(:created_by).class_name('User') }
it { should belong_to(:submitted_by).class_name('User') }
end
end

View file

@ -0,0 +1,32 @@
# frozen_string_literal: true
require 'rails_helper'
describe FormResponse, type: :model do
let(:form_response) { build :form_response }
it 'is valid' do
expect(form_response).to be_valid
end
it 'should be of class form field' do
expect(subject.class).to eq FormResponse
end
describe 'Database table' do
it { should have_db_column :form_id }
it { should have_db_column :created_by_id }
it { should have_db_column :submitted_by_id }
it { should have_db_column :status }
it { should have_db_column :submitted_at }
it { should have_db_column :discarded_at }
it { should have_db_column :created_at }
it { should have_db_column :updated_at }
end
describe 'Relations' do
it { should belong_to(:form) }
it { should belong_to(:created_by).class_name('User') }
it { should belong_to(:submitted_by).class_name('User').optional }
end
end