sanger/sequencescape

View on GitHub
app/models/plate/creator.rb

Summary

Maintainability
A
2 hrs
Test Coverage
A
97%
# frozen_string_literal: true

# A plate creator creates a stamp of a parent plate into one or more children
# A stamp is the complete transfer of content, maintaining the same well locations.
class Plate::Creator < ApplicationRecord # rubocop:todo Metrics/ClassLength
  PlateCreationError = Class.new(StandardError)

  # Join between the {Plate::Creator}, and the child purposes if can create
  class PurposeRelationship < ApplicationRecord
    self.table_name = ('plate_creator_purposes')

    belongs_to :plate_purpose
    belongs_to :plate_creator, class_name: 'Plate::Creator'
  end

  # Join between the {Plate::Creator}, and the valid parent purposes. If there are no
  # valid parent purposes, then all purposes are deemed valid
  class ParentPurposeRelationship < ApplicationRecord
    self.table_name = ('plate_creator_parent_purposes')
    belongs_to :plate_creator, class_name: 'Plate::Creator'
    belongs_to :plate_purpose, class_name: 'Purpose'
  end

  self.table_name = 'plate_creators'

  # These are the plate purposes that will be created when this creator is used.
  has_many :plate_creator_purposes,
           class_name: 'Plate::Creator::PurposeRelationship',
           dependent: :destroy,
           foreign_key: :plate_creator_id,
           inverse_of: :plate_creator
  has_many :plate_purposes, through: :plate_creator_purposes

  has_many :parent_purpose_relationships,
           class_name: 'Plate::Creator::ParentPurposeRelationship',
           dependent: :destroy,
           foreign_key: :plate_creator_id,
           inverse_of: :plate_creator
  has_many :parent_plate_purposes, through: :parent_purpose_relationships, source: :plate_purpose

  serialize :valid_options

  attr_reader :created_asset_group

  def warnings_list
    @warnings_list ||= []
  end

  def warnings
    warnings_list.join(' ')
  end

  # array of hashes containing source and destination plates
  # [
  #   {
  #     :source => #<Plate ...>,
  #     :destinations => [#<Plate ...>, #<Plate ...>]
  #   }
  # ]
  def created_plates
    @created_plates ||= []
  end

  def fail_with_error(msg)
    @created_plates = []
    raise PlateCreationError, msg
  end

  # Executes the plate creation so that the appropriate child plates are built.
  # rubocop:todo Metrics/MethodLength
  def execute(source_plate_barcodes, barcode_printer, scanned_user, should_create_asset_group, creator_parameters = nil)
    @created_plates = []

    new_plates = transaction { create_plates(source_plate_barcodes, scanned_user, creator_parameters) }
    fail_with_error('Plate creation failed') if new_plates.empty?

    new_plates
      .group_by(&:plate_purpose)
      .each do |plate_purpose, plates|
        print_job =
          LabelPrinter::PrintJob.new(
            barcode_printer.name,
            LabelPrinter::Label::PlateCreator,
            plates: plates,
            plate_purpose: plate_purpose,
            user_login: scanned_user.login
          )

        unless print_job.execute
          warnings_list << "Barcode labels failed to print for following plate type: #{plate_purpose.name}"
        end
      end

    @created_asset_group = create_asset_group(created_plates) if should_create_asset_group
    true
  end

  # rubocop:enable Metrics/MethodLength

  # rubocop:todo Metrics/MethodLength, Metrics/AbcSize
  def create_plates_from_tube_racks!(
    tube_racks,
    barcode_printer,
    scanned_user,
    should_create_asset_group,
    _creator_parameters = nil
  )
    # creates plates
    # creates an asset group if user requested one
    # prints the barcode labels

    @created_plates = []
    plate_purpose = plate_purposes.first
    plate_factories = tube_rack_to_plate_factories(tube_racks, plate_purpose)
    unless plate_factories.all?(&:valid?)
      errors = plate_factories.map(&:error_messages)
      fail_with_error("Plate creation failed with the following errors: #{errors}")
    end

    ActiveRecord::Base.transaction do
      plate_factories.each do |factory|
        factory.save
        add_created_plates(factory.tube_rack, [factory.plate])
      end
    end

    @created_asset_group = create_asset_group(created_plates) if should_create_asset_group

    print_job =
      LabelPrinter::PrintJob.new(
        barcode_printer.name,
        LabelPrinter::Label::PlateCreator,
        plates: created_plates.pluck(:destinations).flatten.compact,
        plate_purpose: plate_purpose,
        user_login: scanned_user.login
      )

    warnings_list << 'Barcode labels failed to print.' unless print_job.execute
    true
  end

  # rubocop:enable Metrics/MethodLength, Metrics/AbcSize

  private

  def create_asset_group(created_plates) # rubocop:todo Metrics/MethodLength
    group = nil
    all_wells = created_plates.map { |hash| hash[:destinations].map(&:wells) }.flatten

    study = find_relevant_study(created_plates)
    unless study
      warnings_list << 'Failed to create Asset Group: could not find an appropriate Study to group the plates under.'
      return group
    end

    ActiveRecord::Base.transaction do
      # TO DO: handle exceptions from this?
      group = AssetGroup.create!(study: study, name: asset_group_name)
      group.assets.concat(all_wells)
    end

    group
  end

  def find_relevant_study(created_plates)
    # find a relevant study to put the Asset group under
    # otherwise would have to get user to select one

    # try the link on aliquots
    all_destination_plates = created_plates.pluck(:destinations).flatten
    study = all_destination_plates.map(&:studies).flatten.first
    return study if study

    # try the study_samples table
    all_destination_plates.each do |plate|
      plate.contained_samples.each { |sample| return sample.studies.first if sample.studies.first }
    end

    nil
  end

  def asset_group_name
    prefix = 'plate-creator'
    now = Time.zone.now
    time_now_formatted = "#{now.year}-#{now.month}-#{now.day}-#{now.hour}#{now.min}#{now.sec}"
    suffix = rand(999)
    "#{prefix}-#{time_now_formatted}-#{suffix}"
  end

  def tube_rack_to_plate_factories(tube_racks, plate_purpose)
    tube_racks.map { |rack| ::Heron::Factories::PlateFromRack.new(tube_rack: rack, plate_purpose: plate_purpose) }
  end

  def can_create_plates?(source_plate)
    parent_plate_purposes.empty? || parent_plate_purposes.include?(source_plate.purpose)
  end

  def create_plate_without_parent(creator_parameters)
    plate_purposes.map { |purpose| purpose.create!.tap { |plate| creator_parameters&.set_plate_parameters(plate) } }
  end

  # rubocop:todo Metrics/MethodLength
  def create_plates(source_plate_barcodes, current_user, creator_parameters = nil) # rubocop:todo Metrics/AbcSize
    if source_plate_barcodes.blank?
      # No barcodes have been scanned. This results in empty plates. This behaviour
      # is used in a few circumstances. User comment:
      # bs6: we use it to create 'pico standard' barcodes, as well as 'aliquot' barcodes.
      # The latter is used on the rare occasion that we receive unlabelled samples that
      # we need to record a location for. Not sure there's anything else.
      create_plate_without_parent(creator_parameters).tap { |destinations| add_created_plates(nil, destinations) }
    else
      # In the majority of cases the users are creating stamps of the provided plates.
      scanned_barcodes = source_plate_barcodes.split(/[\s,]+/)
      if scanned_barcodes.blank?
        fail_with_error("Scanned plate barcodes in incorrect format: #{source_plate_barcodes.inspect}")
      end

      # NOTE: Plate barcodes are not unique within certain laboratories.  That means that we cannot do:
      #  plates = Plate.with_barcode(*scanned_barcodes).all(:include => [ :location, { :wells => :aliquots } ])
      # Because then you get multiple matches.  So we take the first match, which is just not right.
      scanned_barcodes.flat_map do |scanned|
        plate =
          Plate.with_barcode(scanned).eager_load(wells: :aliquots).find_by_barcode(scanned) ||
            fail_with_error("Could not find plate with machine barcode #{scanned.inspect}")

        unless can_create_plates?(plate)
          target_purposes = plate_purposes.map(&:name).join(',')
          fail_with_error(
            "Scanned plate #{scanned} has a purpose #{plate.purpose.name} not valid for creating [#{target_purposes}]"
          )
        end
        create_child_plates_from(plate, current_user, creator_parameters).tap do |destinations|
          add_created_plates(plate, destinations)
        end
      end
    end
  end

  # rubocop:enable Metrics/MethodLength

  def add_created_plates(source, destinations)
    created_plates.push(source: source, destinations: destinations)
  end

  # rubocop:todo Metrics/MethodLength
  def create_child_plates_from(plate, current_user, creator_parameters) # rubocop:todo Metrics/AbcSize
    stock_well_picker = plate.plate_purpose.stock_plate? ? ->(w) { [w] } : ->(w) { w.stock_wells }
    parent_wells = plate.wells

    parent_barcode = plate.human_barcode

    # Do we only want to do this for new (SQPD) plate barcodes and still use WD12345 for DN plates?
    children_plate_barcodes = PlateBarcode.create_child_barcodes(parent_barcode, plate_purposes.count)

    plate_purposes
      .zip(children_plate_barcodes)
      .map do |target_plate_purpose, child_plate_barcode|
        child_plate =
          target_plate_purpose.create!(
            :without_wells,
            sanger_barcode: child_plate_barcode,
            size: plate.size
          ) { |child| child.name = "#{target_plate_purpose.name} #{child.human_barcode}" }

        # We should probably just use a transfer here.
        child_plate.wells <<
          parent_wells.map do |well|
            well.dup.tap do |child_well|
              child_well.aliquots = well.aliquots.map(&:dup)
              child_well.stock_wells.attach(stock_well_picker.call(well))
            end
          end

        creator_parameters&.set_plate_parameters(child_plate, plate)

        AssetLink.create_edge!(plate, child_plate)
        plate.events.create_plate!(target_plate_purpose, child_plate, current_user)

        child_plate
      end
  end
  # rubocop:enable Metrics/MethodLength
end