app/models/batch.rb
# frozen_string_literal: true
require 'timeout'
require 'aasm'
# A {Batch} groups 1 or more {Request requests} together to enable processing in a
# {Pipeline}. All requests in a batch get usually processed together, although it is
# possible for requests to get removed from a batch in a handful of cases.
class Batch < ApplicationRecord # rubocop:todo Metrics/ClassLength
include Api::BatchIO::Extensions
include Api::Messages::FlowcellIO::Extensions
include AASM
include SequencingQcBatch
include Commentable
include Uuid::Uuidable
include StandardNamedScopes
include ::Batch::PipelineBehaviour
include ::Batch::StateMachineBehaviour
extend EventfulRecord
# The three states of {Batch} Also @see {SequencingQcBatch}
# @!attribute state
# The main state machine, used to track the batch through the pipeline. Handled by {Batch::StateMachineBehaviour}
# @!attribute production_state
# Also referenced in {Batch::StateMachineBehaviour}. Either nil, or fail. This is updated in Batch#fail_requests and
# Batch#fail. The former is used via BatchesController#fail_items, the latter seems to be unused.
# Is intended to take precedence over both other states to track failures in-spite of QC results.
# @!attribute qc_state
# Primarily for sequencing batches. See {SequencingQcBatch}. Holds the sequencing QC state
DEFAULT_VOLUME = 13
self.per_page = 500
belongs_to :user
belongs_to :assignee, class_name: 'User'
has_many :failures, as: :failable
has_many :messengers, as: :target, inverse_of: :target
has_many :batch_requests, -> { includes(:request).order(:position, :request_id) }, inverse_of: :batch
has_many :requests, -> { distinct }, through: :batch_requests, inverse_of: :batch
has_many :assets, through: :requests, source: :target_asset
has_many :target_assets, through: :requests
has_many :source_assets, -> { distinct }, through: :requests, source: :asset
has_many :submissions, -> { distinct }, through: :requests
has_many :orders, -> { distinct }, through: :requests
has_many :studies, -> { distinct }, through: :orders
has_many :projects, -> { distinct }, through: :orders
has_many :aliquots, -> { distinct }, through: :source_assets
has_many :samples, -> { distinct }, through: :source_assets, source: :samples
has_many :output_labware, -> { distinct }, through: :assets, source: :labware
has_many :input_labware, -> { distinct }, through: :source_assets, source: :labware
has_many_events
has_many_lab_events
accepts_nested_attributes_for :requests
broadcast_with_warren
validate :requests_have_same_read_length,
:requests_have_same_flowcell_type,
:batch_meets_minimum_size,
:all_requests_are_ready?,
:requests_have_same_target_purpose,
on: :create,
if: :pipeline
after_create :generate_target_assets_for_requests, if: :generate_target_assets_on_batch_create?
after_commit :rebroadcast
# Named scope for search by query string behaviour
scope :for_search_query,
->(query) {
user = User.find_by(login: query)
if user
where(user_id: user)
else
with_safe_id(query) # Ensures extra long input (most likely barcodes) doesn't throw an exception
end
}
scope :includes_for_ui, -> { limit(5).includes(:user, :assignee, :pipeline) }
scope :pending_for_ui, -> { where(state: 'pending', production_state: nil).latest_first }
scope :released_for_ui, -> { where(state: 'released', production_state: nil).latest_first }
scope :completed_for_ui, -> { where(state: 'completed', production_state: nil).latest_first }
scope :failed_for_ui, -> { where(production_state: 'fail').includes(:failures).latest_first }
scope :in_progress_for_ui, -> { where(state: 'started', production_state: nil).latest_first }
scope :include_pipeline, -> { includes(pipeline: :uuid_object) }
scope :include_user, -> { includes(:user) }
scope :include_requests,
-> {
includes(
requests: [
:uuid_object,
:request_metadata,
:request_type,
{ submission: :uuid_object },
{ asset: [:uuid_object, { aliquots: %i[sample tag] }] },
{ target_asset: [:uuid_object, { aliquots: %i[sample tag] }] }
]
)
}
scope :latest_first, -> { order(created_at: :desc) }
scope :most_recent, ->(number) { latest_first.limit(number) }
# Returns batches owned or assigned to user. Not filter applied if passed :any
scope :for_user, ->(user) { user == 'all' ? all : where(assignee_id: user).or(where(user_id: user)) }
scope :for_pipeline, ->(pipeline) { where(pipeline_id: pipeline) }
delegate :size, to: :requests
delegate :sequencing?, :generate_target_assets_on_batch_create?, :min_size, to: :pipeline
delegate :name, to: :workflow, prefix: true
alias friendly_name id
def all_requests_are_ready?
# Checks that SequencingRequests have at least one LibraryCreationRequest in passed status before being processed
# (as referred by #75102998)
errors.add :base, 'All requests must be ready to be added to a batch' unless requests.all?(&:ready?)
end
def subject_type
sequencing? ? 'flowcell' : 'batch'
end
def eventful_studies
requests.reduce([]) { |studies, request| studies.concat(request.eventful_studies) }.uniq
end
def flowcell
self if sequencing?
end
def batch_meets_minimum_size
if min_size && (requests.size < min_size)
errors.add :base, "You must create batches of at least #{min_size} requests in the pipeline #{pipeline.name}"
end
end
def requests_have_same_target_purpose
if pipeline.is_a?(CherrypickingPipeline) &&
requests.map { |request| request.request_metadata.target_purpose_id }.uniq.size > 1
errors.add(:base, 'The selected requests must have the same target purpose (Pick To) values')
end
end
def requests_have_same_read_length
unless pipeline.is_read_length_consistent_for_batch?(self)
errors.add :base, "The selected requests must have the same values in their 'Read length' field."
end
end
def requests_have_same_flowcell_type
unless pipeline.is_flowcell_type_consistent_for_batch?(self)
errors.add :base, "The selected requests must have the same values in their 'Flowcell Requested' field."
end
end
# Fail was removed from State Machine (as a state) to allow the addition of qc_state column and features
def fail(reason, comment, ignore_requests = false)
# We've deprecated the ability to fail a batch but not its requests.
# Keep this check here until we're sure we haven't missed anything.
raise StandardError, 'Can not fail batch without failing requests' if ignore_requests
# create failures
failures.create(reason: reason, comment: comment, notify_remote: false)
requests.each do |request|
request.failures.create(reason: reason, comment: comment, notify_remote: true)
EventSender.send_fail_event(request, reason, comment, id) unless request.asset && request.asset.resource?
end
self.production_state = 'fail'
save!
end
# Fail specific requests on this batch
def fail_requests(requests_to_fail, reason, comment, fail_but_charge = false) # rubocop:todo Metrics/MethodLength
ActiveRecord::Base.transaction do
requests
.find(requests_to_fail)
.each do |request|
logger.debug "SENDING FAIL FOR REQUEST #{request.id}, BATCH #{id}, WITH REASON #{reason}"
request.customer_accepts_responsibility! if fail_but_charge
request.failures.create(reason: reason, comment: comment, notify_remote: true)
EventSender.send_fail_event(request, reason, comment, id)
end
update_batch_state(reason, comment)
end
end
def update_batch_state(reason, comment)
if requests.all?(&:terminated?)
failures.create(reason: reason, comment: comment, notify_remote: false)
self.production_state = 'fail'
save!
end
end
def failed?
production_state == 'fail'
end
# Tests whether this Batch has any associated LabEvents
def has_event(event_name)
lab_events.any? { |event| event_name.downcase == event.description.try(:downcase) }
end
def event_with_description(name)
lab_events.order(id: :desc).find_by(description: name)
end
def robot_id
event_with_description('Cherrypick Layout Set')&.descriptor_value('robot_id')
end
def underrun
has_limit? ? (item_limit - batch_requests.size) : 0
end
def control
requests.detect { |request| request.try(:asset).try(:resource?) }
end
def has_control?
control.present?
end
# Sets the position of the requests in the batch to their index in the array.
def assign_positions_to_requests!(request_ids_in_position_order)
disparate_ids = batch_requests.map(&:request_id) - request_ids_in_position_order
raise StandardError, 'Can only sort all requests at once' unless disparate_ids.empty?
BatchRequest.transaction do
batch_requests.each do |batch_request|
batch_request.move_to_position!(request_ids_in_position_order.index(batch_request.request_id) + 1)
end
end
end
alias ordered_requests requests
def assigned_user
assignee.try(:login) || ''
end
def start_requests
requests.with_assets_for_starting_requests.not_failed.map(&:start!)
end
# Returns a list of input labware including their barcodes,
# purposes, and a count of the number of requests associated with the
# batch. Output depends on Pipeline. Some pipelines return an empty relationship
#
# @return [Labware::ActiveRecord_Relation] The associated labware
def input_labware_report
pipeline.input_labware requests
end
# Returns a list of output labware including their barcodes,
# purposes, and a count of the number of requests associated with the
# batch. Output depends on Pipeline. Some pipelines return an empty relationship
#
# @return [Labware::ActiveRecord_Relation] The associated labware
def output_labware_report
pipeline.output_labware requests.with_target
end
def input_plate_group
source_assets.group_by(&:plate)
end
# This looks odd. Why would a request have the same asset as target asset? Why are we filtering them out here?
def output_plate_group
requests.select { |r| r.target_asset != r.asset }.map(&:target_asset).select(&:present?).group_by(&:plate)
end
def output_plates
# We use re-order here as batch_requests applies a default sort order to
# the relationship, which takes preference, even though we're has_many throughing
return output_labware.sort_by(&:id) if output_labware.loaded?
output_labware.reorder(id: :asc)
end
def first_output_plate
Plate.output_by_batch(self).with_wells_and_requests.first
end
def output_plate_purpose
output_plates[0].plate_purpose unless output_plates[0].nil?
end
def output_plate_role
requests.first.try(:role)
end
def plate_group_barcodes
return nil unless pipeline.group_by_parent || requests.first.target_asset.is_a?(Well)
output_plate_group.presence || input_plate_group
end
def plate_barcode(barcode)
barcode.presence || requests.first.target_asset.plate.human_barcode
end
def id_dup
id
end
# Source Labware returns the physical pieces of labware (ie. a plate for wells, but tubes for tubes)
def source_labware
input_labware
end
#
# Verifies that provided barcodes are in the correct locations according to the
# request organization within the batch.
# Either returns true, and logs the event or returns false.
#
# @param [Array<Integer>] barcodes An array of 1-7 digit long barcodes
# @param [User] user The user validating the barcode layout
#
# @return [Bool] true if the layout is correct, false otherwise
#
# rubocop:todo Metrics/MethodLength
def verify_tube_layout(barcodes, user = nil) # rubocop:todo Metrics/AbcSize
requests.each do |request|
barcode = barcodes[request.position - 1]
unless barcode == request.asset.machine_barcode
expected_barcode = request.asset.human_barcode
errors.add(:base, "The tube at position #{request.position} is incorrect: expected #{expected_barcode}.")
end
end
if errors.empty?
lab_events.create(description: 'Tube layout verified', user: user)
true
else
false
end
end
# rubocop:enable Metrics/MethodLength
def release_pending_requests
# We set the unused requests to pending.
# this is to allow unused well to be cherry-picked again
requests.each { |request| detach_request(request) if request.started? }
end
# Remove the request from the batch and remove asset information
def remove_request_ids(request_ids, reason = nil, comment = nil)
ActiveRecord::Base.transaction do
Request
.find(request_ids)
.each do |request|
request.failures.create(reason: reason, comment: comment, notify_remote: true)
detach_request(request)
end
update_batch_state(reason, comment)
end
end
# Remove a request from the batch and reset it to a point where it can be put back into
# the pending queue.
def detach_request(request, current_user = nil)
ActiveRecord::Base.transaction do
unless current_user.nil?
request.add_comment("Used to belong to Batch #{id} removed at #{Time.zone.now}", current_user)
end
pipeline.detach_request_from_batch(self, request)
end
end
def return_request_to_inbox(request, current_user = nil)
ActiveRecord::Base.transaction do
unless current_user.nil?
request.add_comment(
"Used to belong to Batch #{id} returned to inbox unstarted at #{Time.zone.now}",
current_user
)
end
request.return_pending_to_inbox!
end
end
# rubocop:todo Metrics/MethodLength
def reset!(current_user) # rubocop:todo Metrics/AbcSize
ActiveRecord::Base.transaction do
discard!
requests.each do |request|
request.batch = nil
return_request_to_inbox(request, current_user)
end
if requests.last.submission_id.present?
Request
.where(submission_id: requests.last.submission_id, state: 'pending')
.where.not(request_type_id: pipeline.request_type_ids)
.find_each do |request|
request.asset_id = nil
request.save!
end
end
end
end
# rubocop:enable Metrics/MethodLength
# rubocop:todo Metrics/PerceivedComplexity, Metrics/MethodLength, Metrics/AbcSize
def swap(current_user, batch_info = {}) # rubocop:todo Metrics/CyclomaticComplexity
return false if batch_info.empty?
# Find the two lanes that are to be swapped
batch_request_left =
BatchRequest.find_by(batch_id: batch_info['batch_1']['id'], position: batch_info['batch_1']['lane']) or
errors.add('Swap: ', 'The first lane cannot be found')
batch_request_right =
BatchRequest.find_by(batch_id: batch_info['batch_2']['id'], position: batch_info['batch_2']['lane']) or
errors.add('Swap: ', 'The second lane cannot be found')
return unless batch_request_left.present? && batch_request_right.present?
ActiveRecord::Base.transaction do
# Update the lab events for the request so that they reference the batch that the request is moving to
batch_request_left.request.lab_events.each do |event|
event.update!(batch_id: batch_request_right.batch_id) if event.batch_id == batch_request_left.batch_id
end
batch_request_right.request.lab_events.each do |event|
event.update!(batch_id: batch_request_left.batch_id) if event.batch_id == batch_request_right.batch_id
end
# Swap the two batch requests so that they are correct. This involves swapping both the batch and the lane but
# ensuring that the two requests don't clash on position by removing one of them.
original_left_batch_id, original_left_position, original_right_request_id =
batch_request_left.batch_id, batch_request_left.position, batch_request_right.request_id
batch_request_right.destroy
batch_request_left.update!(batch_id: batch_request_right.batch_id, position: batch_request_right.position)
batch_request_right =
BatchRequest.create!(
batch_id: original_left_batch_id,
position: original_left_position,
request_id: original_right_request_id
)
# Finally record the fact that the batch was swapped
batch_request_left.batch.lab_events.create!(
description: 'Lane swap',
# rubocop:todo Layout/LineLength
message:
"Lane #{batch_request_right.position} moved to #{batch_request_left.batch_id} lane #{batch_request_left.position}",
# rubocop:enable Layout/LineLength
user_id: current_user.id
)
batch_request_right.batch.lab_events.create!(
description: 'Lane swap',
# rubocop:todo Layout/LineLength
message:
"Lane #{batch_request_left.position} moved to #{batch_request_right.batch_id} lane #{batch_request_right.position}",
# rubocop:enable Layout/LineLength
user_id: current_user.id
)
end
true
end
# rubocop:enable Metrics/AbcSize, Metrics/MethodLength, Metrics/PerceivedComplexity
def plate_ids_in_study(study)
Plate.plate_ids_from_requests(requests.for_studies(study))
end
def total_volume_to_cherrypick
request = requests.first
return DEFAULT_VOLUME unless request.asset.is_a?(Well)
return DEFAULT_VOLUME unless request.target_asset.is_a?(Well)
request.target_asset.get_requested_volume
end
def robot_verified!(user_id)
return if has_event('robot verified')
pipeline.robot_verified!(self)
lab_events.create(
description: 'Robot verified',
message: 'Robot verification completed and source volumes updated.',
user_id: user_id
)
end
def self.prefix
'BA'
end
def self.valid_barcode?(code)
begin
split_code = barcode_without_pick_number(code)
Barcode.barcode_to_human!(split_code, prefix)
rescue StandardError
return false
end
return false if find_from_barcode(code).nil?
true
end
def self.barcode_without_pick_number(code)
code.split('-').first
end
def self.extract_pick_number(code)
# expecting format 550000555760-1 with pick number at end
split_code = code.split('-')
return Integer(split_code.last) if split_code.size > 1
# default to 1 if the pick number is not present
1
end
class << self
def find_by_barcode(code)
split_code = barcode_without_pick_number(code)
human_batch_barcode = Barcode.number_to_human(split_code)
batch = Batch.find_by(barcode: human_batch_barcode)
batch ||= Batch.find_by(id: human_batch_barcode)
batch
end
alias find_from_barcode find_by_barcode
end
def request_count
requests.count
end
def npg_set_state
if all_requests_qced?
self.state = 'released'
qc_complete
save!
end
end
def downstream_requests_needing_asset(request)
next_requests_needing_asset = request.next_requests.select { |r| r.asset_id.blank? }
yield(next_requests_needing_asset) if next_requests_needing_asset.present?
end
def rebroadcast
messengers.each(&:resend)
end
def pick_information?
pipeline.pick_information?(self)
end
# Summarise the state encapsulated by state and production_state
# Essentially a 'fail' production_state over-rides the 'state'
# We don't use production_state directly as it it 'fail' rather than
# ' failed'
# qc_state it kept separate as its a fairly distinct concept and is
# summarised elsewhere in the interface.
def displayed_status
failed? ? 'failed' : state
end
private
def all_requests_qced?
requests.all? { |request| request.asset.resource? || request.events.family_pass_and_fail.exists? }
end
# rubocop:todo Metrics/MethodLength
def generate_target_assets_for_requests # rubocop:todo Metrics/AbcSize
requests_to_update = []
asset_type = pipeline.asset_type.constantize
requests.reload.each do |request|
next if request.target_asset.present?
# we need to call downstream request before setting the target_asset
# otherwise, the request use the target asset to find the next request
target_asset =
asset_type.create! do |asset|
asset.generate_barcode
asset.generate_name(request.asset.name)
end
downstream_requests_needing_asset(request) do |downstream_requests|
requests_to_update.concat(downstream_requests.map { |r| [r, target_asset.receptacle] })
end
request.update!(target_asset: target_asset)
target_asset.parents << request.asset.labware
end
requests_to_update.each { |request, asset| request.update!(asset: asset) }
end
# rubocop:enable Metrics/MethodLength
end