Pre-sample/control connect

This commit is contained in:
Landon Wark
2023-12-05 10:20:46 -06:00
parent 283e77fee5
commit cddb947ec8
29 changed files with 1357 additions and 1042 deletions

View File

@@ -1,3 +1,13 @@
## 202312.01
- Backups will now create an regenerated xlsx file.
- Report generator now does sums automatically.
## 202311.04
- Added xlsx template files to the database.
- Switched session hand-off to sqlalchemy to abstract parent class.
## 202311.03 ## 202311.03
- Added in tabular log parser. - Added in tabular log parser.

11
TODO.md
View File

@@ -1,5 +1,9 @@
- [ ] Buuuuuuhh. Split polymorphic objects into different tables... and rebuild DB.... FFFFF - [x] Clean up DB objects after failed test fix.
- https://stackoverflow.com/questions/16910782/sqlalchemy-nested-inheritance-polymorphic-relationships - [x] Fix tests.
- [ ] Fix pydant.PydSample.handle_duplicate_samples?
- [ ] See if the number of queries in BasicSubmission functions (and others) can be trimmed down.
- [x] Document code
- Done Submissions up to BasicSample
- [x] Create a result object to facilitate returning function results. - [x] Create a result object to facilitate returning function results.
- [x] Refactor main_window_functions into as many objects (forms, etc.) as possible to clean it up. - [x] Refactor main_window_functions into as many objects (forms, etc.) as possible to clean it up.
- [x] Integrate 'Construct First Strand' into the Artic import. - [x] Integrate 'Construct First Strand' into the Artic import.
@@ -10,7 +14,6 @@
- [x] Move lookup functions into class methods of db objects? - [x] Move lookup functions into class methods of db objects?
- Not sure if will work for associations. - Not sure if will work for associations.
- [x] Update artic submission type database entry to add more technicians. - [x] Update artic submission type database entry to add more technicians.
- [ ] Document code
- [x] Rewrite tests... again. - [x] Rewrite tests... again.
- [x] Have InfoItem change status self.missing to True if value changed. - [x] Have InfoItem change status self.missing to True if value changed.
- [x] Make the kit verifier make more sense. - [x] Make the kit verifier make more sense.
@@ -26,7 +29,7 @@
- [x] Drag and drop files into submission form area? - [x] Drag and drop files into submission form area?
- [ ] Get info for controls into their sample hitpicks. - [ ] Get info for controls into their sample hitpicks.
- [x] Move submission-type specific parser functions into class methods in their respective models. - [x] Move submission-type specific parser functions into class methods in their respective models.
- [ ] Improve function results reporting. - [x] Improve function results reporting.
- Maybe make it a list until it gets to the reporter? - Maybe make it a list until it gets to the reporter?
- [x] Increase robustness of form parsers by adding custom procedures for each. - [x] Increase robustness of form parsers by adding custom procedures for each.
- [x] Rerun Kit integrity if extraction kit changed in the form. - [x] Rerun Kit integrity if extraction kit changed in the form.

View File

@@ -56,7 +56,7 @@ version_path_separator = os # Use os.pathsep. Default configuration used for ne
# output_encoding = utf-8 # output_encoding = utf-8
; sqlalchemy.url = sqlite:///L:\Robotics Laboratory Support\Submissions\submissions.db ; sqlalchemy.url = sqlite:///L:\Robotics Laboratory Support\Submissions\submissions.db
sqlalchemy.url = sqlite:///C:\Users\lwark\Documents\Archives\Submissions_app_backups\DB_backups\submissions-new.db ; sqlalchemy.url = sqlite:///C:\Users\lwark\Documents\Archives\Submissions_app_backups\DB_backups\submissions-new.db
; sqlalchemy.url = sqlite:///C:\Users\lwark\Documents\python\submissions\tests\test_assets\submissions-test.db ; sqlalchemy.url = sqlite:///C:\Users\lwark\Documents\python\submissions\tests\test_assets\submissions-test.db

View File

@@ -0,0 +1,32 @@
"""add templates to submission types
Revision ID: 7e7b6eeca468
Revises:
Create Date: 2023-11-23 08:07:51.103392
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '7e7b6eeca468'
down_revision = None
branch_labels = None
depends_on = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('_submission_types', schema=None) as batch_op:
batch_op.add_column(sa.Column('template_file', sa.BLOB(), nullable=True))
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('_submission_types', schema=None) as batch_op:
batch_op.drop_column('template_file')
# ### end Alembic commands ###

View File

@@ -4,7 +4,7 @@ from pathlib import Path
# Version of the realpython-reader package # Version of the realpython-reader package
__project__ = "submissions" __project__ = "submissions"
__version__ = "202311.3b" __version__ = "202312.1b"
__author__ = {"name":"Landon Wark", "email":"Landon.Wark@phac-aspc.gc.ca"} __author__ = {"name":"Landon Wark", "email":"Landon.Wark@phac-aspc.gc.ca"}
__copyright__ = "2022-2023, Government of Canada" __copyright__ = "2022-2023, Government of Canada"

View File

@@ -1,17 +1,14 @@
import sys import sys
import os import os
# environment variable must be set to enable qtwebengine in network path # environment variable must be set to enable qtwebengine in network path
# if getattr(sys, 'frozen', False): from tools import ctx, setup_logger, check_if_app
# os.environ['QTWEBENGINE_DISABLE_SANDBOX'] = "1"
from tools import get_config, setup_logger, check_if_app
if check_if_app(): if check_if_app():
os.environ['QTWEBENGINE_DISABLE_SANDBOX'] = "1" os.environ['QTWEBENGINE_DISABLE_SANDBOX'] = "1"
# setup custom logger # setup custom logger
logger = setup_logger(verbosity=3) logger = setup_logger(verbosity=3)
# create settings object # create settings object
ctx = get_config(None)
from PyQt6.QtWidgets import QApplication from PyQt6.QtWidgets import QApplication
# from frontend import App
from frontend.widgets.app import App from frontend.widgets.app import App
if __name__ == '__main__': if __name__ == '__main__':

View File

@@ -1,8 +1,50 @@
''' '''
Contains all models for sqlalchemy Contains all models for sqlalchemy
''' '''
import sys
from sqlalchemy.orm import DeclarativeMeta, declarative_base
from sqlalchemy.ext.declarative import declared_attr
if 'pytest' in sys.modules:
from pathlib import Path
sys.path.append(Path(__file__).parents[4].absolute().joinpath("tests").__str__())
Base: DeclarativeMeta = declarative_base()
class BaseClass(Base):
"""
Abstract class to pass ctx values to all SQLAlchemy objects.
Args:
Base (DeclarativeMeta): Declarative base for metadata.
"""
__abstract__ = True
__table_args__ = {'extend_existing': True}
@declared_attr
def __database_session__(cls):
if not 'pytest' in sys.modules:
from tools import ctx
else:
from test_settings import ctx
return ctx.database_session
@declared_attr
def __directory_path__(cls):
if not 'pytest' in sys.modules:
from tools import ctx
else:
from test_settings import ctx
return ctx.directory_path
@declared_attr
def __backup_path__(cls):
if not 'pytest' in sys.modules:
from tools import ctx
else:
from test_settings import ctx
return ctx.backup_path
from tools import Base
from .controls import * from .controls import *
# import order must go: orgs, kit, subs due to circular import issues # import order must go: orgs, kit, subs due to circular import issues
from .organizations import * from .organizations import *

View File

@@ -7,7 +7,7 @@ from sqlalchemy.orm import relationship, Query
import logging import logging
from operator import itemgetter from operator import itemgetter
import json import json
from . import Base from . import BaseClass
from tools import setup_lookup, query_return from tools import setup_lookup, query_return
from datetime import date, datetime from datetime import date, datetime
from typing import List from typing import List
@@ -15,12 +15,11 @@ from dateutil.parser import parse
logger = logging.getLogger(f"submissions.{__name__}") logger = logging.getLogger(f"submissions.{__name__}")
class ControlType(Base): class ControlType(BaseClass):
""" """
Base class of a control archetype. Base class of a control archetype.
""" """
__tablename__ = '_control_types' __tablename__ = '_control_types'
__table_args__ = {'extend_existing': True}
id = Column(INTEGER, primary_key=True) #: primary key id = Column(INTEGER, primary_key=True) #: primary key
name = Column(String(255), unique=True) #: controltype name (e.g. MCS) name = Column(String(255), unique=True) #: controltype name (e.g. MCS)
@@ -37,14 +36,13 @@ class ControlType(Base):
Lookup control archetypes in the database Lookup control archetypes in the database
Args: Args:
ctx (Settings): Settings object passed down from gui.
name (str, optional): Control type name (limits results to 1). Defaults to None. name (str, optional): Control type name (limits results to 1). Defaults to None.
limit (int, optional): Maximum number of results to return. Defaults to 0. limit (int, optional): Maximum number of results to return. Defaults to 0.
Returns: Returns:
models.ControlType|List[models.ControlType]: ControlType(s) of interest. models.ControlType|List[models.ControlType]: ControlType(s) of interest.
""" """
query = cls.metadata.session.query(cls) query = cls.__database_session__.query(cls)
match name: match name:
case str(): case str():
query = query.filter(cls.name==name) query = query.filter(cls.name==name)
@@ -53,13 +51,12 @@ class ControlType(Base):
pass pass
return query_return(query=query, limit=limit) return query_return(query=query, limit=limit)
class Control(Base): class Control(BaseClass):
""" """
Base class of a control sample. Base class of a control sample.
""" """
__tablename__ = '_control_samples' __tablename__ = '_control_samples'
__table_args__ = {'extend_existing': True}
id = Column(INTEGER, primary_key=True) #: primary key id = Column(INTEGER, primary_key=True) #: primary key
parent_id = Column(String, ForeignKey("_control_types.id", name="fk_control_parent_id")) #: primary key of control type parent_id = Column(String, ForeignKey("_control_types.id", name="fk_control_parent_id")) #: primary key of control type
@@ -114,10 +111,9 @@ class Control(Base):
def convert_by_mode(self, mode:str) -> list[dict]: def convert_by_mode(self, mode:str) -> list[dict]:
""" """
split control object into analysis types for controls graphs split this instance into analysis types for controls graphs
Args: Args:
control (models.Control): control to be parsed into list
mode (str): analysis type, 'contains', etc mode (str): analysis type, 'contains', etc
Returns: Returns:
@@ -168,6 +164,21 @@ class Control(Base):
data = {} data = {}
return data return data
@classmethod
def get_modes(cls) -> List[str]:
"""
Get all control modes from database
Returns:
List[str]: List of control mode names.
"""
try:
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}")
cols = []
return cols
@classmethod @classmethod
@setup_lookup @setup_lookup
def query(cls, def query(cls,
@@ -190,15 +201,14 @@ class Control(Base):
Returns: Returns:
models.Control|List[models.Control]: Control object of interest. models.Control|List[models.Control]: Control object of interest.
""" """
query: Query = cls.metadata.session.query(cls) query: Query = cls.__database_session__.query(cls)
# by control type # by control type
match control_type: match control_type:
case ControlType(): case ControlType():
logger.debug(f"Looking up control by control type: {control_type}") # logger.debug(f"Looking up control by control type: {control_type}")
# query = query.join(models.ControlType).filter(models.ControlType==control_type)
query = query.filter(cls.controltype==control_type) query = query.filter(cls.controltype==control_type)
case str(): case str():
logger.debug(f"Looking up control by control type: {control_type}") # logger.debug(f"Looking up control by control type: {control_type}")
query = query.join(ControlType).filter(ControlType.name==control_type) query = query.join(ControlType).filter(ControlType.name==control_type)
case _: case _:
pass pass
@@ -224,7 +234,7 @@ class Control(Base):
end_date = datetime.fromordinal(datetime(1900, 1, 1).toordinal() + end_date - 2).date().strftime("%Y-%m-%d") end_date = datetime.fromordinal(datetime(1900, 1, 1).toordinal() + end_date - 2).date().strftime("%Y-%m-%d")
case _: case _:
end_date = parse(end_date).strftime("%Y-%m-%d") 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"Looking up BasicSubmissions from start date: {start_date} and end date: {end_date}")
query = query.filter(cls.submitted_date.between(start_date, end_date)) query = query.filter(cls.submitted_date.between(start_date, end_date))
match control_name: match control_name:
case str(): case str():
@@ -233,23 +243,3 @@ class Control(Base):
case _: case _:
pass pass
return query_return(query=query, limit=limit) return query_return(query=query, limit=limit)
@classmethod
def get_modes(cls):
"""
Get all control modes from database
Args:
ctx (Settings): Settings object passed down from gui.
Returns:
List[str]: List of control mode names.
"""
rel = cls.metadata.session.query(cls).first()
try:
cols = [item.name for item in list(rel.__table__.columns) if isinstance(item.type, JSON)]
except AttributeError as e:
logger.debug(f"Failed to get available modes from db: {e}")
cols = []
return cols

View File

@@ -2,14 +2,15 @@
All kit and reagent related models All kit and reagent related models
''' '''
from __future__ import annotations from __future__ import annotations
from sqlalchemy import Column, String, TIMESTAMP, JSON, INTEGER, ForeignKey, Interval, Table, FLOAT, func, BLOB from sqlalchemy import Column, String, TIMESTAMP, JSON, INTEGER, ForeignKey, Interval, Table, FLOAT, BLOB
from sqlalchemy.orm import relationship, validates, Query from sqlalchemy.orm import relationship, validates, Query
from sqlalchemy.ext.associationproxy import association_proxy from sqlalchemy.ext.associationproxy import association_proxy
from datetime import date from datetime import date
import logging import logging
from tools import check_authorization, setup_lookup, query_return, Report, Result from tools import check_authorization, setup_lookup, query_return, Report, Result, Settings
from typing import List from typing import List
from . import Base, Organization from pandas import ExcelFile
from . import Base, BaseClass, Organization
logger = logging.getLogger(f'submissions.{__name__}') logger = logging.getLogger(f'submissions.{__name__}')
@@ -21,12 +22,12 @@ reagenttypes_reagents = Table(
extend_existing = True extend_existing = True
) )
class KitType(Base): class KitType(BaseClass):
""" """
Base of kits used in submission processing Base of kits used in submission processing
""" """
__tablename__ = "_kits" __tablename__ = "_kits"
__table_args__ = {'extend_existing': True} # __table_args__ = {'extend_existing': True}
id = Column(INTEGER, primary_key=True) #: primary key id = Column(INTEGER, primary_key=True) #: primary key
name = Column(String(64), unique=True) #: name of kit name = Column(String(64), unique=True) #: name of kit
@@ -54,16 +55,7 @@ class KitType(Base):
def __repr__(self) -> str: def __repr__(self) -> str:
return f"<KitType({self.name})>" return f"<KitType({self.name})>"
def __str__(self) -> str: def get_reagents(self, required:bool=False, submission_type:str|SubmissionType|None=None) -> list:
"""
a string representing this object
Returns:
str: a string representing this object's name
"""
return self.name
def get_reagents(self, required:bool=False, submission_type:str|None=None) -> list:
""" """
Return ReagentTypes linked to kit through KitTypeReagentTypeAssociation. Return ReagentTypes linked to kit through KitTypeReagentTypeAssociation.
@@ -74,9 +66,12 @@ class KitType(Base):
Returns: Returns:
list: List of reagent types list: List of reagent types
""" """
if submission_type != None: match submission_type:
case SubmissionType():
relevant_associations = [item for item in self.kit_reagenttype_associations if submission_type.name in item.uses.keys()]
case str():
relevant_associations = [item for item in self.kit_reagenttype_associations if submission_type in item.uses.keys()] relevant_associations = [item for item in self.kit_reagenttype_associations if submission_type in item.uses.keys()]
else: case _:
relevant_associations = [item for item in self.kit_reagenttype_associations] relevant_associations = [item for item in self.kit_reagenttype_associations]
if required: if required:
return [item.reagent_type for item in relevant_associations if item.required == 1] return [item.reagent_type for item in relevant_associations if item.required == 1]
@@ -109,11 +104,6 @@ class KitType(Base):
map['info'] = {} map['info'] = {}
return map return map
@check_authorization
def save(self):
self.metadata.session.add(self)
self.metadata.session.commit()
@classmethod @classmethod
@setup_lookup @setup_lookup
def query(cls, def query(cls,
@@ -126,7 +116,6 @@ class KitType(Base):
Lookup a list of or single KitType. Lookup a list of or single KitType.
Args: Args:
ctx (Settings): Settings object passed down from gui
name (str, optional): Name of desired kit (returns single instance). Defaults to None. name (str, optional): Name of desired kit (returns single instance). Defaults to None.
used_for (str | models.Submissiontype | None, optional): Submission type the kit is used for. Defaults to None. used_for (str | models.Submissiontype | None, optional): Submission type the kit is used for. Defaults to None.
id (int | None, optional): Kit id in the database. Defaults to None. id (int | None, optional): Kit id in the database. Defaults to None.
@@ -135,10 +124,10 @@ class KitType(Base):
Returns: Returns:
models.KitType|List[models.KitType]: KitType(s) of interest. models.KitType|List[models.KitType]: KitType(s) of interest.
""" """
query: Query = cls.metadata.session.query(cls) query: Query = cls.__database_session__.query(cls)
match used_for: match used_for:
case str(): case str():
logger.debug(f"Looking up kit type by use: {used_for}") # logger.debug(f"Looking up kit type by use: {used_for}")
query = query.filter(cls.used_for.any(name=used_for)) query = query.filter(cls.used_for.any(name=used_for))
case SubmissionType(): case SubmissionType():
query = query.filter(cls.used_for.contains(used_for)) query = query.filter(cls.used_for.contains(used_for))
@@ -146,30 +135,37 @@ class KitType(Base):
pass pass
match name: match name:
case str(): case str():
logger.debug(f"Looking up kit type by name: {name}") # logger.debug(f"Looking up kit type by name: {name}")
query = query.filter(cls.name==name) query = query.filter(cls.name==name)
limit = 1 limit = 1
case _: case _:
pass pass
match id: match id:
case int(): case int():
logger.debug(f"Looking up kit type by id: {id}") # logger.debug(f"Looking up kit type by id: {id}")
query = query.filter(cls.id==id) query = query.filter(cls.id==id)
limit = 1 limit = 1
case str(): case str():
logger.debug(f"Looking up kit type by id: {id}") # logger.debug(f"Looking up kit type by id: {id}")
query = query.filter(cls.id==int(id)) query = query.filter(cls.id==int(id))
limit = 1 limit = 1
case _: case _:
pass pass
return query_return(query=query, limit=limit) return query_return(query=query, limit=limit)
class ReagentType(Base): @check_authorization
def save(self, ctx:Settings):
"""
Add this instance to database and commit
"""
self.__database_session__.add(self)
self.__database_session__.commit()
class ReagentType(BaseClass):
""" """
Base of reagent type abstract Base of reagent type abstract
""" """
__tablename__ = "_reagent_types" __tablename__ = "_reagent_types"
__table_args__ = {'extend_existing': True}
id = Column(INTEGER, primary_key=True) #: primary key id = Column(INTEGER, primary_key=True) #: primary key
name = Column(String(64)) #: name of reagent type name = Column(String(64)) #: name of reagent type
@@ -187,17 +183,17 @@ class ReagentType(Base):
# creator function: https://stackoverflow.com/questions/11091491/keyerror-when-adding-objects-to-sqlalchemy-association-object/11116291#11116291 # 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))
def __str__(self) -> str: # def __str__(self) -> str:
""" # """
string representing this object # string representing this object
Returns: # Returns:
str: string representing this object's name # str: string representing this object's name
""" # """
return self.name # return self.name
def __repr__(self): def __repr__(self):
return f"ReagentType({self.name})" return f"<ReagentType({self.name})>"
@classmethod @classmethod
@setup_lookup @setup_lookup
@@ -211,14 +207,18 @@ class ReagentType(Base):
Lookup reagent types in the database. Lookup reagent types in the database.
Args: Args:
ctx (Settings): Settings object passed down from gui.
name (str | None, optional): Reagent type name. Defaults to None. name (str | None, optional): Reagent type name. Defaults to None.
kit_type (KitType | str | None, optional): Kit the type of interest belongs to. Defaults to None.
reagent (Reagent | str | None, optional): Concrete instance of the type of interest. Defaults to None.
limit (int, optional): maxmimum number of results to return (0 = all). Defaults to 0. limit (int, optional): maxmimum number of results to return (0 = all). Defaults to 0.
Raises:
ValueError: Raised if only kit_type or reagent, not both, given.
Returns: Returns:
models.ReagentType|List[models.ReagentType]: ReagentType or list of ReagentTypes matching filter. ReagentType|List[ReagentType]: ReagentType or list of ReagentTypes matching filter.
""" """
query: Query = cls.metadata.session.query(cls) query: Query = cls.__database_session__.query(cls)
if (kit_type != None and reagent == None) or (reagent != None and kit_type == None): if (kit_type != None and reagent == None) or (reagent != None and kit_type == None):
raise ValueError("Cannot filter without both reagent and kit type.") raise ValueError("Cannot filter without both reagent and kit type.")
elif kit_type == None and reagent == None: elif kit_type == None and reagent == None:
@@ -235,9 +235,8 @@ class ReagentType(Base):
case _: case _:
pass pass
assert reagent.type != [] assert reagent.type != []
logger.debug(f"Looking up reagent type for {type(kit_type)} {kit_type} and {type(reagent)} {reagent}") # logger.debug(f"Looking up reagent type for {type(kit_type)} {kit_type} and {type(reagent)} {reagent}")
logger.debug(f"Kit reagent types: {kit_type.reagent_types}") # logger.debug(f"Kit reagent types: {kit_type.reagent_types}")
# logger.debug(f"Reagent reagent types: {reagent._sa_instance_state}")
result = list(set(kit_type.reagent_types).intersection(reagent.type)) result = list(set(kit_type.reagent_types).intersection(reagent.type))
logger.debug(f"Result: {result}") logger.debug(f"Result: {result}")
try: try:
@@ -246,34 +245,33 @@ class ReagentType(Base):
return None return None
match name: match name:
case str(): case str():
logger.debug(f"Looking up reagent type by name: {name}") # logger.debug(f"Looking up reagent type by name: {name}")
query = query.filter(cls.name==name) query = query.filter(cls.name==name)
limit = 1 limit = 1
case _: case _:
pass pass
return query_return(query=query, limit=limit) return query_return(query=query, limit=limit)
class KitTypeReagentTypeAssociation(Base): class KitTypeReagentTypeAssociation(BaseClass):
""" """
table containing reagenttype/kittype associations table containing reagenttype/kittype associations
DOC: https://docs.sqlalchemy.org/en/14/orm/extensions/associationproxy.html DOC: https://docs.sqlalchemy.org/en/14/orm/extensions/associationproxy.html
""" """
__tablename__ = "_reagenttypes_kittypes" __tablename__ = "_reagenttypes_kittypes"
__table_args__ = {'extend_existing': True}
reagent_types_id = Column(INTEGER, ForeignKey("_reagent_types.id"), primary_key=True) reagent_types_id = Column(INTEGER, ForeignKey("_reagent_types.id"), primary_key=True) #: id of associated reagent type
kits_id = Column(INTEGER, ForeignKey("_kits.id"), primary_key=True) kits_id = Column(INTEGER, ForeignKey("_kits.id"), primary_key=True) #: id of associated reagent type
uses = Column(JSON) uses = Column(JSON) #: map to location on excel sheets of different submission types
required = Column(INTEGER) 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 last_used = Column(String(32)) #: last used lot number of this type of reagent
kit_type = relationship(KitType, back_populates="kit_reagenttype_associations") kit_type = relationship(KitType, back_populates="kit_reagenttype_associations") #: relationship to associated kit
# reference to the "ReagentType" object # reference to the "ReagentType" object
reagent_type = relationship(ReagentType, back_populates="reagenttype_kit_associations") reagent_type = relationship(ReagentType, back_populates="reagenttype_kit_associations") #: relationship to associated reagent type
def __init__(self, kit_type=None, reagent_type=None, uses=None, required=1): 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}") # logger.debug(f"Parameters: Kit={kit_type}, RT={reagent_type}, Uses={uses}, Required={required}")
self.kit_type = kit_type self.kit_type = kit_type
self.reagent_type = reagent_type self.reagent_type = reagent_type
self.uses = uses self.uses = uses
@@ -284,12 +282,38 @@ class KitTypeReagentTypeAssociation(Base):
@validates('required') @validates('required')
def validate_age(self, key, value): def validate_age(self, key, value):
"""
Ensures only 1 & 0 used in 'required'
Args:
key (str): name of attribute
value (_type_): value of attribute
Raises:
ValueError: Raised if bad value given
Returns:
_type_: value
"""
if not 0 <= value < 2: if not 0 <= value < 2:
raise ValueError(f'Invalid required value {value}. Must be 0 or 1.') raise ValueError(f'Invalid required value {value}. Must be 0 or 1.')
return value return value
@validates('reagenttype') @validates('reagenttype')
def validate_reagenttype(self, key, value): def validate_reagenttype(self, key, value):
"""
Ensures reagenttype is an actual ReagentType
Args:
key (str)): name of attribute
value (_type_): value of attribute
Raises:
ValueError: raised if reagenttype is not a ReagentType
Returns:
_type_: ReagentType
"""
if not isinstance(value, ReagentType): if not isinstance(value, ReagentType):
raise ValueError(f'{value} is not a reagenttype') raise ValueError(f'{value} is not a reagenttype')
return value return value
@@ -297,15 +321,14 @@ class KitTypeReagentTypeAssociation(Base):
@classmethod @classmethod
@setup_lookup @setup_lookup
def query(cls, def query(cls,
kit_type:KitType|str|None, kit_type:KitType|str|None=None,
reagent_type:ReagentType|str|None, reagent_type:ReagentType|str|None=None,
limit:int=0 limit:int=0
) -> KitTypeReagentTypeAssociation|List[KitTypeReagentTypeAssociation]: ) -> KitTypeReagentTypeAssociation|List[KitTypeReagentTypeAssociation]:
""" """
Lookup junction of ReagentType and KitType Lookup junction of ReagentType and KitType
Args: Args:
ctx (Settings): Settings object passed down from gui.
kit_type (models.KitType | str | None): KitType of interest. kit_type (models.KitType | str | None): KitType of interest.
reagent_type (models.ReagentType | str | None): ReagentType of interest. reagent_type (models.ReagentType | str | None): ReagentType of interest.
limit (int, optional): Maximum number of results to return (0 = all). Defaults to 0. limit (int, optional): Maximum number of results to return (0 = all). Defaults to 0.
@@ -313,7 +336,7 @@ class KitTypeReagentTypeAssociation(Base):
Returns: Returns:
models.KitTypeReagentTypeAssociation|List[models.KitTypeReagentTypeAssociation]: Junction of interest. models.KitTypeReagentTypeAssociation|List[models.KitTypeReagentTypeAssociation]: Junction of interest.
""" """
query: Query = cls.metadata.session.query(cls) query: Query = cls.__database_session__.query(cls)
match kit_type: match kit_type:
case KitType(): case KitType():
query = query.filter(cls.kit_type==kit_type) query = query.filter(cls.kit_type==kit_type)
@@ -333,17 +356,22 @@ class KitTypeReagentTypeAssociation(Base):
return query_return(query=query, limit=limit) return query_return(query=query, limit=limit)
def save(self) -> Report: def save(self) -> Report:
"""
Adds this instance to the database and commits.
Returns:
Report: Result of save action
"""
report = Report() report = Report()
self.metadata.session.add(self) self.__database_session__.add(self)
self.metadata.session.commit() self.__database_session__.commit()
return report return report
class Reagent(Base): class Reagent(BaseClass):
""" """
Concrete reagent instance Concrete reagent instance
""" """
__tablename__ = "_reagents" __tablename__ = "_reagents"
__table_args__ = {'extend_existing': True}
id = Column(INTEGER, primary_key=True) #: primary key id = Column(INTEGER, primary_key=True) #: primary key
type = relationship("ReagentType", back_populates="instances", secondary=reagenttypes_reagents) #: joined parent reagent type type = relationship("ReagentType", back_populates="instances", secondary=reagenttypes_reagents) #: joined parent reagent type
@@ -359,15 +387,6 @@ class Reagent(Base):
else: else:
return f"<Reagent({self.type.name}-{self.lot})>" return f"<Reagent({self.type.name}-{self.lot})>"
def __str__(self) -> str:
"""
string representing this object
Returns:
str: string representing this object's type and lot number
"""
return str(self.lot)
def to_sub_dict(self, extraction_kit:KitType=None) -> dict: def to_sub_dict(self, extraction_kit:KitType=None) -> dict:
""" """
dictionary containing values necessary for gui dictionary containing values necessary for gui
@@ -376,7 +395,7 @@ class Reagent(Base):
extraction_kit (KitType, optional): KitType to use to get reagent type. Defaults to None. extraction_kit (KitType, optional): KitType to use to get reagent type. Defaults to None.
Returns: Returns:
dict: _description_ dict: representation of the reagent's attributes
""" """
if extraction_kit != None: if extraction_kit != None:
# Get the intersection of this reagent's ReagentType and all ReagentTypes in KitType # Get the intersection of this reagent's ReagentType and all ReagentTypes in KitType
@@ -388,65 +407,52 @@ class Reagent(Base):
else: else:
reagent_role = self.type[0] reagent_role = self.type[0]
try: try:
rtype = reagent_role.name.replace("_", " ").title() rtype = reagent_role.name.replace("_", " ")
except AttributeError: except AttributeError:
rtype = "Unknown" rtype = "Unknown"
# Calculate expiry with EOL from ReagentType # Calculate expiry with EOL from ReagentType
try: try:
place_holder = self.expiry + reagent_role.eol_ext place_holder = self.expiry + reagent_role.eol_ext
except TypeError as e: except (TypeError, AttributeError) as e:
place_holder = date.today() place_holder = date.today()
logger.debug(f"We got a type error setting {self.lot} expiry: {e}. setting to today for testing") logger.debug(f"We got a type error setting {self.lot} expiry: {e}. setting to today for testing")
except AttributeError as e: return dict(
place_holder = date.today() name=self.name,
logger.debug(f"We got an attribute error setting {self.lot} expiry: {e}. Setting to today for testing") type=rtype,
return { lot=self.lot,
"type": rtype, expiry=place_holder.strftime("%Y-%m-%d")
"lot": self.lot, )
"expiry": place_holder.strftime("%Y-%m-%d")
}
def to_reagent_dict(self, extraction_kit:KitType|str=None) -> dict: def update_last_used(self, kit:KitType) -> Report:
""" """
Returns basic reagent dictionary. Updates last used reagent lot for ReagentType/KitType
Args: Args:
extraction_kit (KitType, optional): KitType to use to get reagent type. Defaults to None. kit (KitType): Kit this instance is used in.
Returns: Returns:
dict: Basic reagent dictionary of 'type', 'lot', 'expiry' Report: Result of operation
""" """
if extraction_kit != None: report = Report()
# Get the intersection of this reagent's ReagentType and all ReagentTypes in KitType logger.debug(f"Attempting update of reagent type at intersection of ({self}), ({kit})")
try: rt = ReagentType.query(kit_type=kit, reagent=self, limit=1)
reagent_role = list(set(self.type).intersection(extraction_kit.reagent_types))[0] if rt != None:
# Most will be able to fall back to first ReagentType in itself because most will only have 1. logger.debug(f"got reagenttype {rt}")
except: assoc = KitTypeReagentTypeAssociation.query(kit_type=kit, reagent_type=rt)
reagent_role = self.type[0] if assoc != None:
else: if assoc.last_used != self.lot:
reagent_role = self.type[0] logger.debug(f"Updating {assoc} last used to {self.lot}")
try: assoc.last_used = self.lot
rtype = reagent_role.name result = assoc.save()
except AttributeError: report.add_result(result)
rtype = "Unknown" return report
try: report.add_result(Result(msg=f"Updating last used {rt} was not performed.", status="Information"))
expiry = self.expiry.strftime("%Y-%m-%d") return report
except:
expiry = date.today()
return {
"name":self.name,
"type": rtype,
"lot": self.lot,
"expiry": self.expiry.strftime("%Y-%m-%d")
}
def save(self):
self.metadata.session.add(self)
self.metadata.session.commit()
@classmethod @classmethod
@setup_lookup @setup_lookup
def query(cls, reagent_type:str|ReagentType|None=None, def query(cls,
reagent_type:str|ReagentType|None=None,
lot_number:str|None=None, lot_number:str|None=None,
limit:int=0 limit:int=0
) -> Reagent|List[Reagent]: ) -> Reagent|List[Reagent]:
@@ -454,7 +460,6 @@ class Reagent(Base):
Lookup a list of reagents from the database. Lookup a list of reagents from the database.
Args: Args:
ctx (Settings): Settings object passed down from gui
reagent_type (str | models.ReagentType | None, optional): Reagent type. Defaults to None. reagent_type (str | models.ReagentType | None, optional): Reagent type. Defaults to None.
lot_number (str | None, optional): Reagent lot number. Defaults to None. lot_number (str | None, optional): Reagent lot number. Defaults to None.
limit (int, optional): limit of results returned. Defaults to 0. limit (int, optional): limit of results returned. Defaults to 0.
@@ -462,13 +467,14 @@ class Reagent(Base):
Returns: Returns:
models.Reagent | List[models.Reagent]: reagent or list of reagents matching filter. models.Reagent | List[models.Reagent]: reagent or list of reagents matching filter.
""" """
query: Query = cls.metadata.session.query(cls) # super().query(session)
query: Query = cls.__database_session__.query(cls)
match reagent_type: match reagent_type:
case str(): case str():
logger.debug(f"Looking up reagents by reagent type: {reagent_type}") # logger.debug(f"Looking up reagents by reagent type: {reagent_type}")
query = query.join(cls.type, aliased=True).filter(ReagentType.name==reagent_type) query = query.join(cls.type).filter(ReagentType.name==reagent_type)
case ReagentType(): case ReagentType():
logger.debug(f"Looking up reagents by reagent type: {reagent_type}") # logger.debug(f"Looking up reagents by reagent type: {reagent_type}")
query = query.filter(cls.type.contains(reagent_type)) query = query.filter(cls.type.contains(reagent_type))
case _: case _:
pass pass
@@ -482,35 +488,26 @@ class Reagent(Base):
pass pass
return query_return(query=query, limit=limit) return query_return(query=query, limit=limit)
def update_last_used(self, kit:KitType): def save(self):
report = Report() """
logger.debug(f"Attempting update of reagent type at intersection of ({self}), ({kit})") Add this instance to the database and commit
rt = ReagentType.query(kit_type=kit, reagent=self, limit=1) """
if rt != None: self.__database_session__.add(self)
logger.debug(f"got reagenttype {rt}") self.__database_session__.commit()
assoc = KitTypeReagentTypeAssociation.query(kit_type=kit, reagent_type=rt)
if assoc != None:
if assoc.last_used != self.lot:
logger.debug(f"Updating {assoc} last used to {self.lot}")
assoc.last_used = self.lot
result = assoc.save()
return(report.add_result(result))
return report.add_result(Result(msg=f"Updating last used {rt} was not performed.", status="Information"))
class Discount(Base): class Discount(BaseClass):
""" """
Relationship table for client labs for certain kits. Relationship table for client labs for certain kits.
""" """
__tablename__ = "_discounts" __tablename__ = "_discounts"
__table_args__ = {'extend_existing': True}
id = Column(INTEGER, primary_key=True) #: primary key id = Column(INTEGER, primary_key=True) #: primary key
kit = relationship("KitType") #: joined parent reagent type kit = relationship("KitType") #: joined parent reagent type
kit_id = Column(INTEGER, ForeignKey("_kits.id", ondelete='SET NULL', name="fk_kit_type_id")) kit_id = Column(INTEGER, ForeignKey("_kits.id", ondelete='SET NULL', name="fk_kit_type_id")) #: id of joined kit
client = relationship("Organization") #: joined client lab client = relationship("Organization") #: joined client lab
client_id = Column(INTEGER, ForeignKey("_organizations.id", ondelete='SET NULL', name="fk_org_id")) client_id = Column(INTEGER, ForeignKey("_organizations.id", ondelete='SET NULL', name="fk_org_id")) #: id of joined client
name = Column(String(128)) name = Column(String(128)) #: Short description
amount = Column(FLOAT(2)) amount = Column(FLOAT(2)) #: Dollar amount of discount
def __repr__(self) -> str: def __repr__(self) -> str:
return f"<Discount({self.name})>" return f"<Discount({self.name})>"
@@ -525,7 +522,6 @@ class Discount(Base):
Lookup discount objects (union of kit and organization) Lookup discount objects (union of kit and organization)
Args: Args:
ctx (Settings): Settings object passed down from the gui.
organization (models.Organization | str | int): Organization receiving discount. organization (models.Organization | str | int): Organization receiving discount.
kit_type (models.KitType | str | int): Kit discount received on. kit_type (models.KitType | str | int): Kit discount received on.
@@ -536,60 +532,68 @@ class Discount(Base):
Returns: Returns:
models.Discount|List[models.Discount]: Discount(s) of interest. models.Discount|List[models.Discount]: Discount(s) of interest.
""" """
query: Query = cls.metadata.session.query(cls) query: Query = cls.__database_session__.query(cls)
match organization: match organization:
case Organization(): case Organization():
logger.debug(f"Looking up discount with organization: {organization}") # logger.debug(f"Looking up discount with organization: {organization}")
query = query.filter(cls.client==Organization) query = query.filter(cls.client==Organization)
case str(): case str():
logger.debug(f"Looking up discount with organization: {organization}") # logger.debug(f"Looking up discount with organization: {organization}")
query = query.join(Organization).filter(Organization.name==organization) query = query.join(Organization).filter(Organization.name==organization)
case int(): case int():
logger.debug(f"Looking up discount with organization id: {organization}") # logger.debug(f"Looking up discount with organization id: {organization}")
query = query.join(Organization).filter(Organization.id==organization) query = query.join(Organization).filter(Organization.id==organization)
case _: case _:
# raise ValueError(f"Invalid value for organization: {organization}") # raise ValueError(f"Invalid value for organization: {organization}")
pass pass
match kit_type: match kit_type:
case KitType(): case KitType():
logger.debug(f"Looking up discount with kit type: {kit_type}") # logger.debug(f"Looking up discount with kit type: {kit_type}")
query = query.filter(cls.kit==kit_type) query = query.filter(cls.kit==kit_type)
case str(): case str():
logger.debug(f"Looking up discount with kit type: {kit_type}") # logger.debug(f"Looking up discount with kit type: {kit_type}")
query = query.join(KitType).filter(KitType.name==kit_type) query = query.join(KitType).filter(KitType.name==kit_type)
case int(): case int():
logger.debug(f"Looking up discount with kit type id: {organization}") # logger.debug(f"Looking up discount with kit type id: {organization}")
query = query.join(KitType).filter(KitType.id==kit_type) query = query.join(KitType).filter(KitType.id==kit_type)
case _: case _:
# raise ValueError(f"Invalid value for kit type: {kit_type}") # raise ValueError(f"Invalid value for kit type: {kit_type}")
pass pass
return query.all() return query.all()
class SubmissionType(Base): class SubmissionType(BaseClass):
""" """
Abstract of types of submissions. Abstract of types of submissions.
""" """
__tablename__ = "_submission_types" __tablename__ = "_submission_types"
__table_args__ = {'extend_existing': True}
id = Column(INTEGER, primary_key=True) #: primary key id = Column(INTEGER, primary_key=True) #: primary key
name = Column(String(128), unique=True) #: name of submission type 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. info_map = Column(JSON) #: Where basic information is found in the excel workbook corresponding to this type.
instances = relationship("BasicSubmission", backref="submission_type") instances = relationship("BasicSubmission", backref="submission_type") #: Concrete instances of this type.
# regex = Column(String(512)) # regex = Column(String(512))
# template_file = Column(BLOB) template_file = Column(BLOB) #: Blank form for this type stored as binary.
submissiontype_kit_associations = relationship( submissiontype_kit_associations = relationship(
"SubmissionTypeKitTypeAssociation", "SubmissionTypeKitTypeAssociation",
back_populates="submission_type", back_populates="submission_type",
cascade="all, delete-orphan", cascade="all, delete-orphan",
) ) #: Association of kittypes
kit_types = association_proxy("submissiontype_kit_associations", "kit_type") kit_types = association_proxy("submissiontype_kit_associations", "kit_type") #: Proxy of kittype association
def __repr__(self) -> str: def __repr__(self) -> str:
return f"<SubmissionType({self.name})>" return f"<SubmissionType({self.name})>"
def get_template_file_sheets(self) -> List[str]:
"""
Gets names of sheet in the stored blank form.
Returns:
List[str]: List of sheet names
"""
return ExcelFile(self.template_file).sheet_names
@classmethod @classmethod
@setup_lookup @setup_lookup
def query(cls, def query(cls,
@@ -603,15 +607,16 @@ class SubmissionType(Base):
Args: Args:
ctx (Settings): Settings object passed down from gui ctx (Settings): Settings object passed down from gui
name (str | None, optional): Name of submission type. Defaults to None. 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. limit (int, optional): Maximum number of results to return (0 = all). Defaults to 0.
Returns: Returns:
models.SubmissionType|List[models.SubmissionType]: SubmissionType(s) of interest. models.SubmissionType|List[models.SubmissionType]: SubmissionType(s) of interest.
""" """
query: Query = cls.metadata.session.query(cls) query: Query = cls.__database_session__.query(cls)
match name: match name:
case str(): case str():
logger.debug(f"Looking up submission type by name: {name}") # logger.debug(f"Looking up submission type by name: {name}")
query = query.filter(cls.name==name) query = query.filter(cls.name==name)
limit = 1 limit = 1
case _: case _:
@@ -624,27 +629,28 @@ class SubmissionType(Base):
return query_return(query=query, limit=limit) return query_return(query=query, limit=limit)
def save(self): def save(self):
self.metadata.session.add(self) """
self.metadata.session.commit() Adds this instances to the database and commits.
return None """
self.__database_session__.add(self)
self.__database_session__.commit()
class SubmissionTypeKitTypeAssociation(Base): class SubmissionTypeKitTypeAssociation(BaseClass):
""" """
Abstract of relationship between kits and their submission type. Abstract of relationship between kits and their submission type.
""" """
__tablename__ = "_submissiontypes_kittypes" __tablename__ = "_submissiontypes_kittypes"
__table_args__ = {'extend_existing': True}
submission_types_id = Column(INTEGER, ForeignKey("_submission_types.id"), primary_key=True) submission_types_id = Column(INTEGER, ForeignKey("_submission_types.id"), primary_key=True) #: id of joined submission type
kits_id = Column(INTEGER, ForeignKey("_kits.id"), primary_key=True) kits_id = Column(INTEGER, ForeignKey("_kits.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) mutable_cost_column = Column(FLOAT(2)) #: dollar amount per 96 well plate that can change with number of columns (reagents, tips, etc)
mutable_cost_sample = Column(FLOAT(2)) #: dollar amount that can change with number of samples (reagents, tips, etc) mutable_cost_sample = Column(FLOAT(2)) #: dollar amount that can change with number of samples (reagents, tips, etc)
constant_cost = Column(FLOAT(2)) #: dollar amount per plate that will remain constant (plates, man hours, etc) constant_cost = Column(FLOAT(2)) #: dollar amount per plate that will remain constant (plates, man hours, etc)
kit_type = relationship(KitType, back_populates="kit_submissiontype_associations") kit_type = relationship(KitType, back_populates="kit_submissiontype_associations") #: joined kittype
# reference to the "SubmissionType" object # reference to the "SubmissionType" object
submission_type = relationship(SubmissionType, back_populates="submissiontype_kit_associations") submission_type = relationship(SubmissionType, back_populates="submissiontype_kit_associations") #: joined submission type
def __init__(self, kit_type=None, submission_type=None): def __init__(self, kit_type=None, submission_type=None):
self.kit_type = kit_type self.kit_type = kit_type
@@ -665,28 +671,38 @@ class SubmissionTypeKitTypeAssociation(Base):
submission_type:SubmissionType|str|int|None=None, submission_type:SubmissionType|str|int|None=None,
kit_type:KitType|str|int|None=None, kit_type:KitType|str|int|None=None,
limit:int=0 limit:int=0
): ) -> SubmissionTypeKitTypeAssociation|List[SubmissionTypeKitTypeAssociation]:
query: Query = cls.metadata.session.query(cls) """
Lookup SubmissionTypeKitTypeAssociations of interest.
Args:
submission_type (SubmissionType | str | int | None, optional): Identifier of submission type. Defaults to None.
kit_type (KitType | str | int | None, optional): Identifier of kit type. Defaults to None.
limit (int, optional): Maximum number of results to return (0 = all). Defaults to 0.
Returns:
SubmissionTypeKitTypeAssociation|List[SubmissionTypeKitTypeAssociation]: SubmissionTypeKitTypeAssociation(s) of interest
"""
query: Query = cls.__database_session__.query(cls)
match submission_type: match submission_type:
case SubmissionType(): case SubmissionType():
logger.debug(f"Looking up {cls.__name__} by SubmissionType {submission_type}") # logger.debug(f"Looking up {cls.__name__} by SubmissionType {submission_type}")
query = query.filter(cls.submission_type==submission_type) query = query.filter(cls.submission_type==submission_type)
case str(): case str():
logger.debug(f"Looking up {cls.__name__} by name {submission_type}") # logger.debug(f"Looking up {cls.__name__} by name {submission_type}")
query = query.join(SubmissionType).filter(SubmissionType.name==submission_type) query = query.join(SubmissionType).filter(SubmissionType.name==submission_type)
case int(): case int():
logger.debug(f"Looking up {cls.__name__} by id {submission_type}") # logger.debug(f"Looking up {cls.__name__} by id {submission_type}")
query = query.join(SubmissionType).filter(SubmissionType.id==submission_type) query = query.join(SubmissionType).filter(SubmissionType.id==submission_type)
match kit_type: match kit_type:
case KitType(): case KitType():
logger.debug(f"Looking up {cls.__name__} by KitType {kit_type}") # logger.debug(f"Looking up {cls.__name__} by KitType {kit_type}")
query = query.filter(cls.kit_type==kit_type) query = query.filter(cls.kit_type==kit_type)
case str(): case str():
logger.debug(f"Looking up {cls.__name__} by name {kit_type}") # logger.debug(f"Looking up {cls.__name__} by name {kit_type}")
query = query.join(KitType).filter(KitType.name==kit_type) query = query.join(KitType).filter(KitType.name==kit_type)
case int(): case int():
logger.debug(f"Looking up {cls.__name__} by id {kit_type}") # logger.debug(f"Looking up {cls.__name__} by id {kit_type}")
query = query.join(KitType).filter(KitType.id==kit_type) query = query.join(KitType).filter(KitType.id==kit_type)
limit = query.count() limit = query.count()
return query_return(query=query, limit=limit) return query_return(query=query, limit=limit)

View File

@@ -4,8 +4,8 @@ All client organization related models.
from __future__ import annotations from __future__ import annotations
from sqlalchemy import Column, String, INTEGER, ForeignKey, Table from sqlalchemy import Column, String, INTEGER, ForeignKey, Table
from sqlalchemy.orm import relationship, Query from sqlalchemy.orm import relationship, Query
from . import Base from . import Base, BaseClass
from tools import check_authorization, setup_lookup, query_return from tools import check_authorization, setup_lookup, query_return, Settings
from typing import List from typing import List
import logging import logging
@@ -21,12 +21,11 @@ orgs_contacts = Table(
extend_existing = True extend_existing = True
) )
class Organization(Base): class Organization(BaseClass):
""" """
Base of organization Base of organization
""" """
__tablename__ = "_organizations" __tablename__ = "_organizations"
__table_args__ = {'extend_existing': True}
id = Column(INTEGER, primary_key=True) #: primary key id = Column(INTEGER, primary_key=True) #: primary key
name = Column(String(64)) #: organization name name = Column(String(64)) #: organization name
@@ -34,23 +33,9 @@ class Organization(Base):
cost_centre = Column(String()) #: cost centre used by org for payment cost_centre = Column(String()) #: cost centre used by org for payment
contacts = relationship("Contact", back_populates="organization", secondary=orgs_contacts) #: contacts involved with this org contacts = relationship("Contact", back_populates="organization", secondary=orgs_contacts) #: contacts involved with this org
def __str__(self) -> str:
"""
String representing organization
Returns:
str: string representing organization name
"""
return self.name.replace("_", " ").title()
def __repr__(self) -> str: def __repr__(self) -> str:
return f"<Organization({self.name})>" return f"<Organization({self.name})>"
@check_authorization
def save(self, ctx):
ctx.database_session.add(self)
ctx.database_session.commit()
def set_attribute(self, name:str, value): def set_attribute(self, name:str, value):
setattr(self, name, value) setattr(self, name, value)
@@ -68,24 +53,34 @@ class Organization(Base):
limit (int, optional): Maximum number of results to return (0 = all). Defaults to 0. limit (int, optional): Maximum number of results to return (0 = all). Defaults to 0.
Returns: Returns:
Organization|List[Organization]: _description_ Organization|List[Organization]:
""" """
query: Query = cls.metadata.session.query(cls) query: Query = cls.__database_session__.query(cls)
match name: match name:
case str(): case str():
logger.debug(f"Looking up organization with name: {name}") # logger.debug(f"Looking up organization with name: {name}")
query = query.filter(cls.name==name) query = query.filter(cls.name==name)
limit = 1 limit = 1
case _: case _:
pass pass
return query_return(query=query, limit=limit) return query_return(query=query, limit=limit)
class Contact(Base): @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
"""
ctx.database_session.add(self)
ctx.database_session.commit()
class Contact(BaseClass):
""" """
Base of Contact Base of Contact
""" """
__tablename__ = "_contacts" __tablename__ = "_contacts"
__table_args__ = {'extend_existing': True}
id = Column(INTEGER, primary_key=True) #: primary key id = Column(INTEGER, primary_key=True) #: primary key
name = Column(String(64)) #: contact name name = Column(String(64)) #: contact name
@@ -109,29 +104,32 @@ class Contact(Base):
Args: Args:
name (str | None, optional): Name of the contact. Defaults to None. name (str | None, optional): Name of the contact. Defaults to None.
email (str | None, optional): Email of the contact. Defaults to None.
phone (str | None, optional): Phone number of the contact. Defaults to None.
limit (int, optional): Maximum number of results to return (0 = all). Defaults to 0. limit (int, optional): Maximum number of results to return (0 = all). Defaults to 0.
Returns: Returns:
Contact|List[Contact]: _description_ Contact|List[Contact]: Contact(s) of interest.
""" """
query: Query = cls.metadata.session.query(cls) # super().query(session)
query: Query = cls.__database_session__.query(cls)
match name: match name:
case str(): case str():
logger.debug(f"Looking up contact with name: {name}") # logger.debug(f"Looking up contact with name: {name}")
query = query.filter(cls.name==name) query = query.filter(cls.name==name)
limit = 1 limit = 1
case _: case _:
pass pass
match email: match email:
case str(): case str():
logger.debug(f"Looking up contact with email: {name}") # logger.debug(f"Looking up contact with email: {name}")
query = query.filter(cls.email==email) query = query.filter(cls.email==email)
limit = 1 limit = 1
case _: case _:
pass pass
match phone: match phone:
case str(): case str():
logger.debug(f"Looking up contact with phone: {name}") # logger.debug(f"Looking up contact with phone: {name}")
query = query.filter(cls.phone==phone) query = query.filter(cls.phone==phone)
limit = 1 limit = 1
case _: case _:

File diff suppressed because it is too large Load Diff

View File

@@ -16,7 +16,6 @@ from datetime import date
from dateutil.parser import parse, ParserError from dateutil.parser import parse, ParserError
from tools import check_not_nan, convert_nans_to_nones, Settings from tools import check_not_nan, convert_nans_to_nones, Settings
logger = logging.getLogger(f"submissions.{__name__}") 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 = dict(A=1, B=2, C=3, D=4, E=5, F=6, G=7, H=8)
@@ -28,7 +27,7 @@ class SheetParser(object):
def __init__(self, ctx:Settings, filepath:Path|None = None): def __init__(self, ctx:Settings, filepath:Path|None = None):
""" """
Args: Args:
ctx (Settings): Settings object passed down from gui 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. filepath (Path | None, optional): file path to excel sheet. Defaults to None.
""" """
self.ctx = ctx self.ctx = ctx
@@ -56,6 +55,7 @@ class SheetParser(object):
self.import_reagent_validation_check() self.import_reagent_validation_check()
self.parse_samples() self.parse_samples()
self.finalize_parse() self.finalize_parse()
logger.debug(f"Parser.sub after info scrape: {pformat(self.sub)}")
def parse_info(self): def parse_info(self):
""" """
@@ -70,15 +70,17 @@ class SheetParser(object):
pass pass
case _: case _:
self.sub[k] = v self.sub[k] = v
logger.debug(f"Parser.sub after info scrape: {pformat(self.sub)}")
def parse_reagents(self, extraction_kit:str|None=None): def parse_reagents(self, extraction_kit:str|None=None):
""" """
Pulls reagent info from the excel sheet Pulls reagent info from the excel sheet
Args:
extraction_kit (str | None, optional): Relevant extraction kit for reagent map. Defaults to None.
""" """
if extraction_kit == None: if extraction_kit == None:
extraction_kit = extraction_kit=self.sub['extraction_kit'] extraction_kit = extraction_kit=self.sub['extraction_kit']
logger.debug(f"Parsing reagents for {extraction_kit}") # logger.debug(f"Parsing reagents for {extraction_kit}")
self.sub['reagents'] = ReagentParser(xl=self.xl, submission_type=self.sub['submission_type'], extraction_kit=extraction_kit).parse_reagents() self.sub['reagents'] = ReagentParser(xl=self.xl, submission_type=self.sub['submission_type'], extraction_kit=extraction_kit).parse_reagents()
def parse_samples(self): def parse_samples(self):
@@ -92,13 +94,6 @@ class SheetParser(object):
def import_kit_validation_check(self): def import_kit_validation_check(self):
""" """
Enforce that the parser has an extraction kit Enforce that the parser has an extraction kit
Args:
ctx (Settings): Settings obj passed down from gui
parser_sub (dict): The parser dictionary before going to pydantic
Returns:
List[PydReagent]: List of reagents
""" """
from frontend.widgets.pop_ups import KitSelector from frontend.widgets.pop_ups import KitSelector
if not check_not_nan(self.sub['extraction_kit']['value']): if not check_not_nan(self.sub['extraction_kit']['value']):
@@ -115,18 +110,18 @@ class SheetParser(object):
""" """
Enforce that only allowed reagents get into the Pydantic Model Enforce that only allowed reagents get into the Pydantic Model
""" """
# kit = lookup_kit_types(ctx=self.ctx, name=self.sub['extraction_kit']['value'])
kit = KitType.query(name=self.sub['extraction_kit']['value']) kit = KitType.query(name=self.sub['extraction_kit']['value'])
allowed_reagents = [item.name for item in kit.get_reagents()] allowed_reagents = [item.name for item in kit.get_reagents()]
logger.debug(f"List of reagents for comparison with allowed_reagents: {pformat(self.sub['reagents'])}") # logger.debug(f"List of reagents for comparison with allowed_reagents: {pformat(self.sub['reagents'])}")
# self.sub['reagents'] = [reagent for reagent in self.sub['reagents'] if reagent['value'].type in allowed_reagents]
self.sub['reagents'] = [reagent for reagent in self.sub['reagents'] if reagent.type in allowed_reagents] self.sub['reagents'] = [reagent for reagent in self.sub['reagents'] if reagent.type in allowed_reagents]
def finalize_parse(self): def finalize_parse(self):
"""
Run custom final validations of data for submission subclasses.
"""
finisher = BasicSubmission.find_polymorphic_subclass(polymorphic_identity=self.sub['submission_type']).finalize_parse finisher = BasicSubmission.find_polymorphic_subclass(polymorphic_identity=self.sub['submission_type']).finalize_parse
self.sub = finisher(input_dict=self.sub, xl=self.xl, info_map=self.info_map, plate_map=self.plate_map) self.sub = finisher(input_dict=self.sub, xl=self.xl, info_map=self.info_map, plate_map=self.plate_map)
def to_pydantic(self) -> PydSubmission: def to_pydantic(self) -> PydSubmission:
""" """
Generates a pydantic model of scraped data for validation Generates a pydantic model of scraped data for validation
@@ -134,21 +129,19 @@ class SheetParser(object):
Returns: Returns:
PydSubmission: output pydantic model PydSubmission: output pydantic model
""" """
logger.debug(f"Submission dictionary coming into 'to_pydantic':\n{pformat(self.sub)}") # logger.debug(f"Submission dictionary coming into 'to_pydantic':\n{pformat(self.sub)}")
psm = PydSubmission(filepath=self.filepath, **self.sub) psm = PydSubmission(filepath=self.filepath, **self.sub)
# delattr(psm, "filepath")
return psm return psm
class InfoParser(object): class InfoParser(object):
def __init__(self, xl:pd.ExcelFile, submission_type:str): def __init__(self, xl:pd.ExcelFile, submission_type:str):
logger.debug(f"\n\nHello from InfoParser!") logger.info(f"\n\Hello from InfoParser!\n\n")
# self.ctx = ctx # self.ctx = ctx
self.map = self.fetch_submission_info_map(submission_type=submission_type) self.map = self.fetch_submission_info_map(submission_type=submission_type)
self.xl = xl self.xl = xl
logger.debug(f"Info map for InfoParser: {pformat(self.map)}") logger.debug(f"Info map for InfoParser: {pformat(self.map)}")
def fetch_submission_info_map(self, submission_type:str|dict) -> dict: def fetch_submission_info_map(self, submission_type:str|dict) -> dict:
""" """
Gets location of basic info from the submission_type object in the database. Gets location of basic info from the submission_type object in the database.
@@ -192,6 +185,11 @@ class InfoParser(object):
continue continue
for item in relevant: for item in relevant:
value = df.iat[relevant[item]['row']-1, relevant[item]['column']-1] value = df.iat[relevant[item]['row']-1, relevant[item]['column']-1]
match item:
case "submission_type":
value = value.title()
case _:
pass
logger.debug(f"Setting {item} on {sheet} to {value}") logger.debug(f"Setting {item} on {sheet} to {value}")
if check_not_nan(value): if check_not_nan(value):
if value != "None": if value != "None":
@@ -206,10 +204,6 @@ class InfoParser(object):
continue continue
else: else:
dicto[item] = dict(value=convert_nans_to_nones(value), missing=True) dicto[item] = dict(value=convert_nans_to_nones(value), missing=True)
try:
check = dicto['submission_category'] not in ["", None]
except KeyError:
check = False
return self.custom_parser(input_dict=dicto, xl=self.xl) return self.custom_parser(input_dict=dicto, xl=self.xl)
class ReagentParser(object): class ReagentParser(object):
@@ -220,7 +214,17 @@ class ReagentParser(object):
self.map = self.fetch_kit_info_map(extraction_kit=extraction_kit, submission_type=submission_type) self.map = self.fetch_kit_info_map(extraction_kit=extraction_kit, submission_type=submission_type)
self.xl = xl self.xl = xl
def fetch_kit_info_map(self, extraction_kit:dict, submission_type:str): def fetch_kit_info_map(self, extraction_kit:dict, submission_type:str) -> dict:
"""
Gets location of kit reagents from database
Args:
extraction_kit (dict): Relevant kit information.
submission_type (str): Name of submission type.
Returns:
dict: locations of reagent info for the kit.
"""
if isinstance(extraction_kit, dict): if isinstance(extraction_kit, dict):
extraction_kit = extraction_kit['value'] extraction_kit = extraction_kit['value']
# kit = lookup_kit_types(ctx=self.ctx, name=extraction_kit) # kit = lookup_kit_types(ctx=self.ctx, name=extraction_kit)
@@ -231,7 +235,13 @@ class ReagentParser(object):
del reagent_map['info'] del reagent_map['info']
return reagent_map return reagent_map
def parse_reagents(self) -> list: def parse_reagents(self) -> List[PydReagent]:
"""
Extracts reagent information from the excel form.
Returns:
List[PydReagent]: List of parsed reagents.
"""
listo = [] listo = []
for sheet in self.xl.sheet_names: for sheet in self.xl.sheet_names:
df = self.xl.parse(sheet, header=None, dtype=object) df = self.xl.parse(sheet, header=None, dtype=object)
@@ -271,11 +281,10 @@ class SampleParser(object):
convert sample sub-dataframe to dictionary of records convert sample sub-dataframe to dictionary of records
Args: Args:
ctx (Settings): settings object passed down from gui
df (pd.DataFrame): input sample dataframe df (pd.DataFrame): input sample dataframe
elution_map (pd.DataFrame | None, optional): optional map of elution plate. Defaults to None. elution_map (pd.DataFrame | None, optional): optional map of elution plate. Defaults to None.
""" """
logger.debug("\n\nHello from SampleParser!") logger.debug("\n\nHello from SampleParser!\n\n")
self.samples = [] self.samples = []
# self.ctx = ctx # self.ctx = ctx
self.xl = xl self.xl = xl
@@ -454,40 +463,6 @@ class SampleParser(object):
new_samples.append(PydSample(**translated_dict)) new_samples.append(PydSample(**translated_dict))
return result, new_samples return result, new_samples
# def generate_sample_object(self, input_dict) -> BasicSample:
# """
# Constructs sample object from dict.
# NOTE: Depreciated due to using Pydantic object up until db saving.
# Args:
# input_dict (dict): sample information
# Returns:
# models.BasicSample: Sample object
# """
# database_obj = BasicSample.find_polymorphic_subclass(polymorphic_identity=input_dict['sample_type'])
# # query = input_dict['sample_type'].replace(" ", "")
# # try:
# # # database_obj = getattr(models, query)
# # except AttributeError as e:
# # logger.error(f"Could not find the model {query}. Using generic.")
# # database_obj = models.BasicSample
# logger.debug(f"Searching database for {input_dict['submitter_id']}...")
# # instance = lookup_samples(ctx=self.ctx, submitter_id=str(input_dict['submitter_id']))
# instance = BasicSample.query(submitter_id=str(input_dict['submitter_id']))
# if instance == None:
# logger.debug(f"Couldn't find sample {input_dict['submitter_id']}. Creating new sample.")
# instance = database_obj()
# for k,v in input_dict.items():
# try:
# instance.set_attribute(k, v)
# except Exception as e:
# logger.error(f"Failed to set {k} due to {type(e).__name__}: {e}")
# else:
# logger.debug(f"Sample {instance.submitter_id} already exists, will run update.")
# return dict(sample=instance, row=input_dict['row'], column=input_dict['column'])
def grab_plates(self) -> List[str]: def grab_plates(self) -> List[str]:
""" """
Parse plate names from Parse plate names from
@@ -514,7 +489,6 @@ class PCRParser(object):
Initializes object. Initializes object.
Args: Args:
ctx (dict): settings passed down from gui.
filepath (Path | None, optional): file to parse. Defaults to None. filepath (Path | None, optional): file to parse. Defaults to None.
""" """
# self.ctx = ctx # self.ctx = ctx

View File

@@ -5,7 +5,7 @@ from pandas import DataFrame
import logging import logging
from datetime import date, timedelta from datetime import date, timedelta
import re import re
from typing import Tuple from typing import List, Tuple
from tools import jinja_template_loading, Settings from tools import jinja_template_loading, Settings
logger = logging.getLogger(f"submissions.{__name__}") logger = logging.getLogger(f"submissions.{__name__}")
@@ -27,7 +27,7 @@ def make_report_xlsx(records:list[dict]) -> Tuple[DataFrame, DataFrame]:
df = df.sort_values("Submitting Lab") df = df.sort_values("Submitting Lab")
# aggregate cost and sample count columns # aggregate cost and sample count columns
df2 = df.groupby(["Submitting Lab", "Extraction Kit"]).agg({'Extraction Kit':'count', 'Cost': 'sum', 'Sample Count':'sum'}) df2 = df.groupby(["Submitting Lab", "Extraction Kit"]).agg({'Extraction Kit':'count', 'Cost': 'sum', 'Sample Count':'sum'})
df2 = df2.rename(columns={"Extraction Kit": 'Plate Count'}) df2 = df2.rename(columns={"Extraction Kit": 'Run Count'})
logger.debug(f"Output daftaframe for xlsx: {df2.columns}") logger.debug(f"Output daftaframe for xlsx: {df2.columns}")
df = df.drop('id', axis=1) df = df.drop('id', axis=1)
df = df.sort_values(['Submitting Lab', "Submitted Date"]) df = df.sort_values(['Submitting Lab', "Submitted Date"])
@@ -57,16 +57,16 @@ def make_report_html(df:DataFrame, start_date:date, end_date:date) -> str:
logger.debug(f"Old lab: {old_lab}, Current lab: {lab}") logger.debug(f"Old lab: {old_lab}, Current lab: {lab}")
logger.debug(f"Name: {row[0][1]}") logger.debug(f"Name: {row[0][1]}")
data = [item for item in row[1]] data = [item for item in row[1]]
kit = dict(name=row[0][1], cost=data[1], plate_count=int(data[0]), sample_count=int(data[2])) kit = dict(name=row[0][1], cost=data[1], run_count=int(data[0]), sample_count=int(data[2]))
# if this is the same lab as before add together # if this is the same lab as before add together
if lab == old_lab: if lab == old_lab:
output[-1]['kits'].append(kit) output[-1]['kits'].append(kit)
output[-1]['total_cost'] += kit['cost'] output[-1]['total_cost'] += kit['cost']
output[-1]['total_samples'] += kit['sample_count'] output[-1]['total_samples'] += kit['sample_count']
output[-1]['total_plates'] += kit['plate_count'] output[-1]['total_runs'] += kit['run_count']
# if not the same lab, make a new one # if not the same lab, make a new one
else: else:
adder = dict(lab=lab, kits=[kit], total_cost=kit['cost'], total_samples=kit['sample_count'], total_plates=kit['plate_count']) adder = dict(lab=lab, kits=[kit], total_cost=kit['cost'], total_samples=kit['sample_count'], total_runs=kit['run_count'])
output.append(adder) output.append(adder)
old_lab = lab old_lab = lab
logger.debug(output) logger.debug(output)
@@ -83,10 +83,10 @@ def convert_data_list_to_df(input:list[dict], subtype:str|None=None) -> DataFram
Args: Args:
ctx (dict): settings passed from gui ctx (dict): settings passed from gui
input (list[dict]): list of dictionaries containing records input (list[dict]): list of dictionaries containing records
subtype (str | None, optional): _description_. Defaults to None. subtype (str | None, optional): name of submission type. Defaults to None.
Returns: Returns:
DataFrame: _description_ DataFrame: dataframe of controls
""" """
df = DataFrame.from_records(input) df = DataFrame.from_records(input)
@@ -218,5 +218,14 @@ def drop_reruns_from_df(ctx:Settings, df: DataFrame) -> DataFrame:
df = df.drop(df[df.name == first_run].index) df = df.drop(df[df.name == first_run].index)
return df return df
def make_hitpicks(input:list) -> DataFrame: def make_hitpicks(input:List[dict]) -> DataFrame:
"""
Converts lsit of dictionaries constructed by hitpicking to dataframe
Args:
input (List[dict]): list of hitpicked dictionaries
Returns:
DataFrame: constructed dataframe.
"""
return DataFrame.from_records(input) return DataFrame.from_records(input)

View File

@@ -12,7 +12,6 @@ class RSLNamer(object):
""" """
def __init__(self, instr:str, sub_type:str|None=None, data:dict|None=None): def __init__(self, instr:str, sub_type:str|None=None, data:dict|None=None):
self.submission_type = sub_type self.submission_type = sub_type
if self.submission_type == None: if self.submission_type == None:
self.submission_type = self.retrieve_submission_type(instr=instr) self.submission_type = self.retrieve_submission_type(instr=instr)
logger.debug(f"got submission type: {self.submission_type}") logger.debug(f"got submission type: {self.submission_type}")
@@ -23,6 +22,15 @@ class RSLNamer(object):
@classmethod @classmethod
def retrieve_submission_type(cls, instr:str|Path) -> str: def retrieve_submission_type(cls, instr: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
Returns:
str: parsed submission type
"""
match instr: match instr:
case Path(): case Path():
logger.debug(f"Using path method for {instr}.") logger.debug(f"Using path method for {instr}.")
@@ -32,7 +40,8 @@ class RSLNamer(object):
submission_type = [item.strip().title() for item in wb.properties.category.split(";")][0] submission_type = [item.strip().title() for item in wb.properties.category.split(";")][0]
except AttributeError: except AttributeError:
try: try:
sts = {item.name:item.info_map['all_sheets'] for item in SubmissionType.query(key="all_sheets")} # 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(): for k,v in sts.items():
# This gets the *first* submission type that matches the sheet names in the workbook # This gets the *first* submission type that matches the sheet names in the workbook
if wb.sheetnames == v: if wb.sheetnames == v:

View File

@@ -1,22 +1,24 @@
''' '''
Contains pydantic models and accompanying validators Contains pydantic models and accompanying validators
''' '''
from operator import attrgetter
import uuid import uuid
from pydantic import BaseModel, field_validator, Field from pydantic import BaseModel, field_validator, Field
from datetime import date, datetime, timedelta from datetime import date, datetime, timedelta
from dateutil.parser import parse from dateutil.parser import parse
from dateutil.parser._parser import ParserError from dateutil.parser._parser import ParserError
from typing import List, Any, Tuple, Literal from typing import List, Any, Tuple
from . import RSLNamer from . import RSLNamer
from pathlib import Path from pathlib import Path
import re import re
import logging import logging
from tools import check_not_nan, convert_nans_to_nones, jinja_template_loading, Report, Result from tools import check_not_nan, convert_nans_to_nones, jinja_template_loading, Report, Result, row_map
from backend.db.models import * from backend.db.models import *
from sqlalchemy.exc import StatementError, IntegrityError from sqlalchemy.exc import StatementError, IntegrityError
from PyQt6.QtWidgets import QComboBox, QWidget from PyQt6.QtWidgets import QComboBox, QWidget
from pprint import pformat # from pprint import pformat
from openpyxl import load_workbook from openpyxl import load_workbook, Workbook
from io import BytesIO
logger = logging.getLogger(f"submissions.{__name__}") logger = logging.getLogger(f"submissions.{__name__}")
@@ -87,9 +89,14 @@ class PydReagent(BaseModel):
return values.data['type'] return values.data['type']
def toSQL(self) -> Tuple[Reagent, Report]: def toSQL(self) -> Tuple[Reagent, Report]:
"""
Converts this instance into a backend.db.models.kit.Reagent instance
Returns:
Tuple[Reagent, Report]: Reagent instance and result of function
"""
report = Report() report = Report()
logger.debug(f"Reagent SQL constructor is looking up type: {self.type}, lot: {self.lot}") logger.debug(f"Reagent SQL constructor is looking up type: {self.type}, lot: {self.lot}")
# reagent = lookup_reagents(ctx=self.ctx, lot_number=self.lot)
reagent = Reagent.query(lot_number=self.lot) reagent = Reagent.query(lot_number=self.lot)
logger.debug(f"Result: {reagent}") logger.debug(f"Result: {reagent}")
if reagent == None: if reagent == None:
@@ -105,7 +112,6 @@ class PydReagent(BaseModel):
case "expiry": case "expiry":
reagent.expiry = value reagent.expiry = value
case "type": case "type":
# reagent_type = lookup_reagent_types(ctx=self.ctx, name=value)
reagent_type = ReagentType.query(name=value) reagent_type = ReagentType.query(name=value)
if reagent_type != None: if reagent_type != None:
reagent.type.append(reagent_type) reagent.type.append(reagent_type)
@@ -116,6 +122,16 @@ class PydReagent(BaseModel):
return reagent, report return reagent, report
def toForm(self, parent:QWidget, extraction_kit:str) -> QComboBox: def toForm(self, parent:QWidget, extraction_kit:str) -> QComboBox:
"""
Converts this instance into a form widget
Args:
parent (QWidget): Parent widget of the constructed object
extraction_kit (str): Name of extraction kit used
Returns:
QComboBox: Form object.
"""
from frontend.widgets.submission_widget import ReagentFormWidget from frontend.widgets.submission_widget import ReagentFormWidget
return ReagentFormWidget(parent=parent, reagent=self, extraction_kit=extraction_kit) return ReagentFormWidget(parent=parent, reagent=self, extraction_kit=extraction_kit)
@@ -138,16 +154,19 @@ class PydSample(BaseModel, extra='allow'):
def int_to_str(cls, value): def int_to_str(cls, value):
return str(value) return str(value)
def toSQL(self, submission=None): def toSQL(self, submission:BasicSubmission|str=None) -> Tuple[BasicSample, Result]:
result = None """
Converts this instance into a backend.db.models.submissions.Sample object
Args:
submission (BasicSubmission | str, optional): Submission joined to this sample. Defaults to None.
Returns:
Tuple[BasicSample, Result]: Sample object and result object.
"""
report = None
self.__dict__.update(self.model_extra) self.__dict__.update(self.model_extra)
logger.debug(f"Here is the incoming sample dict: \n{self.__dict__}") logger.debug(f"Here is the incoming sample dict: \n{self.__dict__}")
# instance = lookup_samples(ctx=ctx, submitter_id=self.submitter_id)
# instance = BasicSample.query(submitter_id=self.submitter_id)
# if instance == None:
# logger.debug(f"Sample {self.submitter_id} doesn't exist yet. Looking up sample object with polymorphic identity: {self.sample_type}")
# instance = BasicSample.find_polymorphic_subclass(polymorphic_identity=self.sample_type)()
# instance = BasicSample.query_or_create(**{k:v for k,v in self.__dict__.items() if k not in ['row', 'column']})
instance = BasicSample.query_or_create(sample_type=self.sample_type, submitter_id=self.submitter_id) instance = BasicSample.query_or_create(sample_type=self.sample_type, submitter_id=self.submitter_id)
for key, value in self.__dict__.items(): for key, value in self.__dict__.items():
# logger.debug(f"Setting sample field {key} to {value}") # logger.debug(f"Setting sample field {key} to {value}")
@@ -161,13 +180,6 @@ class PydSample(BaseModel, extra='allow'):
for row, column in zip(self.row, self.column): for row, column in zip(self.row, self.column):
# logger.debug(f"Looking up association with identity: ({submission.submission_type_name} Association)") # logger.debug(f"Looking up association with identity: ({submission.submission_type_name} Association)")
logger.debug(f"Looking up association with identity: ({assoc_type} Association)") logger.debug(f"Looking up association with identity: ({assoc_type} Association)")
# association = lookup_submission_sample_association(ctx=ctx, submission=submission, row=row, column=column)
# association = SubmissionSampleAssociation.query(submission=submission, row=row, column=column)
# logger.debug(f"Returned association: {association}")
# if association == None or association == []:
# logger.debug(f"Looked up association at row {row}, column {column} didn't exist, creating new association.")
# association = SubmissionSampleAssociation.find_polymorphic_subclass(polymorphic_identity=f"{submission.submission_type_name} Association")
# association = association(submission=submission, sample=instance, row=row, column=column)
association = SubmissionSampleAssociation.query_or_create(association_type=f"{assoc_type} Association", association = SubmissionSampleAssociation.query_or_create(association_type=f"{assoc_type} Association",
submission=submission, submission=submission,
sample=instance, sample=instance,
@@ -176,7 +188,7 @@ class PydSample(BaseModel, extra='allow'):
instance.sample_submission_associations.append(association) instance.sample_submission_associations.append(association)
except IntegrityError: except IntegrityError:
instance.metadata.session.rollback() instance.metadata.session.rollback()
return instance, result return instance, report
class PydSubmission(BaseModel, extra='allow'): class PydSubmission(BaseModel, extra='allow'):
filepath: Path filepath: Path
@@ -185,7 +197,7 @@ class PydSubmission(BaseModel, extra='allow'):
submitter_plate_num: dict|None = Field(default=dict(value=None, missing=True), validate_default=True) submitter_plate_num: dict|None = Field(default=dict(value=None, missing=True), validate_default=True)
submitted_date: dict|None submitted_date: dict|None
rsl_plate_num: dict|None = Field(default=dict(value=None, missing=True), validate_default=True) rsl_plate_num: dict|None = Field(default=dict(value=None, missing=True), validate_default=True)
# submitted_date: dict|None submitted_date: dict|None
submitting_lab: dict|None submitting_lab: dict|None
sample_count: dict|None sample_count: dict|None
extraction_kit: dict|None extraction_kit: dict|None
@@ -197,7 +209,7 @@ class PydSubmission(BaseModel, extra='allow'):
@field_validator("submitter_plate_num") @field_validator("submitter_plate_num")
@classmethod @classmethod
def enforce_with_uuid(cls, value): def enforce_with_uuid(cls, value):
logger.debug(f"submitter plate id: {value}") # logger.debug(f"submitter_plate_num coming into pydantic: {value}")
if value['value'] == None or value['value'] == "None": if value['value'] == None or value['value'] == "None":
return dict(value=uuid.uuid4().hex.upper(), missing=True) return dict(value=uuid.uuid4().hex.upper(), missing=True)
else: else:
@@ -250,14 +262,6 @@ class PydSubmission(BaseModel, extra='allow'):
logger.debug(f"RSL-plate initial value: {value['value']} and other values: {values.data}") logger.debug(f"RSL-plate initial value: {value['value']} and other values: {values.data}")
sub_type = values.data['submission_type']['value'] sub_type = values.data['submission_type']['value']
if check_not_nan(value['value']): if check_not_nan(value['value']):
# if lookup_submissions(ctx=values.data['ctx'], rsl_number=value['value']) == None:
# if BasicSubmission.query(rsl_number=value['value']) == None:
# return dict(value=value['value'], missing=False)
# else:
# logger.warning(f"Submission number {value} already exists in DB, attempting salvage with filepath")
# # output = RSLNamer(ctx=values.data['ctx'], instr=values.data['filepath'].__str__(), sub_type=sub_type).parsed_name
# output = RSLNamer(instr=values.data['filepath'].__str__(), sub_type=sub_type).parsed_name
# return dict(value=output, missing=True)
return value return value
else: else:
output = RSLNamer(instr=values.data['filepath'].__str__(), sub_type=sub_type, data=values.data).parsed_name output = RSLNamer(instr=values.data['filepath'].__str__(), sub_type=sub_type, data=values.data).parsed_name
@@ -278,7 +282,6 @@ class PydSubmission(BaseModel, extra='allow'):
return value return value
else: else:
return dict(value=convert_nans_to_nones(value['value']), missing=True) return dict(value=convert_nans_to_nones(value['value']), missing=True)
return value
@field_validator("sample_count", mode='before') @field_validator("sample_count", mode='before')
@classmethod @classmethod
@@ -290,7 +293,6 @@ class PydSubmission(BaseModel, extra='allow'):
@field_validator("extraction_kit", mode='before') @field_validator("extraction_kit", mode='before')
@classmethod @classmethod
def rescue_kit(cls, value): def rescue_kit(cls, value):
if check_not_nan(value): if check_not_nan(value):
if isinstance(value, str): if isinstance(value, str):
return dict(value=value, missing=False) return dict(value=value, missing=False)
@@ -305,6 +307,7 @@ class PydSubmission(BaseModel, extra='allow'):
@field_validator("submission_type", mode='before') @field_validator("submission_type", mode='before')
@classmethod @classmethod
def make_submission_type(cls, value, values): def make_submission_type(cls, value, values):
logger.debug(f"Submission type coming into pydantic: {value}")
if not isinstance(value, dict): if not isinstance(value, dict):
value = {"value": value} value = {"value": value}
if check_not_nan(value['value']): if check_not_nan(value['value']):
@@ -313,6 +316,12 @@ class PydSubmission(BaseModel, extra='allow'):
else: else:
return dict(value=RSLNamer(instr=values.data['filepath'].__str__()).submission_type.title(), missing=True) return dict(value=RSLNamer(instr=values.data['filepath'].__str__()).submission_type.title(), missing=True)
@field_validator("submission_category", mode="before")
def create_category(cls, value):
if not isinstance(value, dict):
return dict(value=value, missing=True)
return value
@field_validator("submission_category") @field_validator("submission_category")
@classmethod @classmethod
def rescue_category(cls, value, values): def rescue_category(cls, value, values):
@@ -321,6 +330,10 @@ class PydSubmission(BaseModel, extra='allow'):
return value return value
def handle_duplicate_samples(self): def handle_duplicate_samples(self):
"""
Collapses multiple samples with same submitter id into one with lists for rows, columns
TODO: Find out if this is really necessary
"""
submitter_ids = list(set([sample.submitter_id for sample in self.samples])) submitter_ids = list(set([sample.submitter_id for sample in self.samples]))
output = [] output = []
for id in submitter_ids: for id in submitter_ids:
@@ -336,7 +349,16 @@ class PydSubmission(BaseModel, extra='allow'):
output.append(dummy) output.append(dummy)
self.samples = output self.samples = output
def improved_dict(self, dictionaries:bool=True): def improved_dict(self, dictionaries:bool=True) -> dict:
"""
Adds model_extra to fields.
Args:
dictionaries (bool, optional): Are dictionaries expected as input? i.e. Should key['value'] be retrieved. Defaults to True.
Returns:
dict: This instance as a dictionary
"""
fields = list(self.model_fields.keys()) + list(self.model_extra.keys()) fields = list(self.model_fields.keys()) + list(self.model_extra.keys())
if dictionaries: if dictionaries:
output = {k:getattr(self, k) for k in fields} output = {k:getattr(self, k) for k in fields}
@@ -344,14 +366,25 @@ class PydSubmission(BaseModel, extra='allow'):
output = {k:(getattr(self, k) if not isinstance(getattr(self, k), dict) else getattr(self, k)['value']) for k in fields} output = {k:(getattr(self, k) if not isinstance(getattr(self, k), dict) else getattr(self, k)['value']) for k in fields}
return output return output
def find_missing(self): def find_missing(self) -> Tuple[dict, dict]:
"""
Retrieves info and reagents marked as missing.
Returns:
Tuple[dict, dict]: Dict for missing info, dict for missing reagents.
"""
info = {k:v for k,v in self.improved_dict().items() if isinstance(v, dict)} info = {k:v for k,v in self.improved_dict().items() if isinstance(v, dict)}
missing_info = {k:v for k,v in info.items() if v['missing']} missing_info = {k:v for k,v in info.items() if v['missing']}
missing_reagents = [reagent for reagent in self.reagents if reagent.missing] missing_reagents = [reagent for reagent in self.reagents if reagent.missing]
return missing_info, missing_reagents return missing_info, missing_reagents
def toSQL(self) -> Tuple[BasicSubmission, Result]: def toSQL(self) -> Tuple[BasicSubmission, Result]:
"""
Converts this instance into a backend.db.models.submissions.BasicSubmission instance
Returns:
Tuple[BasicSubmission, Result]: BasicSubmission instance, result object
"""
self.__dict__.update(self.model_extra) self.__dict__.update(self.model_extra)
instance, code, msg = BasicSubmission.query_or_create(submission_type=self.submission_type['value'], rsl_plate_num=self.rsl_plate_num['value']) 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) result = Result(msg=msg, code=code)
@@ -395,10 +428,42 @@ class PydSubmission(BaseModel, extra='allow'):
return instance, result return instance, result
def toForm(self, parent:QWidget): def toForm(self, parent:QWidget):
"""
Converts this instance into a frontend.widgets.submission_widget.SubmissionFormWidget
Args:
parent (QWidget): parent widget of the constructed object
Returns:
SubmissionFormWidget: Submission form widget
"""
from frontend.widgets.submission_widget import SubmissionFormWidget from frontend.widgets.submission_widget import SubmissionFormWidget
return SubmissionFormWidget(parent=parent, **self.improved_dict()) return SubmissionFormWidget(parent=parent, **self.improved_dict())
def autofill_excel(self, missing_only:bool=True): def autofill_excel(self, missing_only:bool=True, backup:bool=False) -> Workbook:
"""
Fills in relevant information/reagent cells in an excel workbook.
Args:
missing_only (bool, optional): Only fill missing items or all. Defaults to True.
backup (bool, optional): Do a full backup of the submission (adds samples). Defaults to False.
Returns:
Workbook: Filled in workbook
"""
# open a new workbook using openpyxl
if self.filepath.stem.startswith("tmp"):
template = SubmissionType.query(name=self.submission_type['value']).template_file
workbook = load_workbook(BytesIO(template))
missing_only = False
else:
try:
workbook = load_workbook(self.filepath)
except Exception as e:
logger.error(f"Couldn't open workbook due to {e}")
template = SubmissionType.query(name=self.submission_type).template_file
workbook = load_workbook(BytesIO(template))
missing_only = False
if missing_only: if missing_only:
info, reagents = self.find_missing() info, reagents = self.find_missing()
else: else:
@@ -442,8 +507,6 @@ class PydSubmission(BaseModel, extra='allow'):
logger.error(f"Unable to fill in {k}, not found in relevant info.") logger.error(f"Unable to fill in {k}, not found in relevant info.")
logger.debug(f"New reagents: {new_reagents}") logger.debug(f"New reagents: {new_reagents}")
logger.debug(f"New info: {new_info}") logger.debug(f"New info: {new_info}")
# open a new workbook using openpyxl
workbook = load_workbook(self.filepath)
# get list of sheet names # get list of sheet names
sheets = workbook.sheetnames sheets = workbook.sheetnames
# logger.debug(workbook.sheetnames) # logger.debug(workbook.sheetnames)
@@ -468,12 +531,48 @@ class PydSubmission(BaseModel, extra='allow'):
logger.debug(f"Attempting: {item['type']} in row {item['location']['row']}, column {item['location']['column']}") logger.debug(f"Attempting: {item['type']} in row {item['location']['row']}, column {item['location']['column']}")
worksheet.cell(row=item['location']['row'], column=item['location']['column'], value=item['value']) worksheet.cell(row=item['location']['row'], column=item['location']['column'], value=item['value'])
# Hacky way to pop in 'signed by' # Hacky way to pop in 'signed by'
# custom_parser = get_polymorphic_subclass(BasicSubmission, info['submission_type'])
custom_parser = BasicSubmission.find_polymorphic_subclass(polymorphic_identity=self.submission_type['value']) custom_parser = BasicSubmission.find_polymorphic_subclass(polymorphic_identity=self.submission_type['value'])
workbook = custom_parser.custom_autofill(workbook) workbook = custom_parser.custom_autofill(workbook, info=self.improved_dict(), backup=backup)
return workbook return workbook
def construct_filename(self): def autofill_samples(self, workbook:Workbook) -> Workbook:
"""
Fill in sample rows on the excel sheet
Args:
workbook (Workbook): Input excel workbook
Returns:
Workbook: Updated excel workbook
"""
sample_info = SubmissionType.query(name=self.submission_type['value']).info_map['samples']
worksheet = workbook[sample_info["lookup_table"]['sheet']]
samples = sorted(self.samples, key=attrgetter('column', 'row'))
logger.debug(f"Samples: {samples}")
# Fail safe against multiple instances of the same sample
for iii, sample in enumerate(samples, start=1):
row = sample_info['lookup_table']['start_row'] + iii
fields = [field for field in list(sample.model_fields.keys()) + list(sample.model_extra.keys()) if field in sample_info['sample_columns'].keys()]
for field in fields:
column = sample_info['sample_columns'][field]
value = getattr(sample, field)
match value:
case list():
value = value[0]
case _:
value = value
if field == "row":
value = row_map[value]
worksheet.cell(row=row, column=column, value=value)
return workbook
def construct_filename(self) -> str:
"""
Creates filename for this instance
Returns:
str: Output filename
"""
env = jinja_template_loading() env = jinja_template_loading()
template = BasicSubmission.find_polymorphic_subclass(polymorphic_identity=self.submission_type).filename_template() template = BasicSubmission.find_polymorphic_subclass(polymorphic_identity=self.submission_type).filename_template()
logger.debug(f"Using template string: {template}") logger.debug(f"Using template string: {template}")
@@ -484,11 +583,18 @@ class PydSubmission(BaseModel, extra='allow'):
class PydContact(BaseModel): class PydContact(BaseModel):
name: str name: str
phone: str|None phone: str|None
email: str|None email: str|None
def toSQL(self): def toSQL(self) -> Contact:
"""
Converts this instance into a backend.db.models.organization.Contact instance
Returns:
Contact: Contact instance
"""
return Contact(name=self.name, phone=self.phone, email=self.email) return Contact(name=self.name, phone=self.phone, email=self.email)
class PydOrganization(BaseModel): class PydOrganization(BaseModel):
@@ -497,7 +603,13 @@ class PydOrganization(BaseModel):
cost_centre: str cost_centre: str
contacts: List[PydContact]|None contacts: List[PydContact]|None
def toSQL(self): def toSQL(self) -> Organization:
"""
Converts this instance into a backend.db.models.organization.Organization instance.
Returns:
Organization: Organization instance
"""
instance = Organization() instance = Organization()
for field in self.model_fields: for field in self.model_fields:
match field: match field:
@@ -522,7 +634,16 @@ class PydReagentType(BaseModel):
return timedelta(days=value) return timedelta(days=value)
return value return value
def toSQL(self, kit:KitType): def toSQL(self, kit:KitType) -> ReagentType:
"""
Converts this instance into a backend.db.models.ReagentType instance
Args:
kit (KitType): KitType joined to the reagenttype
Returns:
ReagentType: ReagentType instance
"""
# instance: ReagentType = lookup_reagent_types(ctx=ctx, name=self.name) # instance: ReagentType = lookup_reagent_types(ctx=ctx, name=self.name)
instance: ReagentType = ReagentType.query(name=self.name) instance: ReagentType = ReagentType.query(name=self.name)
if instance == None: if instance == None:
@@ -543,14 +664,21 @@ class PydKit(BaseModel):
name: str name: str
reagent_types: List[PydReagentType] = [] reagent_types: List[PydReagentType] = []
def toSQL(self): def toSQL(self) -> Tuple[KitType, Report]:
result = dict(message=None, status='Information') """
Converts this instance into a backend.db.models.kits.KitType instance
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 = lookup_kit_types(ctx=ctx, name=self.name)
instance = KitType.query(name=self.name) instance = KitType.query(name=self.name)
if instance == None: if instance == None:
instance = KitType(name=self.name) instance = KitType(name=self.name)
# instance.reagent_types = [item.toSQL(ctx, instance) for item in self.reagent_types] # instance.reagent_types = [item.toSQL(ctx, instance) for item in self.reagent_types]
[item.toSQL(instance) for item in self.reagent_types] [item.toSQL(instance) for item in self.reagent_types]
return instance, result return instance, report

View File

@@ -4,5 +4,16 @@ from reportlab.lib.units import mm
def make_plate_barcode(text:str, width:int=100, height:int=25) -> Drawing: 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 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") return createBarcodeImageInMemory('Code128', value=text, width=width*mm, height=height*mm, humanReadable=True, format="png")

View File

@@ -4,25 +4,26 @@ Functions for constructing controls graphs using plotly.
import plotly import plotly
import plotly.express as px import plotly.express as px
import pandas as pd import pandas as pd
from pathlib import Path
from plotly.graph_objects import Figure from plotly.graph_objects import Figure
import logging import logging
from backend.excel import get_unique_values_in_df_column from backend.excel import get_unique_values_in_df_column
from tools import Settings
from frontend.widgets.functions import select_save_file
logger = logging.getLogger(f"submissions.{__name__}") logger = logging.getLogger(f"submissions.{__name__}")
def create_charts(ctx:dict, df:pd.DataFrame, ytitle:str|None=None) -> Figure: def create_charts(ctx:Settings, df:pd.DataFrame, ytitle:str|None=None) -> Figure:
""" """
Constructs figures based on parsed pandas dataframe. Constructs figures based on parsed pandas dataframe.
Args: Args:
settings (dict): settings passed down from gui ctx (Settings): settings passed down from gui
df (pd.DataFrame): input dataframe df (pd.DataFrame): input dataframe
group_name (str): controltype ytitle (str | None, optional): title for the y-axis. Defaults to None.
Returns: Returns:
Figure: plotly figure Figure: Plotly figure
""" """
from backend.excel import drop_reruns_from_df from backend.excel import drop_reruns_from_df
# converts starred genera to normal and splits off list of starred # converts starred genera to normal and splits off list of starred
@@ -54,8 +55,6 @@ def create_charts(ctx:dict, df:pd.DataFrame, ytitle:str|None=None) -> Figure:
fig = construct_chart(df=df, modes=modes, ytitle=ytitle) fig = construct_chart(df=df, modes=modes, ytitle=ytitle)
return fig return fig
def generic_figure_markers(fig:Figure, modes:list=[], ytitle:str|None=None) -> Figure: def generic_figure_markers(fig:Figure, modes:list=[], ytitle:str|None=None) -> Figure:
""" """
Adds standard layout to figure. Adds standard layout to figure.
@@ -63,6 +62,7 @@ def generic_figure_markers(fig:Figure, modes:list=[], ytitle:str|None=None) -> F
Args: Args:
fig (Figure): Input figure. fig (Figure): Input figure.
modes (list, optional): List of modes included in figure. Defaults to []. modes (list, optional): List of modes included in figure. Defaults to [].
ytitle (str, optional): Title for the y-axis. Defaults to None.
Returns: Returns:
Figure: Output figure with updated titles, rangeslider, buttons. Figure: Output figure with updated titles, rangeslider, buttons.
@@ -102,7 +102,6 @@ def generic_figure_markers(fig:Figure, modes:list=[], ytitle:str|None=None) -> F
assert type(fig) == Figure assert type(fig) == Figure
return fig return fig
def make_buttons(modes:list, fig_len:int) -> list: def make_buttons(modes:list, fig_len:int) -> list:
""" """
Creates list of buttons with one for each mode to be used in showing/hiding mode traces. Creates list of buttons with one for each mode to be used in showing/hiding mode traces.
@@ -135,7 +134,7 @@ def make_buttons(modes:list, fig_len:int) -> list:
)) ))
return buttons return buttons
def output_figures(settings:dict, figs:list, group_name:str): def output_figures(figs:list, group_name:str):
""" """
Writes plotly figure to html file. Writes plotly figure to html file.
@@ -144,21 +143,19 @@ def output_figures(settings:dict, figs:list, group_name:str):
fig (Figure): input figure object fig (Figure): input figure object
group_name (str): controltype group_name (str): controltype
""" """
with open(Path(settings['folder']['output']).joinpath(f'{group_name}.html'), "w") as f: output = select_save_file(None, default_name=group_name, extension="html")
with open(output, "w") as f:
for fig in figs: for fig in figs:
try: try:
f.write(fig.to_html(full_html=False, include_plotlyjs='cdn')) f.write(fig.to_html(full_html=False, include_plotlyjs='cdn'))
except AttributeError: except AttributeError:
logger.error(f"The following figure was a string: {fig}") logger.error(f"The following figure was a string: {fig}")
def construct_chart(df:pd.DataFrame, modes:list, ytitle:str|None=None) -> Figure: def construct_chart(df:pd.DataFrame, modes:list, ytitle:str|None=None) -> Figure:
""" """
Creates a plotly chart for controls from a pandas dataframe Creates a plotly chart for controls from a pandas dataframe
Args: Args:
ctx (dict): settings passed down from gui
df (pd.DataFrame): input dataframe of controls df (pd.DataFrame): input dataframe of controls
modes (list): analysis modes to construct charts for modes (list): analysis modes to construct charts for
ytitle (str | None, optional): title on the y-axis. Defaults to None. ytitle (str | None, optional): title on the y-axis. Defaults to None.
@@ -200,72 +197,69 @@ def construct_chart(df:pd.DataFrame, modes:list, ytitle:str|None=None) -> Figure
# Below are the individual construction functions. They must be named "construct_{mode}_chart" and # Below are the individual construction functions. They must be named "construct_{mode}_chart" and
# take only json_in and mode to hook into the main processor. # take only json_in and mode to hook into the main processor.
def construct_refseq_chart(settings:dict, df:pd.DataFrame, group_name:str, mode:str) -> Figure: # def construct_refseq_chart(df:pd.DataFrame, group_name:str, mode:str) -> Figure:
""" # """
Constructs intial refseq chart for both contains and matches (depreciated). # Constructs intial refseq chart for both contains and matches (depreciated).
Args: # Args:
settings (dict): settings passed down from gui. # df (pd.DataFrame): dataframe containing all sample data for the group.
df (pd.DataFrame): dataframe containing all sample data for the group. # group_name (str): name of the group being processed.
group_name (str): name of the group being processed. # mode (str): contains or matches, overwritten by hardcoding, so don't think about it too hard.
mode (str): contains or matches, overwritten by hardcoding, so don't think about it too hard.
Returns: # Returns:
Figure: initial figure with contains and matches traces. # Figure: initial figure with contains and matches traces.
""" # """
# This overwrites the mode from the signature, might get confusing. # # This overwrites the mode from the signature, might get confusing.
fig = Figure() # fig = Figure()
modes = ['contains', 'matches'] # modes = ['contains', 'matches']
for ii, mode in enumerate(modes): # for ii, mode in enumerate(modes):
bar = px.bar(df, x="submitted_date", # bar = px.bar(df, x="submitted_date",
y=f"{mode}_ratio", # y=f"{mode}_ratio",
color="target", # color="target",
title=f"{group_name}_{mode}", # title=f"{group_name}_{mode}",
barmode='stack', # barmode='stack',
hover_data=["genus", "name", f"{mode}_hashes"], # hover_data=["genus", "name", f"{mode}_hashes"],
text="genera" # text="genera"
) # )
bar.update_traces(visible = ii == 0) # bar.update_traces(visible = ii == 0)
# Plotly express returns a full figure, so we have to use the data from that figure only. # # Plotly express returns a full figure, so we have to use the data from that figure only.
fig.add_traces(bar.data) # fig.add_traces(bar.data)
# sys.exit(f"number of traces={len(fig.data)}") # # sys.exit(f"number of traces={len(fig.data)}")
return generic_figure_markers(fig=fig, modes=modes) # return generic_figure_markers(fig=fig, modes=modes)
# def construct_kraken_chart(settings:dict, df:pd.DataFrame, group_name:str, mode:str) -> Figure:
# """
# Constructs intial refseq chart for each mode in the kraken config settings. (depreciated)
def construct_kraken_chart(settings:dict, df:pd.DataFrame, group_name:str, mode:str) -> Figure: # Args:
""" # settings (dict): settings passed down from click.
Constructs intial refseq chart for each mode in the kraken config settings. (depreciated) # df (pd.DataFrame): dataframe containing all sample data for the group.
# group_name (str): name of the group being processed.
# mode (str): kraken modes retrieved from config file by setup.
Args: # Returns:
settings (dict): settings passed down from click. # Figure: initial figure with traces for modes
df (pd.DataFrame): dataframe containing all sample data for the group. # """
group_name (str): name of the group being processed. # df[f'{mode}_count'] = pd.to_numeric(df[f'{mode}_count'],errors='coerce')
mode (str): kraken modes retrieved from config file by setup. # df = df.groupby('submitted_date')[f'{mode}_count'].nlargest(2)
Returns:
Figure: initial figure with traces for modes
"""
df[f'{mode}_count'] = pd.to_numeric(df[f'{mode}_count'],errors='coerce')
df = df.groupby('submitted_date')[f'{mode}_count'].nlargest(2)
# The actual percentage from kraken was off due to exclusion of NaN, recalculating.
df[f'{mode}_percent'] = 100 * df[f'{mode}_count'] / df.groupby('submitted_date')[f'{mode}_count'].transform('sum')
modes = settings['modes'][mode]
# This overwrites the mode from the signature, might get confusing.
fig = Figure()
for ii, entry in enumerate(modes):
bar = px.bar(df, x="submitted_date",
y=entry,
color="genus",
title=f"{group_name}_{entry}",
barmode="stack",
hover_data=["genus", "name", "target"],
text="genera",
)
bar.update_traces(visible = ii == 0)
fig.add_traces(bar.data)
return generic_figure_markers(fig=fig, modes=modes)
# # The actual percentage from kraken was off due to exclusion of NaN, recalculating.
# df[f'{mode}_percent'] = 100 * df[f'{mode}_count'] / df.groupby('submitted_date')[f'{mode}_count'].transform('sum')
# modes = settings['modes'][mode]
# # This overwrites the mode from the signature, might get confusing.
# fig = Figure()
# for ii, entry in enumerate(modes):
# bar = px.bar(df, x="submitted_date",
# y=entry,
# color="genus",
# title=f"{group_name}_{entry}",
# barmode="stack",
# hover_data=["genus", "name", "target"],
# text="genera",
# )
# bar.update_traces(visible = ii == 0)
# fig.add_traces(bar.data)
# return generic_figure_markers(fig=fig, modes=modes)
def divide_chunks(input_list:list, chunk_count:int): def divide_chunks(input_list:list, chunk_count:int):
""" """
@@ -281,7 +275,6 @@ def divide_chunks(input_list:list, chunk_count:int):
k, m = divmod(len(input_list), chunk_count) k, m = divmod(len(input_list), chunk_count)
return (input_list[i*k+min(i, m):(i+1)*k+min(i+1, m)] for i in range(chunk_count)) return (input_list[i*k+min(i, m):(i+1)*k+min(i+1, m)] for i in range(chunk_count))
def construct_html(figure:Figure) -> str: def construct_html(figure:Figure) -> str:
""" """
Creates final html code from plotly Creates final html code from plotly

View File

@@ -84,14 +84,17 @@ def make_plate_map(sample_list:list) -> Image:
return new_img return new_img
def make_plate_map_html(sample_list:list, plate_rows:int=8, plate_columns=12) -> str: def make_plate_map_html(sample_list:list, plate_rows:int=8, plate_columns=12) -> str:
try: """
plate_num = sample_list[0]['plate_name'] Constructs an html based plate map.
except IndexError as e:
logger.error(f"Couldn't get a plate number. Will not make plate.") Args:
return None sample_list (list): List of submission samples
except TypeError as e: plate_rows (int, optional): Number of rows in the plate. Defaults to 8.
logger.error(f"No samples for this plate. Nothing to do.") plate_columns (int, optional): Number of columns in the plate. Defaults to 12.
return None
Returns:
str: html output string.
"""
for sample in sample_list: for sample in sample_list:
if sample['positive']: if sample['positive']:
sample['background_color'] = "#f10f07" sample['background_color'] = "#f10f07"
@@ -109,3 +112,4 @@ def make_plate_map_html(sample_list:list, plate_rows:int=8, plate_columns=12) ->
template = env.get_template("plate_map.html") template = env.get_template("plate_map.html")
html = template.render(samples=output_samples, PLATE_ROWS=plate_rows, PLATE_COLUMNS=plate_columns) html = template.render(samples=output_samples, PLATE_ROWS=plate_rows, PLATE_COLUMNS=plate_columns)
return html return html

View File

@@ -11,9 +11,6 @@ from PyQt6.QtWidgets import (
from PyQt6.QtGui import QAction from PyQt6.QtGui import QAction
from pathlib import Path from pathlib import Path
from backend.validators import PydReagent from backend.validators import PydReagent
# from frontend.functions import (
# add_kit_function, add_org_function, link_controls_function, export_csv_function
# )
from tools import check_if_app, Settings, Report from tools import check_if_app, Settings, Report
from .pop_ups import AlertPop from .pop_ups import AlertPop
from .misc import AddReagentForm, LogParser from .misc import AddReagentForm, LogParser
@@ -149,17 +146,12 @@ class App(QMainWindow):
webbrowser.get('windows-default').open(f"file://{url.__str__()}") webbrowser.get('windows-default').open(f"file://{url.__str__()}")
def result_reporter(self): def result_reporter(self):
# def result_reporter(self, result:TypedDict[]|None=None):
""" """
Report any anomolous results - if any - to the user Report any anomolous results - if any - to the user
Args: Args:
result (dict | None, optional): The result from a function. Defaults to None. result (dict | None, optional): The result from a function. Defaults to None.
""" """
# logger.info(f"We got the result: {result}")
# if result != None:
# msg = AlertPop(message=result['message'], status=result['status'])
# msg.exec()
logger.debug(f"Running results reporter for: {self.report.results}") logger.debug(f"Running results reporter for: {self.report.results}")
if len(self.report.results) > 0: if len(self.report.results) > 0:
logger.debug(f"We've got some results!") logger.debug(f"We've got some results!")
@@ -173,43 +165,6 @@ class App(QMainWindow):
else: else:
self.statusBar().showMessage("Action completed sucessfully.", 5000) self.statusBar().showMessage("Action completed sucessfully.", 5000)
# def importSubmission(self, fname:Path|None=None):
# """
# import submission from excel sheet into form
# """
# # from .main_window_functions import import_submission_function
# self.raise_()
# self.activateWindow()
# self = import_submission_function(self, fname)
# logger.debug(f"Result from result reporter: {self.report.results}")
# self.result_reporter()
# def kit_reload(self):
# """
# Removes all reagents from form before running kit integrity completion.
# """
# # from .main_window_functions import kit_reload_function
# self = kit_reload_function(self)
# self.result_reporter()
# def kit_integrity_completion(self):
# """
# Performs check of imported reagents
# 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)
# self.result_reporter()
# def submit_new_sample(self):
# """
# 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)
# self.result_reporter()
def add_reagent(self, reagent_lot:str|None=None, reagent_type:str|None=None, expiry:date|None=None, name:str|None=None): def add_reagent(self, reagent_lot:str|None=None, reagent_type:str|None=None, expiry:date|None=None, name:str|None=None):
""" """
Action to create new reagent in DB. Action to create new reagent in DB.
@@ -217,6 +172,8 @@ class App(QMainWindow):
Args: Args:
reagent_lot (str | None, optional): Parsed reagent from import form. Defaults to None. reagent_lot (str | None, optional): Parsed reagent from import form. Defaults to None.
reagent_type (str | None, optional): Parsed reagent type from import form. Defaults to None. reagent_type (str | None, optional): Parsed reagent type from import form. Defaults to None.
expiry (date | None, optional): Parsed reagent expiry data. Defaults to None.
name (str | None, optional): Parsed reagent name. Defaults to None.
Returns: Returns:
models.Reagent: the constructed reagent object to add to submission models.Reagent: the constructed reagent object to add to submission
@@ -225,117 +182,20 @@ class App(QMainWindow):
if isinstance(reagent_lot, bool): if isinstance(reagent_lot, bool):
reagent_lot = "" reagent_lot = ""
# create form # create form
dlg = AddReagentForm(ctx=self.ctx, reagent_lot=reagent_lot, reagent_type=reagent_type, expiry=expiry, reagent_name=name) dlg = AddReagentForm(reagent_lot=reagent_lot, reagent_type=reagent_type, expiry=expiry, reagent_name=name)
if dlg.exec(): if dlg.exec():
# extract form info # extract form info
# info = extract_form_info(dlg)
info = dlg.parse_form() info = dlg.parse_form()
logger.debug(f"Reagent info: {info}") logger.debug(f"Reagent info: {info}")
# create reagent object # create reagent object
# reagent = construct_reagent(ctx=self.ctx, info_dict=info)
reagent = PydReagent(ctx=self.ctx, **info) reagent = PydReagent(ctx=self.ctx, **info)
# send reagent to db # send reagent to db
# store_reagent(ctx=self.ctx, reagent=reagent)
sqlobj, result = reagent.toSQL() sqlobj, result = reagent.toSQL()
sqlobj.save() sqlobj.save()
# result = store_object(ctx=self.ctx, object=reagent.toSQL()[0])
report.add_result(result) report.add_result(result)
self.result_reporter() self.result_reporter()
return reagent return reagent
# def generateReport(self):
# """
# Action to create a summary of sheet data per client
# """
# # from .main_window_functions import generate_report_function
# self, result = generate_report_function(self)
# self.result_reporter(result)
# def add_kit(self):
# """
# Constructs new kit from yaml and adds to DB.
# """
# # from .main_window_functions import add_kit_function
# self, result = add_kit_function(self)
# self.result_reporter(result)
# def add_org(self):
# """
# Constructs new kit from yaml and adds to DB.
# """
# # from .main_window_functions import add_org_function
# self, result = add_org_function(self)
# self.result_reporter(result)
# def _controls_getter(self):
# """
# Lookup controls from database and send to chartmaker
# """
# # from .main_window_functions import controls_getter_function
# self = controls_getter_function(self)
# self.result_reporter()
# def _chart_maker(self):
# """
# Creates plotly charts for webview
# """
# # from .main_window_functions import chart_maker_function
# self = chart_maker_function(self)
# self.result_reporter()
# def linkControls(self):
# """
# Adds controls pulled from irida to relevant submissions
# NOTE: Depreciated due to improvements in controls scraper.
# """
# # from .main_window_functions import link_controls_function
# self, result = link_controls_function(self)
# self.result_reporter(result)
# def linkExtractions(self):
# """
# Links extraction logs from .csv files to relevant submissions.
# """
# # from .main_window_functions import link_extractions_function
# self, result = link_extractions_function(self)
# self.result_reporter(result)
# def linkPCR(self):
# """
# Links PCR logs from .csv files to relevant submissions.
# """
# # from .main_window_functions import link_pcr_function
# self, result = link_pcr_function(self)
# self.result_reporter(result)
# def importPCRResults(self):
# """
# Imports results exported from Design and Analysis .eds files
# """
# # from .main_window_functions import import_pcr_results_function
# self, result = import_pcr_results_function(self)
# self.result_reporter(result)
# def construct_first_strand(self):
# """
# Converts first strand excel sheet to Biomek CSV
# """
# from .main_window_functions import construct_first_strand_function
# self, result = construct_first_strand_function(self)
# self.result_reporter(result)
# def scrape_reagents(self, *args, **kwargs):
# # from .main_window_functions import scrape_reagents
# logger.debug(f"Args: {args}")
# logger.debug(F"kwargs: {kwargs}")
# self = scrape_reagents(self, args[0])
# self.kit_integrity_completion()
# self.result_reporter()
# def export_csv(self, fname:Path|None=None):
# # from .main_window_functions import export_csv_function
# export_csv_function(self, fname)
def runSearch(self): def runSearch(self):
dlg = LogParser(self) dlg = LogParser(self)
dlg.exec() dlg.exec()
@@ -377,32 +237,7 @@ class AddSubForm(QWidget):
self.tab1.setLayout(self.tab1.layout) self.tab1.setLayout(self.tab1.layout)
self.tab1.layout.addWidget(self.interior) self.tab1.layout.addWidget(self.interior)
self.tab1.layout.addWidget(self.sheetwidget) self.tab1.layout.addWidget(self.sheetwidget)
# create widgets for tab 2
# self.datepicker = ControlsDatePicker()
# self.webengineview = QWebEngineView()
# set tab2 layout
self.tab2.layout = QVBoxLayout(self) self.tab2.layout = QVBoxLayout(self)
# self.control_typer = QComboBox()
# fetch types of controls
# con_types = get_all_Control_Types_names(ctx=parent.ctx)
# con_types = [item.name for item in lookup_control_types(ctx=parent.ctx)]
# con_types = [item.name for item in ControlType.query()]
# self.control_typer.addItems(con_types)
# create custom widget to get types of analysis
# self.mode_typer = QComboBox()
# mode_types = get_all_available_modes(ctx=parent.ctx)
# mode_types = lookup_modes(ctx=parent.ctx)
# mode_types = Control.get_modes()
# self.mode_typer.addItems(mode_types)
# create custom widget to get subtypes of analysis
# self.sub_typer = QComboBox()
# self.sub_typer.setEnabled(False)
# add widgets to tab2 layout
# self.tab2.layout.addWidget(self.datepicker)
# self.tab2.layout.addWidget(self.control_typer)
# self.tab2.layout.addWidget(self.mode_typer)
# self.tab2.layout.addWidget(self.sub_typer)
# self.tab2.layout.addWidget(self.webengineview)
self.controls_viewer = ControlsViewer(self) self.controls_viewer = ControlsViewer(self)
self.tab2.layout.addWidget(self.controls_viewer) self.tab2.layout.addWidget(self.controls_viewer)
self.tab2.setLayout(self.tab2.layout) self.tab2.setLayout(self.tab2.layout)

View File

@@ -3,10 +3,10 @@ from PyQt6.QtWidgets import (
QWidget, QVBoxLayout, QComboBox, QHBoxLayout, QWidget, QVBoxLayout, QComboBox, QHBoxLayout,
QDateEdit, QLabel, QSizePolicy QDateEdit, QLabel, QSizePolicy
) )
from PyQt6.QtCore import QSignalBlocker from PyQt6.QtCore import QSignalBlocker, QLoggingCategory
from backend.db import ControlType, Control, get_control_subtypes from backend.db import ControlType, Control, get_control_subtypes
from PyQt6.QtCore import QDate, QSize from PyQt6.QtCore import QDate, QSize
import logging import logging, sys
from tools import Report, Result from tools import Report, Result
from backend.excel.reports import convert_data_list_to_df from backend.excel.reports import convert_data_list_to_df
from frontend.visualizations.control_charts import create_charts, construct_html from frontend.visualizations.control_charts import create_charts, construct_html
@@ -26,14 +26,10 @@ class ControlsViewer(QWidget):
self.layout = QVBoxLayout(self) self.layout = QVBoxLayout(self)
self.control_typer = QComboBox() self.control_typer = QComboBox()
# fetch types of controls # fetch types of controls
# con_types = get_all_Control_Types_names(ctx=parent.ctx)
# con_types = [item.name for item in lookup_control_types(ctx=parent.ctx)]
con_types = [item.name for item in ControlType.query()] con_types = [item.name for item in ControlType.query()]
self.control_typer.addItems(con_types) self.control_typer.addItems(con_types)
# create custom widget to get types of analysis # create custom widget to get types of analysis
self.mode_typer = QComboBox() self.mode_typer = QComboBox()
# mode_types = get_all_available_modes(ctx=parent.ctx)
# mode_types = lookup_modes(ctx=parent.ctx)
mode_types = Control.get_modes() mode_types = Control.get_modes()
self.mode_typer.addItems(mode_types) self.mode_typer.addItems(mode_types)
# create custom widget to get subtypes of analysis # create custom widget to get subtypes of analysis
@@ -56,27 +52,17 @@ class ControlsViewer(QWidget):
""" """
Lookup controls from database and send to chartmaker Lookup controls from database and send to chartmaker
""" """
# from .main_window_functions import controls_getter_function
self.controls_getter_function() self.controls_getter_function()
# self.result_reporter()
def chart_maker(self): def chart_maker(self):
""" """
Creates plotly charts for webview Creates plotly charts for webview
""" """
# from .main_window_functions import chart_maker_function
self.chart_maker_function() self.chart_maker_function()
# self.result_reporter()
def controls_getter_function(self): def controls_getter_function(self):
""" """
Get controls based on start/end dates Get controls based on start/end dates
Args:
obj (QMainWindow): original app window
Returns:
Tuple[QMainWindow, dict]: Collection of new main app window and result dict
""" """
report = Report() report = Report()
# subtype defaults to disabled # subtype defaults to disabled
@@ -136,8 +122,6 @@ class ControlsViewer(QWidget):
self.subtype = self.sub_typer.currentText() self.subtype = self.sub_typer.currentText()
logger.debug(f"Subtype: {self.subtype}") logger.debug(f"Subtype: {self.subtype}")
# query all controls using the type/start and end dates from the gui # query all controls using the type/start and end dates from the gui
# controls = get_all_controls_by_type(ctx=obj.ctx, con_type=obj.con_type, start_date=obj.start_date, end_date=obj.end_date)
# controls = lookup_controls(ctx=obj.ctx, control_type=obj.con_type, start_date=obj.start_date, end_date=obj.end_date)
controls = Control.query(control_type=self.con_type, start_date=self.start_date, end_date=self.end_date) controls = Control.query(control_type=self.con_type, start_date=self.start_date, end_date=self.end_date)
# if no data found from query set fig to none for reporting in webview # if no data found from query set fig to none for reporting in webview
if controls == None: if controls == None:
@@ -174,7 +158,6 @@ class ControlsDatePicker(QWidget):
""" """
def __init__(self) -> None: def __init__(self) -> None:
super().__init__() super().__init__()
self.start_date = QDateEdit(calendarPopup=True) self.start_date = QDateEdit(calendarPopup=True)
# start date is two months prior to end date by default # start date is two months prior to end date by default
twomonthsago = QDate.currentDate().addDays(-60) twomonthsago = QDate.currentDate().addDays(-60)

View File

@@ -20,14 +20,12 @@ def select_open_file(obj:QMainWindow, file_extension:str) -> Path:
Path: Path of file to be opened Path: Path of file to be opened
""" """
try: try:
# home_dir = Path(obj.ctx.directory_path).resolve().__str__()
home_dir = obj.last_dir.resolve().__str__() home_dir = obj.last_dir.resolve().__str__()
except FileNotFoundError: except FileNotFoundError:
home_dir = Path.home().resolve().__str__() home_dir = Path.home().resolve().__str__()
except AttributeError: except AttributeError:
home_dir = obj.app.last_dir.resolve().__str__() home_dir = obj.app.last_dir.resolve().__str__()
fname = Path(QFileDialog.getOpenFileName(obj, 'Open file', home_dir, filter = f"{file_extension}(*.{file_extension})")[0]) fname = Path(QFileDialog.getOpenFileName(obj, 'Open file', home_dir, filter = f"{file_extension}(*.{file_extension})")[0])
# fname = Path(QFileDialog.getOpenFileName(obj, 'Open file', filter = f"{file_extension}(*.{file_extension})")[0])
obj.last_dir = fname.parent obj.last_dir = fname.parent
return fname return fname
@@ -44,13 +42,11 @@ def select_save_file(obj:QMainWindow, default_name:str, extension:str) -> Path:
Path: Path of file to be opened Path: Path of file to be opened
""" """
try: try:
# home_dir = Path(obj.ctx.directory_path).joinpath(default_name).resolve().__str__()
home_dir = obj.last_dir.joinpath(default_name).resolve().__str__() home_dir = obj.last_dir.joinpath(default_name).resolve().__str__()
except FileNotFoundError: except FileNotFoundError:
home_dir = Path.home().joinpath(default_name).resolve().__str__() home_dir = Path.home().joinpath(default_name).resolve().__str__()
except AttributeError: except AttributeError:
home_dir = obj.app.last_dir.joinpath(default_name).resolve().__str__() home_dir = obj.app.last_dir.joinpath(default_name).resolve().__str__()
fname = Path(QFileDialog.getSaveFileName(obj, "Save File", home_dir, filter = f"{extension}(*.{extension})")[0]) fname = Path(QFileDialog.getSaveFileName(obj, "Save File", home_dir, filter = f"{extension}(*.{extension})")[0])
# fname = Path(QFileDialog.getSaveFileName(obj, "Save File", filter = f"{extension}(*.{extension})")[0])
obj.last_dir = fname.parent obj.last_dir = fname.parent
return fname return fname

View File

@@ -9,7 +9,7 @@ from backend.db import SubmissionTypeKitTypeAssociation, SubmissionType, Reagent
from backend.validators import PydReagentType, PydKit from backend.validators import PydReagentType, PydKit
import logging import logging
from pprint import pformat from pprint import pformat
from tools import Report, Result from tools import Report
from typing import Tuple from typing import Tuple
logger = logging.getLogger(f"submissions.{__name__}") logger = logging.getLogger(f"submissions.{__name__}")
@@ -21,7 +21,6 @@ class KitAdder(QWidget):
""" """
def __init__(self, parent) -> None: def __init__(self, parent) -> None:
super().__init__(parent) super().__init__(parent)
# self.ctx = parent_ctx
self.report = Report() self.report = Report()
self.app = parent.parent self.app = parent.parent
main_box = QVBoxLayout(self) main_box = QVBoxLayout(self)
@@ -30,7 +29,6 @@ class KitAdder(QWidget):
scroll.setWidgetResizable(True) scroll.setWidgetResizable(True)
scrollContent = QWidget(scroll) scrollContent = QWidget(scroll)
self.grid = QGridLayout() self.grid = QGridLayout()
# self.setLayout(self.grid)
scrollContent.setLayout(self.grid) scrollContent.setLayout(self.grid)
# insert submit button at top # insert submit button at top
self.submit_btn = QPushButton("Submit") self.submit_btn = QPushButton("Submit")
@@ -45,7 +43,6 @@ class KitAdder(QWidget):
used_for = QComboBox() used_for = QComboBox()
used_for.setObjectName("used_for") used_for.setObjectName("used_for")
# Insert all existing sample types # Insert all existing sample types
# used_for.addItems([item.name for item in lookup_submission_type(ctx=parent_ctx)])
used_for.addItems([item.name for item in SubmissionType.query()]) used_for.addItems([item.name for item in SubmissionType.query()])
used_for.setEditable(True) used_for.setEditable(True)
self.grid.addWidget(used_for,3,1) self.grid.addWidget(used_for,3,1)
@@ -97,7 +94,6 @@ class KitAdder(QWidget):
report = Report() report = Report()
# get form info # get form info
info, reagents = self.parse_form() info, reagents = self.parse_form()
# info, reagents = extract_form_info(self)
info = {k:v for k,v in info.items() if k in [column.name for column in self.columns] + ['kit_name', 'used_for']} info = {k:v for k,v in info.items() if k in [column.name for column in self.columns] + ['kit_name', 'used_for']}
logger.debug(f"kit info: {pformat(info)}") logger.debug(f"kit info: {pformat(info)}")
logger.debug(f"kit reagents: {pformat(reagents)}") logger.debug(f"kit reagents: {pformat(reagents)}")
@@ -115,7 +111,6 @@ class KitAdder(QWidget):
}} }}
kit.reagent_types.append(PydReagentType(name=reagent['rtname'], eol_ext=reagent['eol'], uses=uses)) kit.reagent_types.append(PydReagentType(name=reagent['rtname'], eol_ext=reagent['eol'], uses=uses))
logger.debug(f"Output pyd object: {kit.__dict__}") logger.debug(f"Output pyd object: {kit.__dict__}")
# result = construct_kit_from_yaml(ctx=self.ctx, kit_dict=info)
sqlobj, result = kit.toSQL(self.ctx) sqlobj, result = kit.toSQL(self.ctx)
report.add_result(result=result) report.add_result(result=result)
sqlobj.save() sqlobj.save()
@@ -153,10 +148,9 @@ class ReagentTypeForm(QWidget):
self.reagent_getter = QComboBox() self.reagent_getter = QComboBox()
self.reagent_getter.setObjectName("rtname") self.reagent_getter.setObjectName("rtname")
# lookup all reagent type names from db # lookup all reagent type names from db
# lookup = lookup_reagent_types(ctx=ctx)
lookup = ReagentType.query() lookup = ReagentType.query()
logger.debug(f"Looked up ReagentType names: {lookup}") logger.debug(f"Looked up ReagentType names: {lookup}")
self.reagent_getter.addItems([item.__str__() for item in lookup]) self.reagent_getter.addItems([item.name for item in lookup])
self.reagent_getter.setEditable(True) self.reagent_getter.setEditable(True)
grid.addWidget(self.reagent_getter,0,1) grid.addWidget(self.reagent_getter,0,1)
grid.addWidget(QLabel("Extension of Life (months):"),0,2) grid.addWidget(QLabel("Extension of Life (months):"),0,2)
@@ -221,3 +215,4 @@ class ReagentTypeForm(QWidget):
logger.debug(f"Adding key {key}, {sub_key} and value {widget.value()} to {info}") logger.debug(f"Adding key {key}, {sub_key} and value {widget.value()} to {info}")
info[key][sub_key] = widget.value() info[key][sub_key] = widget.value()
return info return info

View File

@@ -13,7 +13,7 @@ from backend.db.models import *
import logging import logging
from .pop_ups import AlertPop from .pop_ups import AlertPop
from .functions import select_open_file from .functions import select_open_file
from tools import readInChunks from tools import readInChunks, Settings
logger = logging.getLogger(f"submissions.{__name__}") logger = logging.getLogger(f"submissions.{__name__}")
@@ -23,9 +23,9 @@ class AddReagentForm(QDialog):
""" """
dialog to add gather info about new reagent dialog to add gather info about new reagent
""" """
def __init__(self, ctx:dict, reagent_lot:str|None=None, reagent_type:str|None=None, expiry:date|None=None, reagent_name:str|None=None) -> None: 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__() super().__init__()
self.ctx = ctx # self.ctx = ctx
if reagent_lot == None: if reagent_lot == None:
reagent_lot = reagent_type reagent_lot = reagent_type
@@ -81,7 +81,13 @@ class AddReagentForm(QDialog):
self.setLayout(self.layout) self.setLayout(self.layout)
self.type_input.currentTextChanged.connect(self.update_names) self.type_input.currentTextChanged.connect(self.update_names)
def parse_form(self): def parse_form(self) -> dict:
"""
Converts information in form to dict.
Returns:
dict: Output info
"""
return dict(name=self.name_input.currentText(), return dict(name=self.name_input.currentText(),
lot=self.lot_input.text(), lot=self.lot_input.text(),
expiry=self.exp_input.date().toPyDate(), expiry=self.exp_input.date().toPyDate(),
@@ -93,7 +99,6 @@ class AddReagentForm(QDialog):
""" """
logger.debug(self.type_input.currentText()) logger.debug(self.type_input.currentText())
self.name_input.clear() self.name_input.clear()
# lookup = lookup_reagents(ctx=self.ctx, reagent_type=self.type_input.currentText())
lookup = Reagent.query(reagent_type=self.type_input.currentText()) lookup = Reagent.query(reagent_type=self.type_input.currentText())
self.name_input.addItems(list(set([item.name for item in lookup]))) self.name_input.addItems(list(set([item.name for item in lookup])))
@@ -103,7 +108,6 @@ class ReportDatePicker(QDialog):
""" """
def __init__(self) -> None: def __init__(self) -> None:
super().__init__() super().__init__()
self.setWindowTitle("Select Report Date Range") self.setWindowTitle("Select Report Date Range")
# make confirm/reject buttons # make confirm/reject buttons
QBtn = QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel QBtn = QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel
@@ -125,7 +129,13 @@ class ReportDatePicker(QDialog):
self.layout.addWidget(self.buttonBox) self.layout.addWidget(self.buttonBox)
self.setLayout(self.layout) self.setLayout(self.layout)
def parse_form(self): def parse_form(self) -> dict:
"""
Converts information in this object to a dict
Returns:
dict: output dict.
"""
return dict(start_date=self.start_date.date().toPyDate(), end_date = self.end_date.date().toPyDate()) return dict(start_date=self.start_date.date().toPyDate(), end_date = self.end_date.date().toPyDate())
class FirstStrandSalvage(QDialog): class FirstStrandSalvage(QDialog):
@@ -162,35 +172,6 @@ class FirstStrandSalvage(QDialog):
def parse_form(self): def parse_form(self):
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()}") 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 FirstStrandPlateList(QDialog):
def __init__(self, ctx:Settings) -> None:
super().__init__()
self.setWindowTitle("First Strand Plates")
QBtn = QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel
self.buttonBox = QDialogButtonBox(QBtn)
self.buttonBox.accepted.connect(self.accept)
self.buttonBox.rejected.connect(self.reject)
# ww = [item.rsl_plate_num for item in lookup_submissions(ctx=ctx, submission_type="Wastewater")]
ww = [item.rsl_plate_num for item in BasicSubmission.query(submission_type="Wastewater")]
self.plate1 = QComboBox()
self.plate2 = QComboBox()
self.plate3 = QComboBox()
self.layout = QFormLayout()
for ii, plate in enumerate([self.plate1, self.plate2, self.plate3]):
plate.addItems(ww)
self.layout.addRow(self.tr(f"&Plate {ii+1}:"), plate)
self.layout.addWidget(self.buttonBox)
self.setLayout(self.layout)
def parse_form(self):
output = []
for plate in [self.plate1, self.plate2, self.plate3]:
output.append(plate.currentText())
return output
class LogParser(QDialog): class LogParser(QDialog):
def __init__(self, parent): def __init__(self, parent):

View File

@@ -53,7 +53,6 @@ class KitSelector(QDialog):
super().__init__() super().__init__()
self.setWindowTitle(title) self.setWindowTitle(title)
self.widget = QComboBox() self.widget = QComboBox()
# kits = [item.__str__() for item in lookup_kit_types(ctx=ctx)]
kits = [item.__str__() for item in KitType.query()] kits = [item.__str__() for item in KitType.query()]
self.widget.addItems(kits) self.widget.addItems(kits)
self.widget.setEditable(False) self.widget.setEditable(False)

View File

@@ -1,15 +1,15 @@
''' '''
Contains widgets specific to the submission summary and submission details. Contains widgets specific to the submission summary and submission details.
''' '''
import base64 import base64, logging, json
from datetime import datetime from datetime import datetime
from io import BytesIO from io import BytesIO
import pprint from pprint import pformat
from PyQt6 import QtPrintSupport from PyQt6 import QtPrintSupport
from PyQt6.QtWidgets import ( from PyQt6.QtWidgets import (
QVBoxLayout, QDialog, QTableView, QVBoxLayout, QDialog, QTableView,
QTextEdit, QPushButton, QScrollArea, QTextEdit, QPushButton, QScrollArea,
QMessageBox, QFileDialog, QMenu, QLabel, QMessageBox, QMenu, QLabel,
QDialogButtonBox, QToolBar QDialogButtonBox, QToolBar
) )
from PyQt6.QtWebEngineWidgets import QWebEngineView from PyQt6.QtWebEngineWidgets import QWebEngineView
@@ -17,19 +17,16 @@ from PyQt6.QtCore import Qt, QAbstractTableModel, QSortFilterProxyModel
from PyQt6.QtGui import QAction, QCursor, QPixmap, QPainter from PyQt6.QtGui import QAction, QCursor, QPixmap, QPainter
from backend.db.functions import submissions_to_df from backend.db.functions import submissions_to_df
from backend.db.models import BasicSubmission from backend.db.models import BasicSubmission
from backend.excel import make_hitpicks, make_report_html, make_report_xlsx from backend.excel import make_report_html, make_report_xlsx
from tools import check_if_app, Report, Result from tools import check_if_app, Report, Result, jinja_template_loading, get_first_blank_df_row, row_map
from tools import jinja_template_loading
from xhtml2pdf import pisa from xhtml2pdf import pisa
from pathlib import Path from .pop_ups import QuestionAsker
import logging
from .pop_ups import QuestionAsker, AlertPop
from ..visualizations import make_plate_barcode, make_plate_map, make_plate_map_html from ..visualizations import make_plate_barcode, make_plate_map, make_plate_map_html
from .functions import select_save_file, select_open_file from .functions import select_save_file, select_open_file
from .misc import ReportDatePicker from .misc import ReportDatePicker
import pandas as pd import pandas as pd
from openpyxl.worksheet.worksheet import Worksheet
from getpass import getuser from getpass import getuser
import json
logger = logging.getLogger(f"submissions.{__name__}") logger = logging.getLogger(f"submissions.{__name__}")
@@ -161,16 +158,19 @@ class SubmissionsSheet(QTableView):
detailsAction = QAction('Details', self) detailsAction = QAction('Details', self)
# barcodeAction = QAction("Print Barcode", self) # barcodeAction = QAction("Print Barcode", self)
commentAction = QAction("Add Comment", self) commentAction = QAction("Add Comment", self)
backupAction = QAction("Backup", self)
# hitpickAction = QAction("Hitpicks", self) # hitpickAction = QAction("Hitpicks", self)
renameAction.triggered.connect(lambda: self.delete_item(event)) renameAction.triggered.connect(lambda: self.delete_item(event))
detailsAction.triggered.connect(lambda: self.show_details()) detailsAction.triggered.connect(lambda: self.show_details())
# barcodeAction.triggered.connect(lambda: self.create_barcode()) # barcodeAction.triggered.connect(lambda: self.create_barcode())
commentAction.triggered.connect(lambda: self.add_comment()) commentAction.triggered.connect(lambda: self.add_comment())
backupAction.triggered.connect(lambda: self.regenerate_submission_form())
# hitpickAction.triggered.connect(lambda: self.hit_pick()) # hitpickAction.triggered.connect(lambda: self.hit_pick())
self.menu.addAction(detailsAction) self.menu.addAction(detailsAction)
self.menu.addAction(renameAction) self.menu.addAction(renameAction)
# self.menu.addAction(barcodeAction) # self.menu.addAction(barcodeAction)
self.menu.addAction(commentAction) self.menu.addAction(commentAction)
self.menu.addAction(backupAction)
# self.menu.addAction(hitpickAction) # self.menu.addAction(hitpickAction)
# add other required actions # add other required actions
self.menu.popup(QCursor.pos()) self.menu.popup(QCursor.pos())
@@ -193,64 +193,64 @@ class SubmissionsSheet(QTableView):
return return
self.setData() self.setData()
def hit_pick(self): # def hit_pick(self):
""" # """
Extract positive samples from submissions with PCR results and export to csv. # Extract positive samples from submissions with PCR results and export to csv.
NOTE: For this to work for arbitrary samples, positive samples must have 'positive' in their name # NOTE: For this to work for arbitrary samples, positive samples must have 'positive' in their name
""" # """
# Get all selected rows # # Get all selected rows
indices = self.selectionModel().selectedIndexes() # indices = self.selectionModel().selectedIndexes()
# convert to id numbers # # convert to id numbers
indices = [index.sibling(index.row(), 0).data() for index in indices] # indices = [index.sibling(index.row(), 0).data() for index in indices]
# biomek can handle 4 plates maximum # # biomek can handle 4 plates maximum
if len(indices) > 4: # if len(indices) > 4:
logger.error(f"Error: Had to truncate number of plates to 4.") # logger.error(f"Error: Had to truncate number of plates to 4.")
indices = indices[:4] # indices = indices[:4]
# lookup ids in the database # # lookup ids in the database
# subs = [lookup_submissions(ctx=self.ctx, id=id) for id in indices] # # subs = [lookup_submissions(ctx=self.ctx, id=id) for id in indices]
subs = [BasicSubmission.query(id=id) for id in indices] # subs = [BasicSubmission.query(id=id) for id in indices]
# full list of samples # # full list of samples
dicto = [] # dicto = []
# list to contain plate images # # list to contain plate images
images = [] # images = []
for iii, sub in enumerate(subs): # for iii, sub in enumerate(subs):
# second check to make sure there aren't too many plates # # second check to make sure there aren't too many plates
if iii > 3: # if iii > 3:
logger.error(f"Error: Had to truncate number of plates to 4.") # logger.error(f"Error: Had to truncate number of plates to 4.")
continue # continue
plate_dicto = sub.hitpick_plate(plate_number=iii+1) # plate_dicto = sub.hitpick_plate(plate_number=iii+1)
if plate_dicto == None: # if plate_dicto == None:
continue # continue
image = make_plate_map(plate_dicto) # image = make_plate_map(plate_dicto)
images.append(image) # images.append(image)
for item in plate_dicto: # for item in plate_dicto:
if len(dicto) < 94: # if len(dicto) < 94:
dicto.append(item) # dicto.append(item)
else: # else:
logger.error(f"We had to truncate the number of samples to 94.") # logger.error(f"We had to truncate the number of samples to 94.")
logger.debug(f"We found {len(dicto)} to hitpick") # logger.debug(f"We found {len(dicto)} to hitpick")
# convert all samples to dataframe # # convert all samples to dataframe
df = make_hitpicks(dicto) # df = make_hitpicks(dicto)
df = df[df.positive != False] # df = df[df.positive != False]
logger.debug(f"Size of the dataframe: {df.shape[0]}") # logger.debug(f"Size of the dataframe: {df.shape[0]}")
msg = AlertPop(message=f"We found {df.shape[0]} samples to hitpick", status="INFORMATION") # msg = AlertPop(message=f"We found {df.shape[0]} samples to hitpick", status="INFORMATION")
msg.exec() # msg.exec()
if df.size == 0: # if df.size == 0:
return # return
date = datetime.strftime(datetime.today(), "%Y-%m-%d") # date = datetime.strftime(datetime.today(), "%Y-%m-%d")
# ask for filename and save as csv. # # ask for filename and save as csv.
home_dir = Path(self.ctx.directory_path).joinpath(f"Hitpicks_{date}.csv").resolve().__str__() # home_dir = Path(self.ctx.directory_path).joinpath(f"Hitpicks_{date}.csv").resolve().__str__()
fname = Path(QFileDialog.getSaveFileName(self, "Save File", home_dir, filter=".csv")[0]) # fname = Path(QFileDialog.getSaveFileName(self, "Save File", home_dir, filter=".csv")[0])
if fname.__str__() == ".": # if fname.__str__() == ".":
logger.debug("Saving csv was cancelled.") # logger.debug("Saving csv was cancelled.")
return # return
df.to_csv(fname.__str__(), index=False) # df.to_csv(fname.__str__(), index=False)
# show plate maps # # show plate maps
for image in images: # for image in images:
try: # try:
image.show() # image.show()
except Exception as e: # except Exception as e:
logger.error(f"Could not show image: {e}.") # logger.error(f"Could not show image: {e}.")
def link_extractions(self): def link_extractions(self):
self.link_extractions_function() self.link_extractions_function()
@@ -420,6 +420,7 @@ class SubmissionsSheet(QTableView):
subs = BasicSubmission.query(start_date=info['start_date'], end_date=info['end_date']) subs = BasicSubmission.query(start_date=info['start_date'], end_date=info['end_date'])
# convert each object to dict # convert each object to dict
records = [item.report_dict() for item in subs] records = [item.report_dict() for item in subs]
logger.debug(f"Records: {pformat(records)}")
# make dataframe from record dictionaries # make dataframe from record dictionaries
detailed_df, summary_df = make_report_xlsx(records=records) detailed_df, summary_df = make_report_xlsx(records=records)
html = make_report_html(df=summary_df, start_date=info['start_date'], end_date=info['end_date']) html = make_report_html(df=summary_df, start_date=info['start_date'], end_date=info['end_date'])
@@ -430,23 +431,42 @@ class SubmissionsSheet(QTableView):
writer = pd.ExcelWriter(fname.with_suffix(".xlsx"), engine='openpyxl') writer = pd.ExcelWriter(fname.with_suffix(".xlsx"), engine='openpyxl')
summary_df.to_excel(writer, sheet_name="Report") summary_df.to_excel(writer, sheet_name="Report")
detailed_df.to_excel(writer, sheet_name="Details", index=False) detailed_df.to_excel(writer, sheet_name="Details", index=False)
worksheet = writer.sheets['Report'] worksheet: Worksheet = writer.sheets['Report']
for idx, col in enumerate(summary_df): # loop through all columns for idx, col in enumerate(summary_df, start=1): # loop through all columns
series = summary_df[col] series = summary_df[col]
max_len = max(( max_len = max((
series.astype(str).map(len).max(), # len of largest item series.astype(str).map(len).max(), # len of largest item
len(str(series.name)) # len of column name/header len(str(series.name)) # len of column name/header
)) + 20 # adding a little extra space )) + 20 # adding a little extra space
try: try:
worksheet.column_dimensions[get_column_letter(idx)].width = max_len # worksheet.column_dimensions[get_column_letter(idx=idx)].width = max_len
# Convert idx to letter
col_letter = chr(ord('@') + idx)
worksheet.column_dimensions[col_letter].width = max_len
except ValueError: except ValueError:
pass pass
blank_row = get_first_blank_df_row(summary_df) + 1
logger.debug(f"Blank row index = {blank_row}")
for col in range(3,6):
col_letter = row_map[col]
worksheet.cell(row=blank_row, column=col, value=f"=SUM({col_letter}2:{col_letter}{str(blank_row-1)})")
for cell in worksheet['D']: for cell in worksheet['D']:
if cell.row > 1: if cell.row > 1:
cell.style = 'Currency' cell.style = 'Currency'
writer.close() writer.close()
self.report.add_result(report) self.report.add_result(report)
def regenerate_submission_form(self):
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")
# if msg.exec():
# delete_submission(id=value)
sub = BasicSubmission.query(id=value)
fname = select_save_file(self, default_name=sub.to_pydantic().construct_filename(), extension="xlsx")
sub.backup(fname=fname)
class SubmissionDetails(QDialog): class SubmissionDetails(QDialog):
""" """
a window showing text details of submission a window showing text details of submission
@@ -466,7 +486,7 @@ class SubmissionDetails(QDialog):
# get submision from db # get submision from db
# sub = lookup_submissions(ctx=ctx, id=id) # sub = lookup_submissions(ctx=ctx, id=id)
sub = BasicSubmission.query(id=id) sub = BasicSubmission.query(id=id)
logger.debug(f"Submission details data:\n{pprint.pformat(sub.to_dict())}") logger.debug(f"Submission details data:\n{pformat(sub.to_dict())}")
self.base_dict = sub.to_dict(full_data=True) self.base_dict = sub.to_dict(full_data=True)
# don't want id # don't want id
del self.base_dict['id'] del self.base_dict['id']
@@ -611,8 +631,11 @@ class SubmissionComment(QDialog):
super().__init__(parent) super().__init__(parent)
# self.ctx = ctx # self.ctx = ctx
try:
self.app = parent.parent().parent().parent().parent().parent().parent self.app = parent.parent().parent().parent().parent().parent().parent
print(f"App: {self.app}") print(f"App: {self.app}")
except AttributeError:
pass
self.rsl = rsl self.rsl = rsl
self.setWindowTitle(f"{self.rsl} Submission Comment") self.setWindowTitle(f"{self.rsl} Submission Comment")
# create text field # create text field

View File

@@ -65,9 +65,6 @@ class SubmissionFormContainer(QWidget):
self.app.result_reporter() self.app.result_reporter()
def scrape_reagents(self, *args, **kwargs): def scrape_reagents(self, *args, **kwargs):
# from .main_window_functions import scrape_reagents
# logger.debug(f"Args: {args}")
# logger.debug(F"kwargs: {kwargs}")
print(f"\n\n{inspect.stack()[1].function}\n\n") print(f"\n\n{inspect.stack()[1].function}\n\n")
self.scrape_reagents_function(args[0]) self.scrape_reagents_function(args[0])
self.kit_integrity_completion() self.kit_integrity_completion()
@@ -140,7 +137,7 @@ class SubmissionFormContainer(QWidget):
return return
# create sheetparser using excel sheet and context from gui # create sheetparser using excel sheet and context from gui
try: try:
self.prsr = SheetParser(ctx=self.ctx, filepath=fname) self.prsr = SheetParser(ctx=self.app.ctx, filepath=fname)
except PermissionError: except PermissionError:
logger.error(f"Couldn't get permission to access file: {fname}") logger.error(f"Couldn't get permission to access file: {fname}")
return return
@@ -519,7 +516,7 @@ class SubmissionFormWidget(QWidget):
case 'submitting_lab': case 'submitting_lab':
add_widget = QComboBox() add_widget = QComboBox()
# lookup organizations suitable for submitting_lab (ctx: self.InfoItem.SubmissionFormWidget.SubmissionFormContainer.AddSubForm ) # lookup organizations suitable for submitting_lab (ctx: self.InfoItem.SubmissionFormWidget.SubmissionFormContainer.AddSubForm )
labs = [item.__str__() for item in Organization.query()] labs = [item.name for item in Organization.query()]
# try to set closest match to top of list # try to set closest match to top of list
try: try:
labs = difflib.get_close_matches(value, labs, len(labs), 0) labs = difflib.get_close_matches(value, labs, len(labs), 0)
@@ -536,7 +533,7 @@ class SubmissionFormWidget(QWidget):
add_widget = QComboBox() add_widget = QComboBox()
# lookup existing kits by 'submission_type' decided on by sheetparser # lookup existing kits by 'submission_type' decided on by sheetparser
logger.debug(f"Looking up kits used for {submission_type}") logger.debug(f"Looking up kits used for {submission_type}")
uses = [item.__str__() for item in KitType.query(used_for=submission_type)] uses = [item.name for item in KitType.query(used_for=submission_type)]
obj.uses = uses obj.uses = uses
logger.debug(f"Kits received for {submission_type}: {uses}") logger.debug(f"Kits received for {submission_type}: {uses}")
if check_not_nan(value): if check_not_nan(value):
@@ -616,6 +613,8 @@ class ReagentFormWidget(QWidget):
def __init__(self, parent:QWidget, reagent:PydReagent, extraction_kit:str): def __init__(self, parent:QWidget, reagent:PydReagent, extraction_kit:str):
super().__init__(parent) super().__init__(parent)
# self.setParent(parent) # self.setParent(parent)
self.app = self.parent().parent().parent().parent().parent().parent().parent().parent()
self.reagent = reagent self.reagent = reagent
self.extraction_kit = extraction_kit self.extraction_kit = extraction_kit
# self.ctx = reagent.ctx # self.ctx = reagent.ctx
@@ -640,7 +639,8 @@ class ReagentFormWidget(QWidget):
if wanted_reagent == None: 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?") 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(): if dlg.exec():
wanted_reagent = self.parent().parent().parent().parent().parent().parent().parent().parent().parent.add_reagent(reagent_lot=lot, reagent_type=self.reagent.type, expiry=self.reagent.expiry, name=self.reagent.name) 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 return wanted_reagent, None
else: else:
# In this case we will have an empty reagent and the submission will fail kit integrity check # In this case we will have an empty reagent and the submission will fail kit integrity check
@@ -690,7 +690,7 @@ class ReagentFormWidget(QWidget):
# below was lookup_reagent_by_type_name_and_kit_name, but I couldn't get it to work. # 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 = lookup_reagents(ctx=self.ctx, reagent_type=reagent.type)
lookup = Reagent.query(reagent_type=reagent.type) lookup = Reagent.query(reagent_type=reagent.type)
relevant_reagents = [item.__str__() for item in lookup] relevant_reagents = [str(item.lot) for item in lookup]
output_reg = [] output_reg = []
for rel_reagent in relevant_reagents: for rel_reagent in relevant_reagents:
# extract strings from any sets. # extract strings from any sets.

View File

@@ -35,7 +35,7 @@
</style> </style>
<title>Submission Details for {{ sub['Plate Number'] }}</title> <title>Submission Details for {{ sub['Plate Number'] }}</title>
</head> </head>
{% set excluded = ['reagents', 'samples', 'controls', 'ext_info', 'pcr_info', 'comments', 'barcode', 'platemap', 'export_map'] %} {% set excluded = ['reagents', 'samples', 'controls', 'extraction_info', 'pcr_info', 'comment', 'barcode', 'platemap', 'export_map'] %}
<body> <body>
<h2><u>Submission Details for {{ sub['Plate Number'] }}</u></h2>&nbsp;&nbsp;&nbsp;{% if sub['barcode'] %}<img align='right' height="30px" width="120px" src="data:image/jpeg;base64,{{ sub['barcode'] | safe }}">{% endif %} <h2><u>Submission Details for {{ sub['Plate Number'] }}</u></h2>&nbsp;&nbsp;&nbsp;{% if sub['barcode'] %}<img align='right' height="30px" width="120px" src="data:image/jpeg;base64,{{ sub['barcode'] | safe }}">{% endif %}
<p>{% for key, value in sub.items() if key not in excluded %} <p>{% for key, value in sub.items() if key not in excluded %}

View File

@@ -12,10 +12,10 @@
<h3><u>{{ lab['lab'] }}:</u></h3> <h3><u>{{ lab['lab'] }}:</u></h3>
{% for kit in lab['kits'] %} {% for kit in lab['kits'] %}
<p><b>{{ kit['name'] }}</b></p> <p><b>{{ kit['name'] }}</b></p>
<p> Runs: {{ kit['plate_count'] }}, Samples: {{ kit['sample_count'] }}, Cost: {{ "${:,.2f}".format(kit['cost']) }}</p> <p> Runs: {{ kit['run_count'] }}, Samples: {{ kit['sample_count'] }}, Cost: {{ "${:,.2f}".format(kit['cost']) }}</p>
{% endfor %} {% endfor %}
<p><b>Lab total:</b></p> <p><b>Lab total:</b></p>
<p> Runs: {{ lab['total_plates'] }}, Samples: {{ lab['total_samples'] }}, Cost: {{ "${:,.2f}".format(lab['total_cost']) }}</p> <p> Runs: {{ lab['total_runs'] }}, Samples: {{ lab['total_samples'] }}, Cost: {{ "${:,.2f}".format(lab['total_cost']) }}</p>
<br> <br>
{% endfor %} {% endfor %}
</body> </body>

View File

@@ -13,13 +13,14 @@ import sys, os, stat, platform, getpass
import logging import logging
from logging import handlers from logging import handlers
from pathlib import Path from pathlib import Path
from sqlalchemy.orm import Session, declarative_base, DeclarativeMeta, Query from sqlalchemy.orm import Query, Session
from sqlalchemy import create_engine from sqlalchemy import create_engine
from pydantic import field_validator, BaseModel, Field from pydantic import field_validator, BaseModel, Field
from pydantic_settings import BaseSettings, SettingsConfigDict from pydantic_settings import BaseSettings, SettingsConfigDict
from typing import Any, Tuple, Literal, List from typing import Any, Tuple, Literal, List
import inspect import inspect
logger = logging.getLogger(f"submissions.{__name__}") logger = logging.getLogger(f"submissions.{__name__}")
package_dir = Path(__file__).parents[2].resolve() package_dir = Path(__file__).parents[2].resolve()
@@ -39,9 +40,6 @@ LOGDIR = main_aux_dir.joinpath("logs")
row_map = {1:"A", 2:"B", 3:"C", 4:"D", 5:"E", 6:"F", 7:"G", 8:"H"} row_map = {1:"A", 2:"B", 3:"C", 4:"D", 5:"E", 6:"F", 7:"G", 8:"H"}
Base: DeclarativeMeta = declarative_base()
metadata = Base.metadata
def check_not_nan(cell_contents) -> bool: def check_not_nan(cell_contents) -> bool:
""" """
Check to ensure excel sheet cell contents are not blank. Check to ensure excel sheet cell contents are not blank.
@@ -106,12 +104,12 @@ def check_regex_match(pattern:str, check:str) -> bool:
except TypeError: except TypeError:
return False return False
def massage_common_reagents(reagent_name:str): # def massage_common_reagents(reagent_name:str):
logger.debug(f"Attempting to massage {reagent_name}") # logger.debug(f"Attempting to massage {reagent_name}")
if reagent_name.endswith("water") or "H2O" in reagent_name.upper(): # if reagent_name.endswith("water") or "H2O" in reagent_name.upper():
reagent_name = "molecular_grade_water" # reagent_name = "molecular_grade_water"
reagent_name = reagent_name.replace("µ", "u") # reagent_name = reagent_name.replace("µ", "u")
return reagent_name # return reagent_name
class GroupWriteRotatingFileHandler(handlers.RotatingFileHandler): class GroupWriteRotatingFileHandler(handlers.RotatingFileHandler):
@@ -170,7 +168,7 @@ class Settings(BaseSettings):
def set_backup_path(cls, value): def set_backup_path(cls, value):
if isinstance(value, str): if isinstance(value, str):
value = Path(value) value = Path(value)
metadata.backup_path = value # metadata.backup_path = value
return value return value
@field_validator('directory_path', mode="before") @field_validator('directory_path', mode="before")
@@ -180,7 +178,7 @@ class Settings(BaseSettings):
value = Path(value) value = Path(value)
if not value.exists(): if not value.exists():
value = Path().home() value = Path().home()
metadata.directory_path = value # metadata.directory_path = value
return value return value
@field_validator('database_path', mode="before") @field_validator('database_path', mode="before")
@@ -223,7 +221,7 @@ class Settings(BaseSettings):
logger.debug(f"Using {database_path} for database file.") logger.debug(f"Using {database_path} for database file.")
engine = create_engine(f"sqlite:///{database_path}")#, echo=True, future=True) engine = create_engine(f"sqlite:///{database_path}")#, echo=True, future=True)
session = Session(engine) session = Session(engine)
metadata.session = session # metadata.session = session
return session return session
@field_validator('package', mode="before") @field_validator('package', mode="before")
@@ -239,6 +237,7 @@ def get_config(settings_path: Path|str|None=None) -> Settings:
Args: Args:
settings_path (Path | str | None, optional): Path to config.yml Defaults to None. settings_path (Path | str | None, optional): Path to config.yml Defaults to None.
override (dict | None, optional): dictionary of settings to be used instead of file. Defaults to None.
Returns: Returns:
Settings: Pydantic settings object Settings: Pydantic settings object
@@ -273,7 +272,6 @@ def get_config(settings_path: Path|str|None=None) -> Settings:
settings_path = Path.home().joinpath(".submissions", "config.yml") settings_path = Path.home().joinpath(".submissions", "config.yml")
# finally look in the local config # finally look in the local config
else: else:
# if getattr(sys, 'frozen', False):
if check_if_app(): if check_if_app():
settings_path = Path(sys._MEIPASS).joinpath("files", "config.yml") settings_path = Path(sys._MEIPASS).joinpath("files", "config.yml")
else: else:
@@ -281,7 +279,6 @@ def get_config(settings_path: Path|str|None=None) -> Settings:
with open(settings_path, "r") as dset: with open(settings_path, "r") as dset:
default_settings = yaml.load(dset, Loader=yaml.Loader) default_settings = yaml.load(dset, Loader=yaml.Loader)
# Tell program we need to copy the config.yml to the user directory # Tell program we need to copy the config.yml to the user directory
# copy_settings_trigger = True
# copy settings to config directory # copy settings to config directory
return Settings(**copy_settings(settings_path=CONFIGDIR.joinpath("config.yml"), settings=default_settings)) return Settings(**copy_settings(settings_path=CONFIGDIR.joinpath("config.yml"), settings=default_settings))
else: else:
@@ -390,6 +387,12 @@ def jinja_template_loading():
return env return env
def check_authorization(func): 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): def wrapper(*args, **kwargs):
logger.debug(f"Checking authorization") logger.debug(f"Checking authorization")
if getpass.getuser() in kwargs['ctx'].power_users: if getpass.getuser() in kwargs['ctx'].power_users:
@@ -399,7 +402,7 @@ def check_authorization(func):
return dict(code=1, message="This user does not have permission for this function.", status="warning") return dict(code=1, message="This user does not have permission for this function.", status="warning")
return wrapper return wrapper
def check_if_app(ctx:Settings=None) -> bool: def check_if_app() -> bool:
""" """
Checks if the program is running from pyinstaller compiled Checks if the program is running from pyinstaller compiled
@@ -484,15 +487,6 @@ class Report(BaseModel):
results: List[Result] = Field(default=[]) results: List[Result] = Field(default=[])
# def __init__(self, *args, **kwargs):
# if 'msg' in kwargs.keys():
# res = Result(msg=kwargs['msg'])
# for k,v in kwargs.items():
# if k in ['code', 'status']:
# setattr(res, k, v)
# self.results.append(res)
def __repr__(self): def __repr__(self):
return f"Report(result_count:{len(self.results)})" return f"Report(result_count:{len(self.results)})"
@@ -523,3 +517,8 @@ def readInChunks(fileObj, chunkSize=2048):
if not data: if not data:
break break
yield data yield data
def get_first_blank_df_row(df:pd.DataFrame) -> int:
return len(df) + 1
ctx = get_config(None)