sanger/sequencescape

View on GitHub
app/models/order.rb

Summary

Maintainability
B
5 hrs
Test Coverage
A
91%
# frozen_string_literal: true

# An Order is used as the main means of requesting work in Sequencescape. Its
# key components are:
# Assets/AssetGroup: The assets on which the work will be conducted
# Study: The study for which work is being undertaken
# Project: The project who will be charged for the work
# Request options: The parameters for the request which will be built. eg. read length
# Request Types: An array of request type ids which will be built by the order.
#                This is populated based on the submission template used.
# Submission: Multiple orders may be grouped together in a submission. This
#             associates the two sets of requests, and is usually used to determine
#             what gets pooled together during multiplexing. As a result, sequencing
#             requests may be shared between multiple orders.
class Order < ApplicationRecord # rubocop:todo Metrics/ClassLength
  # Ensure order methods behave correctly
  AssetTypeError = Class.new(StandardError)
  DEFAULT_ASSET_INPUT_METHODS = ['select an asset group'].freeze

  include Uuid::Uuidable
  include Submission::AssetGroupBehaviour
  include Submission::ProjectValidation
  include Submission::RequestOptionsBehaviour
  include Submission::AccessionBehaviour
  include ModelExtensions::Order

  self.inheritance_column = 'sti_type'
  self.per_page = 500

  #  attributes which are not saved for a submission but can be pre-set via SubmissionTemplate
  # return a list of request_types lists  (a sequence of choices) to display in the new view
  attr_writer :request_type_ids_list, :input_field_infos
  attr_writer :asset_input_methods

  # Unused. Maintained because some submission templates attempt to set the info
  attr_writer :info_differential

  # When automating submission creation, it is really useful if we can
  # auto-detect studies and projects based on their aliquots. However we
  # don't want to trigger this behaviour accidentally if someone forgets to
  # specify a study.
  attribute :autodetect_studies, :boolean, default: :autodetection_default
  attribute :autodetect_projects, :boolean, default: :autodetection_default

  # Required at initial construction time ...
  belongs_to :study, optional: true
  belongs_to :project, optional: true
  belongs_to :user, optional: false
  belongs_to :product, optional: true
  belongs_to :order_role, optional: true
  belongs_to :submission, inverse_of: :orders

  # In the case of some cross study/project orders, such as resequencing of
  # mixed pools, there is no study/project on the order itself.
  # In some cases, such as viewing submission, it can be useful to display
  # the associated studies/projects
  has_many :source_asset_studies, -> { distinct }, through: :assets, source: :studies
  has_many :source_asset_projects, -> { distinct }, through: :assets, source: :projects
  has_many :requests, inverse_of: :order, dependent: :restrict_with_exception

  serialize :request_types
  serialize :item_options

  before_validation :set_study_from_aliquots, unless: :cross_study_allowed, if: :autodetect_studies
  before_validation :set_project_from_aliquots, unless: :cross_project_allowed, if: :autodetect_projects

  validates :study, presence: true, unless: :cross_study_allowed
  validates :project, presence: true, unless: :cross_project_allowed
  validates :request_types, presence: true

  validate :study_is_active, on: :create
  validate :assets_are_appropriate
  validate :no_consent_withdrawal

  before_destroy :building_submission?
  after_destroy :on_delete_destroy_submission

  broadcast_with_warren

  scope :include_for_study_view, -> { includes(:submission) }
  scope :containing_samples, ->(samples) { joins(assets: :samples).where(samples: { id: samples }) }
  scope :for_studies, ->(*args) { where(study_id: args) }

  scope :including_associations_for_json,
        lambda {
          includes(
            [:uuid_object, { assets: [:uuid_object] }, { project: :uuid_object }, { study: :uuid_object }, :user]
          )
        }

  delegate :role, to: :order_role, allow_nil: true

  class << self
    alias create_order! create!

    def render_class
      Api::OrderIO
    end
  end

  def autodetection_default
    false
  end

  def complete_building
    check_project_details!
    complete_building_asset_group
  end

  def assets=(assets_to_add)
    super(assets_to_add.map { |a| a.is_a?(Receptacle) ? a : a.receptacle })
  end

  # We can't destroy orders once the submission has been finalized for building
  def building_submission?
    throw :abort unless submission.building?
  end

  def on_delete_destroy_submission
    if building_submission?
      # After destroying an order, if it is the last order on it's submission
      # destroy the submission too.
      orders = submission.orders
      submission.destroy unless orders.size > 1
      return true
    end
    false
  end

  def cross_study_allowed
    false
  end

  def cross_project_allowed
    false
  end

  def cross_compatible?
    false
  end

  # TODO: Figure out why eager loading aliquots/samples returns [] even when
  # we limit order_assets to receptacles.
  def samples
    # naive way
    assets.map(&:samples).flatten.uniq
  end

  def all_samples
    # slightly less naive way
    all_assets.map(&:samples).flatten.uniq
  end

  def all_assets
    pull_assets_from_asset_group if assets.empty? && asset_group.present?
    assets
  end

  def json_root
    'order'
  end

  def asset_uuids
    assets&.select(&:present?)&.map(&:uuid)
  end

  def multiplexed?
    RequestType.where(id: request_types).for_multiplexing.exists?
  end

  def multiplier_for(request_type_id)
    (request_options.dig(:multiplier, request_type_id.to_s) || 1).to_i
  end

  # rubocop:todo Metrics/MethodLength
  def create_request_of_type!(request_type, attributes = {}) # rubocop:todo Metrics/AbcSize
    em = request_type.extract_metadata_from_hash(request_options)
    request_type.create!(attributes) do |request|
      request.submission_id = submission_id
      request.study = study
      request.initial_project = project
      request.user = user
      request.request_metadata_attributes = em
      request.state = request_type.initial_state
      request.order = self

      if request.asset.present?
        unless asset_applicable_to_type?(request_type, request.asset)
          raise AssetTypeError, 'Asset type does not match that expected by request type.'
        end
      end
    end
  end

  # rubocop:enable Metrics/MethodLength

  def duplicates_within(timespan) # rubocop:todo Metrics/AbcSize, Metrics/MethodLength
    matching_orders =
      Order
        .containing_samples(all_samples)
        .where(template_name: template_name)
        .includes(:submission, assets: :samples)
        .where.not(orders: { id: id })
        .where('orders.created_at > ?', Time.current - timespan)
    return false if matching_orders.empty?

    matching_samples = matching_orders.map(&:samples).flatten & all_samples
    matching_submissions = matching_orders.map(&:submission).uniq
    yield matching_samples, matching_orders, matching_submissions if block_given?
    true
  end

  def request_type_ids_list
    @request_type_ids_list ||= [[]]
  end

  def asset_input_methods
    @asset_input_methods ||= DEFAULT_ASSET_INPUT_METHODS
  end

  # request_type_ids_list is set for orders created by submission templates
  # It is used by the input_field_infos section, which controls rendering
  # form fields appropriate to each request type in the submission interface
  # {request_type_ids} is calculated from this in the various sub-classes
  # and gets persisted to the database, and used for the actual construction.
  # TODO: Simplify this
  # - There are a few attributes which all refer to loosely the same thing, a list of request type ids:
  #   * request_type_ids_list - Set by submission templates, but also recalculated on the fly and used in various
  #                             methods
  #   * request_types_ids - Setter on order subclasses.
  #   * request_types - Serialized version on order, persisted in the database
  # - The request_types on the database should become the authoritative source.
  # - request_type_ids_list should just be a setter, which populates request_types.
  #   It may need to transform the input slightly. Ideally we eliminate this entirely, and be consistent between
  #   templates and orders
  # - There appear to be several methods which essentially do the same thing. They should be unified.
  # - I'm not even 100% how request_types_ids factors in.
  def request_types_list
    request_type_ids_list.map { |ids| RequestType.find(ids) }
  end

  def first_request_type
    @first_request_type ||= RequestType.find(request_types.first)
  end

  # Return the list of input fields to edit when creating a new submission
  # Unless you are doing something fancy, fall back on the defaults
  def input_field_infos
    @input_field_infos ||= FieldInfo.for_request_types(request_types_list.flatten)
  end

  def next_request_type_id(request_type_id)
    request_type_ids = request_types.map(&:to_i)
    request_type_ids[request_type_ids.index(request_type_id) + 1]
  end

  # Are we still able to modify this instance?
  def building?
    submission.nil?
  end

  # Returns true if this is an order for sequencing
  def sequencing_order?
    RequestType.find(request_types).any?(&:sequencing?)
  end

  def collect_gigabases_expected?
    input_field_infos.any? { |k| k.key == :gigabases_expected }
  end

  def add_comment(comment_str, user)
    update!(comments: [comments, comment_str].compact.join('; '))

    submission
      .requests
      .for_order_including_submission_based_requests(self)
      .map { |request| request.add_comment(comment_str, user) }
  end

  def friendly_name
    asset_group.try(:name) || asset_group_name || id
  end

  def subject_type
    'order'
  end

  def generate_broadcast_event
    BroadcastEvent::OrderMade.create!(seed: self, user: user)
  end

  def study_is_active
    errors.add(:study, 'is not active') if study.present? && !study.active?
  end

  # returns an array of samples, that potentially can not be included in submission
  def not_ready_samples
    all_samples.reject(&:can_be_included_in_submission?)
  end

  private

  def asset_applicable_to_type?(request_type, asset)
    request_type.asset_type == asset.asset_type_for_request_types.name
  end

  def no_consent_withdrawal
    return true unless all_samples.any?(&:consent_withdrawn?)

    withdrawn_samples = all_samples.select(&:consent_withdrawn?).map(&:friendly_name)
    errors.add(:samples, "in this submission have had patient consent withdrawn: #{withdrawn_samples.to_sentence}")
    false
  end

  def assets_are_appropriate
    all_assets.each do |asset|
      next if asset_applicable_to_type?(first_request_type, asset)

      errors.add(
        :asset,
        "'#{asset.display_name}' is a #{asset.sti_type} which is not suitable for #{first_request_type.name} requests"
      )
    end
    return true if errors.empty?

    false
  end

  def set_study_from_aliquots
    studies = assets.reduce(Set.new) { |set, asset| set.merge(asset.studies) }
    self.study ||= studies.first if studies.one?
  end

  def set_project_from_aliquots
    projects = assets.reduce(Set.new) { |set, asset| set.merge(asset.projects) }
    self.project ||= projects.first if projects.one?
  end
end