From a534d229a85e59dbbd2965f29f9b0c3d63df7aa6 Mon Sep 17 00:00:00 2001 From: Landon Wark Date: Fri, 9 Feb 2024 14:03:35 -0600 Subject: [PATCH] Code cleanup and documentation --- CHANGELOG.md | 4 + TODO.md | 2 + src/submissions/__init__.py | 2 +- src/submissions/backend/db/models/__init__.py | 38 +- src/submissions/backend/db/models/controls.py | 71 +- src/submissions/backend/db/models/kits.py | 544 +++++++--- .../backend/db/models/organizations.py | 31 +- .../backend/db/models/submissions.py | 994 +++++++++++------- src/submissions/backend/excel/parser.py | 63 +- src/submissions/backend/excel/reports.py | 2 +- .../backend/validators/__init__.py | 67 +- src/submissions/backend/validators/pydant.py | 100 +- .../frontend/visualizations/__init__.py | 2 - .../frontend/visualizations/barcode.py | 19 - .../frontend/visualizations/control_charts.py | 4 +- .../frontend/visualizations/plate_map.py | 121 --- src/submissions/frontend/widgets/app.py | 5 - .../frontend/widgets/controls_chart.py | 7 +- .../frontend/widgets/equipment_usage.py | 77 +- .../frontend/widgets/gel_checker.py | 68 +- .../frontend/widgets/kit_creator.py | 17 +- src/submissions/frontend/widgets/misc.py | 17 +- src/submissions/frontend/widgets/pop_ups.py | 20 +- .../frontend/widgets/submission_details.py | 108 +- .../frontend/widgets/submission_table.py | 145 +-- .../widgets/submission_type_creator.py | 50 +- .../frontend/widgets/submission_widget.py | 178 ++-- ...ails.html => basicsubmission_details.html} | 8 +- .../templates/wastewaterartic_details.html | 38 + src/submissions/tools.py | 103 +- 30 files changed, 1558 insertions(+), 1347 deletions(-) delete mode 100644 src/submissions/frontend/visualizations/barcode.py delete mode 100644 src/submissions/frontend/visualizations/plate_map.py rename src/submissions/templates/{submission_details.html => basicsubmission_details.html} (93%) create mode 100644 src/submissions/templates/wastewaterartic_details.html diff --git a/CHANGELOG.md b/CHANGELOG.md index 964badb..5d3e3a7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,7 @@ +## 202402.01 + +- Addition of gel box for Artic quality control. + ## 202401.04 - Large scale database refactor to increase modularity. diff --git a/TODO.md b/TODO.md index 0b7ff1e..460c882 100644 --- a/TODO.md +++ b/TODO.md @@ -1,3 +1,5 @@ +- [x] Create platemap image from html for export to pdf. +- [x] Move plate map maker to submission. - [x] Finish Equipment Parser (add in regex to id asset_number) - [ ] Complete info_map in the SubmissionTypeCreator widget. - [x] Update Artic and add in equipment listings... *sigh*. diff --git a/src/submissions/__init__.py b/src/submissions/__init__.py index ec5a034..af20f70 100644 --- a/src/submissions/__init__.py +++ b/src/submissions/__init__.py @@ -4,7 +4,7 @@ from pathlib import Path # Version of the realpython-reader package __project__ = "submissions" -__version__ = "202401.4b" +__version__ = "202402.1b" __author__ = {"name":"Landon Wark", "email":"Landon.Wark@phac-aspc.gc.ca"} __copyright__ = "2022-2024, Government of Canada" diff --git a/src/submissions/backend/db/models/__init__.py b/src/submissions/backend/db/models/__init__.py index 9be4d47..0cce026 100644 --- a/src/submissions/backend/db/models/__init__.py +++ b/src/submissions/backend/db/models/__init__.py @@ -2,7 +2,7 @@ Contains all models for sqlalchemy ''' import sys -from sqlalchemy.orm import DeclarativeMeta, declarative_base +from sqlalchemy.orm import DeclarativeMeta, declarative_base, Query from sqlalchemy.ext.declarative import declared_attr if 'pytest' in sys.modules: from pathlib import Path @@ -23,10 +23,16 @@ class BaseClass(Base): @declared_attr def __tablename__(cls): + """ + Set tablename to lowercase class name + """ return f"_{cls.__name__.lower()}" @declared_attr def __database_session__(cls): + """ + Pull db session from ctx + """ if not 'pytest' in sys.modules: from tools import ctx else: @@ -35,6 +41,9 @@ class BaseClass(Base): @declared_attr def __directory_path__(cls): + """ + Pull submission directory from ctx + """ if not 'pytest' in sys.modules: from tools import ctx else: @@ -43,14 +52,39 @@ class BaseClass(Base): @declared_attr def __backup_path__(cls): + """ + Pull backup directory from ctx + """ if not 'pytest' in sys.modules: from tools import ctx else: from test_settings import ctx return ctx.backup_path + def query_return(query:Query, limit:int=0): + """ + Execute sqlalchemy query. + + Args: + query (Query): Query object + limit (int, optional): Maximum number of results to return (0 = all). Defaults to 0. + + Returns: + _type_: Query result. + """ + with query.session.no_autoflush: + match limit: + case 0: + return query.all() + case 1: + return query.first() + case _: + return query.limit(limit).all() + def save(self): - # logger.debug(f"Saving {self}") + """ + Add the object to the database and commit + """ try: self.__database_session__.add(self) self.__database_session__.commit() diff --git a/src/submissions/backend/db/models/controls.py b/src/submissions/backend/db/models/controls.py index 0a3472b..46c6238 100644 --- a/src/submissions/backend/db/models/controls.py +++ b/src/submissions/backend/db/models/controls.py @@ -7,7 +7,7 @@ from sqlalchemy.orm import relationship, Query import logging, json from operator import itemgetter from . import BaseClass -from tools import setup_lookup, query_return +from tools import setup_lookup from datetime import date, datetime from typing import List from dateutil.parser import parse @@ -18,7 +18,6 @@ class ControlType(BaseClass): """ Base class of a control archetype. """ - # __tablename__ = '_control_types' id = Column(INTEGER, primary_key=True) #: primary key name = Column(String(255), unique=True) #: controltype name (e.g. MCS) @@ -48,7 +47,7 @@ class ControlType(BaseClass): limit = 1 case _: pass - return query_return(query=query, limit=limit) + return cls.query_return(query=query, limit=limit) def get_subtypes(self, mode:str) -> List[str]: """ @@ -60,10 +59,13 @@ class ControlType(BaseClass): Returns: List[str]: list of subtypes available """ + # Get first instance since all should have same subtypes outs = self.instances[0] + # Get mode of instance jsoner = json.loads(getattr(outs, mode)) logger.debug(f"JSON out: {jsoner.keys()}") try: + # Pick genera (all should have same subtypes) genera = list(jsoner.keys())[0] except IndexError: return [] @@ -74,8 +76,6 @@ class Control(BaseClass): """ Base class of a control sample. """ - - # __tablename__ = '_control_samples' id = Column(INTEGER, primary_key=True) #: primary key parent_id = Column(String, ForeignKey("_controltype.id", name="fk_control_parent_id")) #: primary key of control type @@ -90,10 +90,14 @@ class Control(BaseClass): refseq_version = Column(String(16)) #: version of refseq used in fastq parsing kraken2_version = Column(String(16)) #: version of kraken2 used in fastq parsing kraken2_db_version = Column(String(32)) #: folder name of kraken2 db - sample = relationship("BacterialCultureSample", back_populates="control") - sample_id = Column(INTEGER, ForeignKey("_basicsample.id", ondelete="SET NULL", name="cont_BCS_id")) + sample = relationship("BacterialCultureSample", back_populates="control") #: This control's submission sample + sample_id = Column(INTEGER, ForeignKey("_basicsample.id", ondelete="SET NULL", name="cont_BCS_id")) #: sample id key def __repr__(self) -> str: + """ + Returns: + str: Representation of self + """ return f"" def to_sub_dict(self) -> dict: @@ -103,25 +107,25 @@ class Control(BaseClass): Returns: dict: output dictionary containing: Name, Type, Targets, Top Kraken results """ - # load json string into dict + # logger.debug("loading json string into dict") try: kraken = json.loads(self.kraken) except TypeError: kraken = {} - # calculate kraken count total to use in percentage + # logger.debug("calculating kraken count total to use in percentage") kraken_cnt_total = sum([kraken[item]['kraken_count'] for item in kraken]) new_kraken = [] for item in kraken: - # calculate kraken percent (overwrites what's already been scraped) + # logger.debug("calculating kraken percent (overwrites what's already been scraped)") kraken_percent = kraken[item]['kraken_count'] / kraken_cnt_total new_kraken.append({'name': item, 'kraken_count':kraken[item]['kraken_count'], 'kraken_percent':"{0:.0%}".format(kraken_percent)}) new_kraken = sorted(new_kraken, key=itemgetter('kraken_count'), reverse=True) - # set targets + # logger.debug("setting targets") if self.controltype.targets == []: targets = ["None"] else: targets = self.controltype.targets - # construct output dictionary + # logger.debug("constructing output dictionary") output = { "name" : self.name, "type" : self.controltype.name, @@ -141,49 +145,28 @@ class Control(BaseClass): list[dict]: list of records """ output = [] - # load json string for mode (i.e. contains, matches, kraken2) + # logger.debug("load json string for mode (i.e. contains, matches, kraken2)") try: data = json.loads(getattr(self, mode)) except TypeError: data = {} logger.debug(f"Length of data: {len(data)}") - # dict keys are genera of bacteria, e.g. 'Streptococcus' + # logger.debug("dict keys are genera of bacteria, e.g. 'Streptococcus'") for genus in data: _dict = {} _dict['name'] = self.name _dict['submitted_date'] = self.submitted_date _dict['genus'] = genus - # get Target or Off-target of genus + # logger.debug("get Target or Off-target of genus") _dict['target'] = 'Target' if genus.strip("*") in self.controltype.targets else "Off-target" - # set 'contains_hashes', etc for genus, + # logger.debug("set 'contains_hashes', etc for genus") for key in data[genus]: _dict[key] = data[genus][key] output.append(_dict) - # Have to triage kraken data to keep program from getting overwhelmed + # logger.debug("Have to triage kraken data to keep program from getting overwhelmed") if "kraken" in mode: output = sorted(output, key=lambda d: d[f"{mode}_count"], reverse=True)[:49] return output - - def create_dummy_data(self, mode:str) -> dict: - """ - Create non-zero length data to maintain entry of zero length 'contains' (depreciated) - - Args: - mode (str): analysis type, 'contains', etc - - Returns: - dict: dictionary of 'Nothing' genus - """ - match mode: - case "contains": - data = {"Nothing": {"contains_hashes":"0/400", "contains_ratio":0.0}} - case "matches": - data = {"Nothing": {"matches_hashes":"0/400", "matches_ratio":0.0}} - case "kraken": - data = {"Nothing": {"kraken_percent":0.0, "kraken_count":0}} - case _: - data = {} - return data @classmethod def get_modes(cls) -> List[str]: @@ -194,6 +177,7 @@ class Control(BaseClass): List[str]: List of control mode names. """ try: + # logger.debug("Creating a list of JSON columns in _controls table") cols = [item.name for item in list(cls.__table__.columns) if isinstance(item.type, JSON)] except AttributeError as e: logger.error(f"Failed to get available modes from db: {e}") @@ -243,25 +227,32 @@ class Control(BaseClass): if start_date != None: match start_date: case date(): + # logger.debug(f"Lookup control by start date({start_date})") start_date = start_date.strftime("%Y-%m-%d") case int(): + # logger.debug(f"Lookup control by ordinal start date {start_date}") start_date = datetime.fromordinal(datetime(1900, 1, 1).toordinal() + start_date - 2).date().strftime("%Y-%m-%d") case _: + # logger.debug(f"Lookup control with parsed start date {start_date}") start_date = parse(start_date).strftime("%Y-%m-%d") match end_date: case date(): + # logger.debug(f"Lookup control by end date({end_date})") end_date = end_date.strftime("%Y-%m-%d") case int(): + # logger.debug(f"Lookup control by ordinal end date {end_date}") end_date = datetime.fromordinal(datetime(1900, 1, 1).toordinal() + end_date - 2).date().strftime("%Y-%m-%d") case _: + # logger.debug(f"Lookup control with parsed end date {end_date}") end_date = parse(end_date).strftime("%Y-%m-%d") # logger.debug(f"Looking up BasicSubmissions from start date: {start_date} and end date: {end_date}") query = query.filter(cls.submitted_date.between(start_date, end_date)) match control_name: case str(): + # logger.debug(f"Lookup control by name {control_name}") query = query.filter(cls.name.startswith(control_name)) limit = 1 case _: pass - return query_return(query=query, limit=limit) - + return cls.query_return(query=query, limit=limit) + \ No newline at end of file diff --git a/src/submissions/backend/db/models/kits.py b/src/submissions/backend/db/models/kits.py index 7d18332..c6834e9 100644 --- a/src/submissions/backend/db/models/kits.py +++ b/src/submissions/backend/db/models/kits.py @@ -4,7 +4,7 @@ All kit and reagent related models from __future__ import annotations from sqlalchemy import Column, String, TIMESTAMP, JSON, INTEGER, ForeignKey, Interval, Table, FLOAT, BLOB from sqlalchemy.orm import relationship, validates, Query -from sqlalchemy.ext.associationproxy import association_proxy +from sqlalchemy.ext.associationproxy import association_proxy, AssociationProxy from datetime import date import logging, re from tools import check_authorization, setup_lookup, query_return, Report, Result, Settings @@ -15,6 +15,7 @@ from . import Base, BaseClass, Organization logger = logging.getLogger(f'submissions.{__name__}') +# logger.debug("Table for ReagentType/Reagent relations") reagenttypes_reagents = Table( "_reagenttypes_reagents", Base.metadata, @@ -23,6 +24,7 @@ reagenttypes_reagents = Table( extend_existing = True ) +# logger.debug("Table for EquipmentRole/Equipment relations") equipmentroles_equipment = Table( "_equipmentroles_equipment", Base.metadata, @@ -31,6 +33,7 @@ equipmentroles_equipment = Table( extend_existing=True ) +# logger.debug("Table for Equipment/Process relations") equipment_processes = Table( "_equipment_processes", Base.metadata, @@ -39,6 +42,7 @@ equipment_processes = Table( extend_existing=True ) +# logger.debug("Table for EquipmentRole/Process relations") equipmentroles_processes = Table( "_equipmentroles_processes", Base.metadata, @@ -47,6 +51,7 @@ equipmentroles_processes = Table( extend_existing=True ) +# logger.debug("Table for SubmissionType/Process relations") submissiontypes_processes = Table( "_submissiontypes_processes", Base.metadata, @@ -55,6 +60,7 @@ submissiontypes_processes = Table( extend_existing=True ) +# logger.debug("Table for KitType/Process relations") kittypes_processes = Table( "_kittypes_processes", Base.metadata, @@ -67,12 +73,11 @@ class KitType(BaseClass): """ Base of kits used in submission processing """ - # __tablename__ = "_kits" - + id = Column(INTEGER, primary_key=True) #: primary key name = Column(String(64), unique=True) #: name of kit submissions = relationship("BasicSubmission", back_populates="extraction_kit") #: submissions this kit was used for - processes = relationship("Process", back_populates="kit_types", secondary=kittypes_processes) + processes = relationship("Process", back_populates="kit_types", secondary=kittypes_processes) #: equipment processes used by this kit kit_reagenttype_associations = relationship( "KitTypeReagentTypeAssociation", @@ -80,20 +85,22 @@ class KitType(BaseClass): cascade="all, delete-orphan", ) - # association proxy of "user_keyword_associations" collection - # to "keyword" attribute # creator function: https://stackoverflow.com/questions/11091491/keyerror-when-adding-objects-to-sqlalchemy-association-object/11116291#11116291 - reagent_types = association_proxy("kit_reagenttype_associations", "reagent_type", creator=lambda RT: KitTypeReagentTypeAssociation(reagent_type=RT)) + reagent_types = association_proxy("kit_reagenttype_associations", "reagent_type", creator=lambda RT: KitTypeReagentTypeAssociation(reagent_type=RT)) #: Association proxy to KitTypeReagentTypeAssociation kit_submissiontype_associations = relationship( "SubmissionTypeKitTypeAssociation", back_populates="kit_type", cascade="all, delete-orphan", - ) + ) #: Relation to SubmissionType - used_for = association_proxy("kit_submissiontype_associations", "submission_type") + used_for = association_proxy("kit_submissiontype_associations", "submission_type") #: Association proxy to SubmissionTypeKitTypeAssociation def __repr__(self) -> str: + """ + Returns: + str: A representation of the object. + """ return f"" def get_reagents(self, required:bool=False, submission_type:str|SubmissionType|None=None) -> List[ReagentType]: @@ -109,12 +116,16 @@ class KitType(BaseClass): """ match submission_type: case SubmissionType(): + # logger.debug(f"Getting reagents by SubmissionType {submission_type}") relevant_associations = [item for item in self.kit_reagenttype_associations if item.submission_type==submission_type] case str(): + # logger.debug(f"Getting reagents by str {submission_type}") relevant_associations = [item for item in self.kit_reagenttype_associations if item.submission_type.name==submission_type] case _: + # logger.debug(f"Getting reagents") relevant_associations = [item for item in self.kit_reagenttype_associations] if required: + # logger.debug(f"Filtering by required.") return [item.reagent_type for item in relevant_associations if item.required == 1] else: return [item.reagent_type for item in relevant_associations] @@ -133,14 +144,16 @@ class KitType(BaseClass): # Account for submission_type variable type. match submission_type: case str(): + # logger.debug(f"Constructing xl map with str {submission_type}") assocs = [item for item in self.kit_reagenttype_associations if item.submission_type.name==submission_type] st_assoc = [item for item in self.used_for if submission_type == item.name][0] case SubmissionType(): + # logger.debug(f"Constructing xl map with SubmissionType {submission_type}") assocs = [item for item in self.kit_reagenttype_associations if item.submission_type==submission_type] st_assoc = submission_type case _: raise ValueError(f"Wrong variable type: {type(submission_type)} used!") - # Get all KitTypeReagentTypeAssociation for SubmissionType + # logger.debug("Get all KitTypeReagentTypeAssociation for SubmissionType") for assoc in assocs: try: map[assoc.reagent_type.name] = assoc.uses @@ -176,42 +189,42 @@ class KitType(BaseClass): query: Query = cls.__database_session__.query(cls) match used_for: case str(): - # logger.debug(f"Looking up kit type by use: {used_for}") + # logger.debug(f"Looking up kit type by used_for str: {used_for}") query = query.filter(cls.used_for.any(name=used_for)) case SubmissionType(): + # logger.debug(f"Looking up kit type by used_for SubmissionType: {used_for}") query = query.filter(cls.used_for.contains(used_for)) case _: pass match name: case str(): - # logger.debug(f"Looking up kit type by name: {name}") + # logger.debug(f"Looking up kit type by name str: {name}") query = query.filter(cls.name==name) limit = 1 case _: pass match id: case int(): - # logger.debug(f"Looking up kit type by id: {id}") + # logger.debug(f"Looking up kit type by id int: {id}") query = query.filter(cls.id==id) limit = 1 case str(): - # logger.debug(f"Looking up kit type by id: {id}") + # logger.debug(f"Looking up kit type by id str: {id}") query = query.filter(cls.id==int(id)) limit = 1 case _: pass - return query_return(query=query, limit=limit) + return cls.query_return(query=query, limit=limit) @check_authorization - def save(self, ctx:Settings): + def save(self): super().save() class ReagentType(BaseClass): """ Base of reagent type abstract """ - # __tablename__ = "_reagent_types" - + id = Column(INTEGER, primary_key=True) #: primary key name = Column(String(64)) #: name of reagent type instances = relationship("Reagent", back_populates="type", secondary=reagenttypes_reagents) #: concrete instances of this reagent type @@ -221,14 +234,16 @@ class ReagentType(BaseClass): "KitTypeReagentTypeAssociation", back_populates="reagent_type", cascade="all, delete-orphan", - ) + ) #: Relation to KitTypeReagentTypeAssociation - # association proxy of "user_keyword_associations" collection - # to "keyword" attribute # creator function: https://stackoverflow.com/questions/11091491/keyerror-when-adding-objects-to-sqlalchemy-association-object/11116291#11116291 - kit_types = association_proxy("reagenttype_kit_associations", "kit_type", creator=lambda kit: KitTypeReagentTypeAssociation(kit_type=kit)) + kit_types = association_proxy("reagenttype_kit_associations", "kit_type", creator=lambda kit: KitTypeReagentTypeAssociation(kit_type=kit)) #: Association proxy to KitTypeReagentTypeAssociation - def __repr__(self): + def __repr__(self) -> str: + """ + Returns: + str: Representation of object + """ return f"" @classmethod @@ -262,11 +277,13 @@ class ReagentType(BaseClass): else: match kit_type: case str(): + # logger.debug(f"Lookup ReagentType by kittype str {kit_type}") kit_type = KitType.query(name=kit_type) case _: pass match reagent: case str(): + # logger.debug(f"Lookup ReagentType by reagent str {reagent}") reagent = Reagent.query(lot_number=reagent) case _: pass @@ -281,27 +298,32 @@ class ReagentType(BaseClass): return None match name: case str(): - # logger.debug(f"Looking up reagent type by name: {name}") + # logger.debug(f"Looking up reagent type by name str: {name}") query = query.filter(cls.name==name) limit = 1 case _: pass - return query_return(query=query, limit=limit) + return cls.query_return(query=query, limit=limit) - def to_pydantic(self): + def to_pydantic(self) -> "PydReagent": + """ + Create default PydReagent from this object + + Returns: + PydReagent: PydReagent representation of this object. + """ from backend.validators.pydant import PydReagent return PydReagent(lot=None, type=self.name, name=self.name, expiry=date.today()) @check_authorization - def save(self, ctx:Settings): + def save(self): super().save() class Reagent(BaseClass): """ Concrete reagent instance """ - # __tablename__ = "_reagents" - + id = Column(INTEGER, primary_key=True) #: primary key type = relationship("ReagentType", back_populates="instances", secondary=reagenttypes_reagents) #: joined parent reagent type type_id = Column(INTEGER, ForeignKey("_reagenttype.id", ondelete='SET NULL', name="fk_reagent_type_id")) #: id of parent reagent type @@ -314,8 +336,7 @@ class Reagent(BaseClass): back_populates="reagent", cascade="all, delete-orphan", ) #: Relation to SubmissionSampleAssociation - # association proxy of "user_keyword_associations" collection - # to "keyword" attribute + submissions = association_proxy("reagent_submission_associations", "submission") #: Association proxy to SubmissionSampleAssociation.samples @@ -407,39 +428,38 @@ class Reagent(BaseClass): Returns: models.Reagent | List[models.Reagent]: reagent or list of reagents matching filter. """ - # super().query(session) query: Query = cls.__database_session__.query(cls) match reagent_type: case str(): - # logger.debug(f"Looking up reagents by reagent type: {reagent_type}") + # logger.debug(f"Looking up reagents by reagent type str: {reagent_type}") query = query.join(cls.type).filter(ReagentType.name==reagent_type) case ReagentType(): - # logger.debug(f"Looking up reagents by reagent type: {reagent_type}") + # logger.debug(f"Looking up reagents by reagent type ReagentType: {reagent_type}") query = query.filter(cls.type.contains(reagent_type)) case _: pass match name: case str(): - logger.debug(f"Looking up reagent by name: {name}") + # logger.debug(f"Looking up reagent by name str: {name}") + # Not limited due to multiple reagents having same name. query = query.filter(cls.name==name) case _: pass match lot_number: case str(): - logger.debug(f"Looking up reagent by lot number: {lot_number}") + # logger.debug(f"Looking up reagent by lot number str: {lot_number}") query = query.filter(cls.lot==lot_number) # In this case limit number returned. limit = 1 case _: pass - return query_return(query=query, limit=limit) + return cls.query_return(query=query, limit=limit) class Discount(BaseClass): """ Relationship table for client labs for certain kits. """ - # __tablename__ = "_discounts" - + id = Column(INTEGER, primary_key=True) #: primary key kit = relationship("KitType") #: joined parent reagent type kit_id = Column(INTEGER, ForeignKey("_kittype.id", ondelete='SET NULL', name="fk_kit_type_id")) #: id of joined kit @@ -449,6 +469,10 @@ class Discount(BaseClass): amount = Column(FLOAT(2)) #: Dollar amount of discount def __repr__(self) -> str: + """ + Returns: + str: Representation of this object + """ return f"" @classmethod @@ -474,10 +498,10 @@ class Discount(BaseClass): query: Query = cls.__database_session__.query(cls) match organization: case Organization(): - # logger.debug(f"Looking up discount with organization: {organization}") + # logger.debug(f"Looking up discount with organization Organization: {organization}") query = query.filter(cls.client==Organization) case str(): - # logger.debug(f"Looking up discount with organization: {organization}") + # logger.debug(f"Looking up discount with organization str: {organization}") query = query.join(Organization).filter(Organization.name==organization) case int(): # logger.debug(f"Looking up discount with organization id: {organization}") @@ -487,35 +511,34 @@ class Discount(BaseClass): pass match kit_type: case KitType(): - # logger.debug(f"Looking up discount with kit type: {kit_type}") + # logger.debug(f"Looking up discount with kit type KitType: {kit_type}") query = query.filter(cls.kit==kit_type) case str(): - # logger.debug(f"Looking up discount with kit type: {kit_type}") + # logger.debug(f"Looking up discount with kit type str: {kit_type}") query = query.join(KitType).filter(KitType.name==kit_type) case int(): - # logger.debug(f"Looking up discount with kit type id: {organization}") + # logger.debug(f"Looking up discount with kit type id: {kit_type}") query = query.join(KitType).filter(KitType.id==kit_type) case _: # raise ValueError(f"Invalid value for kit type: {kit_type}") pass - return query.all() + return cls.query_return(query=query) @check_authorization - def save(self, ctx:Settings): + def save(self): super().save() class SubmissionType(BaseClass): """ Abstract of types of submissions. """ - # __tablename__ = "_submission_types" - + id = Column(INTEGER, primary_key=True) #: primary key name = Column(String(128), unique=True) #: name of submission type info_map = Column(JSON) #: Where basic information is found in the excel workbook corresponding to this type. instances = relationship("BasicSubmission", backref="submission_type") #: Concrete instances of this type. template_file = Column(BLOB) #: Blank form for this type stored as binary. - processes = relationship("Process", back_populates="submission_types", secondary=submissiontypes_processes) + processes = relationship("Process", back_populates="submission_types", secondary=submissiontypes_processes) #: Relation to equipment processes used for this type. submissiontype_kit_associations = relationship( "SubmissionTypeKitTypeAssociation", @@ -529,17 +552,21 @@ class SubmissionType(BaseClass): "SubmissionTypeEquipmentRoleAssociation", back_populates="submission_type", cascade="all, delete-orphan" - ) + ) #: Association of equipmentroles - equipment = association_proxy("submissiontype_equipmentrole_associations", "equipment_role") + equipment = association_proxy("submissiontype_equipmentrole_associations", "equipment_role") #: Proxy of equipmentrole associations submissiontype_kit_rt_associations = relationship( "KitTypeReagentTypeAssociation", back_populates="submission_type", cascade="all, delete-orphan" - ) + ) #: triple association of KitTypes, ReagentTypes, SubmissionTypes def __repr__(self) -> str: + """ + Returns: + str: Representation of this object. + """ return f"" def get_template_file_sheets(self) -> List[str]: @@ -551,16 +578,37 @@ class SubmissionType(BaseClass): """ return ExcelFile(self.template_file).sheet_names - def set_template_file(self, ctx:Settings, filepath:Path|str): + def set_template_file(self, filepath:Path|str): + """ + + Sets the binary store to an excel file. + + Args: + filepath (Path | str): Path to the template file. + + Raises: + ValueError: Raised if file is not excel file. + """ if isinstance(filepath, str): filepath = Path(filepath) + try: + xl = ExcelFile(filepath) + except ValueError: + raise ValueError(f"File {filepath} is not of appropriate type.") with open (filepath, "rb") as f: data = f.read() self.template_file = data - self.save(ctx=ctx) + self.save() - def construct_equipment_map(self): + def construct_equipment_map(self) -> List[dict]: + """ + Constructs map of equipment to excel cells. + + Returns: + List[dict]: List of equipment locations in excel sheet + """ output = [] + # logger.debug("Iterating through equipment roles") for item in self.submissiontype_equipmentrole_associations: map = item.uses if map == None: @@ -571,16 +619,36 @@ class SubmissionType(BaseClass): pass output.append(map) return output - # return [item.uses for item in self.submissiontype_equipmentrole_associations] def get_equipment(self, extraction_kit:str|KitType|None=None) -> List['PydEquipmentRole']: + """ + Returns PydEquipmentRole of all equipment associated with this SubmissionType + + Returns: + List['PydEquipmentRole']: List of equipment roles + """ return [item.to_pydantic(submission_type=self, extraction_kit=extraction_kit) for item in self.equipment] - def get_processes_for_role(self, equipment_role:str|EquipmentRole, kit:str|KitType|None=None): + def get_processes_for_role(self, equipment_role:str|EquipmentRole, kit:str|KitType|None=None) -> list: + """ + Get processes associated with this SubmissionType for an EquipmentRole + + Args: + equipment_role (str | EquipmentRole): EquipmentRole of interest + kit (str | KitType | None, optional): Kit of interest. Defaults to None. + + Raises: + TypeError: Raised if wrong type given for equipmentrole + + Returns: + list: list of associated processes + """ match equipment_role: case str(): + # logger.debug(f"Getting processes for equipmentrole str {equipment_role}") relevant = [item.get_all_processes(kit) for item in self.submissiontype_equipmentrole_associations if item.equipment_role.name==equipment_role] case EquipmentRole(): + # logger.debug(f"Getting processes for equipmentrole EquipmentRole {equipment_role}") relevant = [item.get_all_processes(kit) for item in self.submissiontype_equipmentrole_associations if item.equipment_role==equipment_role] case _: raise TypeError(f"Type {type(equipment_role)} is not allowed") @@ -597,7 +665,6 @@ class SubmissionType(BaseClass): Lookup submission type in the database by a number of parameters Args: - ctx (Settings): Settings object passed down from gui name (str | None, optional): Name of submission type. Defaults to None. key (str | None, optional): A key present in the info-map to lookup. Defaults to None. limit (int, optional): Maximum number of results to return (0 = all). Defaults to 0. @@ -608,33 +675,31 @@ class SubmissionType(BaseClass): query: Query = cls.__database_session__.query(cls) match name: case str(): - # logger.debug(f"Looking up submission type by name: {name}") + # logger.debug(f"Looking up submission type by name str: {name}") query = query.filter(cls.name==name) limit = 1 case _: pass match key: case str(): + # logger.debug(f"Looking up submission type by info-map key str: {key}") query = query.filter(cls.info_map.op('->')(key)!=None) case _: pass - return query_return(query=query, limit=limit) + return cls.query_return(query=query, limit=limit) @check_authorization - def save(self, ctx:Settings): + def save(self): """ Adds this instances to the database and commits. """ - # self.__database_session__.add(self) - # self.__database_session__.commit() super().save() class SubmissionTypeKitTypeAssociation(BaseClass): """ Abstract of relationship between kits and their submission type. """ - # __tablename__ = "_submissiontypes_kittypes" - + submission_types_id = Column(INTEGER, ForeignKey("_submissiontype.id"), primary_key=True) #: id of joined submission type kits_id = Column(INTEGER, ForeignKey("_kittype.id"), primary_key=True) #: id of joined kit mutable_cost_column = Column(FLOAT(2)) #: dollar amount per 96 well plate that can change with number of columns (reagents, tips, etc) @@ -654,10 +719,11 @@ class SubmissionTypeKitTypeAssociation(BaseClass): self.constant_cost = 0.00 def __repr__(self) -> str: - return f"" - - def set_attrib(self, name, value): - self.__setattr__(name, value) + """ + Returns: + str: Representation of this object + """ + return f"" @classmethod @setup_lookup @@ -699,15 +765,14 @@ class SubmissionTypeKitTypeAssociation(BaseClass): # logger.debug(f"Looking up {cls.__name__} by id {kit_type}") query = query.join(KitType).filter(KitType.id==kit_type) limit = query.count() - return query_return(query=query, limit=limit) + return cls.query_return(query=query, limit=limit) class KitTypeReagentTypeAssociation(BaseClass): """ table containing reagenttype/kittype associations DOC: https://docs.sqlalchemy.org/en/14/orm/extensions/associationproxy.html """ - # __tablename__ = "_reagenttypes_kittypes" - + reagent_types_id = Column(INTEGER, ForeignKey("_reagenttype.id"), primary_key=True) #: id of associated reagent type kits_id = Column(INTEGER, ForeignKey("_kittype.id"), primary_key=True) #: id of associated reagent type submission_type_id = Column(INTEGER, ForeignKey("_submissiontype.id"), primary_key=True) @@ -715,15 +780,14 @@ class KitTypeReagentTypeAssociation(BaseClass): required = Column(INTEGER) #: whether the reagent type is required for the kit (Boolean 1 or 0) last_used = Column(String(32)) #: last used lot number of this type of reagent - kit_type = relationship(KitType, back_populates="kit_reagenttype_associations") #: relationship to associated kit + kit_type = relationship(KitType, back_populates="kit_reagenttype_associations") #: relationship to associated KitType # reference to the "ReagentType" object - reagent_type = relationship(ReagentType, back_populates="reagenttype_kit_associations") #: relationship to associated reagent type + reagent_type = relationship(ReagentType, back_populates="reagenttype_kit_associations") #: relationship to associated ReagentType - submission_type = relationship(SubmissionType, back_populates="submissiontype_kit_rt_associations") + submission_type = relationship(SubmissionType, back_populates="submissiontype_kit_rt_associations") #: relationship to associated SubmissionType def __init__(self, kit_type=None, reagent_type=None, uses=None, required=1): - # logger.debug(f"Parameters: Kit={kit_type}, RT={reagent_type}, Uses={uses}, Required={required}") self.kit_type = kit_type self.reagent_type = reagent_type self.uses = uses @@ -791,35 +855,45 @@ class KitTypeReagentTypeAssociation(BaseClass): query: Query = cls.__database_session__.query(cls) match kit_type: case KitType(): + # logger.debug(f"Lookup KitTypeReagentTypeAssociation by kit_type KitType {kit_type}") query = query.filter(cls.kit_type==kit_type) case str(): + # logger.debug(f"Lookup KitTypeReagentTypeAssociation by kit_type str {kit_type}") query = query.join(KitType).filter(KitType.name==kit_type) case _: pass match reagent_type: case ReagentType(): + # logger.debug(f"Lookup KitTypeReagentTypeAssociation by reagent_type ReagentType {reagent_type}") query = query.filter(cls.reagent_type==reagent_type) case str(): + # logger.debug(f"Lookup KitTypeReagentTypeAssociation by reagent_type ReagentType {reagent_type}") query = query.join(ReagentType).filter(ReagentType.name==reagent_type) case _: pass if kit_type != None and reagent_type != None: limit = 1 - return query_return(query=query, limit=limit) + return cls.query_return(query=query, limit=limit) class SubmissionReagentAssociation(BaseClass): - - # __tablename__ = "_reagents_submissions" - - reagent_id = Column(INTEGER, ForeignKey("_reagent.id"), primary_key=True) #: id of associated sample - submission_id = Column(INTEGER, ForeignKey("_basicsubmission.id"), primary_key=True) - comments = Column(String(1024)) + """ + table containing submission/reagent associations + DOC: https://docs.sqlalchemy.org/en/14/orm/extensions/associationproxy.html + """ + + reagent_id = Column(INTEGER, ForeignKey("_reagent.id"), primary_key=True) #: id of associated reagent + submission_id = Column(INTEGER, ForeignKey("_basicsubmission.id"), primary_key=True) #: id of associated submission + comments = Column(String(1024)) #: Comments about reagents submission = relationship("BasicSubmission", back_populates="submission_reagent_associations") #: associated submission - reagent = relationship(Reagent, back_populates="reagent_submission_associations") + reagent = relationship(Reagent, back_populates="reagent_submission_associations") #: associatied reagent - def __repr__(self): + def __repr__(self) -> str: + """ + Returns: + str: Representation of this SubmissionReagentAssociation + """ return f"<{self.submission.rsl_plate_num}&{self.reagent.lot}>" def __init__(self, reagent=None, submission=None): @@ -848,80 +922,108 @@ class SubmissionReagentAssociation(BaseClass): query: Query = cls.__database_session__.query(cls) match reagent: case Reagent(): + # logger.debug(f"Lookup SubmissionReagentAssociation by reagent Reagent {reagent}") query = query.filter(cls.reagent==reagent) case str(): - # logger.debug(f"Filtering query with reagent: {reagent}") - reagent = Reagent.query(lot_number=reagent) - query = query.filter(cls.reagent==reagent) + # logger.debug(f"Lookup SubmissionReagentAssociation by reagent str {reagent}") + query = query.join(Reagent).filter(Reagent.lot==reagent) case _: pass - # logger.debug(f"Result of query after reagent: {query.all()}") match submission: case BasicSubmission(): + # logger.debug(f"Lookup SubmissionReagentAssociation by submission BasicSubmission {submission}") query = query.filter(cls.submission==submission) case str(): + # logger.debug(f"Lookup SubmissionReagentAssociation by submission str {submission}") query = query.join(BasicSubmission).filter(BasicSubmission.rsl_plate_num==submission) case int(): + # logger.debug(f"Lookup SubmissionReagentAssociation by submission id {submission}") query = query.join(BasicSubmission).filter(BasicSubmission.id==submission) case _: pass - # logger.debug(f"Result of query after submission: {query.all()}") - return query_return(query=query, limit=limit) + return cls.query_return(query=query, limit=limit) - def to_sub_dict(self, extraction_kit): + def to_sub_dict(self, extraction_kit) -> dict: + """ + Converts this SubmissionReagentAssociation (and associated Reagent) to dict + + Args: + extraction_kit (_type_): Extraction kit of interest + + Returns: + dict: This SubmissionReagentAssociation as dict + """ output = self.reagent.to_sub_dict(extraction_kit) output['comments'] = self.comments return output class Equipment(BaseClass): - - # Currently abstract until ready to implement - # __abstract__ = True - - # __tablename__ = "_equipment" - - id = Column(INTEGER, primary_key=True) - name = Column(String(64)) - nickname = Column(String(64)) - asset_number = Column(String(16)) - roles = relationship("EquipmentRole", back_populates="instances", secondary=equipmentroles_equipment) - processes = relationship("Process", back_populates="equipment", secondary=equipment_processes) + """ + A concrete instance of equipment + """ + + id = Column(INTEGER, primary_key=True) #: id, primary key + name = Column(String(64)) #: equipment name + nickname = Column(String(64)) #: equipment nickname + asset_number = Column(String(16)) #: Given asset number (corpo nickname if you will) + roles = relationship("EquipmentRole", back_populates="instances", secondary=equipmentroles_equipment) #: relation to EquipmentRoles + processes = relationship("Process", back_populates="equipment", secondary=equipment_processes) #: relation to Processes equipment_submission_associations = relationship( "SubmissionEquipmentAssociation", back_populates="equipment", cascade="all, delete-orphan", - ) + ) #: Association with BasicSubmission - submissions = association_proxy("equipment_submission_associations", "submission") + submissions = association_proxy("equipment_submission_associations", "submission") #: proxy to equipment_submission_associations.submission - def __repr__(self): + def __repr__(self) -> str: + """ + Returns: + str: represenation of this Equipment + """ return f"" - def to_dict(self, processes:bool=False): + def to_dict(self, processes:bool=False) -> dict: + """ + This Equipment as a dictionary + + Args: + processes (bool, optional): Whether to include processes. Defaults to False. + + Returns: + dict: _description_ + """ if not processes: return {k:v for k,v in self.__dict__.items() if k != 'processes'} else: return {k:v for k,v in self.__dict__.items()} - def get_processes(self, submission_type:SubmissionType, extraction_kit:str|KitType|None=None): + def get_processes(self, submission_type:SubmissionType, extraction_kit:str|KitType|None=None) -> List[str]: + """ + Get all processes associated with this Equipment for a given SubmissionType + + Args: + submission_type (SubmissionType): SubmissionType of interest + extraction_kit (str | KitType | None, optional): KitType to filter by. Defaults to None. + + Returns: + List[Process]: List of process names + """ processes = [process for process in self.processes if submission_type in process.submission_types] match extraction_kit: case str(): + # logger.debug(f"Filtering processes by extraction_kit str {extraction_kit}") processes = [process for process in processes if extraction_kit in [kit.name for kit in process.kit_types]] case KitType(): + # logger.debug(f"Filtering processes by extraction_kit KitType {extraction_kit}") processes = [process for process in processes if extraction_kit in process.kit_types] case _: pass processes = [process.name for process in processes] - # try: assert all([isinstance(process, str) for process in processes]) - # except AssertionError as e: - # logger.error(processes) - # raise e if len(processes) == 0: processes = [''] - # logger.debug(f"Processes: {processes}") return processes @classmethod @@ -932,38 +1034,64 @@ class Equipment(BaseClass): asset_number:str|None=None, limit:int=0 ) -> Equipment|List[Equipment]: + """ + Lookup a list of or single Equipment. + + Args: + name (str | None, optional): Equipment name. Defaults to None. + nickname (str | None, optional): Equipment nickname. Defaults to None. + asset_number (str | None, optional): Equipment asset number. Defaults to None. + limit (int, optional): Maximum number of results to return (0 = all). Defaults to 0. + + Returns: + Equipment|List[Equipment]: Equipment or list of equipment matching query parameters. + """ query = cls.__database_session__.query(cls) match name: case str(): + # logger.debug(f"Lookup Equipment by name str {name}") query = query.filter(cls.name==name) limit = 1 case _: pass match nickname: case str(): + # logger.debug(f"Lookup Equipment by nickname str {nickname}") query = query.filter(cls.nickname==nickname) limit = 1 case _: pass match asset_number: case str(): + # logger.debug(f"Lookup Equipment by asset_number str {asset_number}") query = query.filter(cls.asset_number==asset_number) limit = 1 case _: pass - return query_return(query=query, limit=limit) + return cls.query_return(query=query, limit=limit) - def to_pydantic(self, submission_type:SubmissionType, extraction_kit:str|KitType|None=None): + def to_pydantic(self, submission_type:SubmissionType, extraction_kit:str|KitType|None=None) -> "PydEquipment": + """ + Creates PydEquipment of this Equipment + + Args: + submission_type (SubmissionType): Relevant SubmissionType + extraction_kit (str | KitType | None, optional): Relevant KitType. Defaults to None. + + Returns: + PydEquipment: _description_ + """ from backend.validators.pydant import PydEquipment return PydEquipment(processes=self.get_processes(submission_type=submission_type, extraction_kit=extraction_kit), role=None, **self.to_dict(processes=False)) - # return PydEquipment(process=None, role=None, **self.__dict__) - - def save(self): - self.__database_session__.add(self) - self.__database_session__.commit() @classmethod def get_regex(cls) -> re.Pattern: + """ + Creates regex to determine tip manufacturer + + Returns: + re.Pattern: regex + """ return re.compile(r""" (?P50\d{5}$)| (?PHC-\d{6}$)| @@ -973,26 +1101,38 @@ class Equipment(BaseClass): re.VERBOSE) class EquipmentRole(BaseClass): + """ + Abstract roles for equipment - # __tablename__ = "_equipment_roles" - - id = Column(INTEGER, primary_key=True) - name = Column(String(32)) - instances = relationship("Equipment", back_populates="roles", secondary=equipmentroles_equipment) - processes = relationship("Process", back_populates='equipment_roles', secondary=equipmentroles_processes) + """ + + id = Column(INTEGER, primary_key=True) #: Role id, primary key + name = Column(String(32)) #: Common name + instances = relationship("Equipment", back_populates="roles", secondary=equipmentroles_equipment) #: Concrete instances (Equipment) of role + processes = relationship("Process", back_populates='equipment_roles', secondary=equipmentroles_processes) #: Associated Processes equipmentrole_submissiontype_associations = relationship( "SubmissionTypeEquipmentRoleAssociation", back_populates="equipment_role", cascade="all, delete-orphan", - ) + ) #: relation to SubmissionTypes - submission_types = association_proxy("equipmentrole_submission_associations", "submission_type") + submission_types = association_proxy("equipmentrole_submission_associations", "submission_type") #: proxy to equipmentrole_submissiontype_associations.submission_type - def __repr__(self): + def __repr__(self) -> str: + """ + Returns: + str: Representation of this EquipmentRole + """ return f"" - def to_dict(self): + def to_dict(self) -> dict: + """ + This EquipmentRole as a dictionary + + Returns: + dict: This EquipmentRole dict + """ output = {} for key, value in self.__dict__.items(): match key: @@ -1003,47 +1143,81 @@ class EquipmentRole(BaseClass): output[key] = value return output - def to_pydantic(self, submission_type:SubmissionType, extraction_kit:str|KitType|None=None): + def to_pydantic(self, submission_type:SubmissionType, extraction_kit:str|KitType|None=None) -> "PydEquipmentRole": + """ + Creates a PydEquipmentRole of this EquipmentRole + + Args: + submission_type (SubmissionType): SubmissionType of interest + extraction_kit (str | KitType | None, optional): KitType of interest. Defaults to None. + + Returns: + PydEquipmentRole: This EquipmentRole as PydEquipmentRole + """ from backend.validators.pydant import PydEquipmentRole + # logger.debug("Creating list of PydEquipment in this role") equipment = [item.to_pydantic(submission_type=submission_type, extraction_kit=extraction_kit) for item in self.instances] - # processes = [item.name for item in self.processes] pyd_dict = self.to_dict() + # logger.debug("Creating list of Processes in this role") pyd_dict['processes'] = self.get_processes(submission_type=submission_type, extraction_kit=extraction_kit) return PydEquipmentRole(equipment=equipment, **pyd_dict) @classmethod @setup_lookup def query(cls, name:str|None=None, id:int|None=None, limit:int=0) -> EquipmentRole|List[EquipmentRole]: + """ + Lookup Equipment roles. + + Args: + name (str | None, optional): EquipmentRole name. Defaults to None. + id (int | None, optional): EquipmentRole id. Defaults to None. + limit (int, optional): Maximum number of results to return (0 = all). Defaults to 0. + + Returns: + EquipmentRole|List[EquipmentRole]: List of EquipmentRoles matching criteria + """ query = cls.__database_session__.query(cls) match id: case int(): + # logger.debug(f"Lookup EquipmentRole by id {id}") query = query.filter(cls.id==id) limit = 1 case _: pass match name: case str(): + # logger.debug(f"Lookup EquipmentRole by name str {name}") query = query.filter(cls.name==name) limit = 1 case _: pass - return query_return(query=query, limit=limit) + return cls.query_return(query=query, limit=limit) def get_processes(self, submission_type:str|SubmissionType|None, extraction_kit:str|KitType|None=None) -> List[Process]: + """ + Get processes used by this EquipmentRole + + Args: + submission_type (str | SubmissionType | None): SubmissionType of interest + extraction_kit (str | KitType | None, optional): KitType of interest. Defaults to None. + + Returns: + List[Process]: _description_ + """ if isinstance(submission_type, str): + # logger.debug(f"Checking if str {submission_type} exists") submission_type = SubmissionType.query(name=submission_type) - # assert all([isinstance(process, Process) for process in self.processes]) - # logger.debug(self.processes) - if submission_type != None: - # for process in self.processes: - # logger.debug(f"Process: {type(process)}: {process}") + if submission_type != None: + # logger.debug("Getting all processes for this EquipmentRole") processes = [process for process in self.processes if submission_type in process.submission_types] else: processes = self.processes match extraction_kit: case str(): + # logger.debug(f"Filtering processes by extraction_kit str {extraction_kit}") processes = [item for item in processes if extraction_kit in [kit.name for kit in item.kit_type]] case KitType(): + # logger.debug(f"Filtering processes by extraction_kit KitType {extraction_kit}") processes = [item for item in processes if extraction_kit in [kit for kit in item.kit_type]] case _: pass @@ -1054,20 +1228,17 @@ class EquipmentRole(BaseClass): return output class SubmissionEquipmentAssociation(BaseClass): - - # Currently abstract until ready to implement - # __abstract__ = True - - # __tablename__ = "_equipment_submissions" - + """ + Abstract association between BasicSubmission and Equipment + """ + equipment_id = Column(INTEGER, ForeignKey("_equipment.id"), primary_key=True) #: id of associated equipment submission_id = Column(INTEGER, ForeignKey("_basicsubmission.id"), primary_key=True) #: id of associated submission role = Column(String(64), primary_key=True) #: name of the role the equipment fills - # process = Column(String(64)) #: name of the process run on this equipment - process_id = Column(INTEGER, ForeignKey("_process.id",ondelete="SET NULL", name="SEA_Process_id")) - start_time = Column(TIMESTAMP) - end_time = Column(TIMESTAMP) - comments = Column(String(1024)) + process_id = Column(INTEGER, ForeignKey("_process.id",ondelete="SET NULL", name="SEA_Process_id")) #: Foreign key of process id + start_time = Column(TIMESTAMP) #: start time of equipment use + end_time = Column(TIMESTAMP) #: end time of equipment use + comments = Column(String(1024)) #: comments about equipment submission = relationship("BasicSubmission", back_populates="submission_equipment_associations") #: associated submission @@ -1078,19 +1249,19 @@ class SubmissionEquipmentAssociation(BaseClass): self.equipment = equipment def to_sub_dict(self) -> dict: + """ + This SubmissionEquipmentAssociation as a dictionary + + Returns: + dict: This SubmissionEquipmentAssociation as a dictionary + """ output = dict(name=self.equipment.name, asset_number=self.equipment.asset_number, comment=self.comments, processes=[self.process.name], role=self.role, nickname=self.equipment.nickname) return output - - def save(self): - self.__database_session__.add(self) - self.__database_session__.commit() class SubmissionTypeEquipmentRoleAssociation(BaseClass): - - # __abstract__ = True - - # __tablename__ = "_submissiontype_equipmentrole" - + """ + Abstract association between SubmissionType and EquipmentRole + """ equipmentrole_id = Column(INTEGER, ForeignKey("_equipmentrole.id"), primary_key=True) #: id of associated equipment submissiontype_id = Column(INTEGER, ForeignKey("_submissiontype.id"), primary_key=True) #: id of associated submission uses = Column(JSON) #: locations of equipment on the submission type excel sheet. @@ -1119,51 +1290,74 @@ class SubmissionTypeEquipmentRoleAssociation(BaseClass): raise ValueError(f'Invalid required value {value}. Must be 0 or 1.') return value - def get_all_processes(self, extraction_kit:KitType|str|None=None): + def get_all_processes(self, extraction_kit:KitType|str|None=None) -> List[Process]: + """ + Get all processes associated with this SubmissionTypeEquipmentRole + + Args: + extraction_kit (KitType | str | None, optional): KitType of interest. Defaults to None. + + Returns: + List[Process]: All associated processes + """ processes = [equipment.get_processes(self.submission_type) for equipment in self.equipment_role.instances] # flatten list processes = [item for items in processes for item in items if item != None ] match extraction_kit: case str(): + # logger.debug(f"Filtering Processes by extraction_kit str {extraction_kit}") processes = [item for item in processes if extraction_kit in [kit.name for kit in item.kit_type]] case KitType(): + # logger.debug(f"Filtering Processes by extraction_kit KitType {extraction_kit}") processes = [item for item in processes if extraction_kit in [kit for kit in item.kit_type]] case _: pass return processes @check_authorization - def save(self, ctx:Settings): - # self.__database_session__.add(self) - # self.__database_session__.commit() + def save(self): super().save() class Process(BaseClass): """ A Process is a method used by a piece of equipment. """ - # __tablename__ = "_process" + + id = Column(INTEGER, primary_key=True) #: Process id, primary key + name = Column(String(64)) #: Process name + submission_types = relationship("SubmissionType", back_populates='processes', secondary=submissiontypes_processes) #: relation to SubmissionType + equipment = relationship("Equipment", back_populates='processes', secondary=equipment_processes) #: relation to Equipment + equipment_roles = relationship("EquipmentRole", back_populates='processes', secondary=equipmentroles_processes) #: relation to EquipmentRoles + submissions = relationship("SubmissionEquipmentAssociation", backref='process') #: relation to SubmissionEquipmentAssociation + kit_types = relationship("KitType", back_populates='processes', secondary=kittypes_processes) #: relation to KitType - id = Column(INTEGER, primary_key=True) - name = Column(String(64)) - submission_types = relationship("SubmissionType", back_populates='processes', secondary=submissiontypes_processes) - equipment = relationship("Equipment", back_populates='processes', secondary=equipment_processes) - equipment_roles = relationship("EquipmentRole", back_populates='processes', secondary=equipmentroles_processes) - submissions = relationship("SubmissionEquipmentAssociation", backref='process') - kit_types = relationship("KitType", back_populates='processes', secondary=kittypes_processes) - - def __repr__(self): + def __repr__(self) -> str: + """ + Returns: + str: Representation of this Process + """ return f" Process|List[Process]: + """ + Lookup Processes + + Args: + name (str | None, optional): Process name. Defaults to None. + limit (int, optional): Maximum number of results to return (0=all). Defaults to 0. + + Returns: + Process|List[Process]: Process(es) matching criteria + """ query = cls.__database_session__.query(cls) match name: case str(): + # logger.debug(f"Lookup Process with name str {name}") query = query.filter(cls.name==name) limit = 1 case _: pass - return query_return(query=query, limit=limit) + return cls.query_return(query=query, limit=limit) diff --git a/src/submissions/backend/db/models/organizations.py b/src/submissions/backend/db/models/organizations.py index c49d03b..a3ca0a3 100644 --- a/src/submissions/backend/db/models/organizations.py +++ b/src/submissions/backend/db/models/organizations.py @@ -5,7 +5,7 @@ from __future__ import annotations from sqlalchemy import Column, String, INTEGER, ForeignKey, Table from sqlalchemy.orm import relationship, Query from . import Base, BaseClass -from tools import check_authorization, setup_lookup, query_return, Settings +from tools import check_authorization, setup_lookup from typing import List import logging @@ -25,8 +25,7 @@ class Organization(BaseClass): """ Base of organization """ - # __tablename__ = "_organizations" - + id = Column(INTEGER, primary_key=True) #: primary key name = Column(String(64)) #: organization name submissions = relationship("BasicSubmission", back_populates="submitting_lab") #: submissions this organization has submitted @@ -34,11 +33,12 @@ class Organization(BaseClass): contacts = relationship("Contact", back_populates="organization", secondary=orgs_contacts) #: contacts involved with this org def __repr__(self) -> str: + """ + Returns: + str: Representation of this Organization + """ return f"" - def set_attribute(self, name:str, value): - setattr(self, name, value) - @classmethod @setup_lookup def query(cls, @@ -63,24 +63,17 @@ class Organization(BaseClass): limit = 1 case _: pass - return query_return(query=query, limit=limit) + return cls.query_return(query=query, limit=limit) @check_authorization - def save(self, ctx:Settings): - """ - Adds this instance to the database and commits - - Args: - ctx (Settings): Settings object passed down from GUI. Necessary to check authorization - """ + def save(self): super().save() class Contact(BaseClass): """ Base of Contact """ - # __tablename__ = "_contacts" - + id = Column(INTEGER, primary_key=True) #: primary key name = Column(String(64)) #: contact name email = Column(String(64)) #: contact email @@ -88,6 +81,10 @@ class Contact(BaseClass): organization = relationship("Organization", back_populates="contacts", uselist=True, secondary=orgs_contacts) #: relationship to joined organization def __repr__(self) -> str: + """ + Returns: + str: Representation of this Contact + """ return f"" @classmethod @@ -133,5 +130,5 @@ class Contact(BaseClass): limit = 1 case _: pass - return query_return(query=query, limit=limit) + return cls.query_return(query=query, limit=limit) \ No newline at end of file diff --git a/src/submissions/backend/db/models/submissions.py b/src/submissions/backend/db/models/submissions.py index e4f2637..00abad9 100644 --- a/src/submissions/backend/db/models/submissions.py +++ b/src/submissions/backend/db/models/submissions.py @@ -3,8 +3,12 @@ Models for the main submission types. ''' from __future__ import annotations from getpass import getuser -import math, json, logging, uuid, tempfile, re, yaml, zipfile -import sys +import math, json, logging, uuid, tempfile, re, yaml, base64 +from zipfile import ZipFile +from tempfile import TemporaryDirectory +from reportlab.graphics.barcode import createBarcodeImageInMemory +from reportlab.graphics.shapes import Drawing +from reportlab.lib.units import mm from operator import attrgetter from pprint import pformat from . import Reagent, SubmissionType, KitType, Organization @@ -15,15 +19,18 @@ from sqlalchemy.ext.associationproxy import association_proxy import pandas as pd from openpyxl import Workbook from openpyxl.worksheet.worksheet import Worksheet +from openpyxl.drawing.image import Image as OpenpyxlImage from . import BaseClass -from tools import check_not_nan, row_map, query_return, setup_lookup, jinja_template_loading +from tools import check_not_nan, row_map, query_return, setup_lookup, jinja_template_loading, rreplace from datetime import datetime, date -from typing import List, Any +from typing import List, Any, Tuple from dateutil.parser import parse from dateutil.parser._parser import ParserError from sqlalchemy.exc import OperationalError as AlcOperationalError, IntegrityError as AlcIntegrityError, StatementError from sqlite3 import OperationalError as SQLOperationalError, IntegrityError as SQLIntegrityError from pathlib import Path +from jinja2.exceptions import TemplateNotFound +from jinja2 import Template logger = logging.getLogger(f"submissions.{__name__}") @@ -32,8 +39,7 @@ class BasicSubmission(BaseClass): """ Concrete of basic submission which polymorphs into BacterialCulture and Wastewater """ - # __tablename__ = "_submissions" - + id = Column(INTEGER, primary_key=True) #: primary key rsl_plate_num = Column(String(32), unique=True, nullable=False) #: RSL name (e.g. RSL-22-0012) submitter_plate_num = Column(String(127), unique=True) #: The number given to the submission by the submitting lab @@ -59,25 +65,24 @@ class BasicSubmission(BaseClass): back_populates="submission", cascade="all, delete-orphan", ) #: Relation to SubmissionSampleAssociation - # association proxy of "user_keyword_associations" collection - # to "keyword" attribute + samples = association_proxy("submission_sample_associations", "sample") #: Association proxy to SubmissionSampleAssociation.samples submission_reagent_associations = relationship( "SubmissionReagentAssociation", back_populates="submission", cascade="all, delete-orphan", - ) #: Relation to SubmissionSampleAssociation - # association proxy of "user_keyword_associations" collection - # to "keyword" attribute - reagents = association_proxy("submission_reagent_associations", "reagent") #: Association proxy to SubmissionSampleAssociation.samples + ) #: Relation to SubmissionReagentAssociation + + reagents = association_proxy("submission_reagent_associations", "reagent") #: Association proxy to SubmissionReagentAssociation.reagent submission_equipment_associations = relationship( "SubmissionEquipmentAssociation", back_populates="submission", cascade="all, delete-orphan" - ) - equipment = association_proxy("submission_equipment_associations", "equipment") + ) #: Relation to Equipment + + equipment = association_proxy("submission_equipment_associations", "equipment") #: Association proxy to SubmissionEquipmentAssociation.equipment # Allows for subclassing into ex. BacterialCulture, Wastewater, etc. __mapper_args__ = { @@ -86,17 +91,12 @@ class BasicSubmission(BaseClass): "with_polymorphic": "*", } - def __repr__(self): - return f"{self.submission_type}Submission({self.rsl_plate_num})" - - def to_string(self) -> str: + def __repr__(self) -> str: """ - string presenting basic submission - Returns: - str: string representing rsl plate number and submitter plate number + str: Representation of this BasicSubmission """ - return f"{self.rsl_plate_num} - {self.submitter_plate_num}" + return f"{self.submission_type}Submission({self.rsl_plate_num})" def to_dict(self, full_data:bool=False, backup:bool=False) -> dict: """ @@ -104,6 +104,7 @@ class BasicSubmission(BaseClass): Args: full_data (bool, optional): indicates if sample dicts to be constructed. Defaults to False. + backup (bool, optional): passed to adjust_to_dict_samples. Defaults to False. Returns: dict: dictionary used in submissions summary and details @@ -139,9 +140,9 @@ class BasicSubmission(BaseClass): except Exception as e: logger.error(f"We got an error retrieving reagents: {e}") reagents = None - # samples = [item.sample.to_sub_dict(submission_rsl=self.rsl_plate_num) for item in self.submission_sample_associations] logger.debug(f"Running samples.") samples = self.adjust_to_dict_samples(backup=backup) + logger.debug("Running equipment") try: equipment = [item.to_sub_dict() for item in self.submission_equipment_associations] if len(equipment) == 0: @@ -153,6 +154,7 @@ class BasicSubmission(BaseClass): reagents = None samples = None equipment = None + # logger.debug("Getting comments") try: comments = self.comment except Exception as e: @@ -248,19 +250,285 @@ class BasicSubmission(BaseClass): # logger.debug(f"Here are the columns for {self.rsl_plate_num}: {columns}") return len(columns) - def hitpick_plate(self, plate_number:int|None=None) -> list: + def hitpick_plate(self) -> list: """ Returns positve sample locations for plate - Args: - plate_number (int | None, optional): Plate id. Defaults to None. - Returns: list: list of htipick dictionaries for each sample """ output_list = [assoc.to_hitpick() for assoc in self.submission_sample_associations] return output_list + + def make_plate_map(self, plate_rows:int=8, plate_columns=12) -> str: + """ + Constructs an html based plate map. + + Args: + sample_list (list): List of submission samples + 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. + """ + # logger.debug("Creating basic hitpick") + sample_list = self.hitpick_plate() + # logger.debug("Setting background colours") + for sample in sample_list: + if sample['positive']: + sample['background_color'] = "#f10f07" + else: + if "colour" in sample.keys(): + sample['background_color'] = "#69d84f" + else: + sample['background_color'] = "#80cbc4" + output_samples = [] + # logger.debug("Setting locations.") + for column in range(1, plate_columns+1): + for row in range(1, plate_rows+1): + try: + well = [item for item in sample_list if item['row'] == row and item['column']==column][0] + except IndexError: + well = dict(name="", row=row, column=column, background_color="#ffffff") + output_samples.append(well) + env = jinja_template_loading() + template = env.get_template("plate_map.html") + html = template.render(samples=output_samples, PLATE_ROWS=plate_rows, PLATE_COLUMNS=plate_columns) + return html + "
" + def get_used_equipment(self) -> List[str]: + """ + Gets EquipmentRole names associated with this BasicSubmission + + Returns: + List[str]: List of names + """ + return [item.role for item in self.submission_equipment_associations] + + def make_plate_barcode(self, width:int=100, height:int=25) -> Drawing: + """ + Creates a barcode image for this BasicSubmission. + + Args: + width (int, optional): Width (pixels) of image. Defaults to 100. + height (int, optional): Height (pixels) of image. Defaults to 25. + + Returns: + Drawing: image object + """ + return createBarcodeImageInMemory('Code128', value=self.rsl_plate_num, width=width*mm, height=height*mm, humanReadable=True, format="png") + + @classmethod + def submissions_to_df(cls, submission_type:str|None=None, limit:int=0) -> pd.DataFrame: + """ + Convert all submissions to dataframe + + Args: + submission_type (str | None, optional): Filter by SubmissionType. Defaults to None. + limit (int, optional): Maximum number of results to return. Defaults to 0. + + Returns: + pd.DataFrame: Pandas Dataframe of all relevant submissions + """ + logger.debug(f"Querying Type: {submission_type}") + logger.debug(f"Using limit: {limit}") + # use lookup function to create list of dicts + subs = [item.to_dict() for item in cls.query(submission_type=submission_type, limit=limit)] + logger.debug(f"Got {len(subs)} submissions.") + df = pd.DataFrame.from_records(subs) + # Exclude sub information + for item in ['controls', 'extraction_info', 'pcr_info', 'comment', 'comments', 'samples', 'reagents', 'equipment']: + try: + df = df.drop(item, axis=1) + except: + logger.warning(f"Couldn't drop '{item}' column from submissionsheet df.") + return df + + def set_attribute(self, key:str, value): + """ + Performs custom attribute setting based on values. + + Args: + key (str): name of attribute + value (_type_): value of attribute + """ + match key: + case "extraction_kit": + # logger.debug(f"Looking up kit {value}") + field_value = KitType.query(name=value) + # logger.debug(f"Got {field_value} for kit {value}") + case "submitting_lab": + # logger.debug(f"Looking up organization: {value}") + field_value = Organization.query(name=value) + # logger.debug(f"Got {field_value} for organization {value}") + case "submitter_plate_num": + # logger.debug(f"Submitter plate id: {value}") + field_value = value + case "samples": + for sample in value: + # logger.debug(f"Parsing {sample} to sql.") + sample, _ = sample.toSQL(submission=self) + return + case "reagents": + field_value = [reagent['value'].toSQL()[0] if isinstance(reagent, dict) else reagent.toSQL()[0] for reagent in value] + case "submission_type": + field_value = SubmissionType.query(name=value) + case "sample_count": + if value == None: + field_value = len(self.samples) + else: + field_value = value + case "ctx" | "csv" | "filepath" | "equipment": + return + case "comment": + if value == "" or value == None or value == 'null': + field_value = None + else: + field_value = dict(name="submitter", text=value, time=datetime.now()) + case _: + field_value = value + # insert into field + try: + self.__setattr__(key, field_value) + except AttributeError: + logger.error(f"Could not set {self} attribute {key} to {value}") + + def update_subsampassoc(self, sample:BasicSample, input_dict:dict): + """ + Update a joined submission sample association. + + Args: + sample (BasicSample): Associated sample. + input_dict (dict): values to be updated + + Returns: + Result: _description_ + """ + assoc = [item for item in self.submission_sample_associations if item.sample==sample][0] + for k,v in input_dict.items(): + try: + setattr(assoc, k, v) + except AttributeError: + logger.error(f"Can't set {k} to {v}") + result = assoc.save() + return result + + def to_pydantic(self, backup:bool=False) -> "PydSubmission": + """ + Converts this instance into a PydSubmission + + Returns: + PydSubmission: converted object. + """ + from backend.validators import PydSubmission, PydSample, PydReagent, PydEquipment + dicto = self.to_dict(full_data=True, backup=backup) + new_dict = {} + for key, value in dicto.items(): + match key: + case "reagents": + new_dict[key] = [PydReagent(**reagent) for reagent in value] + case "samples": + new_dict[key] = [PydSample(**sample) for sample in dicto['samples']] + case "equipment": + try: + new_dict[key] = [PydEquipment(**equipment) for equipment in dicto['equipment']] + except TypeError as e: + logger.error(f"Possible no equipment error: {e}") + case "Plate Number": + new_dict['rsl_plate_num'] = dict(value=value, missing=True) + case "Submitter Plate Number": + new_dict['submitter_plate_num'] = dict(value=value, missing=True) + case _: + logger.debug(f"Setting dict {key} to {value}") + new_dict[key.lower().replace(" ", "_")] = dict(value=value, missing=True) + new_dict['filepath'] = Path(tempfile.TemporaryFile().name) + return PydSubmission(**new_dict) + + def save(self, original:bool=True): + """ + Adds this instance to database and commits. + + Args: + original (bool, optional): Is this the first save. Defaults to True. + """ + if original: + self.uploaded_by = getuser() + super().save() + +# Polymorphic functions + + @classmethod + def construct_regex(cls) -> re.Pattern: + """ + Constructs catchall regex. + + Returns: + re.Pattern: Regular expression pattern to discriminate between submission types. + """ + rstring = rf'{"|".join([item.get_regex() for item in cls.__subclasses__()])}' + regex = re.compile(rstring, flags = re.IGNORECASE | re.VERBOSE) + return regex + + @classmethod + def find_subclasses(cls, attrs:dict|None=None, submission_type:str|SubmissionType|None=None): + """ + Retrieves subclasses of this class matching patterned + + Args: + attrs (dict | None, optional): Attributes to look for. Defaults to None. + submission_type (str | SubmissionType | None, optional): Submission type. Defaults to None. + + Raises: + AttributeError: Raised if attr given, but not found. + + Returns: + _type_: Subclass of interest. + """ + match submission_type: + case str(): + return cls.find_polymorphic_subclass(submission_type) + case SubmissionType(): + return cls.find_polymorphic_subclass(submission_type.name) + case _: + pass + if attrs == None or len(attrs) == 0: + return cls + if any([not hasattr(cls, attr) for attr in attrs]): + # looks for first model that has all included kwargs + try: + model = [subclass for subclass in cls.__subclasses__() if all([hasattr(subclass, attr) for attr in attrs])][0] + except IndexError as e: + raise AttributeError(f"Couldn't find existing class/subclass of {cls} with all attributes:\n{pformat(attrs)}") + else: + model = cls + logger.info(f"Recruiting model: {model}") + return model + + @classmethod + def find_polymorphic_subclass(cls, polymorphic_identity:str|None=None): + """ + Find subclass based on polymorphic identity. + + Args: + polymorphic_identity (str | None, optional): String representing polymorphic identity. Defaults to None. + + Returns: + _type_: Subclass of interest. + """ + # logger.debug(f"Controlling for dict value") + if isinstance(polymorphic_identity, dict): + polymorphic_identity = polymorphic_identity['value'] + if polymorphic_identity != None: + try: + cls = [item for item in cls.__subclasses__() if item.__mapper_args__['polymorphic_identity']==polymorphic_identity][0] + logger.info(f"Recruiting: {cls}") + except Exception as e: + logger.error(f"Could not get polymorph {polymorphic_identity} of {cls} due to {e}") + return cls + +# Child class custom functions + @classmethod def custom_platemap(cls, xl:pd.ExcelFile, plate_map:pd.DataFrame) -> pd.DataFrame: """ @@ -351,78 +619,8 @@ class BasicSubmission(BaseClass): str: Updated name. """ logger.info(f"Hello from {cls.__mapper_args__['polymorphic_identity']} Enforcer!") - # logger.debug(f"Attempting enforcement on {instr} using data: {pformat(data)}") - # sys.exit() return instr - @classmethod - def construct_regex(cls) -> re.Pattern: - """ - Constructs catchall regex. - - Returns: - re.Pattern: Regular expression pattern to discriminate between submission types. - """ - rstring = rf'{"|".join([item.get_regex() for item in cls.__subclasses__()])}' - regex = re.compile(rstring, flags = re.IGNORECASE | re.VERBOSE) - return regex - - @classmethod - def find_subclasses(cls, attrs:dict|None=None, submission_type:str|SubmissionType|None=None): - """ - Retrieves subclasses of this class matching patterned - - Args: - attrs (dict | None, optional): Attributes to look for. Defaults to None. - submission_type (str | SubmissionType | None, optional): Submission type. Defaults to None. - - Raises: - AttributeError: Raised if attr given, but not found. - - Returns: - _type_: Subclass of interest. - """ - match submission_type: - case str(): - return cls.find_polymorphic_subclass(submission_type) - case SubmissionType(): - return cls.find_polymorphic_subclass(submission_type.name) - case _: - pass - if attrs == None or len(attrs) == 0: - return cls - if any([not hasattr(cls, attr) for attr in attrs]): - # looks for first model that has all included kwargs - try: - model = [subclass for subclass in cls.__subclasses__() if all([hasattr(subclass, attr) for attr in attrs])][0] - except IndexError as e: - raise AttributeError(f"Couldn't find existing class/subclass of {cls} with all attributes:\n{pformat(attrs)}") - else: - model = cls - logger.info(f"Recruiting model: {model}") - return model - - @classmethod - def find_polymorphic_subclass(cls, polymorphic_identity:str|None=None): - """ - Find subclass based on polymorphic identity. - - Args: - polymorphic_identity (str | None, optional): String representing polymorphic identity. Defaults to None. - - Returns: - _type_: Subclass of interest. - """ - if isinstance(polymorphic_identity, dict): - polymorphic_identity = polymorphic_identity['value'] - if polymorphic_identity != None: - try: - cls = [item for item in cls.__subclasses__() if item.__mapper_args__['polymorphic_identity']==polymorphic_identity][0] - logger.info(f"Recruiting: {cls}") - except Exception as e: - logger.error(f"Could not get polymorph {polymorphic_identity} of {cls} due to {e}") - return cls - @classmethod def parse_pcr(cls, xl:pd.DataFrame, rsl_number:str) -> list: """ @@ -449,22 +647,6 @@ class BasicSubmission(BaseClass): """ return "{{ rsl_plate_num }}" - @classmethod - def submissions_to_df(cls, submission_type:str|None=None, limit:int=0) -> pd.DataFrame: - logger.debug(f"Querying Type: {submission_type}") - logger.debug(f"Using limit: {limit}") - # use lookup function to create list of dicts - subs = [item.to_dict() for item in cls.query(submission_type=submission_type, limit=limit)] - logger.debug(f"Got {len(subs)} submissions.") - df = pd.DataFrame.from_records(subs) - # Exclude sub information - for item in ['controls', 'extraction_info', 'pcr_info', 'comment', 'comments', 'samples', 'reagents', 'equipment']: - try: - df = df.drop(item, axis=1) - except: - logger.warning(f"Couldn't drop '{item}' column from submissionsheet df.") - return df - @classmethod def custom_sample_autofill_row(cls, sample, worksheet:Worksheet) -> int: """ @@ -479,130 +661,49 @@ class BasicSubmission(BaseClass): """ return None - def set_attribute(self, key:str, value): + @classmethod + def adjust_autofill_samples(cls, samples:List[Any]) -> List[Any]: + logger.info(f"Hello from {cls.__mapper_args__['polymorphic_identity']} sampler") + return samples + + def adjust_to_dict_samples(self, backup:bool=False) -> List[dict]: """ - Performs custom attribute setting based on values. + Updates sample dictionaries with custom values Args: - key (str): name of attribute - value (_type_): value of attribute + backup (bool, optional): Whether to perform backup. Defaults to False. + + Returns: + List[dict]: Updated dictionaries """ - match key: - case "extraction_kit": - # logger.debug(f"Looking up kit {value}") - # field_value = lookup_kit_types(ctx=self.ctx, name=value) - field_value = KitType.query(name=value) - # logger.debug(f"Got {field_value} for kit {value}") - case "submitting_lab": - # logger.debug(f"Looking up organization: {value}") - # field_value = lookup_organizations(ctx=self.ctx, name=value) - field_value = Organization.query(name=value) - # logger.debug(f"Got {field_value} for organization {value}") - case "submitter_plate_num": - # logger.debug(f"Submitter plate id: {value}") - field_value = value - case "samples": - # instance = construct_samples(ctx=ctx, instance=instance, samples=value) - for sample in value: - # logger.debug(f"Parsing {sample} to sql.") - sample, _ = sample.toSQL(submission=self) - # instance.samples.append(sample) - return - case "reagents": - field_value = [reagent['value'].toSQL()[0] if isinstance(reagent, dict) else reagent.toSQL()[0] for reagent in value] - case "submission_type": - # field_value = lookup_submission_type(ctx=self.ctx, name=value) - field_value = SubmissionType.query(name=value) - case "sample_count": - if value == None: - field_value = len(self.samples) - else: - field_value = value - case "ctx" | "csv" | "filepath" | "equipment": - return - case "comment": - if value == "" or value == None or value == 'null': - field_value = None - else: - field_value = dict(name="submitter", text=value, time=datetime.now()) - case _: - field_value = value - # insert into field + logger.debug(f"Hello from {self.__class__.__name__} dictionary sample adjuster.") + return [item.to_sub_dict() for item in self.submission_sample_associations] + + @classmethod + def get_details_template(cls, base_dict:dict) -> Tuple[dict, Template]: + """ + Get the details jinja template for the correct class + + Args: + base_dict (dict): incoming dictionary of Submission fields + + Returns: + Tuple(dict, Template): (Updated dictionary, Template to be rendered) + """ + base_dict['excluded'] = ['excluded', 'reagents', 'samples', 'controls', + 'extraction_info', 'pcr_info', 'comment', + 'barcode', 'platemap', 'export_map', 'equipment'] + env = jinja_template_loading() + temp_name = f"{cls.__name__.lower()}_details.html" + logger.debug(f"Returning template: {temp_name}") try: - setattr(self, key, field_value) - except AttributeError: - logger.error(f"Could not set {self} attribute {key} to {value}") + template = env.get_template(temp_name) + except TemplateNotFound as e: + logger.error(f"Couldn't find template due to {e}") + template = env.get_template("basicsubmission_details.html") + return base_dict, template - def update_subsampassoc(self, sample:BasicSample, input_dict:dict): - """ - Update a joined submission sample association. - - Args: - sample (BasicSample): Associated sample. - input_dict (dict): values to be updated - - Returns: - _type_: _description_ - """ - # assoc = SubmissionSampleAssociation.query(submission=self, sample=sample, limit=1) - assoc = [item for item in self.submission_sample_associations if item.sample==sample][0] - for k,v in input_dict.items(): - try: - setattr(assoc, k, v) - except AttributeError: - logger.error(f"Can't set {k} to {v}") - # result = store_object(ctx=ctx, object=assoc) - result = assoc.save() - return result - - def to_pydantic(self, backup:bool=False): - """ - Converts this instance into a PydSubmission - - Returns: - PydSubmission: converted object. - """ - from backend.validators import PydSubmission, PydSample, PydReagent, PydEquipment - dicto = self.to_dict(full_data=True, backup=backup) - # logger.debug(f"Backup dictionary: {pformat(dicto)}") - # dicto['filepath'] = Path(tempfile.TemporaryFile().name) - new_dict = {} - for key, value in dicto.items(): - match key: - case "reagents": - new_dict[key] = [PydReagent(**reagent) for reagent in value] - case "samples": - new_dict[key] = [PydSample(**sample) for sample in dicto['samples']] - case "equipment": - # logger.debug(f"\n\nEquipment: {dicto['equipment']}\n\n") - try: - new_dict[key] = [PydEquipment(**equipment) for equipment in dicto['equipment']] - except TypeError as e: - logger.error(f"Possible no equipment error: {e}") - case "Plate Number": - new_dict['rsl_plate_num'] = dict(value=value, missing=True) - case "Submitter Plate Number": - new_dict['submitter_plate_num'] = dict(value=value, missing=True) - case _: - logger.debug(f"Setting dict {key} to {value}") - new_dict[key.lower().replace(" ", "_")] = dict(value=value, missing=True) - # new_dict[key.lower().replace(" ", "_")]['value'] = value - # new_dict[key.lower().replace(" ", "_")]['missing'] = True - new_dict['filepath'] = Path(tempfile.TemporaryFile().name) - # logger.debug(f"Dictionary coming into PydSubmission: {pformat(new_dict)}") - # sys.exit() - return PydSubmission(**new_dict) - - def save(self, original:bool=True): - """ - Adds this instance to database and commits. - - Args: - original (bool, optional): Is this the first save. Defaults to True. - """ - if original: - self.uploaded_by = getuser() - super().save() +# Query functions @classmethod @setup_lookup @@ -633,9 +734,10 @@ class BasicSubmission(BaseClass): Returns: models.BasicSubmission | List[models.BasicSubmission]: Submission(s) of interest """ - # logger.debug(f"kwargs coming into query: {kwargs}") + # NOTE: if you go back to using 'model' change the appropriate cls to model in the query filters if submission_type == None: + # find the subclass containing the relevant attributes model = cls.find_subclasses(attrs=kwargs) else: if isinstance(submission_type, SubmissionType): @@ -653,20 +755,27 @@ class BasicSubmission(BaseClass): logger.debug(f"Querying with start date: {start_date} and end date: {end_date}") match start_date: case date(): + # logger.debug(f"Lookup BasicSubmission by start_date({start_date})") start_date = start_date.strftime("%Y-%m-%d") case int(): + # logger.debug(f"Lookup BasicSubmission by ordinal start_date {start_date}") start_date = datetime.fromordinal(datetime(1900, 1, 1).toordinal() + start_date - 2).date().strftime("%Y-%m-%d") case _: + # logger.debug(f"Lookup BasicSubmission by parsed str start_date {start_date}") start_date = parse(start_date).strftime("%Y-%m-%d") match end_date: case date() | datetime(): + # logger.debug(f"Lookup BasicSubmission by end_date({end_date})") end_date = end_date.strftime("%Y-%m-%d") case int(): + # logger.debug(f"Lookup BasicSubmission by ordinal end_date {end_date}") end_date = datetime.fromordinal(datetime(1900, 1, 1).toordinal() + end_date - 2).date().strftime("%Y-%m-%d") case _: + # logger.debug(f"Lookup BasicSubmission by parsed str end_date {end_date}") end_date = parse(end_date).strftime("%Y-%m-%d") # logger.debug(f"Looking up BasicSubmissions from start date: {start_date} and end date: {end_date}") logger.debug(f"Start date {start_date} == End date {end_date}: {start_date==end_date}") + # logger.debug(f"Compensating for same date by using time") if start_date == end_date: start_date = datetime.strptime(start_date, "%Y-%m-%d").strftime("%Y-%m-%d %H:%M:%S.%f") query = query.filter(cls.submitted_date==start_date) @@ -710,7 +819,7 @@ class BasicSubmission(BaseClass): limit = 1 if chronologic: query.order_by(cls.submitted_date) - return query_return(query=query, limit=limit) + return cls.query_return(query=query, limit=limit) @classmethod def query_or_create(cls, submission_type:str|SubmissionType|None=None, **kwargs) -> BasicSubmission: @@ -754,21 +863,15 @@ class BasicSubmission(BaseClass): msg = "This submission already exists.\nWould you like to overwrite?" return instance, code, msg - def get_used_equipment(self) -> List[str]: - return [item.role for item in self.submission_equipment_associations] +# Custom context events for the ui - @classmethod - def adjust_autofill_samples(cls, samples:List[Any]) -> List[Any]: - logger.info(f"Hello from {cls.__mapper_args__['polymorphic_identity']} sampler") - return samples + def custom_context_events(self) -> dict: + """ + Creates dictionary of str:function to be passed to context menu - def adjust_to_dict_samples(self, backup:bool=False): - logger.debug(f"Hello from {self.__class__.__name__} dictionary sample adjuster.") - return [item.to_sub_dict() for item in self.submission_sample_associations] - - # Custom context events for the ui - - def custom_context_events(self): + Returns: + dict: dictionary of functions + """ names = ["Delete", "Details", "Add Comment", "Add Equipment", "Export"] funcs = [self.delete, self.show_details, self.add_comment, self.add_equipment, self.backup] dicto = {item[0]:item[1] for item in zip(names, funcs)} @@ -778,20 +881,33 @@ class BasicSubmission(BaseClass): """ Performs backup and deletes this instance from database. + Args: + obj (_type_, optional): Parent Widget. Defaults to None. + Raises: - e: Raised in something goes wrong. - """ + e: _description_ + """ + from frontend.widgets.pop_ups import QuestionAsker logger.debug("Hello from delete") fname = self.__backup_path__.joinpath(f"{self.rsl_plate_num}-backup({date.today().strftime('%Y%m%d')})") - self.backup(fname=fname, full_backup=True) - self.__database_session__.delete(self) - try: - self.__database_session__.commit() - except (SQLIntegrityError, SQLOperationalError, AlcIntegrityError, AlcOperationalError) as e: - self.__database_session__.rollback() - raise e - + msg = QuestionAsker(title="Delete?", message=f"Are you sure you want to delete {self.rsl_plate_num}?\n") + if msg.exec(): + self.backup(fname=fname, full_backup=True) + self.__database_session__.delete(self) + try: + self.__database_session__.commit() + except (SQLIntegrityError, SQLOperationalError, AlcIntegrityError, AlcOperationalError) as e: + self.__database_session__.rollback() + raise e + obj.setData() + def show_details(self, obj): + """ + Creates Widget for showing submission details. + + Args: + obj (_type_): parent widget + """ logger.debug("Hello from details") from frontend.widgets.submission_details import SubmissionDetails dlg = SubmissionDetails(parent=obj, sub=self) @@ -799,6 +915,12 @@ class BasicSubmission(BaseClass): pass def add_comment(self, obj): + """ + Creates widget for adding comments to submissions + + Args: + obj (_type_): parent widget + """ from frontend.widgets.submission_details import SubmissionComment dlg = SubmissionComment(parent=obj, submission=self) if dlg.exec(): @@ -811,9 +933,14 @@ class BasicSubmission(BaseClass): self.comment = comment logger.debug(self.comment) self.save(original=False) - # logger.debug(f"Save result: {result}") def add_equipment(self, obj): + """ + Creates widget for adding equipment to this submission + + Args: + obj (_type_): parent widget + """ from frontend.widgets.equipment_usage import EquipmentUsage dlg = EquipmentUsage(parent=obj, submission=self) if dlg.exec(): @@ -832,7 +959,9 @@ class BasicSubmission(BaseClass): Exports xlsx and yml info files for this instance. Args: - fname (Path): Filename of xlsx file. + obj (_type_, optional): _description_. Defaults to None. + fname (Path | None, optional): Filename of xlsx file. Defaults to None. + full_backup (bool, optional): Whether or not to make yaml file. Defaults to False. """ logger.debug("Hello from backup.") pyd = self.to_pydantic(backup=True) @@ -880,7 +1009,7 @@ class BacterialCulture(BasicSubmission): return output @classmethod - def get_abbreviation(cls): + def get_abbreviation(cls) -> str: return "BC" @classmethod @@ -934,50 +1063,10 @@ class BacterialCulture(BasicSubmission): from backend.validators import RSLNamer data['abbreviation'] = cls.get_abbreviation() outstr = super().enforce_name(instr=instr, data=data) - # def construct(data:dict|None=None) -> str: - # """ - # Create default plate name. - - # Returns: - # str: new RSL number - # """ - # # logger.debug(f"Attempting to construct RSL number from scratch...") - # directory = cls.__directory_path__.joinpath("Bacteria") - # year = str(datetime.now().year)[-2:] - # if directory.exists(): - # logger.debug(f"Year: {year}") - # relevant_rsls = [] - # all_xlsx = [item.stem for item in directory.rglob("*.xlsx") if bool(re.search(r"RSL-\d{2}-\d{4}", item.stem)) and year in item.stem[4:6]] - # # logger.debug(f"All rsls: {all_xlsx}") - # for item in all_xlsx: - # try: - # relevant_rsls.append(re.match(r"RSL-\d{2}-\d{4}", item).group(0)) - # except Exception as e: - # logger.error(f"Regex error: {e}") - # continue - # # logger.debug(f"Initial xlsx: {relevant_rsls}") - # max_number = max([int(item[-4:]) for item in relevant_rsls]) - # # logger.debug(f"The largest sample number is: {max_number}") - # return f"RSL-{year}-{str(max_number+1).zfill(4)}" - # else: - # # raise FileNotFoundError(f"Unable to locate the directory: {directory.__str__()}") - # return f"RSL-{year}-0000" - # try: - # outstr = re.sub(r"RSL(\d{2})", r"RSL-\1", outstr, flags=re.IGNORECASE) - # except (AttributeError, TypeError) as e: - # outstr = construct() - # # year = datetime.now().year - # # self.parsed_name = f"RSL-{str(year)[-2:]}-0000" - # return re.sub(r"RSL-(\d{2})(\d{4})", r"RSL-\1-\2", outstr, flags=re.IGNORECASE) - # def construct(): - # previous = cls.query(start_date=date.today(), end_date=date.today(), submission_type=cls.__name__) - # max = len(previous) - # return f"RSL-BC-{date.today().strftime('%Y%m%d')}-{max+1}" try: outstr = re.sub(r"(\d{4})-(\d{2})-(\d{2})", r"\1\2\3", outstr) outstr = re.sub(r"BC(\d{6})", r"BC-\1", outstr, flags=re.IGNORECASE) except (AttributeError, TypeError) as e: - # outstr = construct() outstr = RSLNamer.construct_new_plate_name(data=data) return outstr @@ -989,7 +1078,6 @@ class BacterialCulture(BasicSubmission): Returns: str: string for regex construction """ - # return "(?PRSL-?\\d{2}-?\\d{4})" return "(?PRSL(?:-|_)?BC(?:-|_)?20\d{2}-?\d{2}-?\d{2}(?:(_|-)?\d?([^_0123456789\s]|$)?R?\d?)?)" @classmethod @@ -1003,12 +1091,18 @@ class BacterialCulture(BasicSubmission): @classmethod def parse_info(cls, input_dict: dict, xl: pd.ExcelFile | None = None) -> dict: + """ + Extends parent + """ input_dict = super().parse_info(input_dict, xl) input_dict['submitted_date']['missing'] = True return input_dict @classmethod def custom_sample_autofill_row(cls, sample, worksheet: Worksheet) -> int: + """ + Extends parent + """ logger.debug(f"Checking {sample.well}") logger.debug(f"here's the worksheet: {worksheet}") row = super().custom_sample_autofill_row(sample, worksheet) @@ -1028,8 +1122,8 @@ class Wastewater(BasicSubmission): derivative submission type from BasicSubmission """ id = Column(INTEGER, ForeignKey('_basicsubmission.id'), primary_key=True) - ext_technician = Column(String(64)) - pcr_technician = Column(String(64)) + ext_technician = Column(String(64)) #: Name of technician doing extraction + pcr_technician = Column(String(64)) #: Name of technician doing pcr pcr_info = Column(JSON) #: unstructured output from pcr table logger or user(Artic) __mapper_args__ = __mapper_args__ = dict(polymorphic_identity="Wastewater", @@ -1053,7 +1147,7 @@ class Wastewater(BasicSubmission): return output @classmethod - def get_abbreviation(cls): + def get_abbreviation(cls) -> str: return "WW" @classmethod @@ -1118,26 +1212,10 @@ class Wastewater(BasicSubmission): from backend.validators import RSLNamer data['abbreviation'] = cls.get_abbreviation() outstr = super().enforce_name(instr=instr, data=data) - # def construct(data:dict|None=None): - # if "submitted_date" in data.keys(): - # if data['submitted_date']['value'] != None: - # today = data['submitted_date']['value'] - # else: - # today = datetime.now() - # else: - # today = re.search(r"\d{4}(_|-)?\d{2}(_|-)?\d{2}", instr) - # try: - # today = parse(today.group()) - # except AttributeError: - # today = datetime.now() - # return f"RSL-WW-{today.year}{str(today.month).zfill(2)}{str(today.day).zfill(2)}" - # if outstr == None: - # outstr = construct(data) try: outstr = re.sub(r"PCR(-|_)", "", outstr) except AttributeError as e: logger.error(f"Problem using regex: {e}") - # outstr = construct(data) outstr = RSLNamer.construct_new_plate_name(instr=outstr) outstr = outstr.replace("RSLWW", "RSL-WW") outstr = re.sub(r"WW(\d{4})", r"WW-\1", outstr, flags=re.IGNORECASE) @@ -1148,7 +1226,6 @@ class Wastewater(BasicSubmission): # logger.debug(f"Plate number is: {plate_number}") except AttributeError as e: plate_number = "1" - # self.parsed_name = re.sub(r"(\d{8})(-|_\d)?(R\d)?", fr"\1-{plate_number}\3", self.parsed_name) outstr = re.sub(r"(\d{8})(-|_)?\d?(R\d?)?", rf"\1-{plate_number}\3", outstr) # logger.debug(f"After addition of plate number the plate name is: {outstr}") try: @@ -1167,16 +1244,21 @@ class Wastewater(BasicSubmission): Returns: str: String for regex construction """ - # return "(?PRSL(?:-|_)?WW(?:-|_)?20\d{2}-?\d{2}-?\d{2}(?:(_|-)?\d?([^_0123456789\s]|$)R?\d?)?)" return "(?PRSL(?:-|_)?WW(?:-|_)?20\d{2}-?\d{2}-?\d{2}(?:(_|-)?\d?([^_0123456789\s]|$)?R?\d?)?)" @classmethod def adjust_autofill_samples(cls, samples: List[Any]) -> List[Any]: + """ + Extends parent + """ samples = super().adjust_autofill_samples(samples) return [item for item in samples if not item.submitter_id.startswith("EN")] @classmethod def custom_sample_autofill_row(cls, sample, worksheet: Worksheet) -> int: + """ + Extends parent + """ logger.debug(f"Checking {sample.well}") logger.debug(f"here's the worksheet: {worksheet}") row = super().custom_sample_autofill_row(sample, worksheet) @@ -1192,14 +1274,15 @@ class WastewaterArtic(BasicSubmission): derivative submission type for artic wastewater """ id = Column(INTEGER, ForeignKey('_basicsubmission.id'), primary_key=True) + artic_technician = Column(String(64)) #: Name of technician performing artic + dna_core_submission_number = Column(String(64)) #: Number used by core as id + pcr_info = Column(JSON) #: unstructured output from pcr table logger or user(Artic) + gel_image = Column(String(64)) #: file name of gel image in zip file + gel_info = Column(JSON) #: unstructured data from gel. + __mapper_args__ = dict(polymorphic_identity="Wastewater Artic", polymorphic_load="inline", inherit_condition=(id == BasicSubmission.id)) - artic_technician = Column(String(64)) - dna_core_submission_number = Column(String(64)) - pcr_info = Column(JSON) #: unstructured output from pcr table logger or user(Artic) - gel_image = Column(String(64)) - gel_info = Column(JSON) def calculate_base_cost(self): """ @@ -1220,8 +1303,21 @@ class WastewaterArtic(BasicSubmission): except Exception as e: logger.error(f"Calculation error: {e}") + def to_dict(self, full_data:bool=False, backup:bool=False) -> dict: + """ + Extends parent class method to add controls to dict + + Returns: + dict: dictionary used in submissions summary + """ + output = super().to_dict(full_data=full_data) + output['gel_info'] = self.gel_info + output['gel_image'] = self.gel_image + output['dna_core_submission_number'] = self.dna_core_submission_number + return output + @classmethod - def get_abbreviation(cls): + def get_abbreviation(cls) -> str: return "AR" @classmethod @@ -1245,49 +1341,57 @@ class WastewaterArtic(BasicSubmission): return input_dict @classmethod - def en_adapter(cls, input_str) -> str: + def en_adapter(cls, input_str:str) -> str: + """ + Stopgap solution because WW names their ENs different + + Args: + input_str (str): input name + + Returns: + str: output name + """ processed = re.sub(r"[A-Z]", "", input_str) try: en_num = re.search(r"\-\d{1}$", processed).group() - processed = processed.replace(en_num, "", -1) + processed = rreplace(processed, en_num, "") except AttributeError: en_num = "1" en_num = en_num.strip("-") - logger.debug(f"Processed after en-num: {processed}") + # logger.debug(f"Processed after en-num: {processed}") try: plate_num = re.search(r"\-\d{1}$", processed).group() - processed = processed.replace(plate_num, "", -1) + processed = rreplace(processed, plate_num, "") except AttributeError: plate_num = "1" plate_num = plate_num.strip("-") - logger.debug(f"Processed after plate-num: {processed}") + # logger.debug(f"Processed after plate-num: {processed}") day = re.search(r"\d{2}$", processed).group() - processed = processed.replace(day, "", -1) - logger.debug(f"Processed after day: {processed}") + processed = rreplace(processed, day, "") + # logger.debug(f"Processed after day: {processed}") month = re.search(r"\d{2}$", processed).group() - processed = processed.replace(month, "", -1) + processed = rreplace(processed, month, "") processed = processed.replace("--", "") - logger.debug(f"Processed after month: {processed}") + # logger.debug(f"Processed after month: {processed}") year = re.search(r'^(?:\d{2})?\d{2}', processed).group() year = f"20{year}" return f"EN{year}{month}{day}-{en_num}" @classmethod - def enforce_name(cls, instr:str, data:dict|None=None) -> str: + def enforce_name(cls, instr:str|None=None, data:dict|None=None) -> str: """ Extends parent """ from backend.validators import RSLNamer data['abbreviation'] = cls.get_abbreviation() outstr = super().enforce_name(instr=instr, data=data) - # def construct(data:dict|None=None): - # today = datetime.now() - # return f"RSL-AR-{today.year}{str(today.month).zfill(2)}{str(today.day).zfill(2)}" try: outstr = re.sub(r"(\d{4})-(\d{2})-(\d{2})", r"RSL-AR-\1\2\3", outstr, flags=re.IGNORECASE) - except AttributeError: - # outstr = construct() - outstr = RSLNamer.construct_new_plate_name(instr=outstr, data=data) + except (AttributeError, TypeError): + if instr != None: + data['rsl_plate_num'] = instr + # logger.debug(f"Create new plate name from submission parameters") + outstr = RSLNamer.construct_new_plate_name(data=data) try: plate_number = int(re.search(r"_|-\d?_", outstr).group().strip("_").strip("-")) except (AttributeError, ValueError) as e: @@ -1374,6 +1478,17 @@ class WastewaterArtic(BasicSubmission): @classmethod def custom_autofill(cls, input_excel: Workbook, info: dict | None = None, backup: bool = False) -> Workbook: + """ + Adds custom autofill methods for submission. Extends Parent + + Args: + input_excel (Workbook): initial workbook. + info (dict | None, optional): dictionary of additional info. Defaults to None. + backup (bool, optional): Whether this is part of a backup operation. Defaults to False. + + Returns: + Workbook: Updated workbook + """ input_excel = super().custom_autofill(input_excel, info, backup) worksheet = input_excel["First Strand List"] samples = cls.query(rsl_number=info['rsl_plate_num']['value']).submission_sample_associations @@ -1391,7 +1506,6 @@ class WastewaterArtic(BasicSubmission): source_plates.append(assoc) first_samples.append(sample.ww_processing_num) # Pad list to length of 3 - # source_plates = list(set(source_plates)) source_plates += ['None'] * (3 - len(source_plates)) first_samples += [''] * (3 - len(first_samples)) source_plates = zip(source_plates, first_samples, strict=False) @@ -1399,9 +1513,76 @@ class WastewaterArtic(BasicSubmission): logger.debug(f"Plate: {plate}") for jjj, value in enumerate(plate, start=3): worksheet.cell(row=iii, column=jjj, value=value) + logger.debug(f"Info:\n{pformat(info)}") + check = 'gel_info' in info.keys() and info['gel_info']['value'] != None + if check: + # logger.debug(f"Gel info check passed.") + if info['gel_info'] != None: + # logger.debug(f"Gel info not none.") + worksheet = input_excel['Egel results'] + start_row = 21 + start_column = 15 + for row, ki in enumerate(info['gel_info']['value'], start=1): + # logger.debug(f"ki: {ki}") + # logger.debug(f"vi: {vi}") + row = start_row + row + worksheet.cell(row=row, column=start_column, value=ki['name']) + for jjj, kj in enumerate(ki['values'], start=1): + # logger.debug(f"kj: {kj}") + # logger.debug(f"vj: {vj}") + column = start_column + 2 + jjj + worksheet.cell(row=start_row, column=column, value=kj['name']) + worksheet.cell(row=row, column=column, value=kj['value']) + check = 'gel_image' in info.keys() and info['gel_image']['value'] != None + if check: + if info['gel_image'] != None: + worksheet = input_excel['Egel results'] + logger.debug(f"We got an image: {info['gel_image']}") + with ZipFile(cls.__directory_path__.joinpath("submission_imgs.zip")) as zipped: + z = zipped.extract(info['gel_image']['value'], Path(TemporaryDirectory().name)) + img = OpenpyxlImage(z) + img.height = 400 # insert image height in pixels as float or int (e.g. 305.5) + img.width = 600 + img.anchor = 'B9' + worksheet.add_image(img) return input_excel - def adjust_to_dict_samples(self, backup:bool=False): + @classmethod + def get_details_template(cls, base_dict:dict) -> Tuple[dict, Template]: + """ + Get the details jinja template for the correct class. Extends parent + + Args: + base_dict (dict): incoming dictionary of Submission fields + + Returns: + Tuple[dict, Template]: (Updated dictionary, Template to be rendered) + """ + base_dict, template = super().get_details_template(base_dict=base_dict) + base_dict['excluded'] += ['gel_info', 'gel_image', 'headers', "dna_core_submission_number"] + base_dict['DNA Core ID'] = base_dict['dna_core_submission_number'] + check = 'gel_info' in base_dict.keys() and base_dict['gel_info'] != None + if check: + headers = [item['name'] for item in base_dict['gel_info'][0]['values']] + base_dict['headers'] = [''] * (4 - len(headers)) + base_dict['headers'] += headers + logger.debug(f"Gel info: {pformat(base_dict['headers'])}") + check = 'gel_image' in base_dict.keys() and base_dict['gel_image'] != None + if check: + with ZipFile(cls.__directory_path__.joinpath("submission_imgs.zip")) as zipped: + base_dict['gel_image'] = base64.b64encode(zipped.read(base_dict['gel_image'])).decode('utf-8') + return base_dict, template + + def adjust_to_dict_samples(self, backup:bool=False) -> List[dict]: + """ + Updates sample dictionaries with custom values + + Args: + backup (bool, optional): Whether to perform backup. Defaults to False. + + Returns: + List[dict]: Updated dictionaries + """ logger.debug(f"Hello from {self.__class__.__name__} dictionary sample adjuster.") if backup: output = [] @@ -1419,21 +1600,34 @@ class WastewaterArtic(BasicSubmission): output = super().adjust_to_dict_samples(backup=False) return output - def custom_context_events(self): + def custom_context_events(self) -> dict: + """ + Creates dictionary of str:function to be passed to context menu. Extends parent + + Returns: + dict: dictionary of functions + """ events = super().custom_context_events() events['Gel Box'] = self.gel_box return events def gel_box(self, obj): + """ + Creates widget to perform gel viewing operations + + Args: + obj (_type_): parent widget + """ from frontend.widgets.gel_checker import GelBox from frontend.widgets import select_open_file fname = select_open_file(obj=obj, file_extension="jpg") dlg = GelBox(parent=obj, img_path=fname) if dlg.exec(): - img_path, output = dlg.parse_form() + self.dna_core_submission_number, img_path, output = dlg.parse_form() self.gel_image = img_path.name self.gel_info = output - with zipfile.ZipFile(self.__directory_path__.joinpath("submission_imgs.zip"), 'a') as zipf: + logger.debug(pformat(self.gel_info)) + with ZipFile(self.__directory_path__.joinpath("submission_imgs.zip"), 'a') as zipf: # Add a file located at the source_path to the destination within the zip # file. It will overwrite existing files if the names collide, but it # will give a warning @@ -1447,8 +1641,6 @@ class BasicSample(BaseClass): Base of basic sample which polymorphs into BCSample and WWSample """ - # __tablename__ = "_samples" - id = Column(INTEGER, primary_key=True) #: primary key submitter_id = Column(String(64), nullable=False, unique=True) #: identification from submitter sample_type = Column(String(32)) #: subtype of sample @@ -1461,7 +1653,6 @@ class BasicSample(BaseClass): __mapper_args__ = { "polymorphic_identity": "Basic Sample", - # "polymorphic_on": sample_type, "polymorphic_on": case( (sample_type == "Wastewater Sample", "Wastewater Sample"), @@ -1498,7 +1689,7 @@ class BasicSample(BaseClass): except AttributeError: return f" dict: + def to_sub_dict(self) -> dict: """ gui friendly dictionary, extends parent method. @@ -1513,7 +1704,7 @@ class BasicSample(BaseClass): def set_attribute(self, name:str, value): """ - Custom attribute setter + Custom attribute setter (depreciated over built-in __setattr__) Args: name (str): name of attribute @@ -1582,7 +1773,7 @@ class BasicSample(BaseClass): @classmethod def parse_sample(cls, input_dict:dict) -> dict: f""" - Custom sample parser for {cls.__name__} + Custom sample parser Args: input_dict (dict): Basic parser results. @@ -1638,7 +1829,7 @@ class BasicSample(BaseClass): query = query.filter(attr==v) if len(kwargs) > 0: limit = 1 - return query_return(query=query, limit=limit) + return cls.query_return(query=query, limit=limit) @classmethod def query_or_create(cls, sample_type:str|None=None, **kwargs) -> BasicSample: @@ -1691,14 +1882,14 @@ class WastewaterSample(BasicSample): polymorphic_load="inline", inherit_condition=(id == BasicSample.id)) - def to_sub_dict(self, submission_rsl:str) -> dict: + def to_sub_dict(self) -> dict: """ gui friendly dictionary, extends parent method. Returns: dict: well location and name (sample id, organism) NOTE: keys must sync with WWSample to_sub_dict above """ - sample = super().to_sub_dict(submission_rsl=submission_rsl) + sample = super().to_sub_dict() sample['ww_processing_num'] = self.ww_processing_num sample['sample_location'] = self.sample_location sample['received_date'] = self.received_date @@ -1707,6 +1898,15 @@ class WastewaterSample(BasicSample): @classmethod def parse_sample(cls, input_dict: dict) -> dict: + """ + Custom sample parser. Extends parent + + Args: + input_dict (dict): Basic parser results. + + Returns: + dict: Updated parser results. + """ output_dict = super().parse_sample(input_dict) if output_dict['rsl_number'] == None: output_dict['rsl_number'] = output_dict['submitter_id'] @@ -1750,14 +1950,14 @@ class BacterialCultureSample(BasicSample): polymorphic_load="inline", inherit_condition=(id == BasicSample.id)) - def to_sub_dict(self, submission_rsl:str) -> dict: + def to_sub_dict(self) -> dict: """ gui friendly dictionary, extends parent method. Returns: dict: well location and name (sample id, organism) NOTE: keys must sync with WWSample to_sub_dict above """ - sample = super().to_sub_dict(submission_rsl=submission_rsl) + sample = super().to_sub_dict() sample['name'] = self.submitter_id sample['organism'] = self.organism sample['concentration'] = self.concentration @@ -1766,13 +1966,6 @@ class BacterialCultureSample(BasicSample): sample['tooltip'] = f"Control: {self.control.controltype.name} - {self.control.controltype.targets}" return sample - # def to_hitpick(self, submission_rsl: str | None = None) -> dict | None: - # sample = super().to_hitpick(submission_rsl) - # if self.control != None: - # sample['colour'] = [0,128,0] - # sample['tooltip'] += f"
- Control: {self.control.controltype.name} - {self.control.controltype.targets}" - # return sample - # Submission to Sample Associations class SubmissionSampleAssociation(BaseClass): @@ -1780,10 +1973,8 @@ class SubmissionSampleAssociation(BaseClass): table containing submission/sample associations DOC: https://docs.sqlalchemy.org/en/14/orm/extensions/associationproxy.html """ - - # __tablename__ = "_submission_sample" - id = Column(INTEGER, unique=True, nullable=False) + id = Column(INTEGER, unique=True, nullable=False) #: id to be used for inheriting purposes sample_id = Column(INTEGER, ForeignKey("_basicsample.id"), nullable=False) #: id of associated sample submission_id = Column(INTEGER, ForeignKey("_basicsubmission.id"), primary_key=True) #: id of associated submission row = Column(INTEGER, primary_key=True) #: row on the 96 well plate @@ -1832,11 +2023,8 @@ class SubmissionSampleAssociation(BaseClass): """ # Get sample info # logger.debug(f"Running {self.__repr__()}") - sample = self.sample.to_sub_dict(submission_rsl=self.submission) - # sample = {} + sample = self.sample.to_sub_dict() sample['name'] = self.sample.submitter_id - # sample['submitter_id'] = self.sample.submitter_id - # sample['sample_type'] = self.sample.sample_type sample['row'] = self.row sample['column'] = self.column try: @@ -1846,7 +2034,6 @@ class SubmissionSampleAssociation(BaseClass): sample['well'] = None sample['plate_name'] = self.submission.rsl_plate_num sample['positive'] = False - return sample def to_hitpick(self) -> dict|None: @@ -1857,7 +2044,6 @@ class SubmissionSampleAssociation(BaseClass): dict: dictionary of sample id, row and column in elution plate """ # Since there is no PCR, negliable result is necessary. - # assoc = [item for item in self.sample_submission_associations if item.submission.rsl_plate_num==submission_rsl][0] sample = self.to_sub_dict() logger.debug(f"Sample dict to hitpick: {sample}") env = jinja_template_loading() @@ -1867,15 +2053,17 @@ class SubmissionSampleAssociation(BaseClass): tooltip_text += sample['tooltip'] except KeyError: pass - # tooltip_text = f""" - # Sample name: {self.submitter_id}
- # Well: {row_map[fields['row']]}{fields['column']} - # """ sample.update(dict(name=self.sample.submitter_id[:10], tooltip=tooltip_text)) return sample @classmethod - def autoincrement_id(cls): + def autoincrement_id(cls) -> int: + """ + Increments the association id automatically + + Returns: + int: incremented id + """ try: return max([item.id for item in cls.query()]) + 1 except ValueError as e: @@ -1935,15 +2123,19 @@ class SubmissionSampleAssociation(BaseClass): query: Query = cls.__database_session__.query(cls) match submission: case BasicSubmission(): + # logger.debug(f"Lookup SampleSubmissionAssociation with submission BasicSubmission {submission}") query = query.filter(cls.submission==submission) case str(): + # logger.debug(f"Lookup SampleSubmissionAssociation with submission str {submission}") query = query.join(BasicSubmission).filter(BasicSubmission.rsl_plate_num==submission) case _: pass match sample: case BasicSample(): + # logger.debug(f"Lookup SampleSubmissionAssociation with sample BasicSample {sample}") query = query.filter(cls.sample==sample) case str(): + # logger.debug(f"Lookup SampleSubmissionAssociation with sample str {sample}") query = query.join(BasicSample).filter(BasicSample.submitter_id==sample) case _: pass @@ -1953,6 +2145,7 @@ class SubmissionSampleAssociation(BaseClass): query = query.filter(cls.column==column) match exclude_submission_type: case str(): + # logger.debug(f"filter SampleSubmissionAssociation to exclude submission type {exclude_submission_type}") query = query.join(BasicSubmission).filter(BasicSubmission.submission_type_name != exclude_submission_type) case _: pass @@ -1964,7 +2157,7 @@ class SubmissionSampleAssociation(BaseClass): query = query.order_by(BasicSubmission.submitted_date.desc()) else: query = query.order_by(BasicSubmission.submitted_date) - return query_return(query=query, limit=limit) + return cls.query_return(query=query, limit=limit) @classmethod def query_or_create(cls, @@ -1980,6 +2173,7 @@ class SubmissionSampleAssociation(BaseClass): association_type (str, optional): Subclass name. Defaults to "Basic Association". submission (BasicSubmission | str | None, optional): associated submission. Defaults to None. sample (BasicSample | str | None, optional): associated sample. Defaults to None. + id (int | None, optional): association id. Defaults to None. Returns: SubmissionSampleAssociation: Queried or new association. @@ -2020,10 +2214,7 @@ class SubmissionSampleAssociation(BaseClass): raise AttributeError(f"Delete not implemented for {self.__class__}") class WastewaterAssociation(SubmissionSampleAssociation): - """ - Derivative custom Wastewater/Submission Association... fancy. - """ - # sample_id = Column(INTEGER, ForeignKey('_submissionsampleassociation.sample_id'), primary_key=True) + id = Column(INTEGER, ForeignKey("_submissionsampleassociation.id"), primary_key=True) ct_n1 = Column(FLOAT(2)) #: AKA ct for N1 ct_n2 = Column(FLOAT(2)) #: AKA ct for N2 @@ -2031,16 +2222,18 @@ class WastewaterAssociation(SubmissionSampleAssociation): n2_status = Column(String(32)) #: positive or negative for N2 pcr_results = Column(JSON) #: imported PCR status from QuantStudio - # __mapper_args__ = {"polymorphic_identity": "Wastewater Association", "polymorphic_load": "inline"} __mapper_args__ = dict(polymorphic_identity="Wastewater Association", polymorphic_load="inline", - # inherit_condition=(submission_id==SubmissionSampleAssociation.submission_id and - # row==SubmissionSampleAssociation.row and - # column==SubmissionSampleAssociation.column)) - inherit_condition=(id==SubmissionSampleAssociation.id)) - # inherit_foreign_keys=(sample_id == SubmissionSampleAssociation.sample_id, submission_id == SubmissionSampleAssociation.submission_id)) - + inherit_condition=(id==SubmissionSampleAssociation.id)) + def to_sub_dict(self) -> dict: + """ + Returns a sample dictionary updated with instance information. Extends parent + + Returns: + dict: Updated dictionary with row, column and well updated + """ + sample = super().to_sub_dict() sample['ct'] = f"({self.ct_n1}, {self.ct_n2})" try: @@ -2050,6 +2243,12 @@ class WastewaterAssociation(SubmissionSampleAssociation): return sample def to_hitpick(self) -> dict | None: + """ + Outputs a dictionary usable for html plate maps. Extends parent + + Returns: + dict: dictionary of sample id, row and column in elution plate + """ sample = super().to_hitpick() try: sample['tooltip'] += f"
- ct N1: {'{:.2f}'.format(self.ct_n1)} ({self.n1_status})
- ct N2: {'{:.2f}'.format(self.ct_n2)} ({self.n2_status})" @@ -2058,10 +2257,17 @@ class WastewaterAssociation(SubmissionSampleAssociation): return sample @classmethod - def autoincrement_id(cls): + def autoincrement_id(cls) -> int: + """ + Increments the association id automatically. Overrides parent + + Returns: + int: incremented id + """ try: parent = [base for base in cls.__bases__ if base.__name__=="SubmissionSampleAssociation"][0] return max([item.id for item in parent.query()]) + 1 except ValueError as e: logger.error(f"Problem incrementing id: {e}") - return 1 \ No newline at end of file + return 1 + \ No newline at end of file diff --git a/src/submissions/backend/excel/parser.py b/src/submissions/backend/excel/parser.py index 04863fe..c9303b5 100644 --- a/src/submissions/backend/excel/parser.py +++ b/src/submissions/backend/excel/parser.py @@ -13,23 +13,21 @@ import logging, re from collections import OrderedDict from datetime import date from dateutil.parser import parse, ParserError -from tools import check_not_nan, convert_nans_to_nones, Settings, is_missing +from tools import check_not_nan, convert_nans_to_nones, is_missing, row_map logger = logging.getLogger(f"submissions.{__name__}") -row_keys = dict(A=1, B=2, C=3, D=4, E=5, F=6, G=7, H=8) +row_keys = {v:k for k,v in row_map.items()} class SheetParser(object): """ object to pull and contain data from excel file """ - def __init__(self, ctx:Settings, filepath:Path|None = None): + def __init__(self, filepath:Path|None = None): """ Args: - ctx (Settings): Settings object passed down from gui. Necessary for Bacterial to get directory path. filepath (Path | None, optional): file path to excel sheet. Defaults to None. """ - self.ctx = ctx logger.debug(f"\n\nParsing {filepath.__str__()}\n\n") match filepath: case Path(): @@ -46,7 +44,7 @@ class SheetParser(object): raise FileNotFoundError(f"Couldn't parse file {self.filepath}") self.sub = OrderedDict() # make decision about type of sample we have - self.sub['submission_type'] = dict(value=RSLNamer.retrieve_submission_type(instr=self.filepath), missing=True) + self.sub['submission_type'] = dict(value=RSLNamer.retrieve_submission_type(filename=self.filepath), missing=True) # # grab the info map from the submission type in database self.parse_info() self.import_kit_validation_check() @@ -144,7 +142,6 @@ class InfoParser(object): def __init__(self, xl:pd.ExcelFile, submission_type:str): logger.info(f"\n\Hello from InfoParser!\n\n") - # self.ctx = ctx self.map = self.fetch_submission_info_map(submission_type=submission_type) self.xl = xl logger.debug(f"Info map for InfoParser: {pformat(self.map)}") @@ -209,7 +206,6 @@ class ReagentParser(object): def __init__(self, xl:pd.ExcelFile, submission_type:str, extraction_kit:str): logger.debug("\n\nHello from ReagentParser!\n\n") - # self.ctx = ctx self.map = self.fetch_kit_info_map(extraction_kit=extraction_kit, submission_type=submission_type) logger.debug(f"Reagent Parser map: {self.map}") self.xl = xl @@ -227,7 +223,6 @@ class ReagentParser(object): """ if isinstance(extraction_kit, dict): extraction_kit = extraction_kit['value'] - # kit = lookup_kit_types(ctx=self.ctx, name=extraction_kit) kit = KitType.query(name=extraction_kit) if isinstance(submission_type, dict): submission_type = submission_type['value'] @@ -272,7 +267,6 @@ class ReagentParser(object): lot = str(lot) logger.debug(f"Going into pydantic: name: {name}, lot: {lot}, expiry: {expiry}, type: {item.strip()}, comment: {comment}") listo.append(PydReagent(type=item.strip(), lot=lot, expiry=expiry, name=name, comment=comment, missing=missing)) - # logger.debug(f"Returning listo: {listo}") return listo class SampleParser(object): @@ -290,7 +284,6 @@ class SampleParser(object): """ logger.debug("\n\nHello from SampleParser!\n\n") self.samples = [] - # self.ctx = ctx self.xl = xl self.submission_type = submission_type sample_info_map = self.fetch_sample_info_map(submission_type=submission_type) @@ -316,11 +309,9 @@ class SampleParser(object): dict: Info locations. """ logger.debug(f"Looking up submission type: {submission_type}") - # submission_type = lookup_submission_type(ctx=self.ctx, name=submission_type) submission_type = SubmissionType.query(name=submission_type) logger.debug(f"info_map: {pformat(submission_type.info_map)}") sample_info_map = submission_type.info_map['samples'] - # self.custom_parser = get_polymorphic_subclass(models.BasicSubmission, submission_type.name).parse_samples self.custom_sub_parser = BasicSubmission.find_polymorphic_subclass(polymorphic_identity=submission_type.name).parse_samples self.custom_sample_parser = BasicSample.find_polymorphic_subclass(polymorphic_identity=f"{submission_type.name} Sample").parse_sample return sample_info_map @@ -341,7 +332,6 @@ class SampleParser(object): df = pd.DataFrame(df.values[1:], columns=df.iloc[0]) df = df.set_index(df.columns[0]) logger.debug(f"Vanilla platemap: {df}") - # custom_mapper = get_polymorphic_subclass(models.BasicSubmission, self.submission_type) custom_mapper = BasicSubmission.find_polymorphic_subclass(polymorphic_identity=self.submission_type) df = custom_mapper.custom_platemap(self.xl, df) logger.debug(f"Custom platemap:\n{df}") @@ -402,7 +392,6 @@ class SampleParser(object): else: return input_str for sample in self.samples: - # addition = self.lookup_table[self.lookup_table.isin([sample['submitter_id']]).any(axis=1)].squeeze().to_dict() addition = self.lookup_table[self.lookup_table.isin([sample['submitter_id']]).any(axis=1)].squeeze() # logger.debug(addition) if isinstance(addition, pd.DataFrame) and not addition.empty: @@ -433,25 +422,17 @@ class SampleParser(object): # logger.debug(f"Output sample dict: {sample}") logger.debug(f"Final lookup_table: \n\n {self.lookup_table}") - def parse_samples(self, generate:bool=True) -> List[dict]|List[BasicSample]: + def parse_samples(self) -> List[dict]|List[BasicSample]: """ Parse merged platemap\lookup info into dicts/samples - Args: - generate (bool, optional): Indicates if sample objects to be generated from dicts. Defaults to True. - Returns: List[dict]|List[models.BasicSample]: List of samples """ result = None new_samples = [] logger.debug(f"Starting samples: {pformat(self.samples)}") - for ii, sample in enumerate(self.samples): - # try: - # if sample['submitter_id'] in [check_sample['sample'].submitter_id for check_sample in new_samples]: - # sample['submitter_id'] = f"{sample['submitter_id']}-{ii}" - # except KeyError as e: - # logger.error(f"Sample obj: {sample}, error: {e}") + for sample in self.samples: translated_dict = {} for k, v in sample.items(): match v: @@ -483,7 +464,7 @@ class SampleParser(object): for plate in self.plates: df = self.xl.parse(plate['sheet'], header=None) if isinstance(df.iat[plate['row']-1, plate['column']-1], str): - output = RSLNamer.retrieve_rsl_number(instr=df.iat[plate['row']-1, plate['column']-1]) + output = RSLNamer.retrieve_rsl_number(filename=df.iat[plate['row']-1, plate['column']-1]) else: continue plates.append(output) @@ -495,25 +476,43 @@ class EquipmentParser(object): self.submission_type = submission_type self.xl = xl self.map = self.fetch_equipment_map() - # self.equipment = self.parse_equipment() def fetch_equipment_map(self) -> List[dict]: + """ + Gets the map of equipment locations in the submission type's spreadsheet + + Returns: + List[dict]: List of locations + """ submission_type = SubmissionType.query(name=self.submission_type) return submission_type.construct_equipment_map() def get_asset_number(self, input:str) -> str: + """ + Pulls asset number from string. + + Args: + input (str): String to be scraped + + Returns: + str: asset number + """ regex = Equipment.get_regex() logger.debug(f"Using equipment regex: {regex} on {input}") try: return regex.search(input).group().strip("-") except AttributeError: return input - - def parse_equipment(self): + def parse_equipment(self) -> List[PydEquipment]: + """ + Scrapes equipment from xl sheet + + Returns: + List[PydEquipment]: list of equipment + """ logger.debug(f"Equipment parser going into parsing: {pformat(self.__dict__)}") output = [] - # sheets = list(set([item['sheet'] for item in self.map])) # logger.debug(f"Sheets: {sheets}") for sheet in self.xl.sheet_names: df = self.xl.parse(sheet, header=None, dtype=object) @@ -550,7 +549,6 @@ class PCRParser(object): Args: filepath (Path | None, optional): file to parse. Defaults to None. """ - # self.ctx = ctx logger.debug(f"Parsing {filepath.__str__()}") if filepath == None: logger.error(f"No filepath given.") @@ -564,9 +562,8 @@ class PCRParser(object): except PermissionError: logger.error(f"Couldn't get permissions for {filepath.__str__()}. Operation might have been cancelled.") return - # self.pcr = OrderedDict() self.parse_general(sheet_name="Results") - namer = RSLNamer(instr=filepath.__str__()) + namer = RSLNamer(filename=filepath.__str__()) self.plate_num = namer.parsed_name self.submission_type = namer.submission_type logger.debug(f"Set plate number to {self.plate_num} and type to {self.submission_type}") diff --git a/src/submissions/backend/excel/reports.py b/src/submissions/backend/excel/reports.py index 2ba4591..fc1cf13 100644 --- a/src/submissions/backend/excel/reports.py +++ b/src/submissions/backend/excel/reports.py @@ -219,7 +219,7 @@ def drop_reruns_from_df(ctx:Settings, df: DataFrame) -> DataFrame: def make_hitpicks(input:List[dict]) -> DataFrame: """ - Converts lsit of dictionaries constructed by hitpicking to dataframe + Converts list of dictionaries constructed by hitpicking to dataframe Args: input (List[dict]): list of hitpicked dictionaries diff --git a/src/submissions/backend/validators/__init__.py b/src/submissions/backend/validators/__init__.py index 02f425e..e6eb23c 100644 --- a/src/submissions/backend/validators/__init__.py +++ b/src/submissions/backend/validators/__init__.py @@ -2,8 +2,8 @@ import logging, re from pathlib import Path from openpyxl import load_workbook from backend.db.models import BasicSubmission, SubmissionType -from datetime import date from tools import jinja_template_loading +from jinja2 import Template logger = logging.getLogger(f"submissions.{__name__}") @@ -11,14 +11,16 @@ class RSLNamer(object): """ Object that will enforce proper formatting on RSL plate names. """ - def __init__(self, instr:str, sub_type:str|None=None, data:dict|None=None): + def __init__(self, filename:str, sub_type:str|None=None, data:dict|None=None): self.submission_type = sub_type if self.submission_type == None: - self.submission_type = self.retrieve_submission_type(instr=instr) + # logger.debug("Creating submission type because none exists") + self.submission_type = self.retrieve_submission_type(filename=filename) logger.debug(f"got submission type: {self.submission_type}") if self.submission_type != None: + # logger.debug("Retrieving BasicSubmission subclass") enforcer = BasicSubmission.find_polymorphic_subclass(polymorphic_identity=self.submission_type) - self.parsed_name = self.retrieve_rsl_number(instr=instr, regex=enforcer.get_regex()) + self.parsed_name = self.retrieve_rsl_number(filename=filename, regex=enforcer.get_regex()) if data == None: data = dict(submission_type=self.submission_type) if "submission_type" not in data.keys(): @@ -26,26 +28,25 @@ class RSLNamer(object): self.parsed_name = enforcer.enforce_name(instr=self.parsed_name, data=data) @classmethod - def retrieve_submission_type(cls, instr:str|Path) -> str: + def retrieve_submission_type(cls, filename:str|Path) -> str: """ Gets submission type from excel file properties or sheet names or regex pattern match or user input Args: - instr (str | Path): filename + filename (str | Path): filename Returns: str: parsed submission type """ - match instr: + match filename: case Path(): - logger.debug(f"Using path method for {instr}.") - if instr.exists(): - wb = load_workbook(instr) + logger.debug(f"Using path method for {filename}.") + if filename.exists(): + wb = load_workbook(filename) try: submission_type = [item.strip().title() for item in wb.properties.category.split(";")][0] except AttributeError: try: - # sts = {item.name:item.info_map['all_sheets'] for item in SubmissionType.query(key="all_sheets")} sts = {item.name:item.get_template_file_sheets() for item in SubmissionType.query()} for k,v in sts.items(): # This gets the *first* submission type that matches the sheet names in the workbook @@ -54,13 +55,13 @@ class RSLNamer(object): break except: # On failure recurse using filename as string for string method - submission_type = cls.retrieve_submission_type(instr=instr.stem.__str__()) + submission_type = cls.retrieve_submission_type(filename=filename.stem.__str__()) else: - submission_type = cls.retrieve_submission_type(instr=instr.stem.__str__()) + submission_type = cls.retrieve_submission_type(filename=filename.stem.__str__()) case str(): regex = BasicSubmission.construct_regex() - logger.debug(f"Using string method for {instr}.") - m = regex.search(instr) + logger.debug(f"Using string method for {filename}.") + m = regex.search(filename) try: submission_type = m.lastgroup except AttributeError as e: @@ -72,6 +73,7 @@ class RSLNamer(object): except UnboundLocalError: check = True if check: + # logger.debug("Final option, ask the user for submission type") from frontend.widgets import SubmissionTypeSelector dlg = SubmissionTypeSelector(title="Couldn't parse submission type.", message="Please select submission type from list below.") if dlg.exec(): @@ -80,25 +82,25 @@ class RSLNamer(object): return submission_type @classmethod - def retrieve_rsl_number(cls, instr:str|Path, regex:str|None=None): + def retrieve_rsl_number(cls, filename:str|Path, regex:str|None=None): """ Uses regex to retrieve the plate number and submission type from an input string Args: in_str (str): string to be parsed """ - logger.debug(f"Input string to be parsed: {instr}") + logger.debug(f"Input string to be parsed: {filename}") if regex == None: regex = BasicSubmission.construct_regex() else: regex = re.compile(rf'{regex}', re.IGNORECASE | re.VERBOSE) logger.debug(f"Using regex: {regex}") - match instr: + match filename: case Path(): - m = regex.search(instr.stem) + m = regex.search(filename.stem) case str(): logger.debug(f"Using string method.") - m = regex.search(instr) + m = regex.search(filename) case _: pass if m != None: @@ -113,6 +115,15 @@ class RSLNamer(object): @classmethod def construct_new_plate_name(cls, data:dict) -> str: + """ + Make a brand new plate name from submission data. + + Args: + data (dict): incoming submission data + + Returns: + str: Output filename + """ if "submitted_date" in data.keys(): if isinstance(data['submitted_date'], dict): if data['submitted_date']['value'] != None: @@ -135,12 +146,20 @@ class RSLNamer(object): return f"RSL-{data['abbreviation']}-{today.year}{str(today.month).zfill(2)}{str(today.day).zfill(2)}-{plate_number}" @classmethod - def construct_export_name(cls, template, **kwargs): + def construct_export_name(cls, template:Template, **kwargs) -> str: + """ + Make export file name from jinja template. (currently unused) + + Args: + template (jinja2.Template): Template stored in BasicSubmission + + Returns: + str: output file name. + """ logger.debug(f"Kwargs: {kwargs}") logger.debug(f"Template: {template}") environment = jinja_template_loading() template = environment.from_string(template) return template.render(**kwargs) - - -from .pydant import * \ No newline at end of file + +from .pydant import * diff --git a/src/submissions/backend/validators/pydant.py b/src/submissions/backend/validators/pydant.py index 3ba6597..df1c4c4 100644 --- a/src/submissions/backend/validators/pydant.py +++ b/src/submissions/backend/validators/pydant.py @@ -11,17 +11,17 @@ from dateutil.parser._parser import ParserError from typing import List, Tuple from . import RSLNamer from pathlib import Path -from tools import check_not_nan, convert_nans_to_nones, jinja_template_loading, Report, Result, row_map +from tools import check_not_nan, convert_nans_to_nones, Report, Result, row_map from backend.db.models import * from sqlalchemy.exc import StatementError, IntegrityError from PyQt6.QtWidgets import QComboBox, QWidget -# from pprint import pformat from openpyxl import load_workbook, Workbook from io import BytesIO logger = logging.getLogger(f"submissions.{__name__}") class PydReagent(BaseModel): + lot: str|None type: str|None expiry: date|None @@ -103,6 +103,7 @@ class PydReagent(BaseModel): Tuple[Reagent, Report]: Reagent instance and result of function """ report = Report() + # logger.debug("Adding extra fields.") if self.model_extra != None: self.__dict__.update(self.model_extra) logger.debug(f"Reagent SQL constructor is looking up type: {self.type}, lot: {self.lot}") @@ -118,16 +119,17 @@ class PydReagent(BaseModel): match key: case "lot": reagent.lot = value.upper() - case "expiry": - reagent.expiry = value case "type": reagent_type = ReagentType.query(name=value) if reagent_type != None: reagent.type.append(reagent_type) - case "name": - reagent.name = value case "comment": continue + case _: + try: + reagent.__setattr__(key, value) + except AttributeError: + logger.error(f"Couldn't set {key} to {value}") if submission != None: assoc = SubmissionReagentAssociation(reagent=reagent, submission=submission) assoc.comments = self.comment @@ -190,7 +192,8 @@ class PydSample(BaseModel, extra='allow'): case "row" | "column": continue case _: - instance.set_attribute(name=key, value=value) + # instance.set_attribute(name=key, value=value) + instance.__setattr__(key, value) out_associations = [] if submission != None: assoc_type = self.sample_type.replace("Sample", "").strip() @@ -228,11 +231,16 @@ class PydEquipment(BaseModel, extra='ignore'): value=[''] return value - # def toForm(self, parent): - # from frontend.widgets.equipment_usage import EquipmentCheckBox - # return EquipmentCheckBox(parent=parent, equipment=self) - - def toSQL(self, submission:BasicSubmission|str=None): + def toSQL(self, submission:BasicSubmission|str=None) -> Tuple[Equipment, SubmissionEquipmentAssociation]: + """ + Creates Equipment and SubmssionEquipmentAssociations for this PydEquipment + + Args: + submission ( BasicSubmission | str ): BasicSubmission of interest + + Returns: + Tuple[Equipment, SubmissionEquipmentAssociation]: SQL objects + """ if isinstance(submission, str): submission = BasicSubmission.query(rsl_number=submission) equipment = Equipment.query(asset_number=self.asset_number) @@ -242,6 +250,7 @@ class PydEquipment(BaseModel, extra='ignore'): assoc = SubmissionEquipmentAssociation(submission=submission, equipment=equipment) process = Process.query(name=self.processes[0]) if process == None: + # logger.debug("Adding in unknown process.") from frontend.widgets.pop_ups import QuestionAsker dlg = QuestionAsker(title="Add Process?", message=f"Unable to find {self.processes[0]} in the database.\nWould you like to add it?") if dlg.exec(): @@ -254,8 +263,6 @@ class PydEquipment(BaseModel, extra='ignore'): process.save() assoc.process = process assoc.role = self.role - # equipment.equipment_submission_associations.append(assoc) - # equipment.equipment_submission_associations.append(assoc) else: assoc = None return equipment, assoc @@ -357,7 +364,7 @@ class PydSubmission(BaseModel, extra='allow'): if check_not_nan(value['value']): return value else: - output = RSLNamer(instr=values.data['filepath'].__str__(), sub_type=sub_type, data=values.data).parsed_name + output = RSLNamer(filename=values.data['filepath'].__str__(), sub_type=sub_type, data=values.data).parsed_name return dict(value=output, missing=True) @field_validator("technician", mode="before") @@ -407,9 +414,10 @@ class PydSubmission(BaseModel, extra='allow'): return dict(value=value, missing=False) else: # return dict(value=RSLNamer(instr=values.data['filepath'].__str__()).submission_type.title(), missing=True) - return dict(value=RSLNamer.retrieve_submission_type(instr=values.data['filepath']).title(), missing=True) + return dict(value=RSLNamer.retrieve_submission_type(filename=values.data['filepath']).title(), missing=True) @field_validator("submission_category", mode="before") + @classmethod def create_category(cls, value): if not isinstance(value, dict): return dict(value=value, missing=True) @@ -423,6 +431,7 @@ class PydSubmission(BaseModel, extra='allow'): return value @field_validator("samples") + @classmethod def assign_ids(cls, value, values): starting_id = SubmissionSampleAssociation.autoincrement_id() output = [] @@ -431,7 +440,6 @@ class PydSubmission(BaseModel, extra='allow'): output.append(sample) return output - def handle_duplicate_samples(self): """ Collapses multiple samples with same submitter id into one with lists for rows, columns. @@ -439,7 +447,7 @@ class PydSubmission(BaseModel, extra='allow'): """ submitter_ids = list(set([sample.submitter_id for sample in self.samples])) output = [] - for iii, id in enumerate(submitter_ids, start=1): + for id in submitter_ids: relevants = [item for item in self.samples if item.submitter_id==id] if len(relevants) <= 1: output += relevants @@ -447,9 +455,6 @@ class PydSubmission(BaseModel, extra='allow'): rows = [item.row[0] for item in relevants] columns = [item.column[0] for item in relevants] ids = [item.assoc_id[0] for item in relevants] - # for jjj, rel in enumerate(relevants, start=1): - # starting_id += jjj - # ids.append(starting_id) dummy = relevants[0] dummy.assoc_id = ids dummy.row = rows @@ -471,6 +476,7 @@ class PydSubmission(BaseModel, extra='allow'): if dictionaries: output = {k:getattr(self, k) for k in fields} else: + # logger.debug("Extracting 'value' from attributes") output = {k:(getattr(self, k) if not isinstance(getattr(self, k), dict) else getattr(self, k)['value']) for k in fields} return output @@ -493,12 +499,14 @@ class PydSubmission(BaseModel, extra='allow'): Returns: Tuple[BasicSubmission, Result]: BasicSubmission instance, result object """ - self.__dict__.update(self.model_extra) + # self.__dict__.update(self.model_extra) + dicto = self.improved_dict() instance, code, msg = BasicSubmission.query_or_create(submission_type=self.submission_type['value'], rsl_plate_num=self.rsl_plate_num['value']) result = Result(msg=msg, code=code) self.handle_duplicate_samples() logger.debug(f"Here's our list of duplicate removed samples: {self.samples}") - for key, value in self.__dict__.items(): + # for key, value in self.__dict__.items(): + for key, value in dicto.items(): if isinstance(value, dict): value = value['value'] logger.debug(f"Setting {key} to {value}") @@ -600,6 +608,7 @@ class PydSubmission(BaseModel, extra='allow'): info = {k:v for k,v in self.improved_dict().items() if isinstance(v, dict)} reagents = self.reagents if len(reagents + list(info.keys())) == 0: + # logger.warning("No info to fill in, returning") return None logger.debug(f"We have blank info and/or reagents in the excel sheet.\n\tLet's try to fill them in.") # extraction_kit = lookup_kit_types(ctx=self.ctx, name=self.extraction_kit['value']) @@ -610,6 +619,7 @@ class PydSubmission(BaseModel, extra='allow'): # logger.debug(f"Missing reagents going into autofile: {pformat(reagents)}") # logger.debug(f"Missing info going into autofile: {pformat(info)}") new_reagents = [] + # logger.debug("Constructing reagent map and values") for reagent in reagents: new_reagent = {} new_reagent['type'] = reagent.type @@ -626,6 +636,7 @@ class PydSubmission(BaseModel, extra='allow'): logger.error(f"Couldn't get name due to {e}") new_reagents.append(new_reagent) new_info = [] + # logger.debug("Constructing info map and values") for k,v in info.items(): try: new_item = {} @@ -678,6 +689,7 @@ class PydSubmission(BaseModel, extra='allow'): logger.debug(f"Sample info: {pformat(sample_info)}") logger.debug(f"Workbook sheets: {workbook.sheetnames}") worksheet = workbook[sample_info["lookup_table"]['sheet']] + # logger.debug("Sorting samples by row/column") samples = sorted(self.samples, key=attrgetter('column', 'row')) submission_obj = BasicSubmission.find_polymorphic_subclass(polymorphic_identity=self.submission_type) samples = submission_obj.adjust_autofill_samples(samples=samples) @@ -704,6 +716,15 @@ class PydSubmission(BaseModel, extra='allow'): return workbook def autofill_equipment(self, workbook:Workbook) -> Workbook: + """ + Fill in equipment on the excel sheet + + Args: + workbook (Workbook): Input excel workbook + + Returns: + Workbook: Updated excel workbook + """ equipment_map = SubmissionType.query(name=self.submission_type['value']).construct_equipment_map() logger.debug(f"Equipment map: {equipment_map}") # See if all equipment has a location map @@ -712,6 +733,7 @@ class PydSubmission(BaseModel, extra='allow'): logger.warning("Creating 'Equipment' sheet to hold unmapped equipment") workbook.create_sheet("Equipment") equipment = [] + # logger.debug("Contructing equipment info map/values") for ii, equip in enumerate(self.equipment, start=1): loc = [item for item in equipment_map if item['role'] == equip.role][0] try: @@ -746,12 +768,10 @@ class PydSubmission(BaseModel, extra='allow'): Returns: str: Output filename """ - env = jinja_template_loading() template = BasicSubmission.find_polymorphic_subclass(polymorphic_identity=self.submission_type).filename_template() - logger.debug(f"Using template string: {template}") - template = env.from_string(template) - render = template.render(**self.improved_dict(dictionaries=False)).replace("/", "") - logger.debug(f"Template rendered as: {render}") + # logger.debug(f"Using template string: {template}") + render = RSLNamer.construct_export_name(template=template, **self.improved_dict(dictionaries=False)).replace("/", "") + # logger.debug(f"Template rendered as: {render}") return render def check_kit_integrity(self, reagenttypes:list=[]) -> Report: @@ -785,6 +805,7 @@ class PydSubmission(BaseModel, extra='allow'): return report class PydContact(BaseModel): + name: str phone: str|None email: str|None @@ -818,7 +839,8 @@ class PydOrganization(BaseModel): value = [item.toSQL() for item in getattr(self, field)] case _: value = getattr(self, field) - instance.set_attribute(name=field, value=value) + # instance.set_attribute(name=field, value=value) + instance.__setattr__(name=field, value=value) return instance class PydReagentType(BaseModel): @@ -845,19 +867,16 @@ class PydReagentType(BaseModel): Returns: ReagentType: ReagentType instance """ - # instance: ReagentType = lookup_reagent_types(ctx=ctx, name=self.name) instance: ReagentType = ReagentType.query(name=self.name) if instance == None: instance = ReagentType(name=self.name, eol_ext=self.eol_ext) logger.debug(f"This is the reagent type instance: {instance.__dict__}") try: - # assoc = lookup_reagenttype_kittype_association(ctx=ctx, reagent_type=instance, kit_type=kit) assoc = KitTypeReagentTypeAssociation.query(reagent_type=instance, kit_type=kit) except StatementError: assoc = None if assoc == None: assoc = KitTypeReagentTypeAssociation(kit_type=kit, reagent_type=instance, uses=self.uses, required=self.required) - # kit.kit_reagenttype_associations.append(assoc) return instance class PydKit(BaseModel): @@ -872,13 +891,10 @@ class PydKit(BaseModel): Returns: Tuple[KitType, Report]: KitType instance and report of results. """ - # result = dict(message=None, status='Information') report = Report() - # instance = lookup_kit_types(ctx=ctx, name=self.name) instance = KitType.query(name=self.name) if instance == None: instance = KitType(name=self.name) - # instance.reagent_types = [item.toSQL(ctx, instance) for item in self.reagent_types] [item.toSQL(instance) for item in self.reagent_types] return instance, report @@ -888,7 +904,17 @@ class PydEquipmentRole(BaseModel): equipment: List[PydEquipment] processes: List[str]|None - def toForm(self, parent, submission_type, used): + def toForm(self, parent, used:list) -> "RoleComboBox": + """ + Creates a widget for user input into this class. + + Args: + parent (_type_): parent widget + used (list): list of equipment already added to submission + + Returns: + RoleComboBox: widget + """ from frontend.widgets.equipment_usage import RoleComboBox - return RoleComboBox(parent=parent, role=self, submission_type=submission_type, used=used) + return RoleComboBox(parent=parent, role=self, used=used) \ No newline at end of file diff --git a/src/submissions/frontend/visualizations/__init__.py b/src/submissions/frontend/visualizations/__init__.py index 2fa959a..157c0c6 100644 --- a/src/submissions/frontend/visualizations/__init__.py +++ b/src/submissions/frontend/visualizations/__init__.py @@ -2,5 +2,3 @@ Contains all operations for creating charts, graphs and visual effects. ''' from .control_charts import * -from .barcode import * -from .plate_map import * \ No newline at end of file diff --git a/src/submissions/frontend/visualizations/barcode.py b/src/submissions/frontend/visualizations/barcode.py deleted file mode 100644 index 83470eb..0000000 --- a/src/submissions/frontend/visualizations/barcode.py +++ /dev/null @@ -1,19 +0,0 @@ -from reportlab.graphics.barcode import createBarcodeImageInMemory -from reportlab.graphics.shapes import Drawing -from reportlab.lib.units import mm - - -def make_plate_barcode(text:str, width:int=100, height:int=25) -> Drawing: - """ - Creates a barcode image for a given str. - - Args: - text (str): Input string - width (int, optional): Width (pixels) of image. Defaults to 100. - height (int, optional): Height (pixels) of image. Defaults to 25. - - Returns: - Drawing: image object - """ - # return createBarcodeDrawing('Code128', value=text, width=200, height=50, humanReadable=True) - return createBarcodeImageInMemory('Code128', value=text, width=width*mm, height=height*mm, humanReadable=True, format="png") \ No newline at end of file diff --git a/src/submissions/frontend/visualizations/control_charts.py b/src/submissions/frontend/visualizations/control_charts.py index 3cebc15..7be3e05 100644 --- a/src/submissions/frontend/visualizations/control_charts.py +++ b/src/submissions/frontend/visualizations/control_charts.py @@ -12,7 +12,6 @@ from frontend.widgets.functions import select_save_file logger = logging.getLogger(f"submissions.{__name__}") - def create_charts(ctx:Settings, df:pd.DataFrame, ytitle:str|None=None) -> Figure: """ Constructs figures based on parsed pandas dataframe. @@ -40,7 +39,6 @@ def create_charts(ctx:Settings, df:pd.DataFrame, ytitle:str|None=None) -> Figure genera.append("") df['genus'] = df['genus'].replace({'\*':''}, regex=True).replace({"NaN":"Unknown"}) df['genera'] = genera - # df = df.dropna() # remove original runs, using reruns if applicable df = drop_reruns_from_df(ctx=ctx, df=df) # sort by and exclude from @@ -224,4 +222,4 @@ def construct_html(figure:Figure) -> str: else: html += "

No data was retrieved for the given parameters.

" html += '' - return html \ No newline at end of file + return html diff --git a/src/submissions/frontend/visualizations/plate_map.py b/src/submissions/frontend/visualizations/plate_map.py deleted file mode 100644 index b48d460..0000000 --- a/src/submissions/frontend/visualizations/plate_map.py +++ /dev/null @@ -1,121 +0,0 @@ -from pathlib import Path -from PIL import Image, ImageDraw, ImageFont -import numpy as np -from tools import check_if_app, jinja_template_loading -import logging, sys - -logger = logging.getLogger(f"submissions.{__name__}") - -def make_plate_map(sample_list:list) -> Image: - """ - Makes a pillow image of a plate from hitpicks - - Args: - sample_list (list): list of sample dictionaries from the hitpicks - - Returns: - Image: Image of the 96 well plate with positive samples in red. - """ - # If we can't get a plate number, do nothing - try: - plate_num = sample_list[0]['plate_name'] - except IndexError as e: - logger.error(f"Couldn't get a plate number. Will not make plate.") - return None - except TypeError as e: - logger.error(f"No samples for this plate. Nothing to do.") - return None - # Make an 8 row, 12 column, 3 color ints array, filled with white by default - grid = np.full((8,12,3),255, dtype=np.uint8) - # Go through samples and change its row/column to red if positive, else blue - for sample in sample_list: - logger.debug(f"sample keys: {list(sample.keys())}") - # set color of square - if sample['positive']: - colour = [255,0,0] - else: - if 'colour' in sample.keys(): - colour = sample['colour'] - else: - colour = [0,0,255] - grid[int(sample['row'])-1][int(sample['column'])-1] = colour - # Create pixel image from the grid and enlarge - img = Image.fromarray(grid).resize((1200, 800), resample=Image.NEAREST) - # create a drawer over the image - draw = ImageDraw.Draw(img) - # draw grid over the image - y_start = 0 - y_end = img.height - step_size = int(img.width / 12) - for x in range(0, img.width, step_size): - line = ((x, y_start), (x, y_end)) - draw.line(line, fill=128) - x_start = 0 - x_end = img.width - step_size = int(img.height / 8) - for y in range(0, img.height, step_size): - line = ((x_start, y), (x_end, y)) - draw.line(line, fill=128) - del draw - old_size = img.size - new_size = (1300, 900) - # create a new, larger white image to hold the annotations - new_img = Image.new("RGB", new_size, "White") - box = tuple((n - o) // 2 for n, o in zip(new_size, old_size)) - # paste plate map into the new image - new_img.paste(img, box) - # create drawer over the new image - draw = ImageDraw.Draw(new_img) - if check_if_app(): - font_path = Path(sys._MEIPASS).joinpath("files", "resources") - else: - font_path = Path(__file__).parents[2].joinpath('resources').absolute() - logger.debug(f"Font path: {font_path}") - font = ImageFont.truetype(font_path.joinpath('arial.ttf').__str__(), 32) - row_dict = ["A", "B", "C", "D", "E", "F", "G", "H"] - # write the plate number on the image - draw.text((100, 850),plate_num,(0,0,0),font=font) - # write column numbers - for num in range(1,13): - x = (num * 100) - 10 - draw.text((x, 0), str(num), (0,0,0),font=font) - # write row letters - for num in range(1,9): - letter = row_dict[num-1] - y = (num * 100) - 10 - draw.text((10, y), letter, (0,0,0),font=font) - return new_img - -def make_plate_map_html(sample_list:list, plate_rows:int=8, plate_columns=12) -> str: - """ - Constructs an html based plate map. - - Args: - sample_list (list): List of submission samples - 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. - """ - for sample in sample_list: - if sample['positive']: - sample['background_color'] = "#f10f07" - else: - if "colour" in sample.keys(): - sample['background_color'] = "#69d84f" - else: - sample['background_color'] = "#80cbc4" - output_samples = [] - for column in range(1, plate_columns+1): - for row in range(1, plate_rows+1): - try: - well = [item for item in sample_list if item['row'] == row and item['column']==column][0] - except IndexError: - well = dict(name="", row=row, column=column, background_color="#ffffff") - output_samples.append(well) - env = jinja_template_loading() - template = env.get_template("plate_map.html") - html = template.render(samples=output_samples, PLATE_ROWS=plate_rows, PLATE_COLUMNS=plate_columns) - return html - diff --git a/src/submissions/frontend/widgets/app.py b/src/submissions/frontend/widgets/app.py index 541c04b..50a6d29 100644 --- a/src/submissions/frontend/widgets/app.py +++ b/src/submissions/frontend/widgets/app.py @@ -52,7 +52,6 @@ class App(QMainWindow): self._createMenuBar() self._createToolBar() self._connectActions() - # self._controls_getter() self.show() self.statusBar().showMessage('Ready', 5000) @@ -114,14 +113,10 @@ class App(QMainWindow): self.importPCRAction.triggered.connect(self.table_widget.formwidget.import_pcr_results) self.addReagentAction.triggered.connect(self.add_reagent) self.generateReportAction.triggered.connect(self.table_widget.sub_wid.generate_report) - # self.addKitAction.triggered.connect(self.add_kit) - # self.addOrgAction.triggered.connect(self.add_org) self.joinExtractionAction.triggered.connect(self.table_widget.sub_wid.link_extractions) self.joinPCRAction.triggered.connect(self.table_widget.sub_wid.link_pcr) self.helpAction.triggered.connect(self.showAbout) self.docsAction.triggered.connect(self.openDocs) - # self.constructFS.triggered.connect(self.construct_first_strand) - # self.table_widget.formwidget.import_drag.connect(self.importSubmission) self.searchLog.triggered.connect(self.runSearch) def showAbout(self): diff --git a/src/submissions/frontend/widgets/controls_chart.py b/src/submissions/frontend/widgets/controls_chart.py index a7e3a8b..9f4d0c3 100644 --- a/src/submissions/frontend/widgets/controls_chart.py +++ b/src/submissions/frontend/widgets/controls_chart.py @@ -4,9 +4,9 @@ from PyQt6.QtWidgets import ( QDateEdit, QLabel, QSizePolicy ) from PyQt6.QtCore import QSignalBlocker -from backend.db import ControlType, Control#, get_control_subtypes +from backend.db import ControlType, Control from PyQt6.QtCore import QDate, QSize -import logging, sys +import logging from tools import Report, Result from backend.excel.reports import convert_data_list_to_df from frontend.visualizations.control_charts import create_charts, construct_html @@ -88,9 +88,7 @@ class ControlsViewer(QWidget): self.mode = self.mode_typer.currentText() self.sub_typer.clear() # lookup subtypes - # sub_types = get_control_subtypes(type=self.con_type, mode=self.mode) sub_types = ControlType.query(name=self.con_type).get_subtypes(mode=self.mode) - # sub_types = lookup_controls(ctx=obj.ctx, control_type=obj.con_type) if sub_types != []: # block signal that will rerun controls getter and update sub_typer with QSignalBlocker(self.sub_typer) as blocker: @@ -103,7 +101,6 @@ class ControlsViewer(QWidget): self.chart_maker() self.report.add_result(report) - def chart_maker_function(self): """ Create html chart for controls reporting diff --git a/src/submissions/frontend/widgets/equipment_usage.py b/src/submissions/frontend/widgets/equipment_usage.py index a2c0536..facc685 100644 --- a/src/submissions/frontend/widgets/equipment_usage.py +++ b/src/submissions/frontend/widgets/equipment_usage.py @@ -2,9 +2,10 @@ from PyQt6.QtCore import Qt from PyQt6.QtWidgets import (QDialog, QComboBox, QCheckBox, QLabel, QWidget, QHBoxLayout, QVBoxLayout, QDialogButtonBox) -from backend.db.models import SubmissionType, Equipment, BasicSubmission +from backend.db.models import Equipment, BasicSubmission from backend.validators.pydant import PydEquipment, PydEquipmentRole import logging +from typing import List logger = logging.getLogger(f"submissions.{__name__}") @@ -24,19 +25,29 @@ class EquipmentUsage(QDialog): self.populate_form() def populate_form(self): + """ + Create form widgets + """ QBtn = QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel self.buttonBox = QDialogButtonBox(QBtn) self.buttonBox.accepted.connect(self.accept) self.buttonBox.rejected.connect(self.reject) label = self.LabelRow(parent=self) self.layout.addWidget(label) + # logger.debug("Creating widgets for equipment") for eq in self.opt_equipment: - widg = eq.toForm(parent=self, submission_type=self.submission.submission_type, used=self.used_equipment) + widg = eq.toForm(parent=self, used=self.used_equipment) self.layout.addWidget(widg) widg.update_processes() self.layout.addWidget(self.buttonBox) - def parse_form(self): + def parse_form(self) -> List[PydEquipment]: + """ + Pull info from all RoleComboBox widgets + + Returns: + List[PydEquipment]: All equipment pulled from widgets + """ output = [] for widget in self.findChildren(QWidget): match widget: @@ -63,43 +74,18 @@ class EquipmentUsage(QDialog): self.setLayout(self.layout) def check_all(self): + """ + Toggles all checkboxes in the form + """ for object in self.parent().findChildren(QCheckBox): object.setChecked(self.check.isChecked()) - -class EquipmentCheckBox(QWidget): - - def __init__(self, parent, equipment:PydEquipment) -> None: - super().__init__(parent) - self.layout = QHBoxLayout() - self.label = QLabel() - self.label.setMaximumWidth(125) - self.label.setMinimumWidth(125) - self.check = QCheckBox() - if equipment.static: - self.check.setChecked(True) - if equipment.nickname != None: - text = f"{equipment.name} ({equipment.nickname})" - else: - text = equipment.name - self.setObjectName(equipment.name) - self.label.setText(text) - self.layout.addWidget(self.label) - self.layout.addWidget(self.check) - self.setLayout(self.layout) - - def parse_form(self) -> str|None: - if self.check.isChecked(): - return self.objectName() - else: - return None +# TODO: Figure out how this is working again class RoleComboBox(QWidget): - def __init__(self, parent, role:PydEquipmentRole, submission_type:SubmissionType, used:list) -> None: + def __init__(self, parent, role:PydEquipmentRole, used:list) -> None: super().__init__(parent) self.layout = QHBoxLayout() - # label = QLabel() - # label.setText(pool.name) self.role = role self.check = QCheckBox() if role.name in used: @@ -111,14 +97,10 @@ class RoleComboBox(QWidget): self.box.setMinimumWidth(200) self.box.addItems([item.name for item in role.equipment]) self.box.currentTextChanged.connect(self.update_processes) - # self.check = QCheckBox() - # self.layout.addWidget(label) self.process = QComboBox() self.process.setMaximumWidth(200) self.process.setMinimumWidth(200) self.process.setEditable(True) - # self.process.addItems(submission_type.get_processes_for_role(equipment_role=role.name)) - # self.process.addItems(role.processes) self.layout.addWidget(self.check) label = QLabel(f"{role.name}:") label.setMinimumWidth(200) @@ -127,11 +109,12 @@ class RoleComboBox(QWidget): self.layout.addWidget(label) self.layout.addWidget(self.box) self.layout.addWidget(self.process) - # self.layout.addWidget(self.check) self.setLayout(self.layout) - # self.update_processes() def update_processes(self): + """ + Changes processes when equipment is changed + """ equip = self.box.currentText() logger.debug(f"Updating equipment: {equip}") equip2 = [item for item in self.role.equipment if item.name==equip][0] @@ -139,10 +122,16 @@ class RoleComboBox(QWidget): self.process.clear() self.process.addItems([item for item in equip2.processes if item in self.role.processes]) - def parse_form(self) -> str|None: + def parse_form(self) -> PydEquipment|None: + """ + Creates PydEquipment for values in form + + Returns: + PydEquipment|None: PydEquipment matching form + """ eq = Equipment.query(name=self.box.currentText()) - # if self.check.isChecked(): - return PydEquipment(name=eq.name, processes=[self.process.currentText()], role=self.role.name, asset_number=eq.asset_number, nickname=eq.nickname) - # else: - # return None + try: + return PydEquipment(name=eq.name, processes=[self.process.currentText()], role=self.role.name, asset_number=eq.asset_number, nickname=eq.nickname) + except Exception as e: + logger.error(f"Could create PydEquipment due to: {e}") \ No newline at end of file diff --git a/src/submissions/frontend/widgets/gel_checker.py b/src/submissions/frontend/widgets/gel_checker.py index 0e47580..29d77a1 100644 --- a/src/submissions/frontend/widgets/gel_checker.py +++ b/src/submissions/frontend/widgets/gel_checker.py @@ -1,7 +1,7 @@ -# import required modules -# from PyQt6.QtCore import Qt +""" +Gel box for artic quality control +""" from PyQt6.QtWidgets import * -# import sys from PyQt6.QtWidgets import QWidget import numpy as np import pyqtgraph as pg @@ -9,11 +9,17 @@ from PyQt6.QtGui import * from PyQt6.QtCore import * from PIL import Image import numpy as np +import logging +from pprint import pformat +from typing import Tuple, List +from pathlib import Path + +logger = logging.getLogger(f"submissions.{__name__}") # Main window class class GelBox(QDialog): - def __init__(self, parent, img_path): + def __init__(self, parent, img_path:str|Path): super().__init__(parent) # setting title self.setWindowTitle("PyQtGraph") @@ -27,11 +33,12 @@ class GelBox(QDialog): # calling method self.UiComponents() # showing all the widgets - # self.show() # method for components def UiComponents(self): - # widget = QWidget() + """ + Create widgets in ui + """ # setting configuration options pg.setConfigOptions(antialias=True) # creating image view object @@ -39,33 +46,41 @@ class GelBox(QDialog): img = np.array(Image.open(self.img_path).rotate(-90).transpose(Image.FLIP_LEFT_RIGHT)) self.imv.setImage(img)#, xvals=np.linspace(1., 3., data.shape[0])) layout = QGridLayout() + layout.addWidget(QLabel("DNA Core Submission Number"),0,1) + self.core_number = QLineEdit() + layout.addWidget(self.core_number, 0,2) # setting this layout to the widget - # widget.setLayout(layout) # plot window goes on right side, spanning 3 rows - layout.addWidget(self.imv, 0, 0,20,20) + layout.addWidget(self.imv, 1, 1,20,20) # setting this widget as central widget of the main window self.form = ControlsForm(parent=self) - layout.addWidget(self.form,21,1,1,4) + layout.addWidget(self.form,22,1,1,4) QBtn = QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel self.buttonBox = QDialogButtonBox(QBtn) self.buttonBox.accepted.connect(self.accept) self.buttonBox.rejected.connect(self.reject) - layout.addWidget(self.buttonBox, 21, 5, 1, 1)#, alignment=Qt.AlignmentFlag.AlignTop) - # self.buttonBox.clicked.connect(self.submit) + layout.addWidget(self.buttonBox, 22, 5, 1, 1)#, alignment=Qt.AlignmentFlag.AlignTop) self.setLayout(layout) - def parse_form(self): - return self.img_path, self.form.parse_form() - + def parse_form(self) -> Tuple[str, str|Path, list]: + """ + Get relevant values from self/form + Returns: + Tuple[str, str|Path, list]: output values + """ + dna_core_submission_number = self.core_number.text() + return dna_core_submission_number, self.img_path, self.form.parse_form() + class ControlsForm(QWidget): def __init__(self, parent) -> None: super().__init__(parent) self.layout = QGridLayout() + columns = [] rows = [] - for iii, item in enumerate(["Negative Control Key", "Description", "Results - 65 C", "Results - 63 C", "Results - Spike"]): + for iii, item in enumerate(["Negative Control Key", "Description", "Results - 65 C", "Results - 63 C", "Results - Spike"]): label = QLabel(item) self.layout.addWidget(label, 0, iii,1,1) if iii > 1: @@ -85,11 +100,22 @@ class ControlsForm(QWidget): self.layout.addWidget(widge, iii+1, jjj+2, 1, 1) self.setLayout(self.layout) - def parse_form(self): - dicto = {} + def parse_form(self) -> List[dict]: + """ + Pulls the controls statuses from the form. + + Returns: + List[dict]: output of values + """ + output = [] for le in self.findChildren(QLineEdit): label = [item.strip() for item in le.objectName().split(" : ")] - if label[0] not in dicto.keys(): - dicto[label[0]] = {} - dicto[label[0]][label[1]] = le.text() - return dicto \ No newline at end of file + try: + dicto = [item for item in output if item['name']==label[0]][0] + except IndexError: + dicto = dict(name=label[0], values=[]) + dicto['values'].append(dict(name=label[1], value=le.text())) + if label[0] not in [item['name'] for item in output]: + output.append(dicto) + logger.debug(pformat(output)) + return output diff --git a/src/submissions/frontend/widgets/kit_creator.py b/src/submissions/frontend/widgets/kit_creator.py index 3e281f9..98a0ad4 100644 --- a/src/submissions/frontend/widgets/kit_creator.py +++ b/src/submissions/frontend/widgets/kit_creator.py @@ -79,12 +79,10 @@ class KitAdder(QWidget): """ insert new reagent type row """ - print(self.app) # get bottommost row maxrow = self.grid.rowCount() reg_form = ReagentTypeForm(parent=self) reg_form.setObjectName(f"ReagentForm_{maxrow}") - # self.grid.addWidget(reg_form, maxrow + 1,0,1,2) self.grid.addWidget(reg_form, maxrow,0,1,4) def submit(self) -> None: @@ -118,6 +116,12 @@ class KitAdder(QWidget): self.__init__(self.parent()) def parse_form(self) -> Tuple[dict, list]: + """ + Pulls reagent and general info from form + + Returns: + Tuple[dict, list]: dict=info, list=reagents + """ logger.debug(f"Hello from {self.__class__} parser!") info = {} reagents = [] @@ -188,10 +192,19 @@ class ReagentTypeForm(QWidget): ] def remove(self): + """ + Destroys this row of reagenttype from the form + """ self.setParent(None) self.destroy() def parse_form(self) -> dict: + """ + Pulls ReagentType info from the form. + + Returns: + dict: _description_ + """ logger.debug(f"Hello from {self.__class__} parser!") info = {} info['eol'] = self.eol.value() diff --git a/src/submissions/frontend/widgets/misc.py b/src/submissions/frontend/widgets/misc.py index fa40859..73a4862 100644 --- a/src/submissions/frontend/widgets/misc.py +++ b/src/submissions/frontend/widgets/misc.py @@ -25,7 +25,6 @@ class AddReagentForm(QDialog): """ def __init__(self, reagent_lot:str|None=None, reagent_type:str|None=None, expiry:date|None=None, reagent_name:str|None=None) -> None: super().__init__() - # self.ctx = ctx if reagent_lot == None: reagent_lot = reagent_type @@ -41,7 +40,6 @@ class AddReagentForm(QDialog): self.name_input.setObjectName("name") self.name_input.setEditable(True) self.name_input.setCurrentText(reagent_name) - # self.name_input.setText(reagent_name) self.lot_input = QLineEdit() self.lot_input.setObjectName("lot") self.lot_input.setText(reagent_lot) @@ -56,7 +54,6 @@ class AddReagentForm(QDialog): # widget to get reagent type info self.type_input = QComboBox() self.type_input.setObjectName('type') - # self.type_input.addItems([item.name for item in lookup_reagent_types(ctx=ctx)]) self.type_input.addItems([item.name for item in ReagentType.query()]) logger.debug(f"Trying to find index of {reagent_type}") # convert input to user friendly string? @@ -169,7 +166,13 @@ class FirstStrandSalvage(QDialog): self.layout.addWidget(self.buttonBox) self.setLayout(self.layout) - def parse_form(self): + def parse_form(self) -> dict: + """ + Pulls first strand info from form. + + Returns: + dict: Output info + """ return dict(plate=self.rsl_plate_num.text(), submitter_id=self.submitter_id_input.text(), well=f"{self.row_letter.currentText()}{self.column_number.currentText()}") class LogParser(QDialog): @@ -193,9 +196,15 @@ class LogParser(QDialog): def filelookup(self): + """ + Select file to search + """ self.fname = select_open_file(self, "tabular") def runsearch(self): + """ + Gets total/percent occurences of string in tabular file. + """ count: int = 0 total: int = 0 logger.debug(f"Current search term: {self.phrase_looker.currentText()}") diff --git a/src/submissions/frontend/widgets/pop_ups.py b/src/submissions/frontend/widgets/pop_ups.py index b8f7acc..b3bd598 100644 --- a/src/submissions/frontend/widgets/pop_ups.py +++ b/src/submissions/frontend/widgets/pop_ups.py @@ -47,7 +47,7 @@ class AlertPop(QMessageBox): class KitSelector(QDialog): """ - dialog to ask yes/no questions + dialog to input KitType manually """ def __init__(self, title:str, message:str) -> QDialog: super().__init__() @@ -69,12 +69,18 @@ class KitSelector(QDialog): self.layout.addWidget(self.buttonBox) self.setLayout(self.layout) - def getValues(self): + def getValues(self) -> str: + """ + Get KitType(str) from widget + + Returns: + str: KitType as str + """ return self.widget.currentText() class SubmissionTypeSelector(QDialog): """ - dialog to ask yes/no questions + dialog to input SubmissionType manually """ def __init__(self, title:str, message:str) -> QDialog: super().__init__() @@ -97,5 +103,11 @@ class SubmissionTypeSelector(QDialog): self.layout.addWidget(self.buttonBox) self.setLayout(self.layout) - def parse_form(self): + def parse_form(self) -> str: + """ + Pulls SubmissionType(str) from widget + + Returns: + str: SubmissionType as str + """ return self.widget.currentText() diff --git a/src/submissions/frontend/widgets/submission_details.py b/src/submissions/frontend/widgets/submission_details.py index 2caf947..56095f0 100644 --- a/src/submissions/frontend/widgets/submission_details.py +++ b/src/submissions/frontend/widgets/submission_details.py @@ -1,25 +1,25 @@ from PyQt6.QtWidgets import (QDialog, QScrollArea, QPushButton, QVBoxLayout, QMessageBox, - QLabel, QDialogButtonBox, QToolBar, QTextEdit) -from PyQt6.QtGui import QAction, QPixmap + QDialogButtonBox, QTextEdit) from PyQt6.QtWebEngineWidgets import QWebEngineView from PyQt6.QtCore import Qt -from PyQt6 import QtPrintSupport from backend.db.models import BasicSubmission -from ..visualizations import make_plate_barcode, make_plate_map, make_plate_map_html -from tools import check_if_app, jinja_template_loading +from tools import check_if_app from .functions import select_save_file from io import BytesIO +from tempfile import TemporaryFile, TemporaryDirectory +from pathlib import Path from xhtml2pdf import pisa import logging, base64 from getpass import getuser from datetime import datetime from pprint import pformat +from html2image import Html2Image +from PIL import Image +from typing import List logger = logging.getLogger(f"submissions.{__name__}") -env = jinja_template_loading() - class SubmissionDetails(QDialog): """ a window showing text details of submission @@ -27,7 +27,6 @@ class SubmissionDetails(QDialog): def __init__(self, parent, sub:BasicSubmission) -> None: super().__init__(parent) - # self.ctx = ctx try: self.app = parent.parent().parent().parent().parent().parent().parent() except AttributeError: @@ -36,19 +35,16 @@ class SubmissionDetails(QDialog): # create scrollable interior interior = QScrollArea() interior.setParent(self) - # sub = BasicSubmission.query(id=id) self.base_dict = sub.to_dict(full_data=True) logger.debug(f"Submission details data:\n{pformat({k:v for k,v in self.base_dict.items() if k != 'samples'})}") # don't want id del self.base_dict['id'] logger.debug(f"Creating barcode.") if not check_if_app(): - self.base_dict['barcode'] = base64.b64encode(make_plate_barcode(self.base_dict['Plate Number'], width=120, height=30)).decode('utf-8') - logger.debug(f"Hitpicking plate...") - self.plate_dicto = sub.hitpick_plate() + self.base_dict['barcode'] = base64.b64encode(sub.make_plate_barcode(width=120, height=30)).decode('utf-8') logger.debug(f"Making platemap...") - self.base_dict['platemap'] = make_plate_map_html(self.plate_dicto) - self.template = env.get_template("submission_details.html") + self.base_dict['platemap'] = sub.make_plate_map() + self.base_dict, self.template = sub.get_details_template(base_dict=self.base_dict) self.html = self.template.render(sub=self.base_dict) webview = QWebEngineView() webview.setMinimumSize(900, 500) @@ -63,21 +59,29 @@ class SubmissionDetails(QDialog): btn.setParent(self) btn.setFixedWidth(900) btn.clicked.connect(self.export) - + + def export(self): """ Renders submission to html, then creates and saves .pdf file to user selected file. """ fname = select_save_file(obj=self, default_name=self.base_dict['Plate Number'], extension="pdf") - del self.base_dict['platemap'] - export_map = make_plate_map(self.plate_dicto) image_io = BytesIO() + temp_dir = Path(TemporaryDirectory().name) + hti = Html2Image(output_path=temp_dir, size=(1200, 750)) + temp_file = Path(TemporaryFile(dir=temp_dir, suffix=".png").name) + screenshot = hti.screenshot(self.base_dict['platemap'], save_as=temp_file.name) + export_map = Image.open(screenshot[0]) + export_map = export_map.convert('RGB') try: export_map.save(image_io, 'JPEG') except AttributeError: logger.error(f"No plate map found") self.base_dict['export_map'] = base64.b64encode(image_io.getvalue()).decode('utf-8') + del self.base_dict['platemap'] self.html2 = self.template.render(sub=self.base_dict) + with open("test.html", "w") as fw: + fw.write(self.html2) try: with open(fname, "w+b") as f: pisa.CreatePDF(self.html2, dest=f) @@ -88,73 +92,6 @@ class SubmissionDetails(QDialog): msg.setInformativeText(f"Looks like {fname.__str__()} is open.\nPlease close it and try again.") msg.setWindowTitle("Permission Error") msg.exec() - -class BarcodeWindow(QDialog): - - def __init__(self, rsl_num:str): - super().__init__() - # set the title - self.setWindowTitle("Image") - self.layout = QVBoxLayout() - # setting the geometry of window - self.setGeometry(0, 0, 400, 300) - # creating label - self.label = QLabel() - self.img = make_plate_barcode(rsl_num) - self.pixmap = QPixmap() - self.pixmap.loadFromData(self.img) - # adding image to label - self.label.setPixmap(self.pixmap) - # show all the widgets] - QBtn = QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel - self.buttonBox = QDialogButtonBox(QBtn) - self.buttonBox.accepted.connect(self.accept) - self.buttonBox.rejected.connect(self.reject) - self.layout.addWidget(self.label) - self.layout.addWidget(self.buttonBox) - self.setLayout(self.layout) - self._createActions() - self._createToolBar() - self._connectActions() - - def _createToolBar(self): - """ - adds items to menu bar - """ - toolbar = QToolBar("My main toolbar") - toolbar.addAction(self.printAction) - - - def _createActions(self): - """ - creates actions - """ - self.printAction = QAction("&Print", self) - - - def _connectActions(self): - """ - connect menu and tool bar item to functions - """ - self.printAction.triggered.connect(self.print_barcode) - - - def print_barcode(self): - """ - Sends barcode image to printer. - """ - printer = QtPrintSupport.QPrinter() - dialog = QtPrintSupport.QPrintDialog(printer) - if dialog.exec(): - self.handle_paint_request(printer, self.pixmap.toImage()) - - - def handle_paint_request(self, printer:QtPrintSupport.QPrinter, im): - logger.debug(f"Hello from print handler.") - painter = QPainter(printer) - image = QPixmap.fromImage(im) - painter.drawPixmap(120, -20, image) - painter.end() class SubmissionComment(QDialog): """ @@ -163,7 +100,6 @@ class SubmissionComment(QDialog): def __init__(self, parent, submission:BasicSubmission) -> None: super().__init__(parent) - # self.ctx = ctx try: self.app = parent.parent().parent().parent().parent().parent().parent print(f"App: {self.app}") @@ -185,7 +121,7 @@ class SubmissionComment(QDialog): self.layout.addWidget(self.buttonBox, alignment=Qt.AlignmentFlag.AlignBottom) self.setLayout(self.layout) - def parse_form(self): + def parse_form(self) -> List[dict]: """ Adds comment to submission object. """ diff --git a/src/submissions/frontend/widgets/submission_table.py b/src/submissions/frontend/widgets/submission_table.py index 72d1cba..9f084d2 100644 --- a/src/submissions/frontend/widgets/submission_table.py +++ b/src/submissions/frontend/widgets/submission_table.py @@ -1,37 +1,22 @@ ''' Contains widgets specific to the submission summary and submission details. ''' -import base64, logging, json -from datetime import datetime -from io import BytesIO +import logging, json from pprint import pformat -from PyQt6 import QtPrintSupport -from PyQt6.QtWidgets import ( - QVBoxLayout, QDialog, QTableView, - QTextEdit, QPushButton, QScrollArea, - QMessageBox, QMenu, QLabel, - QDialogButtonBox, QToolBar -) -from PyQt6.QtWebEngineWidgets import QWebEngineView +from PyQt6.QtWidgets import QTableView, QMenu from PyQt6.QtCore import Qt, QAbstractTableModel, QSortFilterProxyModel -from PyQt6.QtGui import QAction, QCursor, QPixmap, QPainter -from backend.db.models import BasicSubmission, Equipment +from PyQt6.QtGui import QAction, QCursor +from backend.db.models import BasicSubmission from backend.excel import make_report_html, make_report_xlsx -from tools import check_if_app, Report, Result, jinja_template_loading, get_first_blank_df_row, row_map +from tools import Report, Result, get_first_blank_df_row, row_map from xhtml2pdf import pisa -from .pop_ups import QuestionAsker -from .equipment_usage import EquipmentUsage -from ..visualizations import make_plate_barcode, make_plate_map, make_plate_map_html from .functions import select_save_file, select_open_file from .misc import ReportDatePicker import pandas as pd from openpyxl.worksheet.worksheet import Worksheet -from getpass import getuser logger = logging.getLogger(f"submissions.{__name__}") -env = jinja_template_loading() - class pandasModel(QAbstractTableModel): """ pandas model for inserting summary sheet into gui @@ -89,20 +74,17 @@ class SubmissionsSheet(QTableView): """ super().__init__(parent) self.app = self.parent() - # self.ctx = ctx self.report = Report() self.setData() self.resizeColumnsToContents() self.resizeRowsToContents() self.setSortingEnabled(True) - # self.doubleClicked.connect(self.show_details) self.doubleClicked.connect(lambda x: BasicSubmission.query(id=x.sibling(x.row(), 0).data()).show_details(self)) def setData(self) -> None: """ sets data in model """ - # self.data = submissions_to_df() self.data = BasicSubmission.submissions_to_df() try: self.data['id'] = self.data['id'].apply(str) @@ -114,39 +96,6 @@ class SubmissionsSheet(QTableView): proxyModel.setSourceModel(pandasModel(self.data)) self.setModel(proxyModel) - # def show_details(self, submission:BasicSubmission) -> None: - # """ - # creates detailed data to show in seperate window - # """ - # logger.debug(f"Sheet.app: {self.app}") - # # index = (self.selectionModel().currentIndex()) - # # value = index.sibling(index.row(),0).data() - # dlg = SubmissionDetails(parent=self, sub=submission) - # if dlg.exec(): - # pass - - def create_barcode(self) -> None: - """ - Generates a window for displaying barcode - """ - index = (self.selectionModel().currentIndex()) - value = index.sibling(index.row(),1).data() - logger.debug(f"Selected value: {value}") - dlg = BarcodeWindow(value) - if dlg.exec(): - dlg.print_barcode() - - def add_comment(self) -> None: - """ - Generates a text editor window. - """ - index = (self.selectionModel().currentIndex()) - value = index.sibling(index.row(),1).data() - logger.debug(f"Selected value: {value}") - dlg = SubmissionComment(parent=self, rsl=value) - if dlg.exec(): - dlg.add_comment() - def contextMenuEvent(self, event): """ Creates actions for right click menu events. @@ -158,21 +107,6 @@ class SubmissionsSheet(QTableView): id = id.sibling(id.row(),0).data() submission = BasicSubmission.query(id=id) self.menu = QMenu(self) - # renameAction = QAction('Delete', self) - # detailsAction = QAction('Details', self) - # commentAction = QAction("Add Comment", self) - # equipAction = QAction("Add Equipment", self) - # backupAction = QAction("Export", self) - # renameAction.triggered.connect(lambda: self.delete_item(submission)) - # detailsAction.triggered.connect(lambda: self.show_details(submission)) - # commentAction.triggered.connect(lambda: self.add_comment(submission)) - # backupAction.triggered.connect(lambda: self.regenerate_submission_form(submission)) - # equipAction.triggered.connect(lambda: self.add_equipment(submission)) - # self.menu.addAction(detailsAction) - # self.menu.addAction(renameAction) - # self.menu.addAction(commentAction) - # self.menu.addAction(backupAction) - # self.menu.addAction(equipAction) self.con_actions = submission.custom_context_events() for k in self.con_actions.keys(): logger.debug(f"Adding {k}") @@ -183,57 +117,21 @@ class SubmissionsSheet(QTableView): self.menu.popup(QCursor.pos()) def triggered_action(self, action_name:str): + """ + Calls the triggered action from the context menu + + Args: + action_name (str): name of the action from the menu + """ logger.debug(f"Action: {action_name}") logger.debug(f"Responding with {self.con_actions[action_name]}") func = self.con_actions[action_name] func(obj=self) - def add_equipment(self): - index = (self.selectionModel().currentIndex()) - value = index.sibling(index.row(),0).data() - self.add_equipment_function(rsl_plate_id=value) - - def add_equipment_function(self, submission:BasicSubmission): - # submission = BasicSubmission.query(id=rsl_plate_id) - submission_type = submission.submission_type_name - dlg = EquipmentUsage(parent=self, submission_type=submission_type, submission=submission) - if dlg.exec(): - equipment = dlg.parse_form() - logger.debug(f"We've got equipment: {equipment}") - for equip in equipment: - e = Equipment.query(name=equip.name) - # assoc = SubmissionEquipmentAssociation(submission=submission, equipment=e) - # process = Process.query(name=equip.processes) - # assoc.process = process - # assoc.role = equip.role - _, assoc = equip.toSQL(submission=submission) - # submission.submission_equipment_associations.append(assoc) - logger.debug(f"Appending SubmissionEquipmentAssociation: {assoc}") - # submission.save() - assoc.save() - else: - pass - - def delete_item(self, submission:BasicSubmission): - """ - Confirms user deletion and sends id to backend for deletion. - - Args: - event (_type_): the item of interest - """ - # index = (self.selectionModel().currentIndex()) - # value = index.sibling(index.row(),0).data() - # logger.debug(index) - # msg = QuestionAsker(title="Delete?", message=f"Are you sure you want to delete {index.sibling(index.row(),1).data()}?\n") - msg = QuestionAsker(title="Delete?", message=f"Are you sure you want to delete {submission.rsl_plate_num}?\n") - if msg.exec(): - # delete_submission(id=value) - submission.delete() - else: - return - self.setData() - def link_extractions(self): + """ + Pull extraction logs into the db + """ self.link_extractions_function() self.app.report.add_result(self.report) self.report = Report() @@ -306,6 +204,9 @@ class SubmissionsSheet(QTableView): self.report.add_result(Result(msg=f"We added {count} logs to the database.", status='Information')) def link_pcr(self): + """ + Pull pcr logs into the db + """ self.link_pcr_function() self.app.report.add_result(self.report) self.report = Report() @@ -376,6 +277,9 @@ class SubmissionsSheet(QTableView): self.report.add_result(Result(msg=f"We added {count} logs to the database.", status='Information')) def generate_report(self): + """ + Make a report + """ self.generate_report_function() self.app.report.add_result(self.report) self.report = Report() @@ -436,12 +340,3 @@ class SubmissionsSheet(QTableView): cell.style = 'Currency' writer.close() self.report.add_result(report) - - def regenerate_submission_form(self, submission:BasicSubmission): - # index = (self.selectionModel().currentIndex()) - # value = index.sibling(index.row(),0).data() - # logger.debug(index) - # sub = BasicSubmission.query(id=value) - fname = select_save_file(self, default_name=submission.to_pydantic().construct_filename(), extension="xlsx") - submission.backup(fname=fname, full_backup=False) - diff --git a/src/submissions/frontend/widgets/submission_type_creator.py b/src/submissions/frontend/widgets/submission_type_creator.py index 705aa2e..faecf42 100644 --- a/src/submissions/frontend/widgets/submission_type_creator.py +++ b/src/submissions/frontend/widgets/submission_type_creator.py @@ -2,21 +2,14 @@ from PyQt6.QtCore import Qt from PyQt6.QtWidgets import ( QWidget, QVBoxLayout, QScrollArea, QGridLayout, QPushButton, QLabel, - QLineEdit, QComboBox, QDoubleSpinBox, - QSpinBox, QDateEdit + QLineEdit, QSpinBox ) -from sqlalchemy import FLOAT, INTEGER from sqlalchemy.orm.attributes import InstrumentedAttribute -from backend.db import SubmissionType, Equipment, SubmissionTypeEquipmentRoleAssociation, BasicSubmission -from backend.validators import PydReagentType, PydKit +from backend.db import SubmissionType, BasicSubmission import logging -from pprint import pformat from tools import Report -from typing import Tuple from .functions import select_open_file - - logger = logging.getLogger(f"submissions.{__name__}") class SubmissionTypeAdder(QWidget): @@ -46,35 +39,21 @@ class SubmissionTypeAdder(QWidget): self.grid.addWidget(template_selector,3,1) self.template_label = QLabel("None") self.grid.addWidget(self.template_label,3,2) - # self.grid.addWidget(QLabel("Used For Submission Type:"),3,0) # widget to get uses of kit exclude = ['id', 'submitting_lab_id', 'extraction_kit_id', 'reagents_id', 'extraction_info', 'pcr_info', 'run_cost'] self.columns = {key:value for key, value in BasicSubmission.__dict__.items() if isinstance(value, InstrumentedAttribute)} self.columns = {key:value for key, value in self.columns.items() if hasattr(value, "type") and key not in exclude} for iii, key in enumerate(self.columns): idx = iii + 4 - # convert field name to human readable. - # field_name = key - # self.grid.addWidget(QLabel(field_name),idx,0) - # print(self.columns[key].type) - # match self.columns[key].type: - # case FLOAT(): - # add_widget = QDoubleSpinBox() - # add_widget.setMinimum(0) - # add_widget.setMaximum(9999) - # case INTEGER(): - # add_widget = QSpinBox() - # add_widget.setMinimum(0) - # add_widget.setMaximum(9999) - # case _: - # add_widget = QLineEdit() - # add_widget.setObjectName(key) self.grid.addWidget(InfoWidget(parent=self, key=key), idx,0,1,3) scroll.setWidget(scrollContent) self.submit_btn.clicked.connect(self.submit) template_selector.clicked.connect(self.get_template_path) def submit(self): + """ + Create SubmissionType and send to db + """ info = self.parse_form() ST = SubmissionType(name=self.st_name.text(), info_map=info) try: @@ -84,11 +63,20 @@ class SubmissionTypeAdder(QWidget): logger.error(f"Could not find template file: {self.template_path}") ST.save(ctx=self.app.ctx) - def parse_form(self): + def parse_form(self) -> dict: + """ + Pulls info from form + + Returns: + dict: information from form + """ widgets = [widget for widget in self.findChildren(QWidget) if isinstance(widget, InfoWidget)] return {widget.objectName():widget.parse_form() for widget in widgets} def get_template_path(self): + """ + Sets path for loading a submission form template + """ self.template_path = select_open_file(obj=self, file_extension="xlsx") self.template_label.setText(self.template_path.__str__()) @@ -113,7 +101,13 @@ class InfoWidget(QWidget): self.column.setObjectName("column") grid.addWidget(self.column,2,3) - def parse_form(self): + def parse_form(self) -> dict: + """ + Pulls info from the Info form. + + Returns: + dict: sheets, row, column + """ return dict( sheets = self.sheet.text().split(","), row = self.row.value(), diff --git a/src/submissions/frontend/widgets/submission_widget.py b/src/submissions/frontend/widgets/submission_widget.py index 5d2de2f..8f8fdd8 100644 --- a/src/submissions/frontend/widgets/submission_widget.py +++ b/src/submissions/frontend/widgets/submission_widget.py @@ -5,7 +5,7 @@ from PyQt6.QtWidgets import ( from PyQt6.QtCore import pyqtSignal from pathlib import Path from . import select_open_file, select_save_file -import logging, difflib, inspect, json, sys +import logging, difflib, inspect, json from pathlib import Path from tools import Report, Result, check_not_nan from backend.excel.parser import SheetParser, PCRParser @@ -16,7 +16,6 @@ from backend.db import ( ) from pprint import pformat from .pop_ups import QuestionAsker, AlertPop -# from .misc import ReagentFormWidget from typing import List, Tuple from datetime import date @@ -24,6 +23,7 @@ logger = logging.getLogger(f"submissions.{__name__}") class SubmissionFormContainer(QWidget): + # A signal carrying a path import_drag = pyqtSignal(Path) def __init__(self, parent: QWidget) -> None: @@ -31,19 +31,24 @@ class SubmissionFormContainer(QWidget): super().__init__(parent) self.app = self.parent().parent() self.report = Report() - # self.parent = parent self.setAcceptDrops(True) + # if import_drag is emitted, importSubmission will fire self.import_drag.connect(self.importSubmission) def dragEnterEvent(self, event): + """ + Allow drag if file. + """ if event.mimeData().hasUrls(): event.accept() else: event.ignore() def dropEvent(self, event): + """ + Sets filename when file dropped + """ fname = Path([u.toLocalFile() for u in event.mimeData().urls()][0]) - logger.debug(f"App: {self.app}") self.app.last_dir = fname.parent self.import_drag.emit(fname) @@ -52,7 +57,6 @@ class SubmissionFormContainer(QWidget): """ import submission from excel sheet into form """ - # from .main_window_functions import import_submission_function self.app.raise_() self.app.activateWindow() self.import_submission_function(fname) @@ -62,6 +66,9 @@ class SubmissionFormContainer(QWidget): self.app.result_reporter() def scrape_reagents(self, *args, **kwargs): + """ + Called when a reagent is changed. + """ caller = inspect.stack()[1].function.__repr__().replace("'", "") logger.debug(f"Args: {args}, kwargs: {kwargs}") self.scrape_reagents_function(args[0], caller=caller) @@ -80,7 +87,6 @@ class SubmissionFormContainer(QWidget): NOTE: this will not change self.reagents which should be fine since it's only used when looking up """ - # from .main_window_functions import kit_integrity_completion_function self.kit_integrity_completion_function() self.app.report.add_result(self.report) self.report = Report() @@ -94,14 +100,12 @@ class SubmissionFormContainer(QWidget): """ Attempt to add sample to database when 'submit' button clicked """ - # from .main_window_functions import submit_new_sample_function self.submit_new_sample_function() self.app.report.add_result(self.report) self.report = Report() self.app.result_reporter() def export_csv(self, fname:Path|None=None): - # from .main_window_functions import export_csv_function self.export_csv_function(fname) def import_submission_function(self, fname:Path|None=None): @@ -116,12 +120,11 @@ class SubmissionFormContainer(QWidget): """ logger.debug(f"\n\nStarting Import...\n\n") report = Report() - # logger.debug(obj.ctx) - # initialize samples try: self.form.setParent(None) except AttributeError: pass + # initialize samples self.samples = [] self.missing_info = [] # set file dialog @@ -129,7 +132,6 @@ class SubmissionFormContainer(QWidget): fname = select_open_file(self, file_extension="xlsx") logger.debug(f"Attempting to parse file: {fname}") if not fname.exists(): - # result = dict(message=f"File {fname.__str__()} not found.", status="critical") report.add_result(Result(msg=f"File {fname.__str__()} not found.", status="critical")) self.report.add_result(report) return @@ -141,14 +143,9 @@ class SubmissionFormContainer(QWidget): return except AttributeError: self.prsr = SheetParser(ctx=self.app.ctx, filepath=fname) - # try: logger.debug(f"Submission dictionary:\n{pformat(self.prsr.sub)}") self.pyd = self.prsr.to_pydantic() logger.debug(f"Pydantic result: \n\n{pformat(self.pyd)}\n\n") - # except Exception as e: - # report.add_result(Result(msg=f"Problem creating pydantic model:\n\n{e}", status="Critical")) - # self.report.add_result(report) - # return self.form = self.pyd.toForm(parent=self) self.layout().addWidget(self.form) kit_widget = self.form.find_widgets(object_name="extraction_kit")[0].input @@ -176,11 +173,8 @@ class SubmissionFormContainer(QWidget): """ self.form.reagents = [] logger.debug(f"\n\n{caller}\n\n") - # assert caller == "import_submission_function" report = Report() logger.debug(f"Extraction kit: {extraction_kit}") - # obj.reagents = [] - # obj.missing_reagents = [] # Remove previous reagent widgets try: old_reagents = self.form.find_widgets() @@ -191,14 +185,6 @@ class SubmissionFormContainer(QWidget): for reagent in old_reagents: if isinstance(reagent, ReagentFormWidget) or isinstance(reagent, QPushButton): reagent.setParent(None) - # reagents = obj.prsr.parse_reagents(extraction_kit=extraction_kit) - # logger.debug(f"Got reagents: {reagents}") - # for reagent in obj.prsr.sub['reagents']: - # # create label - # if reagent.parsed: - # obj.reagents.append(reagent) - # else: - # obj.missing_reagents.append(reagent) match caller: case "import_submission_function": self.form.reagents = self.prsr.sub['reagents'] @@ -231,11 +217,9 @@ class SubmissionFormContainer(QWidget): logger.debug(f"Kit selector: {kit_widget}") # get current kit being used self.ext_kit = kit_widget.currentText() - # for reagent in obj.pyd.reagents: for reagent in self.form.reagents: logger.debug(f"Creating widget for {reagent}") add_widget = ReagentFormWidget(parent=self, reagent=reagent, extraction_kit=self.ext_kit) - # add_widget.setParent(sub_form_container.form) self.form.layout().addWidget(add_widget) if reagent.missing: missing_reagents.append(reagent) @@ -275,7 +259,6 @@ class SubmissionFormContainer(QWidget): self.pyd: PydSubmission = self.form.parse_form() logger.debug(f"Submission: {pformat(self.pyd)}") logger.debug("Checking kit integrity...") - # result = check_kit_integrity(sub=self.pyd) result = self.pyd.check_kit_integrity() report.add_result(result) if len(result.results) > 0: @@ -283,7 +266,6 @@ class SubmissionFormContainer(QWidget): return base_submission, result = self.pyd.toSQL() # logger.debug(f"Base submission: {base_submission.to_dict()}") - # sys.exit() # check output message for issues match result.code: # code 0: everything is fine. @@ -309,9 +291,7 @@ class SubmissionFormContainer(QWidget): # add reagents to submission object for reagent in base_submission.reagents: # logger.debug(f"Updating: {reagent} with {reagent.lot}") - # update_last_used(reagent=reagent, kit=base_submission.extraction_kit) reagent.update_last_used(kit=base_submission.extraction_kit) - # sys.exit() # logger.debug(f"Here is the final submission: {pformat(base_submission.__dict__)}") # logger.debug(f"Parsed reagents: {pformat(base_submission.reagents)}") # logger.debug(f"Sending submission: {base_submission.rsl_plate_num} to database.") @@ -324,24 +304,15 @@ class SubmissionFormContainer(QWidget): # reset form self.form.setParent(None) # logger.debug(f"All attributes of obj: {pformat(self.__dict__)}") - # wkb = self.pyd.autofill_excel() - # if wkb != None: - # fname = select_save_file(obj=self, default_name=self.pyd.construct_filename(), extension="xlsx") - # try: - # wkb.save(filename=fname.__str__()) - # except PermissionError: - # logger.error("Hit a permission error when saving workbook. Cancelled?") - # if hasattr(self.pyd, 'csv'): - # dlg = QuestionAsker("Export CSV?", "Would you like to export the csv file?") - # if dlg.exec(): - # fname = select_save_file(self, f"{self.pyd.construct_filename()}.csv", extension="csv") - # try: - # self.pyd.csv.to_csv(fname.__str__(), index=False) - # except PermissionError: - # logger.debug(f"Could not get permissions to {fname}. Possibly the request was cancelled.") self.report.add_result(report) def export_csv_function(self, fname:Path|None=None): + """ + Save the submission's csv file. + + Args: + fname (Path | None, optional): Input filename. Defaults to None. + """ if isinstance(fname, bool) or fname == None: fname = select_save_file(obj=self, default_name=self.pyd.construct_filename(), extension="csv") try: @@ -351,6 +322,9 @@ class SubmissionFormContainer(QWidget): logger.debug(f"Could not get permissions to {fname}. Possibly the request was cancelled.") def import_pcr_results(self): + """ + Pull QuantStudio results into db + """ self.import_pcr_results_function() self.app.report.add_result(self.report) self.report = Report() @@ -370,7 +344,6 @@ class SubmissionFormContainer(QWidget): fname = select_open_file(self, file_extension="xlsx") parser = PCRParser(filepath=fname) logger.debug(f"Attempting lookup for {parser.plate_num}") - # sub = lookup_submission_by_rsl_num(ctx=obj.ctx, rsl_num=parser.plate_num) sub = BasicSubmission.query(rsl_number=parser.plate_num) try: logger.debug(f"Found submission: {sub.rsl_plate_num}") @@ -378,14 +351,11 @@ class SubmissionFormContainer(QWidget): # If no plate is found, may be because this is a repeat. Lop off the '-1' or '-2' and repeat logger.error(f"Submission of number {parser.plate_num} not found. Attempting rescue of plate repeat.") parser.plate_num = "-".join(parser.plate_num.split("-")[:-1]) - # sub = lookup_submission_by_rsl_num(ctx=obj.ctx, rsl_num=parser.plate_num) - # sub = lookup_submissions(ctx=obj.ctx, rsl_number=parser.plate_num) sub = BasicSubmission.query(rsl_number=parser.plate_num) try: logger.debug(f"Found submission: {sub.rsl_plate_num}") except AttributeError: logger.error(f"Rescue of {parser.plate_num} failed.") - # return obj, dict(message="Couldn't find a submission with that RSL number.", status="warning") self.report.add_result(Result(msg="Couldn't find a submission with that RSL number.", status="Warning")) return # Check if PCR info already exists @@ -407,7 +377,6 @@ class SubmissionFormContainer(QWidget): logger.debug(f"Final pcr info for {sub.rsl_plate_num}: {sub.pcr_info}") else: sub.pcr_info = json.dumps([parser.pcr]) - # obj.ctx.database_session.add(sub) logger.debug(f"Existing {type(sub.pcr_info)}: {sub.pcr_info}") logger.debug(f"Inserting {type(json.dumps(parser.pcr))}: {json.dumps(parser.pcr)}") sub.save(original=False) @@ -419,18 +388,13 @@ class SubmissionFormContainer(QWidget): sample_dict = [item for item in parser.samples if item['sample']==sample.rsl_number][0] except IndexError: continue - # update_subsampassoc_with_pcr(submission=sub, sample=sample, input_dict=sample_dict) sub.update_subsampassoc(sample=sample, input_dict=sample_dict) self.report.add_result(Result(msg=f"We added PCR info to {sub.rsl_plate_num}.", status='Information')) - # return obj, result class SubmissionFormWidget(QWidget): def __init__(self, parent: QWidget, **kwargs) -> None: super().__init__(parent) - # self.ignore = [None, "", "qt_spinbox_lineedit", "qt_scrollarea_viewport", "qt_scrollarea_hcontainer", - # "qt_scrollarea_vcontainer", "submit_btn" - # ] self.ignore = ['filepath', 'samples', 'reagents', 'csv', 'ctx', 'comment', 'equipment'] self.recover = ['filepath', 'samples', 'csv', 'comment', 'equipment'] layout = QVBoxLayout() @@ -441,25 +405,53 @@ class SubmissionFormWidget(QWidget): layout.addWidget(add_widget) else: setattr(self, k, v) - self.setLayout(layout) - def create_widget(self, key:str, value:dict, submission_type:str|None=None): + def create_widget(self, key:str, value:dict, submission_type:str|None=None) -> "self.InfoItem": + """ + Make an InfoItem widget to hold a field + + Args: + key (str): Name of the field + value (dict): Value of field + submission_type (str | None, optional): Submissiontype as str. Defaults to None. + + Returns: + self.InfoItem: Form widget to hold name:value + """ if key not in self.ignore: return self.InfoItem(self, key=key, value=value, submission_type=submission_type) return None def clear_form(self): + """ + Removes all form widgets + """ for item in self.findChildren(QWidget): item.setParent(None) def find_widgets(self, object_name:str|None=None) -> List[QWidget]: + """ + Gets all widgets filtered by object name + + Args: + object_name (str | None, optional): name to filter by. Defaults to None. + + Returns: + List[QWidget]: Widgets matching filter + """ query = self.findChildren(QWidget) if object_name != None: query = [widget for widget in query if widget.objectName()==object_name] return query def parse_form(self) -> PydSubmission: + """ + Transforms form info into PydSubmission + + Returns: + PydSubmission: Pydantic submission object + """ logger.debug(f"Hello from form parser!") info = {} reagents = [] @@ -483,8 +475,6 @@ class SubmissionFormWidget(QWidget): value = getattr(self, item) logger.debug(f"Setting {item}") info[item] = value - # app = self.parent().parent().parent().parent().parent().parent().parent().parent - # submission = PydSubmission(filepath=self.filepath, reagents=reagents, samples=self.samples, **info) submission = PydSubmission(reagents=reagents, **info) return submission @@ -513,7 +503,13 @@ class SubmissionFormWidget(QWidget): case QLineEdit(): self.input.textChanged.connect(self.update_missing) - def parse_form(self): + def parse_form(self) -> Tuple[str, dict]: + """ + Pulls info from widget into dict + + Returns: + Tuple[str, dict]: name of field, {value, missing} + """ match self.input: case QLineEdit(): value = self.input.text() @@ -526,6 +522,18 @@ class SubmissionFormWidget(QWidget): return self.input.objectName(), dict(value=value, missing=self.missing) def set_widget(self, parent: QWidget, key:str, value:dict, submission_type:str|None=None) -> QWidget: + """ + Creates form widget + + Args: + parent (QWidget): parent widget + key (str): name of field + value (dict): value, and is it missing from scrape + submission_type (str | None, optional): SubmissionType as str. Defaults to None. + + Returns: + QWidget: Form object + """ try: value = value['value'] except (TypeError, KeyError): @@ -565,7 +573,6 @@ class SubmissionFormWidget(QWidget): obj.ext_kit = uses[0] add_widget.addItems(uses) # Run reagent scraper whenever extraction kit is changed. - # add_widget.currentTextChanged.connect(obj.scrape_reagents) case 'submitted_date': # uses base calendar add_widget = QDateEdit(calendarPopup=True) @@ -578,7 +585,6 @@ class SubmissionFormWidget(QWidget): case 'submission_category': add_widget = QComboBox() cats = ['Diagnostic', "Surveillance", "Research"] - # cats += [item.name for item in lookup_submission_type(ctx=obj.ctx)] cats += [item.name for item in SubmissionType.query()] try: cats.insert(0, cats.pop(cats.index(value))) @@ -593,10 +599,12 @@ class SubmissionFormWidget(QWidget): if add_widget != None: add_widget.setObjectName(key) add_widget.setParent(parent) - return add_widget def update_missing(self): + """ + Set widget status to updated + """ self.missing = True self.label.updated(self.objectName()) @@ -622,6 +630,13 @@ class SubmissionFormWidget(QWidget): self.setText(f"MISSING {output}") def updated(self, key:str, title:bool=True): + """ + Mark widget as updated + + Args: + key (str): Name of the field + title (bool, optional): Use title case. Defaults to True. + """ if title: output = key.replace('_', ' ').title() else: @@ -632,12 +647,9 @@ class ReagentFormWidget(QWidget): def __init__(self, parent:QWidget, reagent:PydReagent, extraction_kit:str): super().__init__(parent) - # self.setParent(parent) self.app = self.parent().parent().parent().parent().parent().parent().parent().parent() - self.reagent = reagent self.extraction_kit = extraction_kit - # self.ctx = reagent.ctx layout = QVBoxLayout() self.label = self.ReagentParsedLabel(reagent=reagent) layout.addWidget(self.label) @@ -652,14 +664,18 @@ class ReagentFormWidget(QWidget): self.lot.currentTextChanged.connect(self.updated) def parse_form(self) -> Tuple[PydReagent, dict]: + """ + Pulls form info into PydReagent + + Returns: + Tuple[PydReagent, dict]: PydReagent and Report(?) + """ lot = self.lot.currentText() - # wanted_reagent = lookup_reagents(ctx=self.ctx, lot_number=lot, reagent_type=self.reagent.type) wanted_reagent = Reagent.query(lot_number=lot, reagent_type=self.reagent.type) # if reagent doesn't exist in database, off to add it (uses App.add_reagent) if wanted_reagent == None: dlg = QuestionAsker(title=f"Add {lot}?", message=f"Couldn't find reagent type {self.reagent.type}: {lot} in the database.\n\nWould you like to add it?") if dlg.exec(): - print(self.app) wanted_reagent = self.app.add_reagent(reagent_lot=lot, reagent_type=self.reagent.type, expiry=self.reagent.expiry, name=self.reagent.name) return wanted_reagent, None else: @@ -669,15 +685,15 @@ class ReagentFormWidget(QWidget): else: # Since this now gets passed in directly from the parser -> pyd -> form and the parser gets the name # from the db, it should no longer be necessary to query the db with reagent/kit, but with rt name directly. - # rt = lookup_reagent_types(ctx=self.ctx, name=self.reagent.type) - # rt = lookup_reagent_types(ctx=self.ctx, kit_type=self.extraction_kit, reagent=wanted_reagent) rt = ReagentType.query(name=self.reagent.type) if rt == None: - # rt = lookup_reagent_types(ctx=self.ctx, kit_type=self.extraction_kit, reagent=wanted_reagent) rt = ReagentType.query(kit_type=self.extraction_kit, reagent=wanted_reagent) return PydReagent(name=wanted_reagent.name, lot=wanted_reagent.lot, type=rt.name, expiry=wanted_reagent.expiry, parsed=not self.missing), None def updated(self): + """ + Set widget status to updated + """ self.missing = True self.label.updated(self.reagent.type) @@ -696,19 +712,21 @@ class ReagentFormWidget(QWidget): self.setText(f"MISSING {reagent.type}") def updated(self, reagent_type:str): + """ + Marks widget as updated + + Args: + reagent_type (str): _description_ + """ self.setText(f"UPDATED {reagent_type}") class ReagentLot(QComboBox): def __init__(self, reagent, extraction_kit:str) -> None: super().__init__() - # self.ctx = reagent.ctx self.setEditable(True) - # if reagent.parsed: - # pass logger.debug(f"Attempting lookup of reagents by type: {reagent.type}") # below was lookup_reagent_by_type_name_and_kit_name, but I couldn't get it to work. - # lookup = lookup_reagents(ctx=self.ctx, reagent_type=reagent.type) lookup = Reagent.query(reagent_type=reagent.type) relevant_reagents = [str(item.lot) for item in lookup] output_reg = [] @@ -726,11 +744,8 @@ class ReagentFormWidget(QWidget): if check_not_nan(reagent.lot): relevant_reagents.insert(0, str(reagent.lot)) else: - # TODO: look up the last used reagent of this type in the database - # looked_up_rt = lookup_reagenttype_kittype_association(ctx=self.ctx, reagent_type=reagent.type, kit_type=extraction_kit) looked_up_rt = KitTypeReagentTypeAssociation.query(reagent_type=reagent.type, kit_type=extraction_kit) try: - # looked_up_reg = lookup_reagents(ctx=self.ctx, lot_number=looked_up_rt.last_used) looked_up_reg = Reagent.query(lot_number=looked_up_rt.last_used) except AttributeError: looked_up_reg = None @@ -752,4 +767,3 @@ class ReagentFormWidget(QWidget): logger.debug(f"New relevant reagents: {relevant_reagents}") self.setObjectName(f"lot_{reagent.type}") self.addItems(relevant_reagents) - diff --git a/src/submissions/templates/submission_details.html b/src/submissions/templates/basicsubmission_details.html similarity index 93% rename from src/submissions/templates/submission_details.html rename to src/submissions/templates/basicsubmission_details.html index ce811d0..b484429 100644 --- a/src/submissions/templates/submission_details.html +++ b/src/submissions/templates/basicsubmission_details.html @@ -1,6 +1,7 @@ + {% block head %} Submission Details for {{ sub['Plate Number'] }} + {% endblock %} - {% set excluded = ['reagents', 'samples', 'controls', 'extraction_info', 'pcr_info', 'comment', 'barcode', 'platemap', 'export_map', 'equipment'] %} + {% block body %} +

Submission Details for {{ sub['Plate Number'] }}

   {% if sub['barcode'] %}{% endif %} -

{% for key, value in sub.items() if key not in excluded %} +

{% for key, value in sub.items() if key not in sub['excluded'] %}     {{ key }}: {% if key=='Cost' %}{% if sub['Cost'] %} {{ "${:,.2f}".format(value) }}{% endif %}{% else %}{{ value }}{% endif %}
{% endfor %}

Reagents:

@@ -111,5 +114,6 @@

Plate map:

{% endif %} + {% endblock %} diff --git a/src/submissions/templates/wastewaterartic_details.html b/src/submissions/templates/wastewaterartic_details.html new file mode 100644 index 0000000..d5be42f --- /dev/null +++ b/src/submissions/templates/wastewaterartic_details.html @@ -0,0 +1,38 @@ +{% extends "basicsubmission_details.html" %} + + + {% block head %} + {{ super() }} + {% endblock %} + + + + {% block body %} + {{ super() }} + {% if sub['gel_info'] %} +
+

Gel Box:

+ {% if sub['gel_image'] %} +
+ + {% endif %} +
+ + + {% for header in sub['headers'] %} + + {% endfor %} + + {% for field in sub['gel_info'] %} + + + {% for item in field['values'] %} + + {% endfor %} + + {% endfor %} +
{{ header }}
{{ field['name'] }}{{ item['value'] }}
+
+ {% endif %} + {% endblock %} + diff --git a/src/submissions/tools.py b/src/submissions/tools.py index 2ac45b6..c15821f 100644 --- a/src/submissions/tools.py +++ b/src/submissions/tools.py @@ -95,6 +95,16 @@ def convert_nans_to_nones(input_str) -> str|None: return None def check_regex_match(pattern:str, check:str) -> bool: + """ + Determines if a pattern matches a str + + Args: + pattern (str): regex pattern string + check (str): string to be checked + + Returns: + bool: match found? + """ try: return bool(re.match(fr"{pattern}", check)) except TypeError: @@ -375,37 +385,6 @@ def jinja_template_loading() -> Environment: env.globals['STATIC_PREFIX'] = loader_path.joinpath("static", "css") return env -def check_authorization(func): - """ - Decorator to check if user is authorized to access function - - Args: - func (_type_): Function to be used. - """ - def wrapper(*args, **kwargs): - logger.debug(f"Checking authorization") - if getpass.getuser() in kwargs['ctx'].power_users: - return func(*args, **kwargs) - else: - logger.error(f"User {getpass.getuser()} is not authorized for this function.") - return dict(code=1, message="This user does not have permission for this function.", status="warning") - return wrapper - -# def check_authorization(user:str): -# def decorator(function): -# def wrapper(*args, **kwargs): -# # funny_stuff() -# # print(argument) -# power_users = -# if user in ctx.power_users: -# result = function(*args, **kwargs) -# else: -# logger.error(f"User {getpass.getuser()} is not authorized for this function.") -# result = dict(code=1, message="This user does not have permission for this function.", status="warning") -# return result -# return wrapper -# return decorator - def check_if_app() -> bool: """ Checks if the program is running from pyinstaller compiled @@ -431,7 +410,7 @@ def convert_well_to_row_column(input_str:str) -> Tuple[int, int]: Returns: Tuple[int, int]: row, column """ - row_keys = dict(A=1, B=2, C=3, D=4, E=5, F=6, G=7, H=8) + row_keys = {v:k for k,v in row_map.items()} try: row = int(row_keys[input_str[0].upper()]) column = int(input_str[1:]) @@ -439,27 +418,13 @@ def convert_well_to_row_column(input_str:str) -> Tuple[int, int]: return None, None return row, column -def query_return(query:Query, limit:int=0): +def setup_lookup(func): """ - Execute sqlalchemy query. + Checks to make sure all args are allowed Args: - query (Query): Query object - limit (int, optional): Maximum number of results to return (0 = all). Defaults to 0. - - Returns: - _type_: Query result. + func (_type_): _description_ """ - with query.session.no_autoflush: - match limit: - case 0: - return query.all() - case 1: - return query.first() - case _: - return query.limit(limit).all() - -def setup_lookup(func): def wrapper(*args, **kwargs): for k, v in locals().items(): if k == "kwargs": @@ -509,32 +474,30 @@ class Report(BaseModel): except AttributeError: logger.error(f"Problem adding result.") case Report(): - + # logger.debug(f"Adding all results in report to new report") for res in result.results: logger.debug(f"Adding {res} from to results.") self.results.append(res) case _: pass - -def readInChunks(fileObj, chunkSize=2048): - """ - Lazy function to read a file piece by piece. - Default chunk size: 2kB. - - """ - while True: - data = fileObj.readlines(chunkSize) - if not data: - break - yield data - -def get_first_blank_df_row(df:pd.DataFrame) -> int: - return len(df) + 1 -def is_missing(value:Any) -> Tuple[Any, bool]: - if check_not_nan(value): - return value, False - else: - return convert_nans_to_nones(value), True +def rreplace(s, old, new): + return (s[::-1].replace(old[::-1],new[::-1], 1))[::-1] ctx = get_config(None) + +def check_authorization(func): + """ + Decorator to check if user is authorized to access function + + Args: + func (_type_): Function to be used. + """ + def wrapper(*args, **kwargs): + logger.debug(f"Checking authorization") + if getpass.getuser() in ctx.power_users: + return func(*args, **kwargs) + else: + logger.error(f"User {getpass.getuser()} is not authorized for this function.") + return dict(code=1, message="This user does not have permission for this function.", status="warning") + return wrapper \ No newline at end of file