diff --git a/.nvmrc b/.nvmrc index 7af24b7ddb..6fa8dec4cd 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -22.11.0 +22.13.0 diff --git a/.ruby-version b/.ruby-version index 5ae69bd5f0..9c25013dbb 100644 --- a/.ruby-version +++ b/.ruby-version @@ -1 +1 @@ -3.2.5 +3.3.6 diff --git a/Dockerfile b/Dockerfile index f64ba4780f..8209798ccf 100644 --- a/Dockerfile +++ b/Dockerfile @@ -2,7 +2,7 @@ ARG CHIPSET=default # Use the correct base image depending on the architecture # For Apple M1 Chip, run: docker build --build-arg CHIPSET=m1 . -FROM ruby:3.2.5-slim AS base_default +FROM ruby:3.3.6-slim AS base_default FROM --platform=linux/amd64 ruby:3.2.5-slim AS base_m1 FROM base_${CHIPSET} AS base diff --git a/Gemfile.lock b/Gemfile.lock index 60c8c361b3..1c4933d500 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -34,34 +34,34 @@ GEM specs: aasm (5.5.0) concurrent-ruby (~> 1.0) - actioncable (7.0.8.1) - actionpack (= 7.0.8.1) - activesupport (= 7.0.8.1) + actioncable (7.0.8.7) + actionpack (= 7.0.8.7) + activesupport (= 7.0.8.7) nio4r (~> 2.0) websocket-driver (>= 0.6.1) - actionmailbox (7.0.8.1) - actionpack (= 7.0.8.1) - activejob (= 7.0.8.1) - activerecord (= 7.0.8.1) - activestorage (= 7.0.8.1) - activesupport (= 7.0.8.1) + actionmailbox (7.0.8.7) + actionpack (= 7.0.8.7) + activejob (= 7.0.8.7) + activerecord (= 7.0.8.7) + activestorage (= 7.0.8.7) + activesupport (= 7.0.8.7) mail (>= 2.7.1) net-imap net-pop net-smtp - actionmailer (7.0.8.1) - actionpack (= 7.0.8.1) - actionview (= 7.0.8.1) - activejob (= 7.0.8.1) - activesupport (= 7.0.8.1) + actionmailer (7.0.8.7) + actionpack (= 7.0.8.7) + actionview (= 7.0.8.7) + activejob (= 7.0.8.7) + activesupport (= 7.0.8.7) mail (~> 2.5, >= 2.5.4) net-imap net-pop net-smtp rails-dom-testing (~> 2.0) - actionpack (7.0.8.1) - actionview (= 7.0.8.1) - activesupport (= 7.0.8.1) + actionpack (7.0.8.7) + actionview (= 7.0.8.7) + activesupport (= 7.0.8.7) rack (~> 2.0, >= 2.2.4) rack-test (>= 0.6.3) rails-dom-testing (~> 2.0) @@ -69,37 +69,37 @@ GEM actionpack-xml_parser (2.0.1) actionpack (>= 5.0) railties (>= 5.0) - actiontext (7.0.8.1) - actionpack (= 7.0.8.1) - activerecord (= 7.0.8.1) - activestorage (= 7.0.8.1) - activesupport (= 7.0.8.1) + actiontext (7.0.8.7) + actionpack (= 7.0.8.7) + activerecord (= 7.0.8.7) + activestorage (= 7.0.8.7) + activesupport (= 7.0.8.7) globalid (>= 0.6.0) nokogiri (>= 1.8.5) - actionview (7.0.8.1) - activesupport (= 7.0.8.1) + actionview (7.0.8.7) + activesupport (= 7.0.8.7) builder (~> 3.1) erubi (~> 1.4) rails-dom-testing (~> 2.0) rails-html-sanitizer (~> 1.1, >= 1.2.0) - activejob (7.0.8.1) - activesupport (= 7.0.8.1) + activejob (7.0.8.7) + activesupport (= 7.0.8.7) globalid (>= 0.3.6) - activemodel (7.0.8.1) - activesupport (= 7.0.8.1) - activerecord (7.0.8.1) - activemodel (= 7.0.8.1) - activesupport (= 7.0.8.1) + activemodel (7.0.8.7) + activesupport (= 7.0.8.7) + activerecord (7.0.8.7) + activemodel (= 7.0.8.7) + activesupport (= 7.0.8.7) activerecord-import (1.7.0) activerecord (>= 4.2) - activestorage (7.0.8.1) - actionpack (= 7.0.8.1) - activejob (= 7.0.8.1) - activerecord (= 7.0.8.1) - activesupport (= 7.0.8.1) + activestorage (7.0.8.7) + actionpack (= 7.0.8.7) + activejob (= 7.0.8.7) + activerecord (= 7.0.8.7) + activesupport (= 7.0.8.7) marcel (~> 1.0) mini_mime (>= 1.1.0) - activesupport (7.0.8.1) + activesupport (7.0.8.7) concurrent-ruby (~> 1.0, >= 1.0.2) i18n (>= 1.6, < 2) minitest (>= 5.1) @@ -196,7 +196,7 @@ GEM activerecord (>= 5.a) database_cleaner-core (~> 2.0.0) database_cleaner-core (2.0.1) - date (3.4.0) + date (3.4.1) delayed_job (4.1.11) activesupport (>= 3.0, < 8.0) delayed_job_active_record (4.1.8) @@ -206,14 +206,14 @@ GEM docile (1.4.0) domain_name (0.6.20240107) dry-cli (1.0.0) - erubi (1.13.0) + erubi (1.13.1) exception_notification (4.5.0) actionmailer (>= 5.2, < 8) activesupport (>= 5.2, < 8) - factory_bot (6.4.6) + factory_bot (6.5.0) activesupport (>= 5.0.0) - factory_bot_rails (6.4.3) - factory_bot (~> 6.4) + factory_bot_rails (6.4.4) + factory_bot (~> 6.5) railties (>= 5.0.0) ffi (1.16.3) flipper (0.25.4) @@ -257,7 +257,7 @@ GEM rb-fsevent (~> 0.10, >= 0.10.3) rb-inotify (~> 0.9, >= 0.9.10) logger (1.6.1) - loofah (2.23.1) + loofah (2.24.0) crass (~> 1.0.2) nokogiri (>= 1.12.0) mail (2.8.1) @@ -273,7 +273,7 @@ GEM mime-types-data (3.2024.0305) mini_magick (4.12.0) mini_mime (1.1.5) - minitest (5.25.1) + minitest (5.25.4) minitest-profiler (0.0.2) activesupport (>= 4.1.0) minitest (>= 5.3.3) @@ -285,7 +285,7 @@ GEM mustermann (3.0.0) ruby2_keywords (~> 0.0.1) mysql2 (0.5.6) - net-imap (0.5.1) + net-imap (0.5.5) date net-protocol net-ldap (0.19.0) @@ -297,11 +297,11 @@ GEM net-protocol netrc (0.11.0) nio4r (2.7.4) - nokogiri (1.16.7-arm64-darwin) + nokogiri (1.18.1-arm64-darwin) racc (~> 1.4) - nokogiri (1.16.7-x86_64-darwin) + nokogiri (1.18.1-x86_64-darwin) racc (~> 1.4) - nokogiri (1.16.7-x86_64-linux) + nokogiri (1.18.1-x86_64-linux-gnu) racc (~> 1.4) ostruct (0.6.0) parallel (1.26.3) @@ -334,22 +334,22 @@ GEM rack (~> 2.2, >= 2.2.4) rack-proxy (0.7.7) rack - rack-test (2.1.0) + rack-test (2.2.0) rack (>= 1.3) - rails (7.0.8.1) - actioncable (= 7.0.8.1) - actionmailbox (= 7.0.8.1) - actionmailer (= 7.0.8.1) - actionpack (= 7.0.8.1) - actiontext (= 7.0.8.1) - actionview (= 7.0.8.1) - activejob (= 7.0.8.1) - activemodel (= 7.0.8.1) - activerecord (= 7.0.8.1) - activestorage (= 7.0.8.1) - activesupport (= 7.0.8.1) + rails (7.0.8.7) + actioncable (= 7.0.8.7) + actionmailbox (= 7.0.8.7) + actionmailer (= 7.0.8.7) + actionpack (= 7.0.8.7) + actiontext (= 7.0.8.7) + actionview (= 7.0.8.7) + activejob (= 7.0.8.7) + activemodel (= 7.0.8.7) + activerecord (= 7.0.8.7) + activestorage (= 7.0.8.7) + activesupport (= 7.0.8.7) bundler (>= 1.15.0) - railties (= 7.0.8.1) + railties (= 7.0.8.7) rails-controller-testing (1.0.5) actionpack (>= 5.0.1.rc1) actionview (>= 5.0.1.rc1) @@ -363,13 +363,13 @@ GEM activesupport (>= 4.2) choice (~> 0.2.0) ruby-graphviz (~> 1.2) - rails-html-sanitizer (1.6.0) + rails-html-sanitizer (1.6.2) loofah (~> 2.21) - nokogiri (~> 1.14) + nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0) rails-perftest (0.0.7) - railties (7.0.8.1) - actionpack (= 7.0.8.1) - activesupport (= 7.0.8.1) + railties (7.0.8.7) + actionpack (= 7.0.8.7) + activesupport (= 7.0.8.7) method_source rake (>= 12.2) thor (~> 1.0) @@ -454,7 +454,7 @@ GEM rubocop-rspec (~> 3, >= 3.0.1) ruby-graphviz (1.2.5) rexml - ruby-prof (1.7.0) + ruby-prof (1.7.1) ruby-progressbar (1.13.0) ruby-units (4.0.3) ruby-vips (2.2.1) @@ -515,7 +515,7 @@ GEM thor (1.3.2) tilt (2.4.0) timecop (0.9.10) - timeout (0.4.2) + timeout (0.4.3) traceroute (0.8.1) rails (>= 3.0.0) tzinfo (2.0.6) @@ -535,7 +535,8 @@ GEM crack (>= 0.3.2) hashdiff (>= 0.4.0, < 2.0.0) websocket (1.2.11) - websocket-driver (0.7.6) + websocket-driver (0.7.7) + base64 websocket-extensions (>= 0.1.0) websocket-extensions (0.1.5) whenever (1.0.0) diff --git a/app/controllers/api/v2/orders_controller.rb b/app/controllers/api/v2/orders_controller.rb index 63a1e71e45..553298c46c 100644 --- a/app/controllers/api/v2/orders_controller.rb +++ b/app/controllers/api/v2/orders_controller.rb @@ -8,5 +8,82 @@ class OrdersController < JSONAPI::ResourceController # By default JSONAPI::ResourceController provides most the standard # behaviour, and in many cases this file may be left empty. end + + class OrderProcessor < JSONAPI::Processor + before_create_resource :prepare_context + + private + + def prepare_context + context[:template] = find_template + context[:template_attributes] = template_attributes unless context[:template].nil? + end + + def find_template + template_uuid = params[:data][:attributes][:submission_template_uuid] + return nil if template_uuid.nil? # No errors -- we just don't have a template. + + template = SubmissionTemplate.with_uuid(template_uuid).first + raise JSONAPI::Exceptions::InvalidFieldValue.new(:submission_template_uuid, template_uuid) if template.nil? + + template + end + + def template_attributes + parameters = params[:data][:attributes][:submission_template_attributes] + + raise JSONAPI::Exceptions::ParameterMissing, :submission_template_attributes if parameters.nil? + + make_template_attributes(permitted_attributes(parameters)) + end + + def permitted_attributes(attributes) + attributes.permit( + { asset_uuids: [], request_options: {} }, + :autodetect_projects, + :autodetect_studies, + :user_uuid + ) + end + + def make_template_attributes(attributes) + { + assets: extract_assets(attributes), + autodetect_projects: attributes[:autodetect_projects], + autodetect_studies: attributes[:autodetect_studies], + request_options: require_attribute(attributes, :request_options), + user: extract_user(attributes) + }.compact + end + + def extract_assets(attributes) + asset_uuids = require_attribute(attributes, :asset_uuids) + return nil if asset_uuids.nil? + + asset_uuids.map do |uuid| + uuid_obj = Uuid.find_by(external_id: uuid) + raise JSONAPI::Exceptions::InvalidFieldValue.new(:asset_uuids, uuid) if uuid_obj.nil? + uuid_obj&.resource + end + end + + def extract_user(attributes) + user_uuid = require_attribute(attributes, :user_uuid) + return nil if user_uuid.nil? + + user = User.with_uuid(user_uuid).first + raise JSONAPI::Exceptions::InvalidFieldValue.new(:user_uuid, user_uuid) if user.nil? + + user + end + + def require_attribute(attributes, key) + value = attributes.require(key) + value = value.to_h if value.instance_of?(ActionController::Parameters) && value.permitted? + value + rescue ActionController::ParameterMissing + raise JSONAPI::Exceptions::ParameterMissing, "submission_template_attributes.#{key}" + end + end end end diff --git a/app/controllers/api/v2/tag_layouts_controller.rb b/app/controllers/api/v2/tag_layouts_controller.rb index bc2472640e..d3330aea1f 100644 --- a/app/controllers/api/v2/tag_layouts_controller.rb +++ b/app/controllers/api/v2/tag_layouts_controller.rb @@ -10,6 +10,30 @@ class TagLayoutsController < JSONAPI::ResourceController end class TagLayoutProcessor < JSONAPI::Processor + # Override the default behaviour for a JSONAPI::Processor when creating a new resource. + # We need to check whether a template UUID was given, and, if so, copy its data into this + # new TagLayoutResource. The creation will be prevented if any data from the template is also + # included in the create request as additional values. e.g. a request has a template UUID and + # also specifies a direction or a tag_group. In this case, the error will indicate that the + # template UUID was not an allowed attribute. + def create_resource + errors = [] + template = find_template { |new_errors| errors += new_errors } + merge_template_data(template) { |new_errors| errors += new_errors } unless template.nil? + + return JSONAPI::ErrorsOperationResult.new(JSONAPI::BAD_REQUEST, errors) unless errors.empty? + + # Perform the usual create actions. + resource = TagLayoutResource.create(context) + result = resource.replace_fields(params[:data]) + + record_template_use(template, resource) + + JSONAPI::ResourceOperationResult.new((result == :completed ? :created : :accepted), resource) + end + + private + def find_template template_uuid = params[:data][:attributes][:tag_layout_template_uuid] return nil if template_uuid.nil? # No errors -- we just don't have a template. @@ -72,28 +96,6 @@ def enforce_uniqueness? def record_template_use(template, resource) template&.record_template_use(TagLayout.find(resource.id).plate, enforce_uniqueness?) end - - # Override the default behaviour for a JSONAPI::Processor when creating a new resource. - # We need to check whether a template UUID was given, and, if so, copy its data into this - # new TagLayoutResource. The creation will be prevented if any data from the template is also - # included in the create request as additional values. e.g. a request has a template UUID and - # also specifies a direction or a tag_group. In this case, the error will indicate that the - # template UUID was not an allowed attribute. - def create_resource - errors = [] - template = find_template { |new_errors| errors += new_errors } - merge_template_data(template) { |new_errors| errors += new_errors } unless template.nil? - - return JSONAPI::ErrorsOperationResult.new(JSONAPI::BAD_REQUEST, errors) unless errors.empty? - - # Perform the usual create actions. - resource = TagLayoutResource.create(context) - result = resource.replace_fields(params[:data]) - - record_template_use(template, resource) - - JSONAPI::ResourceOperationResult.new((result == :completed ? :created : :accepted), resource) - end end end end diff --git a/app/controllers/samples_controller.rb b/app/controllers/samples_controller.rb index 2e21f2782e..a9aa814e8f 100644 --- a/app/controllers/samples_controller.rb +++ b/app/controllers/samples_controller.rb @@ -248,6 +248,7 @@ def default_permitted_metadata_fields subject treatment donor_id + huMFre_code ] } end diff --git a/app/models/api/sample_io.rb b/app/models/api/sample_io.rb index 5acaac3742..b54b90443a 100644 --- a/app/models/api/sample_io.rb +++ b/app/models/api/sample_io.rb @@ -98,6 +98,7 @@ def json_root map_attribute_to_json_attribute(:treatment) map_attribute_to_json_attribute(:date_of_consent_withdrawn) map_attribute_to_json_attribute(:user_id_of_consent_withdrawn, 'marked_as_consent_withdrawn_by') + map_attribute_to_json_attribute(:huMFre_code) end extra_json_attributes do |_object, json_attributes| diff --git a/app/models/order_template.rb b/app/models/order_template.rb index 17d4c6d0d4..215acc3a1d 100644 --- a/app/models/order_template.rb +++ b/app/models/order_template.rb @@ -3,3 +3,6 @@ # If we remove this, then we break our API endpoints. Some of which, at least at one point, actually had # external users. OrderTemplate = SubmissionTemplate + +# TODO: {API v1 removal} this alias was created for v1 and should not be in v2. When v1 is removed, we should try +# to remove this alias as well. diff --git a/app/models/sample.rb b/app/models/sample.rb index 105b86ef96..1dc5ee358b 100644 --- a/app/models/sample.rb +++ b/app/models/sample.rb @@ -147,6 +147,8 @@ class Current < ActiveSupport::CurrentAttributes custom_attribute(:date_of_consent_withdrawn) custom_attribute(:user_id_of_consent_withdrawn) + custom_attribute(:huMFre_code) + # These fields are warehoused, so need to match the encoding restrictions there # This excludes supplementary characters, which include emoji and rare kanji # @note phenotype isn't currently broadcast but has a field waiting in the warehouse @@ -226,6 +228,17 @@ class Current < ActiveSupport::CurrentAttributes # Sample::Metadata tracks sample information, either for use in the lab, or passing to # the EBI class Metadata + # HuMFre numbers contain tissue information, which is only relevant for human samples. + validates :huMFre_code, + length: { + maximum: 16 + }, + format: { + with: %r{\A(?:\d{2}/\d{2,}|\d{2}/\d{4}-\d{3})\z}, + message: 'must match a valid format, e.g. 12/34 or 12/2023-001' + }, + allow_blank: true + # This constraint doesn't match that described in the manifest, and is more permissive # It was added on conversion of out database to utf8 to address concerns that this would # lead to an increase in characters that their pipeline cannot process. Only a handful diff --git a/app/resources/api/v2/base_resource.rb b/app/resources/api/v2/base_resource.rb index 0a9dbd2495..a713b3dfaa 100644 --- a/app/resources/api/v2/base_resource.rb +++ b/app/resources/api/v2/base_resource.rb @@ -17,6 +17,7 @@ class BaseResource < JSONAPI::Resource abstract # Loaded on the base class so that they can be loaded globally. + Order.descendants.each { |subclass| model_hint model: subclass, resource: :order } Purpose.descendants.each { |subclass| model_hint model: subclass, resource: :purpose } Plate.descendants.each { |subclass| model_hint model: subclass, resource: :plate } Tube.descendants.each { |subclass| model_hint model: subclass, resource: :tube } diff --git a/app/resources/api/v2/order_resource.rb b/app/resources/api/v2/order_resource.rb index e06622ea27..52859bd4e8 100644 --- a/app/resources/api/v2/order_resource.rb +++ b/app/resources/api/v2/order_resource.rb @@ -2,37 +2,135 @@ module Api module V2 - # @todo This documentation does not yet include a detailed description of what this resource represents. - # @todo This documentation does not yet include detailed descriptions for relationships, attributes and filters. - # @todo This documentation does not yet include any example usage of the API via cURL or similar. + # Provides a JSON:API representation of {Order} which are used as the main means of requesting work. + # Creation of this resource via a `POST` request which must include a {#submission_template_uuid=} and a + # {#submission_template_attributes=} hash. # - # @note Access this resource via the `/api/v2/orders/` endpoint. + # @note This resource cannot be modified after creation: its endpoint will not accept `PATCH` requests. + # @note Access this resource via the `/api/v2/tube_from_tube_creations/` endpoint. # - # Provides a JSON:API representation of {Order}. + # @example POST request with child purpose and parent tube specified by UUID (deprecated) + # POST /api/v2/tube_from_tube_creations/ + # { + # "data": { + # "type": "tube_from_tube_creations", + # "attributes": { + # "child_purpose_uuid": "11111111-2222-3333-4444-555555666666", + # "parent_uuid": "33333333-4444-5555-6666-777777888888", + # "user_uuid": "99999999-0000-1111-2222-333333444444" + # } + # } + # } + # + # @example POST request with child purpose and parent tube specified by relationship + # POST /api/v2/tube_from_tube_creations/ + # { + # "data": { + # "type": "tube_from_tube_creations", + # "attributes": {}, + # "relationships": { + # "child_purpose": { + # "data": { "type": "tube_purposes", "id": "123" } + # }, + # "parent": { + # "data": { "type": "tubes", "id": "456" } + # }, + # "user": { + # "data": { "type": "users", "id": "789" } + # } + # } + # } + # } + # + # @example GET request for all TubeFromTubeCreation resources + # GET /api/v2/tube_from_tube_creations/ + # + # @example GET request for a TubeFromTubeCreation with ID 123 + # GET /api/v2/tube_from_tube_creations/123/ # # For more information about JSON:API see the [JSON:API Specifications](https://jsonapi.org/format/) # or look at the [JSONAPI::Resources](http://jsonapi-resources.com/) package for Sequencescape's implementation # of the JSON:API standard. class OrderResource < BaseResource - # Constants... + ### + # Attributes + ### - # model_name / model_hint if required + # @!attribute [r] order_type + # @return [String] the STI type of the {Order}. + attribute :order_type, delegate: :sti_type, readonly: true - default_includes :uuid_object + # @!attribute [r] request_options + # @return [Hash] request options for the {Order}. + # @note These can only be set once upon creation of the {Order}. + attribute :request_options, readonly: true - # Associations: + # @!attribute [r] request_types + # @return [Array] the IDs of types of request in this {Order}. + # @note These can only be set once upon creation of the {Order}. + attribute :request_types, readonly: true # Attributes + # @!attribute [r] uuid + # @return [String] the UUID for this {Order}. attribute :uuid, readonly: true - attribute :request_options, write_once: true - # Filters + ### + # Relationships + ### + + # @!attribute [r] project + # @return [ProjectResource] the project associated with this Order. + # @note This can only be set once upon creation of the Order. + has_one :project, readonly: true + + # @!attribute [r] study + # @return [StudyResource] the study associated with this Order. + # @note This can only be set once upon creation of the {Order}. + has_one :study, readonly: true + + # @!attribute [r] user + # @return [UserResource] the user who created this {Order}. + # @note This can only be set once upon creation of the {Order}. + has_one :user, readonly: true + + ### + # Templated creation + ### + + # These are defined here to assist with the creation of Orders from SubmissionTemplates. + # The values given are not stored in the Order and will be consumed by the OrderProcessor. + + # @!attribute [w] submission_template_uuid + # The UUID of the {SubmissionTemplate} to use when creating this {Order}. + # @note This is mandatory when creating new {Order}s via the API. It is not stored. + attribute :submission_template_uuid, writeonly: true + attr_writer :submission_template_uuid # Do not store this on the model. It's consumed by the OrderProcessor. + + # @!attribute [w] submission_template_attributes + # A hash of additional attributes to use when creating this {Order} from a given {SubmissionTemplate}. + # The structure of this hash is as follows: + # + # ```json + # { + # "asset_uuids": [String], + # "autodetect_projects": Boolean, // optional + # "autodetect_studies": Boolean, // optional + # "request_options": Hash, + # "user_uuid": String + # } + # ``` + # + # @note This is mandatory when creating new {Order}s via the API. It is not stored. + attribute :submission_template_attributes, writeonly: true + attr_writer :submission_template_attributes # Do not store this on the model. It's consumed by the OrderProcessor. - # Custom methods - # These shouldn't be used for business logic, and a more about - # I/O and isolating implementation details. + def self.create(context) + return super if context[:template].nil? - # Class method overrides + order = context[:template].create_order!(context[:template_attributes]) + new(order, context) + end end end end diff --git a/app/uat_actions/uat_actions/generate_plate_concentrations.rb b/app/uat_actions/uat_actions/generate_plate_concentrations.rb index ac97aacb5a..aaf04ab299 100644 --- a/app/uat_actions/uat_actions/generate_plate_concentrations.rb +++ b/app/uat_actions/uat_actions/generate_plate_concentrations.rb @@ -48,7 +48,7 @@ def self.default validates :concentration_units, presence: { message: 'needs a choice' } validates :minimum_concentration, numericality: { only_integer: false } validates :maximum_concentration, numericality: { greater_than: 0, only_integer: false } - validate :maximum_greater_than_minimum + validate :maximum_greater_than_or_equal_to_minimum def perform qc_assay_results = construct_qc_assay @@ -58,10 +58,10 @@ def perform private - def maximum_greater_than_minimum - return true if max_conc > min_conc + def maximum_greater_than_or_equal_to_minimum + return true if max_conc >= min_conc - errors.add(:maximum_concentration, 'needs to be greater than minimum concentration') + errors.add(:maximum_concentration, 'needs to be greater than or equal to minimum concentration') false end diff --git a/app/uat_actions/uat_actions/generate_plate_volumes.rb b/app/uat_actions/uat_actions/generate_plate_volumes.rb new file mode 100644 index 0000000000..ddb1033609 --- /dev/null +++ b/app/uat_actions/uat_actions/generate_plate_volumes.rb @@ -0,0 +1,101 @@ +# frozen_string_literal: true + +# Will generate volumes for a given plate +class UatActions::GeneratePlateVolumes < UatActions + self.title = 'Generate volumes for a plate' + self.description = 'Generate a set of randomised volumes for a plate.' + self.category = :quality_control + + form_field :plate_barcode, + :text_field, + label: 'Plate barcode', + help: + 'Enter the barcode of the plate for which you want to add volumes. ' \ + 'NB. only well containing aliquots will have volumes set.' + form_field :minimum_volume, + :number_field, + label: 'Minimum volume (µl)', + help: 'The minimum volume the wells should have.', + options: { + minimum: 0 + } + form_field :maximum_volume, + :number_field, + label: 'Maximum volume (µl)', + help: 'The maximum volume the wells should have.', + options: { + minimum: 0 + } + + # + # Returns a default copy of the UatAction which will be used to fill in the form, with values + # for the units, and min and max volumes. + # + # @return [UatActions::GeneratePlateVolumes] A default object for rendering a form + def self.default + new(minimum_volume: 0, maximum_volume: 100) + end + + validates :plate_barcode, presence: { message: 'could not be found' } + validates :minimum_volume, numericality: { only_integer: false } + validates :maximum_volume, numericality: { greater_than: 0, only_integer: false } + validate :maximum_greater_than_or_equal_to_minimum + + def perform + qc_assay_results = construct_qc_assay + report['number_well_volumes_written'] = qc_assay_results[:num_wells_written] + qc_assay_results[:qc_assay_success] + end + + private + + def maximum_greater_than_or_equal_to_minimum + return true if max_vol >= min_vol + + errors.add(:maximum_volume, 'needs to be greater than or equal to minimum volume') + false + end + + def labware + @labware ||= Plate.find_by_barcode(plate_barcode.strip) + end + + def key + @key ||= 'volume' + end + + def min_vol + @min_vol ||= minimum_volume.to_f + end + + def max_vol + @max_vol ||= maximum_volume.to_f + end + + def create_random_volume + value = (rand * (max_vol - min_vol)) + min_vol + format('%.3f', value) + end + + def construct_qc_assay + qc_assay = QcAssay.new + num_wells_written = 0 + + labware.wells.each do |well| + next if well.aliquots.empty? + + QcResult.create!( + asset: well, + key: key, + value: create_random_volume, + units: 'µl', + assay_type: 'UAT_Testing', + assay_version: 'UAT_version', + qc_assay: qc_assay + ) + num_wells_written += 1 + end + qc_assay_success = qc_assay.save + { qc_assay_success:, num_wells_written: } + end +end diff --git a/app/views/batches/_cherrypick_single_worksheet.html.erb b/app/views/batches/_cherrypick_single_worksheet.html.erb index 91e04b260e..6bb66c2c36 100644 --- a/app/views/batches/_cherrypick_single_worksheet.html.erb +++ b/app/views/batches/_cherrypick_single_worksheet.html.erb @@ -92,6 +92,7 @@ diff --git a/app/views/shared/metadata/edit/_sample.html.erb b/app/views/shared/metadata/edit/_sample.html.erb index 999a30919e..02f1f264fc 100644 --- a/app/views/shared/metadata/edit/_sample.html.erb +++ b/app/views/shared/metadata/edit/_sample.html.erb @@ -80,6 +80,8 @@ <%= metadata_fields.text_field(:disease) %> <%= metadata_fields.text_field(:treatment)%> + <%= metadata_fields.text_field(:huMFre_code)%> + <% metadata_fields.finalize_related_fields %> <% end %> <% end %> diff --git a/app/views/shared/metadata/show/_sample.html.erb b/app/views/shared/metadata/show/_sample.html.erb index 23f5aa732f..d75a44439e 100644 --- a/app/views/shared/metadata/show/_sample.html.erb +++ b/app/views/shared/metadata/show/_sample.html.erb @@ -64,5 +64,7 @@ <%= metadata_fields.plain_value(:disease) %> <%= metadata_fields.plain_value(:treatment)%> + <%= metadata_fields.plain_value(:huMFre_code)%> + <% end %> <% end %> diff --git a/config/locales/metadata/en.yml b/config/locales/metadata/en.yml index 2abb785013..db6ed0d4de 100644 --- a/config/locales/metadata/en.yml +++ b/config/locales/metadata/en.yml @@ -325,6 +325,8 @@ en: edit_info: "EGA" genome_size: label: Genome Size + huMFre_code: + label: HuMFre Code study: metadata: diff --git a/config/routes.rb b/config/routes.rb index 2be4715c9e..3a2223498c 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -34,7 +34,7 @@ jsonapi_resources :lanes jsonapi_resources :lot_types jsonapi_resources :lots - jsonapi_resources :orders + jsonapi_resources :orders, except: %i[update] jsonapi_resources :pick_lists jsonapi_resources :plate_conversions, except: %i[update] jsonapi_resources :plate_creations, except: %i[update] diff --git a/db/migrate/20241211143636_add_hu_m_fre_code_to_sample_metadata.rb b/db/migrate/20241211143636_add_hu_m_fre_code_to_sample_metadata.rb new file mode 100644 index 0000000000..d16094607d --- /dev/null +++ b/db/migrate/20241211143636_add_hu_m_fre_code_to_sample_metadata.rb @@ -0,0 +1,6 @@ +# frozen_string_literal: true +class AddHuMFreCodeToSampleMetadata < ActiveRecord::Migration[7.0] + def change + add_column :sample_metadata, :huMFre_code, :string, limit: 16 + end +end diff --git a/db/schema.rb b/db/schema.rb index 11da36d9d8..187e3c4e3e 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.0].define(version: 2024_11_06_103710) do +ActiveRecord::Schema[7.0].define(version: 2024_12_11_143636) do create_table "aliquot_indices", id: :integer, charset: "utf8mb4", collation: "utf8mb4_unicode_ci", options: "ENGINE=InnoDB ROW_FORMAT=DYNAMIC", force: :cascade do |t| t.integer "aliquot_id", null: false t.integer "lane_id", null: false @@ -1395,6 +1395,7 @@ t.integer "user_id_of_consent_withdrawn" t.boolean "consent_withdrawn", default: false, null: false t.string "collected_by", comment: "Name of persons or institute who collected the specimen" + t.string "huMFre_code", limit: 16 t.index ["sample_ebi_accession_number"], name: "index_sample_metadata_on_sample_ebi_accession_number" t.index ["sample_id"], name: "index_sample_metadata_on_sample_id" t.index ["supplier_name"], name: "index_sample_metadata_on_supplier_name" diff --git a/features/samples/xml_interface.feature b/features/samples/xml_interface.feature index 26f533159e..960ec1e0d4 100644 --- a/features/samples/xml_interface.feature +++ b/features/samples/xml_interface.feature @@ -68,6 +68,7 @@ Feature: The XML interface to the samples Time Point Donor Id Collected By + HuMFre Code """ diff --git a/package.json b/package.json index 7f11641e6b..4bf23961ea 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "dependencies": { "@fortawesome/fontawesome-free": "^6.5.2", - "@rails/ujs": "^7.1.3", + "@rails/ujs": "^7.1.501", "@ttskch/select2-bootstrap4-theme": "^1.5.2", "bootstrap": "^4.6.2", "css-loader": "^6.0.0", @@ -16,7 +16,7 @@ "jsbarcode": "^3.11.6", "jszip": "^3.10.1", "popper.js": "^1.16.1", - "postcss": "^8.4.39", + "postcss": "^8.4.49", "postcss-flexbugs-fixes": "^5.0.2", "postcss-import": "^14.1.0", "postcss-preset-env": "^7.8.3", diff --git a/spec/factories/submission_factories.rb b/spec/factories/submission_factories.rb index dfb79715f1..62d56d9dcb 100644 --- a/spec/factories/submission_factories.rb +++ b/spec/factories/submission_factories.rb @@ -12,13 +12,20 @@ factory :submission_template do transient do - request_type_ids_list { request_types.map { |rt| [rt.id] } } + project { nil } + study { nil } request_types { [] } end submission_class_name { LinearSubmission.name } sequence(:name) { |i| "Template #{i}" } - submission_parameters { { request_type_ids_list: } } + submission_parameters do + { + request_type_ids_list: request_types.map { |rt| [rt.id] }, + project_id: project&.id, + study_id: study&.id + }.compact + end product_catalogue { |pc| pc.association(:single_product_catalogue) } factory :cherrypick_submission_template do diff --git a/spec/features/patient_consent_withdrawl_spec.rb b/spec/features/patient_consent_withdrawl_spec.rb index 027e1eebf6..ee860f1931 100644 --- a/spec/features/patient_consent_withdrawl_spec.rb +++ b/spec/features/patient_consent_withdrawl_spec.rb @@ -55,7 +55,7 @@ context 'an order' do # Lifted straight from the feature test with minimal rspecification # and optimization - let(:submission_template) { create(:submission_template, request_type_ids_list: [[create(:request_type).id]]) } + let(:submission_template) { create(:submission_template, request_types: [create(:request_type)]) } let(:sample_tube) { create(:sample_tube, sample:) } let(:asset_group) { create(:asset_group, assets: [sample_tube.receptacle]) } diff --git a/spec/models/api/sample_io_spec.rb b/spec/models/api/sample_io_spec.rb index 3a24a4e9c7..a219f9150e 100644 --- a/spec/models/api/sample_io_spec.rb +++ b/spec/models/api/sample_io_spec.rb @@ -62,7 +62,8 @@ sample_strain_att: 'stuff about strain', consent_withdrawn: false, donor_id: 2, - developmental_stage: 'thing' + developmental_stage: 'thing', + huMFre_code: '23/21' } ) end @@ -133,7 +134,8 @@ 'developmental_stage' => 'thing', 'donor_id' => '2', 'reference_genome' => 'ReferenceGenome1', - 'component_sample_uuids' => [{ uuid: comp_sample1.uuid }, { uuid: comp_sample2.uuid }] + 'component_sample_uuids' => [{ uuid: comp_sample1.uuid }, { uuid: comp_sample2.uuid }], + 'huMFre_code' => '23/21' } end diff --git a/spec/models/sample_spec.rb b/spec/models/sample_spec.rb index daa588a53d..838ff5f62c 100644 --- a/spec/models/sample_spec.rb +++ b/spec/models/sample_spec.rb @@ -259,6 +259,30 @@ end end + context 'huMFre code' do + let(:sample) { create(:sample) } + + it 'defaults to null when not specified' do + expect(sample.sample_metadata.huMFre_code).to be_nil + end + + it 'fails to update/save when huMFre code value is invalid' do + expect(sample.sample_metadata.huMFre_code).to be_nil + sample.sample_metadata.huMFre_code = 'humFre1' + expect(sample.sample_metadata.save).to be false + end + + it 'contains huMFre code value when it is correctly specified' do + sample.sample_metadata.update!(huMFre_code: '12/12') + expect(sample.sample_metadata.huMFre_code).to eq '12/12' + end + + it 'can have the huMFre code blanked' do + sample.sample_metadata.update!(huMFre_code: nil) + expect(sample.sample_metadata.huMFre_code).to be_nil + end + end + context '(DPL-148) on updating sample metadata' do let(:sample) { create(:sample) } diff --git a/spec/requests/api/v2/orders_spec.rb b/spec/requests/api/v2/orders_spec.rb index 0faeec1378..fcda248506 100644 --- a/spec/requests/api/v2/orders_spec.rb +++ b/spec/requests/api/v2/orders_spec.rb @@ -2,56 +2,636 @@ require 'rails_helper' require './spec/requests/api/v2/shared_examples/api_key_authenticatable' +require './spec/requests/api/v2/shared_examples/requests' describe 'Orders API', with: :api_v2 do - let(:base_endpoint) { '/api/v2/orders' } + let(:model_class) { Order } + let(:base_endpoint) { "/api/v2/#{resource_type}" } + let(:resource_type) { model_class.name.demodulize.pluralize.underscore } it_behaves_like 'ApiKeyAuthenticatable' - context 'with multiple orders' do - before { create_list(:order, 5) } + context 'with a list of resources' do + let(:resource_count) { 5 } - it 'sends a list of orders' do - api_get base_endpoint + before { create_list(:order, resource_count) } - # test for the 200 status-code - expect(response).to have_http_status(:success) + describe '#GET all resources' do + before { api_get base_endpoint } - # check to make sure the right amount of messages are returned - expect(json['data'].length).to eq(5) + it 'responds with a success http code' do + expect(response).to have_http_status(:success) + end + + it 'returns all the resources' do + expect(json['data'].length).to eq(resource_count) + end end + end + + context 'with a single resource' do + describe '#GET resource by ID' do + let(:resource) { create(:order) } + + context 'without included relationships' do + before { api_get "#{base_endpoint}/#{resource.id}" } + + it 'responds with a success http code' do + expect(response).to have_http_status(:success) + end + + it 'returns the resource with the correct id' do + expect(json.dig('data', 'id')).to eq(resource.id.to_s) + end + + it 'returns the resource with the correct type' do + expect(json.dig('data', 'type')).to eq(resource_type) + end + + it 'returns the correct value for the order_type attributes' do + expect(json.dig('data', 'attributes', 'order_type')).to eq(resource.sti_type) + end + + it 'returns the correct value for the request_options attributes' do + expect(json.dig('data', 'attributes', 'request_options')).to eq(resource.request_options) + end + + it 'returns the correct value for the request_types attributes' do + expect(json.dig('data', 'attributes', 'request_types')).to eq(resource.request_types) + end + + it 'returns the correct value for the uuid attributes' do + expect(json.dig('data', 'attributes', 'uuid')).to eq(resource.uuid) + end + + it 'excludes the unfetchable submission_template_uuid' do + expect(json.dig('data', 'attributes', 'submission_template_uuid')).not_to be_present + end + + it 'excludes the unfetchable submission_template_attributes' do + expect(json.dig('data', 'attributes', 'submission_template_attributes')).not_to be_present + end + + it 'returns a reference to the project relationship' do + expect(json.dig('data', 'relationships', 'project')).to be_present + end + + it 'returns a reference to the study relationship' do + expect(json.dig('data', 'relationships', 'study')).to be_present + end - # Check filters, ESPECIALLY if they aren't simple attribute filters + it 'returns a reference to the user relationship' do + expect(json.dig('data', 'relationships', 'user')).to be_present + end + + it 'does not include attributes for related resources' do + expect(json['included']).not_to be_present + end + end + + context 'with included relationships' do + it_behaves_like 'a GET request including a has_one relationship', 'project' + it_behaves_like 'a GET request including a has_one relationship', 'study' + it_behaves_like 'a GET request including a has_one relationship', 'user' + end + end end - context 'with a order' do + describe '#PATCH a resource' do let(:resource_model) { create(:order) } - + let(:purpose) { create(:tube_purpose) } let(:payload) do - { - 'data' => { - 'id' => resource_model.id, - 'type' => 'orders', - 'attributes' => { - # Set new attributes + { data: { id: resource_model.id, type: resource_type, attributes: { child_purpose_uuid: [purpose.uuid] } } } + end + + it 'finds no route for the method' do + expect { api_patch "#{base_endpoint}/#{resource_model.id}", payload }.to raise_error( + ActionController::RoutingError + ) + end + end + + describe '#POST a create request' do + let(:project) { create(:project) } + let(:study) { create(:study) } + let(:request_types) { create_list(:request_type, 1, asset_type: 'Well') } + let(:template) { create(:submission_template, request_types:, project:, study:) } + let(:assets) { create(:plate_with_tagged_wells).wells[0..2] } + let(:user) { create(:user) } + + context 'with a valid payload' do + shared_examples 'a valid request' do + before { api_post base_endpoint, payload } + + it 'creates a new resource' do + expect { api_post base_endpoint, payload }.to change(model_class, :count).by(1) + end + + it 'responds with success' do + expect(response).to have_http_status(:success) + end + + it 'returns the resource with the correct type' do + expect(json.dig('data', 'type')).to eq(resource_type) + end + + it 'returns the correct value for the order_type attributes' do + new_record = model_class.last + expect(json.dig('data', 'attributes', 'order_type')).to eq(new_record.sti_type) + end + + it 'returns the correct value for the request_options attributes' do + new_record = model_class.last + expect(json.dig('data', 'attributes', 'request_options')).to eq(new_record.request_options) + end + + it 'returns the correct value for the request_types attributes' do + new_record = model_class.last + expect(json.dig('data', 'attributes', 'request_types')).to eq(new_record.request_types) + end + + it 'returns the correct value for the uuid attributes' do + new_record = model_class.last + expect(json.dig('data', 'attributes', 'uuid')).to eq(new_record.uuid) + end + + it 'excludes the unfetchable submission_template_uuid' do + expect(json.dig('data', 'attributes', 'submission_template_uuid')).not_to be_present + end + + it 'excludes the unfetchable submission_template_attributes' do + expect(json.dig('data', 'attributes', 'submission_template_attributes')).not_to be_present + end + + it 'returns a reference to the project relationship' do + expect(json.dig('data', 'relationships', 'project')).to be_present + end + + it 'returns a reference to the study relationship' do + expect(json.dig('data', 'relationships', 'study')).to be_present + end + + it 'returns a reference to the user relationship' do + expect(json.dig('data', 'relationships', 'user')).to be_present + end + + it "associates the template's project with the new record" do + new_record = model_class.last + expect(new_record.project).to eq(project) + end + + it "associates the template's study with the new record" do + new_record = model_class.last + expect(new_record.study).to eq(study) + end + + it 'associates the user with the new record' do + new_record = model_class.last + expect(new_record.user).to eq(user) + end + end + + context 'with complete attributes' do + let(:payload) do + { + data: { + type: resource_type, + attributes: { + submission_template_uuid: template.uuid, + submission_template_attributes: { + asset_uuids: assets.map(&:uuid), + autodetect_projects: false, + autodetect_studies: false, + request_options: { + library_type: 'Chromium single cell 3 prime v3', + fragment_size_required_from: '200', + fragment_size_required_to: '800' + }, + user_uuid: user.uuid + } + } + } + } + end + + it_behaves_like 'a valid request' + end + + context 'with minimal attributes' do + let(:payload) do + { + data: { + type: resource_type, + attributes: { + submission_template_uuid: template.uuid, + submission_template_attributes: { + asset_uuids: assets.map(&:uuid), + request_options: { + no_options: true + }, + user_uuid: user.uuid + } + } + } + } + end + + it_behaves_like 'a valid request' + end + end + + describe 'with the project specified by the template' do + let(:template) { create(:submission_template, request_types:, project:, study:) } + let(:autodetect_projects) { false } + let(:payload) do + { + data: { + type: resource_type, + attributes: { + submission_template_uuid: template.uuid, + submission_template_attributes: { + asset_uuids: assets.map(&:uuid), + autodetect_projects: autodetect_projects, + request_options: { + no_options: true + }, + user_uuid: user.uuid + } + } + } + } + end + + before { api_post base_endpoint, payload } + + context 'when autocomplete is false' do + let(:autodetect_projects) { false } + + it "associates the template's project with the new record" do + new_record = model_class.last + expect(new_record.project).to eq(project) + end + end + + context 'when autocomplete is true' do + let(:autodetect_projects) { true } + + it "associates the template's project with the new record" do + new_record = model_class.last + expect(new_record.project).to eq(project) + end + end + end + + describe 'without the project specified by the template' do + let(:template) { create(:submission_template, request_types:, study:) } + let(:autodetect_projects) { false } + let(:payload) do + { + data: { + type: resource_type, + attributes: { + submission_template_uuid: template.uuid, + submission_template_attributes: { + asset_uuids: assets.map(&:uuid), + autodetect_projects: autodetect_projects, + request_options: { + no_options: true + }, + user_uuid: user.uuid + } + } + } + } + end + + before { api_post base_endpoint, payload } + + context 'when autocomplete is false' do + let(:autodetect_projects) { false } + + it 'fails validation on the model' do + # Not ideal, but we haven't implemented handling of validation errors for Order from the model via this + # processor. + expect(json.dig('errors', 0, 'meta', 'exception')).to include("Project can't be blank") + end + end + + context 'when autocomplete is true' do + let(:autodetect_projects) { true } + + it 'associates the unique project from assets with the new record' do + new_record = model_class.last + expect(new_record.project).to eq(assets.first.projects.first) + end + end + end + + describe 'with the study specified by the template' do + let(:template) { create(:submission_template, request_types:, project:, study:) } + let(:autodetect_studies) { false } + let(:payload) do + { + data: { + type: resource_type, + attributes: { + submission_template_uuid: template.uuid, + submission_template_attributes: { + asset_uuids: assets.map(&:uuid), + autodetect_studies: autodetect_studies, + request_options: { + no_options: true + }, + user_uuid: user.uuid + } + } } } - } + end + + before { api_post base_endpoint, payload } + + context 'when autocomplete is false' do + let(:autodetect_studies) { false } + + it "associates the template's study with the new record" do + new_record = model_class.last + expect(new_record.study).to eq(study) + end + end + + context 'when autocomplete is true' do + let(:autodetect_studies) { true } + + it "associates the template's study with the new record" do + new_record = model_class.last + expect(new_record.study).to eq(study) + end + end + end + + describe 'without the study specified by the template' do + let(:template) { create(:submission_template, request_types:, project:) } + let(:autodetect_studies) { false } + let(:payload) do + { + data: { + type: resource_type, + attributes: { + submission_template_uuid: template.uuid, + submission_template_attributes: { + asset_uuids: assets.map(&:uuid), + autodetect_studies: autodetect_studies, + request_options: { + no_options: true + }, + user_uuid: user.uuid + } + } + } + } + end + + before { api_post base_endpoint, payload } + + context 'when autocomplete is false' do + let(:autodetect_studies) { false } + + it 'fails validation on the model' do + # Not ideal, but we haven't implemented handling of validation errors for Order from the model via this + # processor. + expect(json.dig('errors', 0, 'meta', 'exception')).to include("Study can't be blank") + end + end + + context 'when autocomplete is true' do + let(:autodetect_studies) { true } + + it 'associates the unique study from assets with the new record' do + new_record = model_class.last + expect(new_record.study).to eq(assets.first.studies.first) + end + end + end + + context 'with a read-only attribute in the payload' do + context 'with order_type' do + let(:disallowed_value) { 'order_type' } + let(:payload) { { data: { type: resource_type, attributes: { order_type: 'read-only' } } } } + + it_behaves_like 'a POST request with a disallowed value' + end + + context 'with request_options' do + let(:disallowed_value) { 'request_options' } + let(:payload) { { data: { type: resource_type, attributes: { request_options: 'read-only' } } } } + + it_behaves_like 'a POST request with a disallowed value' + end + + context 'with request_types' do + let(:disallowed_value) { 'request_types' } + let(:payload) { { data: { type: resource_type, attributes: { request_types: 'read-only' } } } } + + it_behaves_like 'a POST request with a disallowed value' + end + + context 'with uuid' do + let(:disallowed_value) { 'uuid' } + let(:payload) { { data: { type: resource_type, attributes: { uuid: 'read-only' } } } } + + it_behaves_like 'a POST request with a disallowed value' + end end - it 'sends an individual order' do - api_get "#{base_endpoint}/#{resource_model.id}" - expect(response).to have_http_status(:success) - expect(json.dig('data', 'type')).to eq('orders') + context 'with a read-only relationship in the payload' do + context 'with project' do + let(:disallowed_value) { 'project' } + let(:payload) do + { data: { type: resource_type, relationships: { project: { data: { id: '1', type: 'projects' } } } } } + end + + it_behaves_like 'a POST request with a disallowed value' + end + + context 'with study' do + let(:disallowed_value) { 'study' } + let(:payload) do + { data: { type: resource_type, relationships: { study: { data: { id: '1', type: 'studies' } } } } } + end + + it_behaves_like 'a POST request with a disallowed value' + end + + context 'with user' do + let(:disallowed_value) { 'user' } + let(:payload) do + { data: { type: resource_type, relationships: { user: { data: { id: '1', type: 'users' } } } } } + end + + it_behaves_like 'a POST request with a disallowed value' + end end - # Remove if immutable - it 'allows update of a order' do - api_patch "#{base_endpoint}/#{resource_model.id}", payload - expect(response).to have_http_status(:success) - expect(json.dig('data', 'type')).to eq('orders') - # Double check at least one of the attributes - # eg. expect(json.dig('data', 'attributes', 'state')).to eq('started') + context 'without a required attribute' do + let(:project) { create(:project) } + let(:study) { create(:study) } + let(:request_types) { create_list(:request_type, 1, asset_type: 'Well') } + let(:template) { create(:submission_template, request_types:, project:, study:) } + let(:assets) { create(:plate_with_tagged_wells).wells[0..2] } + let(:user) { create(:user) } + + context 'without a submission_template_uuid' do + let(:payload) do + { + data: { + type: resource_type, + attributes: { + submission_template_attributes: { + asset_uuids: assets.map(&:uuid), + request_options: { + no_options: true + }, + user_uuid: user.uuid + } + } + } + } + end + let(:expected_error_details) do + ['user - must exist', "study - can't be blank", "project - can't be blank", "request_types - can't be blank"] + end + + it 'fails to create the resource' do + # Not providing a submission_template_uuid is a valid use case as we default to the normal JSONAPI::Resources + # behaviour, but it will fail validation on the new Order model as it needs certain attributes set, but + # they're all read-only on the API. + api_post base_endpoint, payload + + expect(json['errors'].pluck('detail')).to match_array(expected_error_details) + end + end + + context 'without submission_template_attributes' do + let(:payload) { { data: { type: resource_type, attributes: { submission_template_uuid: template.uuid } } } } + let(:error_detail_message) { 'The required parameter, submission_template_attributes, is missing.' } + + it_behaves_like 'a bad POST request with a specific error' + end + + context 'without submission_template_attributes.asset_uuids' do + let(:payload) do + { + data: { + type: resource_type, + attributes: { + submission_template_uuid: template.uuid, + submission_template_attributes: { + request_options: { + no_options: true + }, + user_uuid: user.uuid + } + } + } + } + end + let(:error_detail_message) { 'The required parameter, submission_template_attributes.asset_uuids, is missing.' } + + it_behaves_like 'a bad POST request with a specific error' + end + + context 'without submission_template_attributes.request_options' do + let(:payload) do + { + data: { + type: resource_type, + attributes: { + submission_template_uuid: template.uuid, + submission_template_attributes: { + asset_uuids: assets.map(&:uuid), + user_uuid: user.uuid + } + } + } + } + end + let(:error_detail_message) do + 'The required parameter, submission_template_attributes.request_options, is missing.' + end + + it_behaves_like 'a bad POST request with a specific error' + end + + context 'without submission_template_attributes.user_uuid' do + let(:payload) do + { + data: { + type: resource_type, + attributes: { + submission_template_uuid: template.uuid, + submission_template_attributes: { + asset_uuids: assets.map(&:uuid), + request_options: { + no_options: true + } + } + } + } + } + end + let(:error_detail_message) { 'The required parameter, submission_template_attributes.user_uuid, is missing.' } + + it_behaves_like 'a bad POST request with a specific error' + end + end + + context 'with an invalid UUID' do + context 'with an invalid asset_uuid' do + let(:payload) do + { + data: { + type: resource_type, + attributes: { + submission_template_uuid: template.uuid, + submission_template_attributes: { + asset_uuids: ['not-a-valid-uuid'], + request_options: { + no_options: true + }, + user_uuid: user.uuid + } + } + } + } + end + let(:error_detail_message) { 'not-a-valid-uuid is not a valid value for asset_uuids.' } + + it_behaves_like 'a bad POST request with a specific error' + end + + context 'with an invalid user_uuid' do + let(:payload) do + { + data: { + type: resource_type, + attributes: { + submission_template_uuid: template.uuid, + submission_template_attributes: { + asset_uuids: assets.map(&:uuid), + request_options: { + no_options: true + }, + user_uuid: 'not-a-valid-uuid' + } + } + } + } + end + let(:error_detail_message) { 'not-a-valid-uuid is not a valid value for user_uuid.' } + + it_behaves_like 'a bad POST request with a specific error' + end end end end diff --git a/spec/requests/api/v2/shared_examples/requests.rb b/spec/requests/api/v2/shared_examples/requests.rb index 829b65e79c..3e3ec9c9d6 100644 --- a/spec/requests/api/v2/shared_examples/requests.rb +++ b/spec/requests/api/v2/shared_examples/requests.rb @@ -50,6 +50,22 @@ end end +shared_examples 'a bad POST request with a specific error' do + before { api_post base_endpoint, payload } + + it 'does not create a new resource' do + expect { api_post base_endpoint, payload }.not_to change(model_class, :count) + end + + it 'responds with unprocessable_entity' do + expect(response).to have_http_status(:bad_request) + end + + it 'specifies the expected error message' do + expect(json.dig('errors', 0, 'detail')).to eq(error_detail_message) + end +end + shared_examples 'a GET request including a has_one relationship' do |related_name| before { api_get "#{base_endpoint}/#{resource.id}?include=#{related_name}" } diff --git a/spec/resources/api/v2/order_resource_spec.rb b/spec/resources/api/v2/order_resource_spec.rb index 43f815ae86..5428ac9f7e 100644 --- a/spec/resources/api/v2/order_resource_spec.rb +++ b/spec/resources/api/v2/order_resource_spec.rb @@ -8,22 +8,58 @@ let(:resource_model) { build_stubbed(:order) } - # Test attributes - it 'works', :aggregate_failures do # rubocop:todo RSpec/ExampleWording - expect(resource).to have_attribute :uuid - expect(resource).not_to have_updatable_field(:id) - expect(resource).not_to have_updatable_field(:uuid) - end + # Model Name + it { is_expected.to have_model_name 'Order' } + + # Attributes + it { is_expected.to have_readonly_attribute :order_type } + it { is_expected.to have_readonly_attribute :request_options } + it { is_expected.to have_readonly_attribute :request_types } + it { is_expected.to have_readonly_attribute :uuid } + + # Relationships + it { is_expected.to have_a_readonly_has_one(:project).with_class_name('Project') } + it { is_expected.to have_a_readonly_has_one(:study).with_class_name('Study') } + it { is_expected.to have_a_readonly_has_one(:user).with_class_name('User') } + + # Template attributes + it { is_expected.to have_writeonly_attribute :submission_template_uuid } + it { is_expected.to have_writeonly_attribute :submission_template_attributes } + + # Custom methods + describe '#self.create' do + context 'without a template in the context' do + let(:context) { {} } + + it 'returns an OrderResource created by the super class' do + allow(described_class.superclass).to receive(:create).with(context).and_return(resource) - # Updatable fields - # eg. it { is_expected.to have_updatable_field(:state) } + expect(described_class.create(context)).to eq(resource) + end + end - # Filters - # eg. it { is_expected.to filter(:order_type) } + context 'with a template in the context' do + let(:context) { { template:, template_attributes: } } + let(:template) { instance_double(SubmissionTemplate) } + let(:template_attributes) { {} } - # Associations - # eg. it { is_expected.to have_many(:samples).with_class_name('Sample') } + before { allow(template).to receive(:create_order!).with(template_attributes).and_return(resource_model) } - # Custom method tests - # Add tests for any custom methods you've added. + it 'does not call create on the super class' do + allow(described_class.superclass).to receive(:create) + + described_class.create(context) + + expect(described_class.superclass).not_to have_received(:create) + end + + it 'creates a new OrderResource with a new Order created by the SubmissionTemplate' do + allow(described_class).to receive(:new) + + described_class.create(context) + + expect(described_class).to have_received(:new).with(resource_model, context) + end + end + end end diff --git a/spec/uat_actions/generate_plate_concentrations_spec.rb b/spec/uat_actions/generate_plate_concentrations_spec.rb index 0c15ece3cb..ed1bb8d8c4 100644 --- a/spec/uat_actions/generate_plate_concentrations_spec.rb +++ b/spec/uat_actions/generate_plate_concentrations_spec.rb @@ -3,9 +3,36 @@ require 'rails_helper' describe UatActions::GeneratePlateConcentrations do + let(:plate) { create(:plate_with_untagged_wells, sample_count: 3) } + let(:plate_barcode) { plate.barcodes.first.barcode } + let(:uat_action) { described_class.new(parameters) } + let!(:saved_action) { uat_action.save } + + context 'with default options' do + let(:parameters) { {} } + + it 'returns a default' do + expect(described_class.default).to be_a described_class + end + + it 'has a nil plate_barcode' do + expect(described_class.default.plate_barcode).to be_nil + end + + it 'has a minimum_concentration of 0' do + expect(described_class.default.minimum_concentration).to eq 0 + end + + it 'has a maximum_concentration of 100' do + expect(described_class.default.maximum_concentration).to eq 100 + end + + it 'has a concentration_units of ng/ul' do + expect(described_class.default.concentration_units).to eq 'ng/ul' + end + end + context 'with valid options' do - let(:plate) { create(:plate_with_untagged_wells, sample_count: 3) } - let(:uat_action) { described_class.new(parameters) } let(:report) do # A report is a hash of key value pairs which get returned to the user. # It should include information such as barcodes and identifiers @@ -15,15 +42,15 @@ context 'when ng per ul concentrations' do let(:parameters) do { - plate_barcode: plate.barcodes.first.barcode, + plate_barcode: plate_barcode, concentration_units: 'ng/ul', minimum_concentration: 0, maximum_concentration: 30 } end - it 'can be performed' do - expect(uat_action.perform).to be true + it 'can be saved' do + expect(saved_action).to be true expect(uat_action.report).to eq report expect(plate.wells.map(&:qc_results).size).to eq 3 expect(plate.wells.first.qc_results.first.assay_type).to eq 'UAT_Testing' @@ -32,16 +59,11 @@ context 'when nM concentrations' do let(:parameters) do - { - plate_barcode: plate.barcodes.first.barcode, - concentration_units: 'nM', - minimum_concentration: 0, - maximum_concentration: 30 - } + { plate_barcode: plate_barcode, concentration_units: 'nM', minimum_concentration: 0, maximum_concentration: 30 } end - it 'can be performed' do - expect(uat_action.perform).to be true + it 'can be saved' do + expect(saved_action).to be true expect(uat_action.report).to eq report expect(plate.wells.map(&:qc_results).size).to eq 3 expect(plate.wells.first.qc_results.first.assay_type).to eq 'UAT_Testing' @@ -49,7 +71,63 @@ end end - it 'returns a default' do - expect(described_class.default).to be_a described_class + context 'with invalid options' do + let(:parameters) do + { + plate_barcode: plate_barcode, + concentration_units: 'ng/ul', + minimum_concentration: 30, + maximum_concentration: 10 + } + end + + it 'has a minimum_concentration of 30' do + expect(uat_action.minimum_concentration).to eq 30 + end + + it 'has a maximum_concentration of 10' do + expect(uat_action.maximum_concentration).to eq 10 + end + + it 'is invalid' do + expect(uat_action.valid?).to be false + end + + it 'can not be saved' do + expect(saved_action).to be false + end + + it 'adds an error' do + expect(uat_action.errors.full_messages).to include( + 'Maximum concentration needs to be greater than or equal to minimum concentration' + ) + end + end + + context 'with equal minimum and maximum concentrations' do + let(:parameters) do + { + plate_barcode: plate_barcode, + concentration_units: 'ng/ul', + minimum_concentration: 10, + maximum_concentration: 10 + } + end + + it 'can be saved' do + expect(saved_action).to be true + end + + it 'generates the correct report' do + expect(uat_action.report).to eq('number_well_concentrations_written' => 3) + end + + it 'creates the correct number of QC results' do + expect(plate.wells.map(&:qc_results).size).to eq 3 + end + + it 'sets the concentrations to be within the specified range' do + expect(plate.wells.map { |well| well.qc_results.first.value.to_f }).to all(eq 10) + end end end diff --git a/spec/uat_actions/generate_plate_volumes_spec.rb b/spec/uat_actions/generate_plate_volumes_spec.rb new file mode 100644 index 0000000000..c169b8d01f --- /dev/null +++ b/spec/uat_actions/generate_plate_volumes_spec.rb @@ -0,0 +1,107 @@ +# frozen_string_literal: true + +require 'rails_helper' + +describe UatActions::GeneratePlateVolumes do + let(:plate) { create(:plate_with_untagged_wells, sample_count: 3) } + let(:plate_barcode) { plate.barcodes.first.barcode } + let(:uat_action) { described_class.new(parameters) } + let!(:performed_action) { uat_action.perform } + + context 'with valid options' do + let(:report) do + # A report is a hash of key value pairs which get returned to the user. + # It should include information such as barcodes and identifiers + { 'number_well_volumes_written' => 3 } + end + + let(:parameters) { { plate_barcode: plate_barcode, minimum_volume: 0, maximum_volume: 30 } } + + it 'can be performed' do + expect(performed_action).to be true + end + + it 'generates the correct report' do + expect(uat_action.report).to eq report + end + + it 'creates the correct number of QC results' do + expect(plate.wells.map(&:qc_results).size).to eq 3 + end + + it 'sets the correct assay type for the first QC result' do + expect(plate.wells.first.qc_results.first.assay_type).to eq 'UAT_Testing' + end + + it 'sets the volumes to be within the specified range' do + expect(plate.wells.map { |well| well.qc_results.first.value.to_f }).to all(be_between(0, 30)) + end + end + + context 'with default options' do + let(:parameters) { { plate_barcode: } } + + it 'returns an instance of described_class' do + expect(described_class.default).to be_a described_class + end + + it 'has a nil plate_barcode' do + expect(described_class.default.plate_barcode).to be_nil + end + + it 'has a minimum_volume of 0' do + expect(described_class.default.minimum_volume).to eq 0 + end + + it 'has a maximum_volume of 100' do + expect(described_class.default.maximum_volume).to eq 100 + end + end + + context 'with invalid options' do + let(:parameters) { { plate_barcode: plate_barcode, minimum_volume: 110, maximum_volume: 10 } } + let!(:saved_action) { uat_action.save } + + it 'has a minimum_volume of 110' do + expect(uat_action.minimum_volume).to eq 110 + end + + it 'has a maximum_volume of 10' do + expect(uat_action.maximum_volume).to eq 10 + end + + it 'is invalid' do + expect(uat_action.valid?).to be false + end + + it 'can not be saved' do + expect(saved_action).to be false + end + + it 'adds an error' do + expect(uat_action.errors.full_messages).to include( + 'Maximum volume needs to be greater than or equal to minimum volume' + ) + end + end + + context 'with equal minimum and maximum volumes' do + let(:parameters) { { plate_barcode: plate_barcode, minimum_volume: 10, maximum_volume: 10 } } + + it 'can be performed' do + expect(performed_action).to be true + end + + it 'generates the correct report' do + expect(uat_action.report).to eq('number_well_volumes_written' => 3) + end + + it 'creates the correct number of QC results' do + expect(plate.wells.map(&:qc_results).size).to eq 3 + end + + it 'sets the volumes to be within the specified range' do + expect(plate.wells.map { |well| well.qc_results.first.value.to_f }).to all(eq 10) + end + end +end diff --git a/yarn.lock b/yarn.lock index 5f00b9aca4..5c0ed3b6c6 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1911,10 +1911,10 @@ resolved "https://registry.yarnpkg.com/@prettier/plugin-ruby/-/plugin-ruby-4.0.4.tgz#73d85fc2a1731a3f62b57ac3116cf1c234027cb6" integrity sha512-lCpvfS/dQU5WrwN3AQ5vR8qrvj2h5gE41X08NNzAAXvHdM4zwwGRcP2sHSxfu6n6No+ljWCVx95NvJPFTTjCTg== -"@rails/ujs@^7.1.3": - version "7.1.3" - resolved "https://registry.yarnpkg.com/@rails/ujs/-/ujs-7.1.3.tgz#6d94a68b7da5046147d31716e0c187a4ead4fb93" - integrity sha512-FxtgKNvvIonoBE1TK7U10VMf6CYvzq8SIZ1XZ1Q8zcn/BEXzPzid3zC9qFiojuI5WXVwWhO8GFqApq0stD+OqQ== +"@rails/ujs@^7.1.501": + version "7.1.501" + resolved "https://registry.yarnpkg.com/@rails/ujs/-/ujs-7.1.501.tgz#e560a7b6885a12a659c4beb47f4336c8a9353056" + integrity sha512-7EDRGUlgns12IgP3SXVSaxA3CwRzbLOypPXn1EqEZiZ/NS/PwaQ/oa7Z2VRO4B46JifoVr0PYg+G5ERSGQJHxQ== "@rollup/pluginutils@^4.1.1": version "4.1.2" @@ -5769,6 +5769,11 @@ picocolors@^1.0.0, picocolors@^1.0.1: resolved "https://registry.yarnpkg.com/picocolors/-/picocolors-1.0.1.tgz#a8ad579b571952f0e5d25892de5445bcfe25aaa1" integrity sha512-anP1Z8qwhkbmu7MFP5iTt+wQKXgwzf7zTyGlcdzabySa9vd0Xt392U0rVmz9poOaBj0uHJKyyo9/upk0HrEQew== +picocolors@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/picocolors/-/picocolors-1.1.1.tgz#3d321af3eab939b083c8f929a1d12cda81c26b6b" + integrity sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA== + picomatch@^2.0.4, picomatch@^2.2.1, picomatch@^2.2.2, picomatch@^2.2.3: version "2.3.1" resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-2.3.1.tgz#3ba3833733646d9d3e4995946c1365a67fb07a42" @@ -6130,6 +6135,15 @@ postcss@^8.4.33: picocolors "^1.0.0" source-map-js "^1.2.0" +postcss@^8.4.49: + version "8.4.49" + resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.4.49.tgz#4ea479048ab059ab3ae61d082190fabfd994fe19" + integrity sha512-OCVPnIObs4N29kxTjzLfUryOkvZEq+pf8jTF0lg8E7uETuWHA+v7j3c/xJmiqpX450191LlmZfUKkXxkTry7nA== + dependencies: + nanoid "^3.3.7" + picocolors "^1.1.1" + source-map-js "^1.2.1" + prelude-ls@^1.2.1: version "1.2.1" resolved "https://registry.yarnpkg.com/prelude-ls/-/prelude-ls-1.2.1.tgz#debc6489d7a6e6b0e7611888cec880337d316396" @@ -6724,6 +6738,11 @@ sortablejs@^1.15.2: resolved "https://registry.yarnpkg.com/source-map-js/-/source-map-js-1.2.0.tgz#16b809c162517b5b8c3e7dcd315a2a5c2612b2af" integrity sha512-itJW8lvSA0TXEphiRoawsCksnlf8SyvmFzIhltqAHluXd88pkCd+cXJVHTDwdCr0IzwptSm035IHQktUu1QUMg== +source-map-js@^1.2.1: + version "1.2.1" + resolved "https://registry.yarnpkg.com/source-map-js/-/source-map-js-1.2.1.tgz#1ce5650fddd87abc099eda37dcff024c2667ae46" + integrity sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA== + source-map-resolve@^0.5.0, source-map-resolve@^0.5.2: version "0.5.3" resolved "https://registry.yarnpkg.com/source-map-resolve/-/source-map-resolve-0.5.3.tgz#190866bece7553e1f8f267a2ee82c606b5509a1a"