Basic Procedure addition working.

This commit is contained in:
lwark
2025-05-23 15:18:18 -05:00
parent 489332b2de
commit 4e1e06bb5e
7 changed files with 187 additions and 27 deletions

View File

@@ -555,6 +555,8 @@ class BaseClass(Base):
return output_date return output_date
class LogMixin(Base): class LogMixin(Base):
tracking_exclusion: ClassVar = ['artic_technician', 'clientsubmissionsampleassociation', tracking_exclusion: ClassVar = ['artic_technician', 'clientsubmissionsampleassociation',
'submission_reagent_associations', 'submission_equipment_associations', 'submission_reagent_associations', 'submission_equipment_associations',

View File

@@ -14,12 +14,15 @@ from sqlalchemy.ext.hybrid import hybrid_property
from datetime import date, datetime, timedelta from datetime import date, datetime, timedelta
from tools import check_authorization, setup_lookup, Report, Result, check_regex_match, yaml_regex_creator, timezone, \ from tools import check_authorization, setup_lookup, Report, Result, check_regex_match, yaml_regex_creator, timezone, \
jinja_template_loading jinja_template_loading
from typing import List, Literal, Generator, Any, Tuple from typing import List, Literal, Generator, Any, Tuple, TYPE_CHECKING
from pandas import ExcelFile from pandas import ExcelFile
from pathlib import Path from pathlib import Path
from . import Base, BaseClass, ClientLab, LogMixin from . import Base, BaseClass, ClientLab, LogMixin
from io import BytesIO from io import BytesIO
if TYPE_CHECKING:
from backend.db.models.submissions import Run
logger = logging.getLogger(f'procedure.{__name__}') logger = logging.getLogger(f'procedure.{__name__}')
reagentrole_reagent = Table( reagentrole_reagent = Table(
@@ -159,7 +162,7 @@ class KitType(BaseClass):
def get_reagents(self, def get_reagents(self,
required_only: bool = False, required_only: bool = False,
proceduretype: str | SubmissionType | None = None proceduretype: str | ProcedureType | None = None
) -> Generator[ReagentRole, None, None]: ) -> Generator[ReagentRole, None, None]:
""" """
Return ReagentTypes linked to kittype through KitTypeReagentTypeAssociation. Return ReagentTypes linked to kittype through KitTypeReagentTypeAssociation.
@@ -181,9 +184,9 @@ class KitType(BaseClass):
case _: case _:
relevant_associations = [item for item in self.kittypereagentroleassociation] relevant_associations = [item for item in self.kittypereagentroleassociation]
if required_only: if required_only:
return (item.reagent_role for item in relevant_associations if item.required == 1) return (item.reagentrole for item in relevant_associations if item.required == 1)
else: else:
return (item.reagent_role for item in relevant_associations) return (item.reagentrole for item in relevant_associations)
def construct_xl_map_for_use(self, proceduretype: str | SubmissionType) -> Tuple[dict | None, KitType]: def construct_xl_map_for_use(self, proceduretype: str | SubmissionType) -> Tuple[dict | None, KitType]:
""" """
@@ -536,6 +539,9 @@ class ReagentRole(BaseClass):
logger.debug(f"Constructing OmniReagentRole with name {self.name}") logger.debug(f"Constructing OmniReagentRole with name {self.name}")
return OmniReagentRole(instance_object=self, name=self.name, eol_ext=self.eol_ext) return OmniReagentRole(instance_object=self, name=self.name, eol_ext=self.eol_ext)
@property
def reagents(self):
return [f"{reagent.name} - {reagent.lot}" for reagent in self.reagent]
class Reagent(BaseClass, LogMixin): class Reagent(BaseClass, LogMixin):
""" """
@@ -1034,7 +1040,8 @@ class ProcedureType(BaseClass):
id = Column(INTEGER, primary_key=True) id = Column(INTEGER, primary_key=True)
name = Column(String(64)) name = Column(String(64))
reagent_map = Column(JSON) reagent_map = Column(JSON)
plate_size = Column(INTEGER, default=0) plate_columns = Column(INTEGER, default=0)
plate_rows = Column(INTEGER, default=0)
procedure = relationship("Procedure", procedure = relationship("Procedure",
back_populates="proceduretype") #: Concrete control of this type. back_populates="proceduretype") #: Concrete control of this type.
@@ -1140,6 +1147,54 @@ class ProcedureType(BaseClass):
raise TypeError(f"Type {type(equipmentrole)} is not allowed") raise TypeError(f"Type {type(equipmentrole)} is not allowed")
return list(set([item for items in relevant for item in items if item is not None])) return list(set([item for items in relevant for item in items if item is not None]))
@property
def as_dict(self):
return dict(
name=self.name,
kittype=[item.name for item in self.kittype]
)
def construct_dummy_procedure(self):
from backend.validators.pydant import PydProcedure
output = dict(
proceduretype=self,
#name=dict(value=self.name, missing=True),
#possible_kits=[kittype.name for kittype in self.kittype],
repeat=False,
plate_map=self.construct_plate_map()
)
return PydProcedure(**output)
def construct_plate_map(self) -> str:
"""
Constructs an html based plate map for procedure details.
Args:
sample_list (list): List of procedure sample
plate_rows (int, optional): Number of rows in the plate. Defaults to 8.
plate_columns (int, optional): Number of columns in the plate. Defaults to 12.
Returns:
str: html output string.
"""
if self.plate_rows == 0 or self.plate_columns == 0:
return "<br/>"
plate_rows = range(1, self.plate_rows + 1)
plate_columns = range(1, self.plate_columns + 1)
total_wells = self.plate_columns * self.plate_rows
vw = round((-0.07 * total_wells) + 12.2, 1)
wells = [dict(name="", row=row, column=column, background_color="#ffffff")
for row in plate_rows
for column in plate_columns]
# NOTE: An overly complicated list comprehension create a list of sample locations
# NOTE: next will return a blank cell if no value found for row/column
env = jinja_template_loading()
template = env.get_template("plate_map.html")
html = template.render(plate_rows=self.plate_rows, plate_columns=self.plate_columns, samples=wells, vw=vw)
return html + "<br/>"
class Procedure(BaseClass): class Procedure(BaseClass):
id = Column(INTEGER, primary_key=True) id = Column(INTEGER, primary_key=True)
@@ -1164,7 +1219,8 @@ class Procedure(BaseClass):
) #: Relation to ProcedureReagentAssociation ) #: Relation to ProcedureReagentAssociation
reagents = association_proxy("procedurereagentassociation", reagents = association_proxy("procedurereagentassociation",
"reagent", creator=lambda reg: ProcedureReagentAssociation(reagent=reg)) #: Association proxy to RunReagentAssociation.reagent "reagent", creator=lambda reg: ProcedureReagentAssociation(
reagent=reg)) #: Association proxy to RunReagentAssociation.reagent
procedureequipmentassociation = relationship( procedureequipmentassociation = relationship(
"ProcedureEquipmentAssociation", "ProcedureEquipmentAssociation",
@@ -1193,7 +1249,8 @@ class Procedure(BaseClass):
@classmethod @classmethod
@setup_lookup @setup_lookup
def query(cls, id: int|None = None, name: str | None = None, limit: int = 0, **kwargs) -> Procedure | List[Procedure]: def query(cls, id: int | None = None, name: str | None = None, limit: int = 0, **kwargs) -> Procedure | List[
Procedure]:
query: Query = cls.__database_session__.query(cls) query: Query = cls.__database_session__.query(cls)
match id: match id:
case int(): case int():
@@ -2431,7 +2488,8 @@ class Tips(BaseClass, LogMixin):
) )
if full_data: if full_data:
subs = [ subs = [
dict(plate=item.procedure.procedure.rsl_plate_num, role=item.role_name, sub_date=item.procedure.procedure.clientsubmission.submitted_date) dict(plate=item.procedure.procedure.rsl_plate_num, role=item.role_name,
sub_date=item.procedure.procedure.clientsubmission.submitted_date)
for item in self.tipsprocedureassociation] for item in self.tipsprocedureassociation]
output['procedure'] = sorted(subs, key=itemgetter("sub_date"), reverse=True) output['procedure'] = sorted(subs, key=itemgetter("sub_date"), reverse=True)
output['excluded'] = ['missing', 'procedure', 'excluded', 'editable'] output['excluded'] = ['missing', 'procedure', 'excluded', 'editable']
@@ -2466,7 +2524,8 @@ class ProcedureTypeTipRoleAssociation(BaseClass):
proceduretype_id = Column(INTEGER, ForeignKey("_proceduretype.id"), proceduretype_id = Column(INTEGER, ForeignKey("_proceduretype.id"),
primary_key=True) #: id of associated procedure primary_key=True) #: id of associated procedure
uses = Column(JSON) #: locations of equipment on the procedure type excel sheet. uses = Column(JSON) #: locations of equipment on the procedure type excel sheet.
static = Column(INTEGER, default=1) #: if 1 this piece of equipment will always be used, otherwise it will need to be selected from list? static = Column(INTEGER,
default=1) #: if 1 this piece of equipment will always be used, otherwise it will need to be selected from list?
proceduretype = relationship(ProcedureType, proceduretype = relationship(ProcedureType,
back_populates="proceduretypetiproleassociation") #: associated procedure back_populates="proceduretypetiproleassociation") #: associated procedure
tiprole = relationship(TipRole, tiprole = relationship(TipRole,

View File

@@ -15,8 +15,6 @@ from operator import itemgetter
from pprint import pformat from pprint import pformat
from pandas import DataFrame from pandas import DataFrame
from sqlalchemy.ext.hybrid import hybrid_property from sqlalchemy.ext.hybrid import hybrid_property
from . import Base, BaseClass, Reagent, SubmissionType, KitType, ClientLab, Contact, LogMixin from . import Base, BaseClass, Reagent, SubmissionType, KitType, ClientLab, Contact, LogMixin
from sqlalchemy import Column, String, TIMESTAMP, INTEGER, ForeignKey, JSON, FLOAT, case, func, Table from sqlalchemy import Column, String, TIMESTAMP, INTEGER, ForeignKey, JSON, FLOAT, case, func, Table
from sqlalchemy.orm import relationship, validates, Query from sqlalchemy.orm import relationship, validates, Query
@@ -1122,8 +1120,13 @@ class Run(BaseClass, LogMixin):
return output return output
def add_procedure(self, obj, proceduretype_name: str): def add_procedure(self, obj, proceduretype_name: str):
from frontend.widgets.procedure_creation import ProcedureCreation
procedure_type = next((proceduretype for proceduretype in self.allowed_procedures if proceduretype.name == proceduretype_name)) procedure_type = next((proceduretype for proceduretype in self.allowed_procedures if proceduretype.name == proceduretype_name))
logger.debug(f"Got ProcedureType: {procedure_type}") logger.debug(f"Got ProcedureType: {procedure_type}")
dlg = ProcedureCreation(parent=obj, run=self, proceduretype=procedure_type)
if dlg.exec():
pass
def delete(self, obj=None): def delete(self, obj=None):
""" """

View File

@@ -208,4 +208,4 @@ class RSLNamer(object):
from .pydant import PydSubmission, PydKitType, PydContact, PydOrganization, PydSample, PydReagent, PydReagentRole, \ from .pydant import PydSubmission, PydKitType, PydContact, PydOrganization, PydSample, PydReagent, PydReagentRole, \
PydEquipment, PydEquipmentRole, PydTips, PydProcess, PydElastic, PydClientSubmission PydEquipment, PydEquipmentRole, PydTips, PydProcess, PydElastic, PydClientSubmission, PydProcedure

View File

@@ -24,7 +24,6 @@ logger = logging.getLogger(f"procedure.{__name__}")
class PydBaseClass(BaseModel, extra='allow', validate_assignment=True): class PydBaseClass(BaseModel, extra='allow', validate_assignment=True):
_sql_object: ClassVar = None _sql_object: ClassVar = None
@model_validator(mode="before") @model_validator(mode="before")
@@ -240,7 +239,6 @@ class PydReagent(BaseModel):
class PydSample(PydBaseClass): class PydSample(PydBaseClass):
sample_id: str sample_id: str
sampletype: str | None = Field(default=None) sampletype: str | None = Field(default=None)
submission_rank: int | List[int] | None = Field(default=0, validate_default=True) submission_rank: int | List[int] | None = Field(default=0, validate_default=True)
@@ -1336,8 +1334,65 @@ class PydElastic(BaseModel, extra="allow", arbitrary_types_allowed=True):
# NOTE: Generified objects below: # NOTE: Generified objects below:
class PydClientSubmission(PydBaseClass): class PydProcedure(PydBaseClass, arbitrary_types_allowed=True):
proceduretype: ProcedureType | None = Field(default=None)
name: dict = Field(default=dict(value="NA", missing=True), validate_default=True)
technician: dict = Field(default=dict(value="NA", missing=True))
repeat: bool = Field(default=False)
kittype: dict = Field(default=dict(value="NA", missing=True))
possible_kits: list | None = Field(default=[], validate_default=True)
plate_map: str | None = Field(default=None)
reagent: list | None = Field(default=[])
reagentrole: dict | None = Field(default={}, validate_default=True)
@field_validator("name")
@classmethod
def rescue_name(cls, value, values):
if value['value'] == cls.model_fields['name'].default['value']:
if values.data['proceduretype']:
value['value'] = values.data['proceduretype'].name
return value
@field_validator("possible_kits")
@classmethod
def rescue_possible_kits(cls, value, values):
if not value:
if values.data['proceduretype']:
value = [kittype.name for kittype in values.data['proceduretype'].kittype]
return value
@field_validator("name", "technician", "kittype")
@classmethod
def set_colour(cls, value):
if value["missing"]:
value["colour"] = "FE441D"
else:
value["colour"] = "6ffe1d"
return value
@field_validator("reagentrole")
@classmethod
def rescue_reagentrole(cls, value, values):
if not value:
if values.data['kittype']['value'] != cls.model_fields['kittype'].default['value']:
kittype = KitType.query(name=values.data['kittype']['value'])
value = {item.name: item.reagents for item in kittype.reagentrole}
return value
def update_kittype_reagentroles(self, kittype: str | KitType):
if kittype == self.__class__.model_fields['kittype'].default['value']:
return
if isinstance(kittype, str):
kittype_obj = KitType.query(name=kittype)
try:
self.reagentrole = {item.name: item.reagents for item in
kittype_obj.get_reagents(proceduretype=self.proceduretype)}
except AttributeError:
self.reagentrole = {}
self.possible_kits.insert(0, self.possible_kits.pop(self.possible_kits.index(kittype)))
class PydClientSubmission(PydBaseClass):
# sql_object: ClassVar = ClientSubmission # sql_object: ClassVar = ClientSubmission
filepath: Path filepath: Path
@@ -1412,5 +1467,3 @@ class PydClientSubmission(PydBaseClass):
""" """
from frontend.widgets.submission_widget import ClientSubmissionFormWidget from frontend.widgets.submission_widget import ClientSubmissionFormWidget
return ClientSubmissionFormWidget(parent=parent, clientsubmission=self, samples=samples, disable=disable) return ClientSubmissionFormWidget(parent=parent, clientsubmission=self, samples=samples, disable=disable)

View File

@@ -40,4 +40,47 @@
cursor: pointer; cursor: pointer;
} }
div.procedure_creation_leftright {
width:100%;
overflow:auto;
border-style: solid;
border-width: 2px;
background-color: #FFFFFF;
display: flex;
}
div.procedure_creation_leftright div.left {
height: 100%;
width:15%;
float:left;
border-style: solid;
border-width: 1px;
background-color: #FFF4A3;
padding: 5px;
flex: 0.25;
}
div.procedure_creation_leftright div.right {
height: 100%;
width:84%;
float:left;
border-style: solid;
border-width: 1px;
background-color:#D9EEE1;
padding: 5px;
flex: 0.75;
object-fit: contain;
}
.dropdown {
width: 100%;
}
.form_text {
width: 97%;
}
div.gallery {
width: 100%;
object-fit: contain;
}

View File

@@ -1,6 +1,6 @@
<div class="gallery" style="display: grid;grid-template-columns: repeat({{ PLATE_COLUMNS }}, 7.5vw);grid-template-rows: repeat({{ PLATE_ROWS }}, 7.5vw);grid-gap: 2px;"> <div class="gallery" style="display: grid;grid-template-columns: repeat({{ plate_columns }}, {{ vw }}vw);grid-template-rows: repeat({{ plate_rows }}, {{ vw }}vw);grid-gap: 2px;">
{% for sample in samples %} {% for sample in samples %}
<div class="well data-link sample" id="{{sample['submitter_id']}}" style="background-color: {{sample['background_color']}}; <div class="well data-link sample" id="{{sample['submitter_id']}}" style="background-color: {{ sample['background_color'] }};
border: 1px solid #000; border: 1px solid #000;
padding: 20px; padding: 20px;
grid-column-start: {{sample['column']}}; grid-column-start: {{sample['column']}};