Skip to content

Commit

Permalink
refactor: better encapsulate page rendering and labware functions
Browse files Browse the repository at this point in the history
  • Loading branch information
StephenHulme committed Mar 7, 2024
1 parent f4a5288 commit cd81fe8
Show file tree
Hide file tree
Showing 8 changed files with 225 additions and 255 deletions.
144 changes: 55 additions & 89 deletions app/controllers/pipeline_progress_overview_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,71 +4,53 @@
class PipelineProgressOverviewController < ApplicationController
# Retrieves data from Sequencescape and populates variables to be used in the UI
def show
page_size = 500

# URL query parameters
@pipeline_group_name = params[:id]
@from_date = from_date(params)
@purpose = params[:purpose]

# Group related pipelines together
# Pipeline details

# hash of pipelines by group
@pipelines_for_group = Settings.pipelines.retrieve_pipeline_config_for_group(@pipeline_group_name)

# list of group-purposes in order
@ordered_purpose_list = Settings.pipelines.combine_and_order_pipelines(@pipelines_for_group)

# purpose_pipeline_map is a hash of hashes, like:
# "Purpose 2" => {
# "Pipeline A" => {
# "parent" => "Purpose 1",
# "child" => "Purpose 3"
# },
# "Pipeline B" => {
# "parents" => "Purpose 1",
# "child" => nil
# }
@purpose_pipeline_map = Settings.pipelines.purpose_to_pipelines_map(@ordered_purpose_list, @pipelines_for_group)
@ordered_purposes_for_pipelines =
@pipelines_for_group.index_with { |pipeline| Settings.pipelines.order_pipeline(pipeline) }

labware_records = arrange_labware_records(@ordered_purpose_list, from_date(params))

# TODO: improve performance by only requesting full records when a @purpose is selected
@grouped = mould_data_for_view(@ordered_purpose_list, labware_records)
@grouped_state_counts = count_states(@grouped)
# Labware results
@labware = compile_labware_for_purpose(@ordered_purpose_list, page_size, @from_date, @ordered_purpose_list)
end

def from_date(params)
params[:date]&.to_date || Time.zone.today.prev_month
end

# Split out requests for the last purpose and the rest of the purposes so that
# the labware for the last purpose can be filtered by those that have
# ancestors including at least one purpose from the rest.
def arrange_labware_records(ordered_purposes, from_date)
page_size = 500

specific_purposes = ordered_purposes.first(ordered_purposes.count - 1)
specific_labware_records = retrieve_labware(page_size, from_date, specific_purposes)
general_labware_records = retrieve_labware(page_size, from_date, ordered_purposes.last)

specific_labware_records +
filter_labware_records_by_ancestor_purpose_names(general_labware_records, specific_purposes)
end

# Filter a list of labware records such that we only keep those that have at
# least one ancestor with a purpose from the given allow list.
def filter_labware_records_by_ancestor_purpose_names(labware_records, purpose_names_list)
# Filter out labware that is not related of the given list of purpose names.
# A labware is related to a purpose if it is that purpose or has an ancestor
# that is that purpose.
def filter_labware_by_related_purpose(labware_records, purpose_names)
labware_records.select do |labware|
ancestor_purpose_names = labware.ancestors.map { |ancestor| ancestor.purpose.name }
ancestor_purpose_names.any? { |purpose_name| purpose_names_list.include?(purpose_name) }
purpose_names.include?(labware.purpose.name) ||
labware.ancestors.any? { |ancestor| purpose_names.include?(ancestor.purpose.name) }
end
end

# Retrieves labware through the Sequencescape V2 API
# Combines pages into one list
# Combines labware with and without children
# Returns a list of Sequencescape::Api::V2::Labware
def retrieve_labware(page_size, from_date, purposes)
labware = query_labware(page_size, from_date, purposes, nil)
labware_without_children = query_labware(page_size, from_date, purposes, false)

# filter out labware without children from labware, matching on ID
labware_without_children_ids = labware_without_children.to_set(&:id)
labware_with_children = labware.reject { |labware_record| labware_without_children_ids.include?(labware_record.id) }

labware_with_children.each { |labware_record| labware_record.has_children = true }
labware_without_children.each { |labware_record| labware_record.has_children = false }

labware_without_children + labware_with_children
end

def query_labware(page_size, from_date, purposes, with_children)
labware_query =
Sequencescape::Api::V2::Labware
Expand All @@ -85,57 +67,41 @@ def query_labware(page_size, from_date, purposes, with_children)
Sequencescape::Api::V2.merge_page_results(labware_query)
end

# Returns following structure (example):
#
# {
# "LTHR Cherrypick" => [
# {
# :record => #<Sequencescape::Api::V2::Labware...>,
# :state => "pending"
# }
# ],
# "LTHR-384 PCR 1" => [{}]
# }
def mould_data_for_view(purposes, labware_records)
{}.tap do |output|
# Make sure there's an entry for each of the purposes, even if no records
purposes.each { |p| output[p] = [] }

labware_records.each do |rec|
next unless rec.purpose

state = decide_state(rec)
next if state == 'cancelled'

state_with_children = rec.has_children ? "#{state} (parent)" : state

output[rec.purpose.name] << { record: rec, state: state, state_with_children: state_with_children }
end
end
end

def decide_state(labware)
# TODO: the default of pending is a false assumption - see RVI cherrypick
labware.state_changes&.max_by(&:id)&.target_state || 'pending'
end

# Counts of number of labware in each state for each purpose
# Returns a hash with the following structure:
# {
# "LTHR Cherrypick" => {
# "pending" => 5,
# "started" => 2
# },
# "LTHR-384 PCR 1" => {
# "pending" => 5,
# "started" => 2
# }
# }
def count_states(grouped)
{}.tap do |output|
grouped.each do |purpose, records|
output[purpose] = records.group_by { |r| r[:state_with_children] }.transform_values(&:count)
end
def add_children_metadata(labware_records, has_children, state_suffix)
labware_records.each do |labware_record|
labware_record.has_children = has_children
labware_record.state = decide_state(labware_record)
labware_record.state_with_children = "#{labware_record.state}#{state_suffix}"
end
end

def query_labware_with_children(page_size, from_date, purposes)
labware_all = query_labware(page_size, from_date, purposes, nil)
labware_without_children = query_labware(page_size, from_date, purposes, false)

# filter out labware without children from labware, matching on ID
labware_without_children_ids = labware_without_children.to_set(&:id)
labware_with_children =
labware_all.reject { |labware_record| labware_without_children_ids.include?(labware_record.id) }

labware_with_children = add_children_metadata(labware_with_children, true, ' (parent)')
labware_without_children = add_children_metadata(labware_without_children, false, '')

labware_without_children + labware_with_children
end

# Given a list of purposes, retrieve labware records for those purposes and
# their ancestors purposes, and filter out any from another pipeline or that have been cancelled.
def compile_labware_for_purpose(query_purposes, page_size, from_date, ordered_purposes)
related_purposes = ordered_purposes.first(ordered_purposes.count - 1)

labwares = query_labware_with_children(page_size, from_date, query_purposes)
labwares = labwares.reject { |labware| labware.state == 'canceled' }
filter_labware_by_related_purpose(labwares, related_purposes)
end
end
4 changes: 4 additions & 0 deletions app/helpers/labware_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -54,4 +54,8 @@ def colours_by_location
def labware_by_state(labwares)
labwares.group_by(&:state)
end

def labware_for_purpose(labwares, purpose_name)
labwares.select { |labware| labware.purpose.name == purpose_name }
end
end
2 changes: 1 addition & 1 deletion app/models/pipeline_list.rb
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ def combine_and_order_pipelines(pipeline_names)
flatten_relationships_into_purpose_list(combined_relationships)
end

# Orders the pipeline based on its relationships.
# Orders the purposes within a pipeline based on the relationships.
#
# @param pipeline_name [String] The name of the pipeline to order.
#
Expand Down
42 changes: 42 additions & 0 deletions app/views/pipeline_progress_overview/_labware_table.html.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
<%
def state_with_children_badge(labware_record)
state_with_children = labware_record[:state_with_children]
render partial: 'state_text_badge', locals: { state: state_with_children, text: state_with_children.humanize }
end

def render_header
content_tag(:tr) do
content_tag(:th, 'Barcode') +
content_tag(:th, 'Created') +
content_tag(:th, 'State') +
content_tag(:th, 'Updated')
end
end

def render_row(labware_record)
content_tag(:tr) do
content_tag(:td, link_to("#{labware_record.labware_barcode&.human}", url_for(labware_record))) +
content_tag(:td, labware_record.created_at&.strftime('%Y-%m-%d')) +
content_tag(:td, state_with_children_badge(labware_record) ) +
content_tag(:td, labware_record.updated_at.strftime('%Y-%m-%d'))
end
end

def render_table(labware)
number_colums = render_header.scan(/<th>/).size

content_tag(:table, class: 'table table-striped table-sm border', style: 'table-layout: fixed') do
content_tag(:thead) { render_header } +
content_tag(:tbody) do
if labware.nil? || labware.empty?
content_tag(:tr) { content_tag(:td, 'No labware found', colspan: number_colums, class: 'text-center text-muted') }
else
# list of labware, with a link to each
labware.map { |labware_record| render_row(labware_record) }.join.html_safe
end
end
end
end
%>

<%= render_table(labware) %>
17 changes: 17 additions & 0 deletions app/views/pipeline_progress_overview/_purpose_card.html.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
<%
# For the labware with the given purpose, groups the labware by state, and return a hash of state_with_children => count.
def grouped_state_counts(purpose_name)
labware_for_purpose(@labware, purpose_name).group_by(&:state_with_children).transform_values(&:size)
end
%>

<%= card title: "#{purpose_name} (#{labware_for_purpose(@labware, purpose_name).size})", css_class: 'lightweight-card rounded-0 text-center' do %>

<% grouped_state_counts(purpose_name).each do |state_with_children, count| %>
<%= render partial: 'state_text_badge', locals: { state: state_with_children, text: "#{state_with_children.humanize} #{count}" } %>
<% end %>

<span class="float-right">
<%= link_to ">", pipeline_progress_overview_path(id: @pipeline_group_name, purpose: purpose_name, date: @from_date), class: "btn btn-sm btn-info" %>
</span>
<% end %>
91 changes: 91 additions & 0 deletions app/views/pipeline_progress_overview/_purpose_graph.html.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
<%
# Custom component functions

# Checks if a given pipeline is present in the purpose pipeline map for a specific purpose.
#
# @param pipeline_name [String] The name of the pipeline to check.
# @param purpose_name [String] The name of the purpose to look for.
#
# @return [Boolean] Returns true if the pipeline is found in the keys of the purpose's pipeline map, false otherwise.
def purpose_is_in_pipeline(pipeline_name, purpose_name)
@purpose_pipeline_map[purpose_name].keys.include?(pipeline_name)
end

# Checks if the first purpose in a pipeline has occurred.
#
# @param pipeline_name [String] The name of the pipeline to check.
# @param purpose_name [String] The name of the purpose to look for.
#
# @return [Boolean] Returns true if the purpose is the first in the pipeline and has occurred, false otherwise.
def pipeline_first_purpose_has_occurred(pipeline_name, purpose_name)
first_purpose_in_pipeline = @ordered_purposes_for_pipelines[pipeline_name].first

# get list of purposes prior to the given purpose
purposes_prior_to_purpose = @ordered_purpose_list[0..@ordered_purpose_list.index(purpose_name)]

# has the first purpose in the pipeline occurred?
purposes_prior_to_purpose.include?(first_purpose_in_pipeline)
end

# Checks if the last purpose in a pipeline is still to occur.
#
# @param pipeline_name [String] The name of the pipeline to check.
# @param purpose_name [String] The name of the purpose to look for.
#
# @return [Boolean] Returns true if the purpose is the last in the pipeline and has not occurred, false otherwise.
def pipeline_last_purpose_is_still_to_occur(pipeline_name, purpose_name)
last_purpose_in_pipeline = @ordered_purposes_for_pipelines[pipeline_name].last

# get list of purposes after the given purpose
purposes_after_purpose = @ordered_purpose_list[@ordered_purpose_list.index(purpose_name)..-1]

# has the last purpose in the pipeline occurred?
purposes_after_purpose.include?(last_purpose_in_pipeline)
end

def show_pipeline_in_graph(pipeline_name, purpose_name)
purpose_is_in_pipeline(pipeline_name, purpose_name) ||
pipeline_first_purpose_has_occurred(pipeline_name, purpose_name) &&
pipeline_last_purpose_is_still_to_occur(pipeline_name, purpose_name)
end

def is_first_purpose_in_pipeline(pipeline_name, purpose_name)
@ordered_purposes_for_pipelines[pipeline_name].first == purpose_name
end

def is_last_purpose_in_pipeline(pipeline_name, purpose_name)
@ordered_purposes_for_pipelines[pipeline_name].last == purpose_name
end

def graph_classes(pipeline_name, purpose_name)
# purpose_pipeline_map is a hash of hashes, like:
# "Purpose 2" => {
# "Pipeline A" => {
# "parent" => "Purpose 1",
# "child" => "Purpose 3"
# },
# "Pipeline B" => {
# "parents" => "Purpose 1",
# "child" => nil
# }
parent_purpose_name = @purpose_pipeline_map.dig(pipeline_name, purpose_name, 'parent')
child_purpose_name = @purpose_pipeline_map.dig(pipeline_name, purpose_name, 'child')

# provide spacing
classes = ['pipeline']
# show the coloured line
classes << 'pipeline-shown' if show_pipeline_in_graph(pipeline_name, purpose_name)
# indicate that the purpose is part of the pipeline
classes << 'pipeline-purpose' if purpose_is_in_pipeline(pipeline_name, purpose_name)
# decoration for the first and last purposes in each pipeline
classes << 'pipeline-start' if is_first_purpose_in_pipeline(pipeline_name, purpose_name)
classes << 'pipeline-end' if is_last_purpose_in_pipeline(pipeline_name, purpose_name)

classes.join(' ')
end
%>
<%= content_tag :div, class: "stacker-graph" do %>
<% @pipelines_for_group.each do |pipeline_name| %>
<%= content_tag :span, '▼', class: graph_classes(pipeline_name, purpose_name) %>
<% end %>
<% end %>
11 changes: 11 additions & 0 deletions app/views/pipeline_progress_overview/_state_text_badge.html.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<%
@state_colours = {
'pending' => 'warning',
'passed' => 'success',
'failed' => 'danger',
'pending (parent)' => 'secondary',
'passed (parent)' => 'secondary',
'failed (parent)' => 'secondary'
}
%>
<%= content_tag(:span, text, class: "badge badge-pill badge-#{@state_colours[state]}") %>
Loading

0 comments on commit cd81fe8

Please sign in to comment.