From 4e1e06bb5ef85c950e9763d0024d972784e57db0 Mon Sep 17 00:00:00 2001 From: lwark Date: Fri, 23 May 2025 15:18:18 -0500 Subject: [PATCH] Basic Procedure addition working. --- src/submissions/backend/db/models/__init__.py | 2 + src/submissions/backend/db/models/kits.py | 93 +++++++++++++++---- .../backend/db/models/submissions.py | 7 +- .../backend/validators/__init__.py | 2 +- src/submissions/backend/validators/pydant.py | 63 ++++++++++++- src/submissions/templates/css/styles.css | 43 +++++++++ src/submissions/templates/plate_map.html | 4 +- 7 files changed, 187 insertions(+), 27 deletions(-) diff --git a/src/submissions/backend/db/models/__init__.py b/src/submissions/backend/db/models/__init__.py index 9b7dbea..81a870c 100644 --- a/src/submissions/backend/db/models/__init__.py +++ b/src/submissions/backend/db/models/__init__.py @@ -555,6 +555,8 @@ class BaseClass(Base): return output_date + + class LogMixin(Base): tracking_exclusion: ClassVar = ['artic_technician', 'clientsubmissionsampleassociation', 'submission_reagent_associations', 'submission_equipment_associations', diff --git a/src/submissions/backend/db/models/kits.py b/src/submissions/backend/db/models/kits.py index 2f3897c..c09310a 100644 --- a/src/submissions/backend/db/models/kits.py +++ b/src/submissions/backend/db/models/kits.py @@ -14,12 +14,15 @@ from sqlalchemy.ext.hybrid import hybrid_property from datetime import date, datetime, timedelta from tools import check_authorization, setup_lookup, Report, Result, check_regex_match, yaml_regex_creator, timezone, \ 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 pathlib import Path from . import Base, BaseClass, ClientLab, LogMixin from io import BytesIO +if TYPE_CHECKING: + from backend.db.models.submissions import Run + logger = logging.getLogger(f'procedure.{__name__}') reagentrole_reagent = Table( @@ -159,7 +162,7 @@ class KitType(BaseClass): def get_reagents(self, required_only: bool = False, - proceduretype: str | SubmissionType | None = None + proceduretype: str | ProcedureType | None = None ) -> Generator[ReagentRole, None, None]: """ Return ReagentTypes linked to kittype through KitTypeReagentTypeAssociation. @@ -181,9 +184,9 @@ class KitType(BaseClass): case _: relevant_associations = [item for item in self.kittypereagentroleassociation] 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: - 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]: """ @@ -536,6 +539,9 @@ class ReagentRole(BaseClass): logger.debug(f"Constructing OmniReagentRole with name {self.name}") 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): """ @@ -841,11 +847,11 @@ class SubmissionType(BaseClass): info_map = Column(JSON) #: Where parsable information is found in the excel workbook corresponding to this type. defaults = Column(JSON) #: Basic information about this procedure type clientsubmission = relationship("ClientSubmission", - back_populates="submissiontype") #: Concrete control of this type. + back_populates="submissiontype") #: Concrete control of this type. template_file = Column(BLOB) #: Blank form for this type stored as binary. sample_map = Column(JSON) #: Where sample information is found in the excel sheet corresponding to this type. proceduretype = relationship("ProcedureType", back_populates="submissiontype", - secondary=submissiontype_proceduretype) #: run this kittype was used for + secondary=submissiontype_proceduretype) #: run this kittype was used for def __repr__(self) -> str: """ @@ -1034,7 +1040,8 @@ class ProcedureType(BaseClass): id = Column(INTEGER, primary_key=True) name = Column(String(64)) 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", back_populates="proceduretype") #: Concrete control of this type. @@ -1140,6 +1147,54 @@ class ProcedureType(BaseClass): 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])) + @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 "
" + 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 + "
" + class Procedure(BaseClass): id = Column(INTEGER, primary_key=True) @@ -1164,7 +1219,8 @@ class Procedure(BaseClass): ) #: Relation to 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", @@ -1193,7 +1249,8 @@ class Procedure(BaseClass): @classmethod @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) match id: case int(): @@ -1674,9 +1731,9 @@ class Equipment(BaseClass, LogMixin): nickname = Column(String(64)) #: equipment nickname asset_number = Column(String(16)) #: Given asset number (corpo nickname if you will) equipmentrole = relationship("EquipmentRole", back_populates="equipment", - secondary=equipmentrole_equipment) #: relation to EquipmentRoles + secondary=equipmentrole_equipment) #: relation to EquipmentRoles process = relationship("Process", back_populates="equipment", - secondary=equipment_process) #: relation to Processes + secondary=equipment_process) #: relation to Processes tips = relationship("Tips", back_populates="equipment", secondary=equipment_tips) #: relation to Processes equipmentprocedureassociation = relationship( @@ -1686,7 +1743,7 @@ class Equipment(BaseClass, LogMixin): ) #: Association with BasicRun procedure = association_proxy("equipmentprocedureassociation", - "procedure") #: proxy to equipmentprocedureassociation.procedure + "procedure") #: proxy to equipmentprocedureassociation.procedure def to_dict(self, processes: bool = False) -> dict: """ @@ -1888,7 +1945,7 @@ class EquipmentRole(BaseClass): equipment = relationship("Equipment", back_populates="equipmentrole", secondary=equipmentrole_equipment) #: Concrete control (Equipment) of reagentrole process = relationship("Process", back_populates='equipmentrole', - secondary=equipmentrole_process) #: Associated Processes + secondary=equipmentrole_process) #: Associated Processes equipmentroleproceduretypeassociation = relationship( "ProcedureTypeEquipmentRoleAssociation", @@ -1897,7 +1954,7 @@ class EquipmentRole(BaseClass): ) #: relation to SubmissionTypes proceduretype = association_proxy("equipmentroleproceduretypeassociation", - "proceduretype") #: proxy to equipmentroleproceduretypeassociation.proceduretype + "proceduretype") #: proxy to equipmentroleproceduretypeassociation.proceduretype def to_dict(self) -> dict: """ @@ -2018,7 +2075,7 @@ class ProcedureEquipmentAssociation(BaseClass): comments = Column(String(1024)) #: comments about equipment procedure = relationship(Procedure, - back_populates="procedureequipmentassociation") #: associated procedure + back_populates="procedureequipmentassociation") #: associated procedure equipment = relationship(Equipment, back_populates="equipmentprocedureassociation") #: associated equipment @@ -2431,7 +2488,8 @@ class Tips(BaseClass, LogMixin): ) if full_data: 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] output['procedure'] = sorted(subs, key=itemgetter("sub_date"), reverse=True) output['excluded'] = ['missing', 'procedure', 'excluded', 'editable'] @@ -2466,7 +2524,8 @@ class ProcedureTypeTipRoleAssociation(BaseClass): proceduretype_id = Column(INTEGER, ForeignKey("_proceduretype.id"), primary_key=True) #: id of associated procedure 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, back_populates="proceduretypetiproleassociation") #: associated procedure tiprole = relationship(TipRole, diff --git a/src/submissions/backend/db/models/submissions.py b/src/submissions/backend/db/models/submissions.py index 9e0fcdd..04280a9 100644 --- a/src/submissions/backend/db/models/submissions.py +++ b/src/submissions/backend/db/models/submissions.py @@ -15,8 +15,6 @@ from operator import itemgetter from pprint import pformat from pandas import DataFrame from sqlalchemy.ext.hybrid import hybrid_property - - 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.orm import relationship, validates, Query @@ -1122,8 +1120,13 @@ class Run(BaseClass, LogMixin): return output 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)) 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): """ diff --git a/src/submissions/backend/validators/__init__.py b/src/submissions/backend/validators/__init__.py index 45673b0..a07ef8e 100644 --- a/src/submissions/backend/validators/__init__.py +++ b/src/submissions/backend/validators/__init__.py @@ -208,4 +208,4 @@ class RSLNamer(object): from .pydant import PydSubmission, PydKitType, PydContact, PydOrganization, PydSample, PydReagent, PydReagentRole, \ - PydEquipment, PydEquipmentRole, PydTips, PydProcess, PydElastic, PydClientSubmission + PydEquipment, PydEquipmentRole, PydTips, PydProcess, PydElastic, PydClientSubmission, PydProcedure diff --git a/src/submissions/backend/validators/pydant.py b/src/submissions/backend/validators/pydant.py index 2b890fb..d67f9cd 100644 --- a/src/submissions/backend/validators/pydant.py +++ b/src/submissions/backend/validators/pydant.py @@ -24,7 +24,6 @@ logger = logging.getLogger(f"procedure.{__name__}") class PydBaseClass(BaseModel, extra='allow', validate_assignment=True): - _sql_object: ClassVar = None @model_validator(mode="before") @@ -240,7 +239,6 @@ class PydReagent(BaseModel): class PydSample(PydBaseClass): - sample_id: str sampletype: str | None = Field(default=None) 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: -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 filepath: Path @@ -1412,5 +1467,3 @@ class PydClientSubmission(PydBaseClass): """ from frontend.widgets.submission_widget import ClientSubmissionFormWidget return ClientSubmissionFormWidget(parent=parent, clientsubmission=self, samples=samples, disable=disable) - - diff --git a/src/submissions/templates/css/styles.css b/src/submissions/templates/css/styles.css index c890cd1..a14b6cf 100644 --- a/src/submissions/templates/css/styles.css +++ b/src/submissions/templates/css/styles.css @@ -40,4 +40,47 @@ 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; +} diff --git a/src/submissions/templates/plate_map.html b/src/submissions/templates/plate_map.html index dbc867c..586db7d 100644 --- a/src/submissions/templates/plate_map.html +++ b/src/submissions/templates/plate_map.html @@ -1,6 +1,6 @@ -