Increased robustness of form parsers.

This commit is contained in:
Landon Wark
2023-10-06 14:22:59 -05:00
parent e484eabb22
commit 1b6d415788
27 changed files with 747 additions and 284 deletions

View File

@@ -1,5 +1,14 @@
## 202310.01
- Controls linker is now depreciated.
- Controls will now be directly added to their submissions instead of having to run linker.
- Submission details now has additional html functionality in plate map.
- Added Submission Category to fields.
- Increased robustness of form parsers by adding custom procedures for each.
## 202309.04 ## 202309.04
- Updated KitAdder to add location info as well.
- Extraction kit can now be updated after import. - Extraction kit can now be updated after import.
- Large scale refactoring to improve efficiency of database functions. - Large scale refactoring to improve efficiency of database functions.

View File

@@ -49,7 +49,16 @@ This is meant to import .xslx files created from the Design & Analysis Software
## Adding new Kit: ## Adding new Kit:
1. Instructions to come. 1. Click "Add Kit" tab in the tab bar.
2. Select the Submission type from the drop down menu.
3. Fill in the kit name (required) and other fields (optional).
4. For each reagent type in the kit click the "Add Reagent Type" button.
5. Fill in the name of the reagent type. Alternatively select from already existing types in the drop down.
6. Fill in the reagent location in the excel submission sheet.
a. For example if the reagent name is in a sheet called "Reagent Info" in row 12, column 1, type "Reagent Info" in the "Excel Location Sheet Name" field.
b. Set 12 in the "Name Row" and 1 in the "Name Column".
c. Repeat 6b for the Lot and the Expiry row and columns.
7. Click the "Submit" button at the top.
## Linking Controls: ## Linking Controls:

View File

@@ -1,7 +1,12 @@
- [ ] Get info for controls into their sample hitpicks.
- [x] Move submission-type specific parser functions into class methods in their respective models.
- [ ] Improve results reporting.
- Maybe make it a list until it gets to the reporter?
- [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.
- [x] Clean up db.functions. - [x] Clean up db.functions.
- [ ] Make kits easier to add. - [x] Make kits easier to add.
- [ ] Clean up & document code... again. - [x] Clean up & document code... again.
- Including paring down the logging.debugs - Including paring down the logging.debugs
- Also including reducing number of functions in db.functions - Also including reducing number of functions in db.functions
- [x] Fix Tests... again. - [x] Fix Tests... again.

View File

@@ -56,8 +56,8 @@ 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
[post_write_hooks] [post_write_hooks]

View File

@@ -0,0 +1,32 @@
"""adding submission category
Revision ID: b95478ffb4a3
Revises: 9a133efb3ffd
Create Date: 2023-10-03 14:00:09.663055
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'b95478ffb4a3'
down_revision = '9a133efb3ffd'
branch_labels = None
depends_on = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('_submissions', schema=None) as batch_op:
batch_op.add_column(sa.Column('submission_category', sa.String(length=64), nullable=True))
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('_submissions', schema=None) as batch_op:
batch_op.drop_column('submission_category')
# ### 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__ = "202309.4b" __version__ = "202310.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

@@ -76,10 +76,10 @@ def store_object(ctx:Settings, object) -> dict|None:
dbs.merge(object) dbs.merge(object)
try: try:
dbs.commit() dbs.commit()
except (sqlite3.IntegrityError, sqlalchemy.exc.IntegrityError) as e: except (SQLIntegrityError, AlcIntegrityError) as e:
logger.debug(f"Hit an integrity error : {e}") logger.debug(f"Hit an integrity error : {e}")
dbs.rollback() dbs.rollback()
return {"message":f"This object {object} already exists, so we can't add it.", "status":"Critical"} return {"message":f"This object {object} already exists, so we can't add it.\n{e}", "status":"Critical"}
except (SQLOperationalError, AlcOperationalError): except (SQLOperationalError, AlcOperationalError):
logger.error(f"Hit an operational error: {e}") logger.error(f"Hit an operational error: {e}")
dbs.rollback() dbs.rollback()

View File

@@ -10,6 +10,7 @@ from datetime import date, timedelta
from dateutil.parser import parse from dateutil.parser import parse
from typing import Tuple from typing import Tuple
from sqlalchemy.exc import IntegrityError, SAWarning from sqlalchemy.exc import IntegrityError, SAWarning
from . import store_object
logger = logging.getLogger(f"submissions.{__name__}") logger = logging.getLogger(f"submissions.{__name__}")
@@ -157,7 +158,7 @@ def construct_samples(ctx:Settings, instance:models.BasicSubmission, samples:Lis
models.BasicSubmission: Updated submission object. models.BasicSubmission: Updated submission object.
""" """
for sample in samples: for sample in samples:
sample_instance = lookup_samples(ctx=ctx, submitter_id=sample['sample'].submitter_id) sample_instance = lookup_samples(ctx=ctx, submitter_id=str(sample['sample'].submitter_id))
if sample_instance == None: if sample_instance == None:
sample_instance = sample['sample'] sample_instance = sample['sample']
else: else:
@@ -174,7 +175,7 @@ def construct_samples(ctx:Settings, instance:models.BasicSubmission, samples:Lis
try: try:
assoc = getattr(models, f"{sample_query}Association") assoc = getattr(models, f"{sample_query}Association")
except AttributeError as e: except AttributeError as e:
logger.error(f"Couldn't get type specific association. Getting generic.") logger.error(f"Couldn't get type specific association using {sample_instance.sample_type.replace('Sample', '').strip()}. Getting generic.")
assoc = models.SubmissionSampleAssociation assoc = models.SubmissionSampleAssociation
assoc = assoc(submission=instance, sample=sample_instance, row=sample['row'], column=sample['column']) assoc = assoc(submission=instance, sample=sample_instance, row=sample['row'], column=sample['column'])
instance.submission_sample_associations.append(assoc) instance.submission_sample_associations.append(assoc)
@@ -189,7 +190,7 @@ def construct_samples(ctx:Settings, instance:models.BasicSubmission, samples:Lis
continue continue
return instance return instance
def construct_kit_from_yaml(ctx:Settings, exp:dict) -> dict: def construct_kit_from_yaml(ctx:Settings, kit_dict:dict) -> dict:
""" """
Create and store a new kit in the database based on a .yml file Create and store a new kit in the database based on a .yml file
TODO: split into create and store functions TODO: split into create and store functions
@@ -206,36 +207,33 @@ def construct_kit_from_yaml(ctx:Settings, exp:dict) -> dict:
if not check_is_power_user(ctx=ctx): if not check_is_power_user(ctx=ctx):
logger.debug(f"{getuser()} does not have permission to add kits.") logger.debug(f"{getuser()} does not have permission to add kits.")
return {'code':1, 'message':"This user does not have permission to add kits.", "status":"warning"} return {'code':1, 'message':"This user does not have permission to add kits.", "status":"warning"}
# iterate through keys in dict submission_type = lookup_submission_type(ctx=ctx, name=kit_dict['used_for'])
for type in exp: logger.debug(f"Looked up submission type: {kit_dict['used_for']} and got {submission_type}")
# A submission type may use multiple kits. kit = models.KitType(name=kit_dict["kit_name"])
for kt in exp[type]['kits']: kt_st_assoc = models.SubmissionTypeKitTypeAssociation(kit_type=kit, submission_type=submission_type)
logger.debug(f"Looking up submission type: {type}") for k,v in kit_dict.items():
# submission_type = lookup_submissiontype_by_name(ctx=ctx, type_name=type) if k not in ["reagent_types", "kit_name", "used_for"]:
submission_type = lookup_submission_type(ctx=ctx, name=type) kt_st_assoc.set_attrib(k, v)
logger.debug(f"Looked up submission type: {submission_type}") kit.kit_submissiontype_associations.append(kt_st_assoc)
kit = models.KitType(name=kt) # A kit contains multiple reagent types.
kt_st_assoc = models.SubmissionTypeKitTypeAssociation(kit_type=kit, submission_type=submission_type) for r in kit_dict['reagent_types']:
kt_st_assoc.constant_cost = exp[type]["kits"][kt]["constant_cost"] # check if reagent type already exists.
kt_st_assoc.mutable_cost_column = exp[type]["kits"][kt]["mutable_cost_column"] logger.debug(f"Constructing reagent type: {r}")
kt_st_assoc.mutable_cost_sample = exp[type]["kits"][kt]["mutable_cost_sample"] rtname = massage_common_reagents(r['rtname'])
kit.kit_submissiontype_associations.append(kt_st_assoc) # look_up = ctx.database_session.query(models.ReagentType).filter(models.ReagentType.name==rtname).first()
# A kit contains multiple reagent types. look_up = lookup_reagent_types(name=rtname)
for r in exp[type]['kits'][kt]['reagenttypes']: if look_up == None:
# check if reagent type already exists. rt = models.ReagentType(name=rtname.strip(), eol_ext=timedelta(30*r['eol']))
r = massage_common_reagents(r) else:
look_up = ctx.database_session.query(models.ReagentType).filter(models.ReagentType.name==r).first() rt = look_up
if look_up == None: uses = {kit_dict['used_for']:{k:v for k,v in r.items() if k not in ['eol']}}
rt = models.ReagentType(name=r.strip(), eol_ext=timedelta(30*exp[type]['kits'][kt]['reagenttypes'][r]['eol_ext']), last_used="") assoc = models.KitTypeReagentTypeAssociation(kit_type=kit, reagent_type=rt, uses=uses)
else: # ctx.database_session.add(rt)
rt = look_up store_object(ctx=ctx, object=rt)
assoc = models.KitTypeReagentTypeAssociation(kit_type=kit, reagent_type=rt, uses={}) kit.kit_reagenttype_associations.append(assoc)
ctx.database_session.add(rt) logger.debug(f"Kit construction reagent type: {rt.__dict__}")
kit.kit_reagenttype_associations.append(assoc) logger.debug(f"Kit construction kit: {kit.__dict__}")
logger.debug(f"Kit construction reagent type: {rt.__dict__}") store_object(ctx=ctx, object=kit)
logger.debug(f"Kit construction kit: {kit.__dict__}")
ctx.database_session.add(kit)
ctx.database_session.commit()
return {'code':0, 'message':'Kit has been added', 'status': 'information'} return {'code':0, 'message':'Kit has been added', 'status': 'information'}
def construct_org_from_yaml(ctx:Settings, org:dict) -> dict: def construct_org_from_yaml(ctx:Settings, org:dict) -> dict:

View File

@@ -209,7 +209,11 @@ def lookup_submissions(ctx:Settings,
match rsl_number: match rsl_number:
case str(): case str():
logger.debug(f"Looking up BasicSubmission with rsl number: {rsl_number}") logger.debug(f"Looking up BasicSubmission with rsl number: {rsl_number}")
rsl_number = RSLNamer(ctx=ctx, instr=rsl_number).parsed_name try:
rsl_number = RSLNamer(ctx=ctx, instr=rsl_number).parsed_name
except AttributeError as e:
logger.error(f"No parsed name found, returning None.")
return None
# query = query.filter(models.BasicSubmission.rsl_plate_num==rsl_number) # query = query.filter(models.BasicSubmission.rsl_plate_num==rsl_number)
query = query.filter(model.rsl_plate_num==rsl_number) query = query.filter(model.rsl_plate_num==rsl_number)
limit = 1 limit = 1
@@ -306,6 +310,7 @@ def lookup_controls(ctx:Settings,
control_type:models.ControlType|str|None=None, control_type:models.ControlType|str|None=None,
start_date:date|str|int|None=None, start_date:date|str|int|None=None,
end_date:date|str|int|None=None, end_date:date|str|int|None=None,
control_name:str|None=None,
limit:int=0 limit:int=0
) -> models.Control|List[models.Control]: ) -> models.Control|List[models.Control]:
query = setup_lookup(ctx=ctx, locals=locals()).query(models.Control) query = setup_lookup(ctx=ctx, locals=locals()).query(models.Control)
@@ -343,6 +348,12 @@ def lookup_controls(ctx:Settings,
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(models.Control.submitted_date.between(start_date, end_date)) query = query.filter(models.Control.submitted_date.between(start_date, end_date))
match control_name:
case str():
query = query.filter(models.Control.name.startswith(control_name))
limit = 1
case _:
pass
return query_return(query=query, limit=limit) return query_return(query=query, limit=limit)
def lookup_control_types(ctx:Settings, limit:int=0) -> models.ControlType|List[models.ControlType]: def lookup_control_types(ctx:Settings, limit:int=0) -> models.ControlType|List[models.ControlType]:

View File

@@ -236,3 +236,24 @@ def update_subsampassoc_with_pcr(ctx:Settings, submission:models.BasicSubmission
result = store_object(ctx=ctx, object=assoc) result = store_object(ctx=ctx, object=assoc)
return result return result
def get_polymorphic_subclass(base:object, polymorphic_identity:str|None=None):
"""
Retrieves any subclasses of given base class whose polymorphic identity matches the string input.
Args:
base (object): Base (parent) class
polymorphic_identity (str | None): Name of subclass of interest. (Defaults to None)
Returns:
_type_: Subclass, or parent class on
"""
if polymorphic_identity == None:
return base
else:
try:
return [item for item in base.__subclasses__() if item.__mapper_args__['polymorphic_identity']==polymorphic_identity][0]
except Exception as e:
logger.error(f"Could not get polymorph {polymorphic_identity} of {base} due to {e}")
return base

View File

@@ -2,11 +2,11 @@
Contains all models for sqlalchemy Contains all models for sqlalchemy
''' '''
from typing import Any from typing import Any
from sqlalchemy.orm import declarative_base from sqlalchemy.orm import declarative_base, DeclarativeMeta
import logging import logging
from pprint import pformat from pprint import pformat
Base = declarative_base() Base: DeclarativeMeta = declarative_base()
metadata = Base.metadata metadata = Base.metadata
logger = logging.getLogger(f"submissions.{__name__}") logger = logging.getLogger(f"submissions.{__name__}")

View File

@@ -332,4 +332,7 @@ class SubmissionTypeKitTypeAssociation(Base):
self.constant_cost = 0.00 self.constant_cost = 0.00
def __repr__(self) -> str: def __repr__(self) -> str:
return f"<SubmissionTypeKitTypeAssociation({self.submission_type.name})" return f"<SubmissionTypeKitTypeAssociation({self.submission_type.name})"
def set_attrib(self, name, value):
self.__setattr__(name, value)

View File

@@ -13,6 +13,9 @@ from sqlalchemy.ext.associationproxy import association_proxy
import uuid import uuid
from pandas import Timestamp from pandas import Timestamp
from dateutil.parser import parse from dateutil.parser import parse
import re
import pandas as pd
from tools import row_map
logger = logging.getLogger(f"submissions.{__name__}") logger = logging.getLogger(f"submissions.{__name__}")
@@ -43,6 +46,7 @@ class BasicSubmission(Base):
run_cost = Column(FLOAT(2)) #: total cost of running the plate. Set from constant and mutable kit costs at time of creation. run_cost = Column(FLOAT(2)) #: total cost of running the plate. Set from constant and mutable kit costs at time of creation.
uploaded_by = Column(String(32)) #: user name of person who submitted the submission to the database. uploaded_by = Column(String(32)) #: user name of person who submitted the submission to the database.
comment = Column(JSON) comment = Column(JSON)
submission_category = Column(String(64))
submission_sample_associations = relationship( submission_sample_associations = relationship(
"SubmissionSampleAssociation", "SubmissionSampleAssociation",
@@ -83,7 +87,7 @@ class BasicSubmission(Base):
dict: dictionary used in submissions summary and details dict: dictionary used in submissions summary and details
""" """
# get lab from nested organization object # get lab from nested organization object
logger.debug(f"Converting {self.rsl_plate_num} to dict...") # logger.debug(f"Converting {self.rsl_plate_num} to dict...")
try: try:
sub_lab = self.submitting_lab.name sub_lab = self.submitting_lab.name
except AttributeError: except AttributeError:
@@ -125,6 +129,7 @@ class BasicSubmission(Base):
"id": self.id, "id": self.id,
"Plate Number": self.rsl_plate_num, "Plate Number": self.rsl_plate_num,
"Submission Type": self.submission_type_name, "Submission Type": self.submission_type_name,
"Submission Category": self.submission_category,
"Submitter Plate Number": self.submitter_plate_num, "Submitter Plate Number": self.submitter_plate_num,
"Submitted Date": self.submitted_date.strftime("%Y-%m-%d"), "Submitted Date": self.submitted_date.strftime("%Y-%m-%d"),
"Submitting Lab": sub_lab, "Submitting Lab": sub_lab,
@@ -232,6 +237,34 @@ class BasicSubmission(Base):
else: else:
continue continue
return output_list return output_list
@classmethod
def parse_info(cls, input_dict:dict, xl:pd.ExcelFile|None=None) -> dict:
"""
Update submission dictionary with type specific information
Args:
input_dict (dict): Input sample dictionary
Returns:
dict: Updated sample dictionary
"""
logger.debug(f"Calling {cls.__name__} info parser.")
return input_dict
@classmethod
def parse_samples(cls, input_dict:dict) -> dict:
"""
Update sample dictionary with type specific information
Args:
input_dict (dict): Input sample dictionary
Returns:
dict: Updated sample dictionary
"""
logger.debug(f"Called {cls.__name__} sample parser")
return input_dict
# Below are the custom submission types # Below are the custom submission types
@@ -252,7 +285,7 @@ class BacterialCulture(BasicSubmission):
output = super().to_dict(full_data=full_data) output = super().to_dict(full_data=full_data)
if full_data: if full_data:
output['controls'] = [item.to_sub_dict() for item in self.controls] output['controls'] = [item.to_sub_dict() for item in self.controls]
return output return output
class Wastewater(BasicSubmission): class Wastewater(BasicSubmission):
""" """
@@ -278,6 +311,23 @@ class Wastewater(BasicSubmission):
output['Technician'] = f"Enr: {self.technician}, Ext: {self.ext_technician}, PCR: {self.pcr_technician}" output['Technician'] = f"Enr: {self.technician}, Ext: {self.ext_technician}, PCR: {self.pcr_technician}"
return output return output
@classmethod
def parse_info(cls, input_dict:dict, xl:pd.ExcelFile|None=None) -> dict:
"""
Update submission dictionary with type specific information. Extends parent
Args:
input_dict (dict): Input sample dictionary
Returns:
dict: Updated sample dictionary
"""
input_dict = super().parse_info(input_dict)
if xl != None:
input_dict['csv'] = xl.parse("Copy to import file")
return input_dict
class WastewaterArtic(BasicSubmission): class WastewaterArtic(BasicSubmission):
""" """
derivative submission type for artic wastewater derivative submission type for artic wastewater
@@ -303,6 +353,25 @@ class WastewaterArtic(BasicSubmission):
except Exception as e: except Exception as e:
logger.error(f"Calculation error: {e}") logger.error(f"Calculation error: {e}")
@classmethod
def parse_samples(cls, input_dict: dict) -> dict:
"""
Update sample dictionary with type specific information. Extends parent.
Args:
input_dict (dict): Input sample dictionary
Returns:
dict: Updated sample dictionary
"""
input_dict = super().parse_samples(input_dict)
input_dict['sample_type'] = "Wastewater Sample"
# Because generate_sample_object needs the submitter_id and the artic has the "({origin well})"
# at the end, this has to be done here. No moving to sqlalchemy object :(
input_dict['submitter_id'] = re.sub(r"\s\(.+\)$", "", str(input_dict['submitter_id'])).strip()
return input_dict
class BasicSample(Base): class BasicSample(Base):
""" """
Base of basic sample which polymorphs into BCSample and WWSample Base of basic sample which polymorphs into BCSample and WWSample
@@ -364,26 +433,31 @@ class BasicSample(Base):
Returns: Returns:
dict: 'well' and sample submitter_id as 'name' dict: 'well' and sample submitter_id as 'name'
""" """
row_map = {1:"A", 2:"B", 3:"C", 4:"D", 5:"E", 6:"F", 7:"G", 8:"H"}
self.assoc = [item for item in self.sample_submission_associations if item.submission.rsl_plate_num==submission_rsl][0] assoc = [item for item in self.sample_submission_associations if item.submission.rsl_plate_num==submission_rsl][0]
sample = {} sample = {}
try: try:
sample['well'] = f"{row_map[self.assoc.row]}{self.assoc.column}" sample['well'] = f"{row_map[assoc.row]}{assoc.column}"
except KeyError as e: except KeyError as e:
logger.error(f"Unable to find row {self.assoc.row} in row_map.") logger.error(f"Unable to find row {assoc.row} in row_map.")
sample['well'] = None sample['well'] = None
sample['name'] = self.submitter_id sample['name'] = self.submitter_id
return sample return sample
def to_hitpick(self, submission_rsl:str|None=None) -> dict|None: def to_hitpick(self, submission_rsl:str|None=None) -> dict|None:
""" """
Outputs a dictionary of locations Outputs a dictionary usable for html plate maps.
Returns: Returns:
dict: dictionary of sample id, row and column in elution plate dict: dictionary of sample id, row and column in elution plate
""" """
# Since there is no PCR, negliable result is necessary. # Since there is no PCR, negliable result is necessary.
return dict(name=self.submitter_id, positive=False) assoc = [item for item in self.sample_submission_associations if item.submission.rsl_plate_num==submission_rsl][0]
tooltip_text = f"""
Sample name: {self.submitter_id}<br>
Well: {row_map[assoc.row]}{assoc.column}
"""
return dict(name=self.submitter_id, positive=False, tooltip=tooltip_text)
class WastewaterSample(BasicSample): class WastewaterSample(BasicSample):
""" """
@@ -445,42 +519,24 @@ class WastewaterSample(BasicSample):
value = self.submitter_id value = self.submitter_id
super().set_attribute(name, value) super().set_attribute(name, value)
def to_sub_dict(self, submission_rsl:str) -> dict:
"""
Gui friendly dictionary. Extends parent method.
This version will include PCR status.
Args:
submission_rsl (str): RSL plate number (passed down from the submission.to_dict() functino)
Returns:
dict: Alphanumeric well id and sample name
"""
# Get the relevant submission association for this sample
sample = super().to_sub_dict(submission_rsl=submission_rsl)
# check if PCR data exists.
try:
check = self.assoc.ct_n1 != None and self.assoc.ct_n2 != None
except AttributeError as e:
check = False
if check:
sample['name'] = f"{self.submitter_id}\n\t- ct N1: {'{:.2f}'.format(self.assoc.ct_n1)} ({self.assoc.n1_status})\n\t- ct N2: {'{:.2f}'.format(self.assoc.ct_n2)} ({self.assoc.n2_status})"
return sample
def to_hitpick(self, submission_rsl:str) -> dict|None: def to_hitpick(self, submission_rsl:str) -> dict|None:
""" """
Outputs a dictionary of locations if sample is positive Outputs a dictionary usable for html plate maps. Extends parent method.
Returns: Returns:
dict: dictionary of sample id, row and column in elution plate dict: dictionary of sample id, row and column in elution plate
""" """
sample = super().to_hitpick(submission_rsl=submission_rsl) sample = super().to_hitpick(submission_rsl=submission_rsl)
assoc = [item for item in self.sample_submission_associations if item.submission.rsl_plate_num==submission_rsl][0]
# if either n1 or n2 is positive, include this sample # if either n1 or n2 is positive, include this sample
try: try:
sample['positive'] = any(["positive" in item for item in [self.assoc.n1_status, self.assoc.n2_status]]) sample['positive'] = any(["positive" in item for item in [assoc.n1_status, assoc.n2_status]])
except (TypeError, AttributeError) as e: except (TypeError, AttributeError) as e:
logger.error(f"Couldn't check positives for {self.rsl_number}. Looks like there isn't PCR data.") logger.error(f"Couldn't check positives for {self.rsl_number}. Looks like there isn't PCR data.")
try:
sample['tooltip'] += f"<br>- ct N1: {'{:.2f}'.format(assoc.ct_n1)} ({assoc.n1_status})<br>- ct N2: {'{:.2f}'.format(assoc.ct_n2)} ({assoc.n2_status})"
except (TypeError, AttributeError) as e:
logger.error(f"Couldn't set tooltip for {self.rsl_number}. Looks like there isn't PCR data.")
return sample return sample
class BacterialCultureSample(BasicSample): class BacterialCultureSample(BasicSample):

View File

@@ -6,7 +6,7 @@ import pprint
from typing import List from typing import List
import pandas as pd import pandas as pd
from pathlib import Path from pathlib import Path
from backend.db import models, lookup_kit_types, lookup_submission_type, lookup_samples from backend.db import models, lookup_kit_types, lookup_submission_type, lookup_samples, get_polymorphic_subclass
from backend.pydant import PydSubmission, PydReagent from backend.pydant import PydSubmission, PydReagent
import logging import logging
from collections import OrderedDict from collections import OrderedDict
@@ -91,12 +91,11 @@ class SheetParser(object):
Pulls basic information from the excel sheet Pulls basic information from the excel sheet
""" """
info = InfoParser(ctx=self.ctx, xl=self.xl, submission_type=self.sub['submission_type']['value']).parse_info() info = InfoParser(ctx=self.ctx, xl=self.xl, submission_type=self.sub['submission_type']['value']).parse_info()
parser_query = f"parse_{self.sub['submission_type']['value'].replace(' ', '_').lower()}" # parser_query = f"parse_{self.sub['submission_type']['value'].replace(' ', '_').lower()}"
try: # custom_parser = getattr(self, parser_query)
custom_parser = getattr(self, parser_query)
info = custom_parser(info) # except AttributeError:
except AttributeError: # logger.error(f"Couldn't find submission parser: {parser_query}")
logger.error(f"Couldn't find submission parser: {parser_query}")
for k,v in info.items(): for k,v in info.items():
match k: match k:
case "sample": case "sample":
@@ -120,41 +119,41 @@ class SheetParser(object):
""" """
self.sample_result, self.sub['samples'] = SampleParser(ctx=self.ctx, xl=self.xl, submission_type=self.sub['submission_type']['value']).parse_samples() self.sample_result, self.sub['samples'] = SampleParser(ctx=self.ctx, xl=self.xl, submission_type=self.sub['submission_type']['value']).parse_samples()
def parse_bacterial_culture(self, input_dict) -> dict: # def parse_bacterial_culture(self, input_dict) -> dict:
""" # """
Update submission dictionary with type specific information # Update submission dictionary with type specific information
Args: # Args:
input_dict (dict): Input sample dictionary # input_dict (dict): Input sample dictionary
Returns: # Returns:
dict: Updated sample dictionary # dict: Updated sample dictionary
""" # """
return input_dict # return input_dict
def parse_wastewater(self, input_dict) -> dict: # def parse_wastewater(self, input_dict) -> dict:
""" # """
Update submission dictionary with type specific information # Update submission dictionary with type specific information
Args: # Args:
input_dict (dict): Input sample dictionary # input_dict (dict): Input sample dictionary
Returns: # Returns:
dict: Updated sample dictionary # dict: Updated sample dictionary
""" # """
return input_dict # return input_dict
def parse_wastewater_artic(self, input_dict:dict) -> dict: # def parse_wastewater_artic(self, input_dict:dict) -> dict:
""" # """
Update submission dictionary with type specific information # Update submission dictionary with type specific information
Args: # Args:
input_dict (dict): Input sample dictionary # input_dict (dict): Input sample dictionary
Returns: # Returns:
dict: Updated sample dictionary # dict: Updated sample dictionary
""" # """
return input_dict # return input_dict
def import_kit_validation_check(self): def import_kit_validation_check(self):
@@ -206,6 +205,7 @@ class InfoParser(object):
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: {pprint.pformat(self.map)}") logger.debug(f"Info map for InfoParser: {pprint.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:
""" """
@@ -223,6 +223,8 @@ class InfoParser(object):
# submission_type = lookup_submissiontype_by_name(ctx=self.ctx, type_name=submission_type['value']) # submission_type = lookup_submissiontype_by_name(ctx=self.ctx, type_name=submission_type['value'])
submission_type = lookup_submission_type(ctx=self.ctx, name=submission_type['value']) submission_type = lookup_submission_type(ctx=self.ctx, name=submission_type['value'])
info_map = submission_type.info_map info_map = submission_type.info_map
# Get the parse_info method from the submission type specified
self.custom_parser = get_polymorphic_subclass(models.BasicSubmission, submission_type.name).parse_info
return info_map return info_map
def parse_info(self) -> dict: def parse_info(self) -> dict:
@@ -263,7 +265,13 @@ class InfoParser(object):
continue continue
else: else:
dicto[item] = dict(value=convert_nans_to_nones(value), parsed=False) dicto[item] = dict(value=convert_nans_to_nones(value), parsed=False)
return dicto try:
check = dicto['submission_category'] not in ["", None]
except KeyError:
check = False
return self.custom_parser(input_dict=dicto, xl=self.xl)
class ReagentParser(object): class ReagentParser(object):
@@ -351,6 +359,7 @@ class SampleParser(object):
submission_type = lookup_submission_type(ctx=self.ctx, name=submission_type) submission_type = lookup_submission_type(ctx=self.ctx, name=submission_type)
logger.debug(f"info_map: {pprint.pformat(submission_type.info_map)}") logger.debug(f"info_map: {pprint.pformat(submission_type.info_map)}")
sample_info_map = submission_type.info_map['samples'] sample_info_map = submission_type.info_map['samples']
self.custom_parser = get_polymorphic_subclass(models.BasicSubmission, submission_type.name).parse_samples
return sample_info_map return sample_info_map
def construct_plate_map(self, plate_map_location:dict) -> pd.DataFrame: def construct_plate_map(self, plate_map_location:dict) -> pd.DataFrame:
@@ -473,12 +482,12 @@ class SampleParser(object):
except KeyError: except KeyError:
translated_dict[k] = convert_nans_to_nones(v) translated_dict[k] = convert_nans_to_nones(v)
translated_dict['sample_type'] = f"{self.submission_type} Sample" translated_dict['sample_type'] = f"{self.submission_type} Sample"
parser_query = f"parse_{translated_dict['sample_type'].replace(' ', '_').lower()}" # parser_query = f"parse_{translated_dict['sample_type'].replace(' ', '_').lower()}"
try: # try:
custom_parser = getattr(self, parser_query) # custom_parser = getattr(self, parser_query)
translated_dict = custom_parser(translated_dict) translated_dict = self.custom_parser(translated_dict)
except AttributeError: # except AttributeError:
logger.error(f"Couldn't get custom parser: {parser_query}") # logger.error(f"Couldn't get custom parser: {parser_query}")
if generate: if generate:
new_samples.append(self.generate_sample_object(translated_dict)) new_samples.append(self.generate_sample_object(translated_dict))
else: else:
@@ -502,7 +511,7 @@ class SampleParser(object):
logger.error(f"Could not find the model {query}. Using generic.") logger.error(f"Could not find the model {query}. Using generic.")
database_obj = models.BasicSample database_obj = models.BasicSample
logger.debug(f"Searching database for {input_dict['submitter_id']}...") logger.debug(f"Searching database for {input_dict['submitter_id']}...")
instance = lookup_samples(ctx=self.ctx, submitter_id=input_dict['submitter_id']) instance = lookup_samples(ctx=self.ctx, submitter_id=str(input_dict['submitter_id']))
if instance == None: if instance == None:
logger.debug(f"Couldn't find sample {input_dict['submitter_id']}. Creating new sample.") logger.debug(f"Couldn't find sample {input_dict['submitter_id']}. Creating new sample.")
instance = database_obj() instance = database_obj()
@@ -516,63 +525,63 @@ class SampleParser(object):
return dict(sample=instance, row=input_dict['row'], column=input_dict['column']) return dict(sample=instance, row=input_dict['row'], column=input_dict['column'])
def parse_bacterial_culture_sample(self, input_dict:dict) -> dict: # def parse_bacterial_culture_sample(self, input_dict:dict) -> dict:
""" # """
Update sample dictionary with bacterial culture specific information # Update sample dictionary with bacterial culture specific information
Args: # Args:
input_dict (dict): Input sample dictionary # input_dict (dict): Input sample dictionary
Returns: # Returns:
dict: Updated sample dictionary # dict: Updated sample dictionary
""" # """
logger.debug("Called bacterial culture sample parser") # logger.debug("Called bacterial culture sample parser")
return input_dict # return input_dict
def parse_wastewater_sample(self, input_dict:dict) -> dict: # def parse_wastewater_sample(self, input_dict:dict) -> dict:
""" # """
Update sample dictionary with wastewater specific information # Update sample dictionary with wastewater specific information
Args: # Args:
input_dict (dict): Input sample dictionary # input_dict (dict): Input sample dictionary
Returns: # Returns:
dict: Updated sample dictionary # dict: Updated sample dictionary
""" # """
logger.debug(f"Called wastewater sample parser") # logger.debug(f"Called wastewater sample parser")
return input_dict # return input_dict
def parse_wastewater_artic_sample(self, input_dict:dict) -> dict: # def parse_wastewater_artic_sample(self, input_dict:dict) -> dict:
""" # """
Update sample dictionary with artic specific information # Update sample dictionary with artic specific information
Args: # Args:
input_dict (dict): Input sample dictionary # input_dict (dict): Input sample dictionary
Returns: # Returns:
dict: Updated sample dictionary # dict: Updated sample dictionary
""" # """
logger.debug("Called wastewater artic sample parser") # logger.debug("Called wastewater artic sample parser")
input_dict['sample_type'] = "Wastewater Sample" # input_dict['sample_type'] = "Wastewater Sample"
# Because generate_sample_object needs the submitter_id and the artic has the "({origin well})" # # Because generate_sample_object needs the submitter_id and the artic has the "({origin well})"
# at the end, this has to be done here. No moving to sqlalchemy object :( # # at the end, this has to be done here. No moving to sqlalchemy object :(
input_dict['submitter_id'] = re.sub(r"\s\(.+\)$", "", str(input_dict['submitter_id'])).strip() # input_dict['submitter_id'] = re.sub(r"\s\(.+\)$", "", str(input_dict['submitter_id'])).strip()
return input_dict # return input_dict
def parse_first_strand_sample(self, input_dict:dict) -> dict: # def parse_first_strand_sample(self, input_dict:dict) -> dict:
""" # """
Update sample dictionary with first strand specific information # Update sample dictionary with first strand specific information
Args: # Args:
input_dict (dict): Input sample dictionary # input_dict (dict): Input sample dictionary
Returns: # Returns:
dict: Updated sample dictionary # dict: Updated sample dictionary
""" # """
logger.debug("Called first strand sample parser") # logger.debug("Called first strand sample parser")
input_dict['well'] = re.search(r"\s\((.*)\)$", input_dict['submitter_id']).groups()[0] # input_dict['well'] = re.search(r"\s\((.*)\)$", input_dict['submitter_id']).groups()[0]
input_dict['submitter_id'] = re.sub(r"\s\(.*\)$", "", str(input_dict['submitter_id'])).strip() # input_dict['submitter_id'] = re.sub(r"\s\(.*\)$", "", str(input_dict['submitter_id'])).strip()
return input_dict # return input_dict
def grab_plates(self) -> List[str]: def grab_plates(self) -> List[str]:
""" """

View File

@@ -12,8 +12,6 @@ logger = logging.getLogger(f"submissions.{__name__}")
env = jinja_template_loading() env = jinja_template_loading()
logger = logging.getLogger(f"submissions.{__name__}")
def make_report_xlsx(records:list[dict]) -> Tuple[DataFrame, DataFrame]: def make_report_xlsx(records:list[dict]) -> Tuple[DataFrame, DataFrame]:
""" """
create the dataframe for a report create the dataframe for a report

View File

@@ -86,6 +86,7 @@ class PydSubmission(BaseModel, extra=Extra.allow):
sample_count: dict|None sample_count: dict|None
extraction_kit: dict|None extraction_kit: dict|None
technician: dict|None technician: dict|None
submission_category: dict|None = Field(default=dict(value=None, parsed=False), validate_default=True)
reagents: List[dict] = [] reagents: List[dict] = []
samples: List[Any] samples: List[Any]
@@ -205,3 +206,11 @@ class PydSubmission(BaseModel, extra=Extra.allow):
return dict(value=value, parsed=True) return dict(value=value, parsed=True)
else: else:
return dict(value=RSLNamer(ctx=values.data['ctx'], instr=values.data['filepath'].__str__()).submission_type.title(), parsed=False) return dict(value=RSLNamer(ctx=values.data['ctx'], instr=values.data['filepath'].__str__()).submission_type.title(), parsed=False)
@field_validator("submission_category")
@classmethod
def rescue_category(cls, value, values):
if value['value'] not in ["Research", "Diagnostic", "Surveillance"]:
value['value'] = values.data['submission_type']['value']
return value

View File

@@ -1,22 +1,26 @@
''' '''
Constructs main application. Constructs main application.
''' '''
from pprint import pformat
import sys import sys
from typing import Tuple
from PyQt6.QtWidgets import ( from PyQt6.QtWidgets import (
QMainWindow, QToolBar, QMainWindow, QToolBar,
QTabWidget, QWidget, QVBoxLayout, QTabWidget, QWidget, QVBoxLayout,
QComboBox, QHBoxLayout, QComboBox, QHBoxLayout,
QScrollArea QScrollArea, QLineEdit, QDateEdit,
QSpinBox
) )
from PyQt6.QtCore import Qt
from PyQt6.QtGui import QAction from PyQt6.QtGui import QAction
from PyQt6.QtWebEngineWidgets import QWebEngineView from PyQt6.QtWebEngineWidgets import QWebEngineView
from pathlib import Path from pathlib import Path
from backend.db import ( from backend.db import (
construct_reagent, store_object, lookup_control_types, lookup_modes construct_reagent, store_object, lookup_control_types, lookup_modes
) )
from .all_window_functions import extract_form_info # from .all_window_functions import extract_form_info
from tools import check_if_app, Settings from tools import check_if_app, Settings
from frontend.custom_widgets import SubmissionsSheet, AlertPop, AddReagentForm, KitAdder, ControlsDatePicker from frontend.custom_widgets import SubmissionsSheet, AlertPop, AddReagentForm, KitAdder, ControlsDatePicker, ImportReagent
import logging import logging
from datetime import date from datetime import date
import webbrowser import webbrowser
@@ -51,7 +55,9 @@ class App(QMainWindow):
self._createToolBar() self._createToolBar()
self._connectActions() self._connectActions()
self._controls_getter() self._controls_getter()
# self.status_bar = self.statusBar()
self.show() self.show()
self.statusBar().showMessage('Ready', 5000)
def _createMenuBar(self): def _createMenuBar(self):
@@ -73,7 +79,7 @@ class App(QMainWindow):
fileMenu.addAction(self.importPCRAction) fileMenu.addAction(self.importPCRAction)
methodsMenu.addAction(self.constructFS) methodsMenu.addAction(self.constructFS)
reportMenu.addAction(self.generateReportAction) reportMenu.addAction(self.generateReportAction)
maintenanceMenu.addAction(self.joinControlsAction) # maintenanceMenu.addAction(self.joinControlsAction)
maintenanceMenu.addAction(self.joinExtractionAction) maintenanceMenu.addAction(self.joinExtractionAction)
maintenanceMenu.addAction(self.joinPCRAction) maintenanceMenu.addAction(self.joinPCRAction)
@@ -99,7 +105,7 @@ class App(QMainWindow):
self.generateReportAction = QAction("Make Report", self) self.generateReportAction = QAction("Make Report", self)
self.addKitAction = QAction("Import Kit", self) self.addKitAction = QAction("Import Kit", self)
self.addOrgAction = QAction("Import Org", self) self.addOrgAction = QAction("Import Org", self)
self.joinControlsAction = QAction("Link Controls") # self.joinControlsAction = QAction("Link Controls")
self.joinExtractionAction = QAction("Link Extraction Logs") self.joinExtractionAction = QAction("Link Extraction Logs")
self.joinPCRAction = QAction("Link PCR Logs") self.joinPCRAction = QAction("Link PCR Logs")
self.helpAction = QAction("&About", self) self.helpAction = QAction("&About", self)
@@ -122,7 +128,7 @@ class App(QMainWindow):
self.table_widget.mode_typer.currentIndexChanged.connect(self._controls_getter) self.table_widget.mode_typer.currentIndexChanged.connect(self._controls_getter)
self.table_widget.datepicker.start_date.dateChanged.connect(self._controls_getter) self.table_widget.datepicker.start_date.dateChanged.connect(self._controls_getter)
self.table_widget.datepicker.end_date.dateChanged.connect(self._controls_getter) self.table_widget.datepicker.end_date.dateChanged.connect(self._controls_getter)
self.joinControlsAction.triggered.connect(self.linkControls) # self.joinControlsAction.triggered.connect(self.linkControls)
self.joinExtractionAction.triggered.connect(self.linkExtractions) self.joinExtractionAction.triggered.connect(self.linkExtractions)
self.joinPCRAction.triggered.connect(self.linkPCR) self.joinPCRAction.triggered.connect(self.linkPCR)
self.helpAction.triggered.connect(self.showAbout) self.helpAction.triggered.connect(self.showAbout)
@@ -149,6 +155,7 @@ 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, result:dict|None=None): def result_reporter(self, result:dict|None=None):
# 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
@@ -158,6 +165,8 @@ class App(QMainWindow):
if result != None: if result != None:
msg = AlertPop(message=result['message'], status=result['status']) msg = AlertPop(message=result['message'], status=result['status'])
msg.exec() msg.exec()
else:
self.statusBar().showMessage("Action completed sucessfully.", 5000)
def importSubmission(self): def importSubmission(self):
""" """
@@ -211,13 +220,15 @@ class App(QMainWindow):
dlg = AddReagentForm(ctx=self.ctx, reagent_lot=reagent_lot, reagent_type=reagent_type, expiry=expiry, reagent_name=name) dlg = AddReagentForm(ctx=self.ctx, 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 = extract_form_info(dlg)
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 = construct_reagent(ctx=self.ctx, info_dict=info)
# send reagent to db # send reagent to db
# store_reagent(ctx=self.ctx, reagent=reagent) # store_reagent(ctx=self.ctx, reagent=reagent)
result = store_object(ctx=self.ctx, object=reagent) result = store_object(ctx=self.ctx, object=reagent)
self.result_reporter(result=result)
return reagent return reagent
def generateReport(self): def generateReport(self):
@@ -263,6 +274,7 @@ class App(QMainWindow):
def linkControls(self): def linkControls(self):
""" """
Adds controls pulled from irida to relevant submissions Adds controls pulled from irida to relevant submissions
NOTE: Depreciated due to improvements in controls scraper.
""" """
from .main_window_functions import link_controls_function from .main_window_functions import link_controls_function
self, result = link_controls_function(self) self, result = link_controls_function(self)
@@ -327,7 +339,7 @@ class AddSubForm(QWidget):
self.tabs.addTab(self.tab2,"Controls") self.tabs.addTab(self.tab2,"Controls")
self.tabs.addTab(self.tab3, "Add Kit") self.tabs.addTab(self.tab3, "Add Kit")
# Create submission adder form # Create submission adder form
self.formwidget = QWidget(self) self.formwidget = SubmissionFormWidget(self)
self.formlayout = QVBoxLayout(self) self.formlayout = QVBoxLayout(self)
self.formwidget.setLayout(self.formlayout) self.formwidget.setLayout(self.formlayout)
self.formwidget.setFixedWidth(300) self.formwidget.setFixedWidth(300)
@@ -381,3 +393,32 @@ class AddSubForm(QWidget):
self.layout.addWidget(self.tabs) self.layout.addWidget(self.tabs)
self.setLayout(self.layout) self.setLayout(self.layout)
class SubmissionFormWidget(QWidget):
def __init__(self, parent: QWidget) -> None:
logger.debug(f"Setting form widget...")
super().__init__(parent)
self.ignore = [None, "", "qt_spinbox_lineedit", "qt_scrollarea_viewport", "qt_scrollarea_hcontainer",
"qt_scrollarea_vcontainer", "submit_btn"
]
def parse_form(self) -> Tuple[dict, list]:
logger.debug(f"Hello from parser!")
info = {}
reagents = []
widgets = [widget for widget in self.findChildren(QWidget) if widget.objectName() not in self.ignore]
for widget in widgets:
logger.debug(f"Parsed widget: {widget.objectName()} of type {type(widget)}")
match widget:
case ImportReagent():
reagents.append(dict(name=widget.objectName().replace("lot_", ""), lot=widget.currentText()))
case QLineEdit():
info[widget.objectName()] = widget.text()
case QComboBox():
info[widget.objectName()] = widget.currentText()
case QDateEdit():
info[widget.objectName()] = widget.date().toPyDate()
logger.debug(f"Info: {pformat(info)}")
logger.debug(f"Reagents: {pformat(reagents)}")
return info, reagents

View File

@@ -54,6 +54,7 @@ def select_save_file(obj:QMainWindow, default_name:str, extension:str) -> Path:
def extract_form_info(object) -> dict: def extract_form_info(object) -> dict:
""" """
retrieves object names and values from form retrieves object names and values from form
DEPRECIATED. Replaced by individual form parser methods.
Args: Args:
object (_type_): the form widget object (_type_): the form widget
@@ -64,7 +65,7 @@ def extract_form_info(object) -> dict:
from frontend.custom_widgets import ReagentTypeForm from frontend.custom_widgets import ReagentTypeForm
dicto = {} dicto = {}
reagents = {} reagents = []
logger.debug(f"Object type: {type(object)}") logger.debug(f"Object type: {type(object)}")
# grab all widgets in form # grab all widgets in form
try: try:
@@ -85,8 +86,17 @@ def extract_form_info(object) -> dict:
case ReagentTypeForm(): case ReagentTypeForm():
reagent = extract_form_info(item) reagent = extract_form_info(item)
logger.debug(f"Reagent found: {reagent}") logger.debug(f"Reagent found: {reagent}")
reagents[reagent["name"].strip()] = {'eol_ext':int(reagent['eol'])} if isinstance(reagent, tuple):
reagent = reagent[0]
# reagents[reagent["name"].strip()] = {'eol':int(reagent['eol'])}
reagents.append({k:v for k,v in reagent.items() if k not in ['', 'qt_spinbox_lineedit']})
# value for ad hoc check above # value for ad hoc check above
if isinstance(dicto, tuple):
logger.warning(f"Got tuple for dicto for some reason.")
dicto = dicto[0]
if isinstance(reagents, tuple):
logger.warning(f"Got tuple for reagents for some reason.")
reagents = reagents[0]
if reagents != {}: if reagents != {}:
return dicto, reagents return dicto, reagents
return dicto return dicto

View File

@@ -2,22 +2,25 @@
Contains miscellaneous widgets for frontend functions Contains miscellaneous widgets for frontend functions
''' '''
from datetime import date from datetime import date
from pprint import pformat
from PyQt6.QtWidgets import ( from PyQt6.QtWidgets import (
QLabel, QVBoxLayout, QLabel, QVBoxLayout,
QLineEdit, QComboBox, QDialog, QLineEdit, QComboBox, QDialog,
QDialogButtonBox, QDateEdit, QSizePolicy, QWidget, QDialogButtonBox, QDateEdit, QSizePolicy, QWidget,
QGridLayout, QPushButton, QSpinBox, QDoubleSpinBox, QGridLayout, QPushButton, QSpinBox, QDoubleSpinBox,
QHBoxLayout QHBoxLayout, QScrollArea
) )
from PyQt6.QtCore import Qt, QDate, QSize from PyQt6.QtCore import Qt, QDate, QSize
from tools import check_not_nan, jinja_template_loading, Settings from tools import check_not_nan, jinja_template_loading, Settings
from ..all_window_functions import extract_form_info from backend.db.functions import construct_kit_from_yaml, \
from backend.db import construct_kit_from_yaml, \
lookup_reagent_types, lookup_reagents, lookup_submission_type, lookup_reagenttype_kittype_association lookup_reagent_types, lookup_reagents, lookup_submission_type, lookup_reagenttype_kittype_association
from backend.db.models import SubmissionTypeKitTypeAssociation
from sqlalchemy import FLOAT, INTEGER, String
import logging import logging
import numpy as np import numpy as np
from .pop_ups import AlertPop from .pop_ups import AlertPop
from backend.pydant import PydReagent from backend.pydant import PydReagent
from typing import Tuple
logger = logging.getLogger(f"submissions.{__name__}") logger = logging.getLogger(f"submissions.{__name__}")
@@ -84,6 +87,12 @@ 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):
return dict(name=self.name_input.currentText(),
lot=self.lot_input.text(),
expiry=self.exp_input.date().toPyDate(),
type=self.type_input.currentText())
def update_names(self): def update_names(self):
""" """
Updates reagent names form field with examples from reagent type Updates reagent names form field with examples from reagent type
@@ -121,6 +130,9 @@ 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):
return dict(start_date=self.start_date.date().toPyDate(), end_date = self.end_date.date().toPyDate())
class KitAdder(QWidget): class KitAdder(QWidget):
""" """
dialog to get information to add kit dialog to get information to add kit
@@ -128,8 +140,14 @@ class KitAdder(QWidget):
def __init__(self, parent_ctx:Settings) -> None: def __init__(self, parent_ctx:Settings) -> None:
super().__init__() super().__init__()
self.ctx = parent_ctx self.ctx = parent_ctx
main_box = QVBoxLayout(self)
scroll = QScrollArea(self)
main_box.addWidget(scroll)
scroll.setWidgetResizable(True)
scrollContent = QWidget(scroll)
self.grid = QGridLayout() self.grid = QGridLayout()
self.setLayout(self.grid) # self.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")
self.grid.addWidget(self.submit_btn,0,0,1,1) self.grid.addWidget(self.submit_btn,0,0,1,1)
@@ -138,42 +156,65 @@ class KitAdder(QWidget):
kit_name = QLineEdit() kit_name = QLineEdit()
kit_name.setObjectName("kit_name") kit_name.setObjectName("kit_name")
self.grid.addWidget(kit_name,2,1) self.grid.addWidget(kit_name,2,1)
self.grid.addWidget(QLabel("Used For Sample Type:"),3,0) self.grid.addWidget(QLabel("Used For Submission Type:"),3,0)
# widget to get uses of kit # widget to get uses of kit
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(lookup_all_sample_types(ctx=parent_ctx))
used_for.addItems([item.name for item in lookup_submission_type(ctx=parent_ctx)]) used_for.addItems([item.name for item in lookup_submission_type(ctx=parent_ctx)])
used_for.setEditable(True) used_for.setEditable(True)
self.grid.addWidget(used_for,3,1) self.grid.addWidget(used_for,3,1)
# set cost per run # Get all fields in SubmissionTypeKitTypeAssociation
self.grid.addWidget(QLabel("Constant cost per full plate (plates, work hours, etc.):"),4,0) self.columns = [item for item in SubmissionTypeKitTypeAssociation.__table__.columns if len(item.foreign_keys) == 0]
# widget to get constant cost for iii, column in enumerate(self.columns):
const_cost = QDoubleSpinBox() #QSpinBox() idx = iii + 4
const_cost.setObjectName("const_cost") # convert field name to human readable.
const_cost.setMinimum(0) field_name = column.name.replace("_", " ").title()
const_cost.setMaximum(9999) self.grid.addWidget(QLabel(field_name),idx,0)
self.grid.addWidget(const_cost,4,1) match column.type:
self.grid.addWidget(QLabel("Cost per column (multidrop reagents, etc.):"),5,0) case FLOAT():
# widget to get mutable costs per column add_widget = QDoubleSpinBox()
mut_cost_col = QDoubleSpinBox() #QSpinBox() add_widget.setMinimum(0)
mut_cost_col.setObjectName("mut_cost_col") add_widget.setMaximum(9999)
mut_cost_col.setMinimum(0) case INTEGER():
mut_cost_col.setMaximum(9999) add_widget = QSpinBox()
self.grid.addWidget(mut_cost_col,5,1) add_widget.setMinimum(0)
self.grid.addWidget(QLabel("Cost per sample (tips, reagents, etc.):"),6,0) add_widget.setMaximum(9999)
# widget to get mutable costs per column case _:
mut_cost_samp = QDoubleSpinBox() #QSpinBox() add_widget = QLineEdit()
mut_cost_samp.setObjectName("mut_cost_samp") add_widget.setObjectName(column.name)
mut_cost_samp.setMinimum(0) self.grid.addWidget(add_widget, idx,1)
mut_cost_samp.setMaximum(9999) # self.grid.addWidget(QLabel("Constant cost per full plate (plates, work hours, etc.):"),4,0)
self.grid.addWidget(mut_cost_samp,6,1) # # widget to get constant cost
# const_cost = QDoubleSpinBox() #QSpinBox()
# const_cost.setObjectName("const_cost")
# const_cost.setMinimum(0)
# const_cost.setMaximum(9999)
# self.grid.addWidget(const_cost,4,1)
# self.grid.addWidget(QLabel("Cost per column (multidrop reagents, etc.):"),5,0)
# # widget to get mutable costs per column
# mut_cost_col = QDoubleSpinBox() #QSpinBox()
# mut_cost_col.setObjectName("mut_cost_col")
# mut_cost_col.setMinimum(0)
# mut_cost_col.setMaximum(9999)
# self.grid.addWidget(mut_cost_col,5,1)
# self.grid.addWidget(QLabel("Cost per sample (tips, reagents, etc.):"),6,0)
# # widget to get mutable costs per column
# mut_cost_samp = QDoubleSpinBox() #QSpinBox()
# mut_cost_samp.setObjectName("mut_cost_samp")
# mut_cost_samp.setMinimum(0)
# mut_cost_samp.setMaximum(9999)
# self.grid.addWidget(mut_cost_samp,6,1)
# button to add additional reagent types # button to add additional reagent types
self.add_RT_btn = QPushButton("Add Reagent Type") self.add_RT_btn = QPushButton("Add Reagent Type")
self.grid.addWidget(self.add_RT_btn) self.grid.addWidget(self.add_RT_btn)
self.add_RT_btn.clicked.connect(self.add_RT) self.add_RT_btn.clicked.connect(self.add_RT)
self.submit_btn.clicked.connect(self.submit) self.submit_btn.clicked.connect(self.submit)
scroll.setWidget(scrollContent)
self.ignore = [None, "", "qt_spinbox_lineedit", "qt_scrollarea_viewport", "qt_scrollarea_hcontainer",
"qt_scrollarea_vcontainer", "submit_btn"
]
def add_RT(self) -> None: def add_RT(self) -> None:
""" """
@@ -181,9 +222,11 @@ class KitAdder(QWidget):
""" """
# get bottommost row # get bottommost row
maxrow = self.grid.rowCount() maxrow = self.grid.rowCount()
reg_form = ReagentTypeForm(parent_ctx=self.ctx) reg_form = ReagentTypeForm(ctx=self.ctx)
reg_form.setObjectName(f"ReagentForm_{maxrow}") reg_form.setObjectName(f"ReagentForm_{maxrow}")
self.grid.addWidget(reg_form, maxrow + 1,0,1,2) # self.grid.addWidget(reg_form, maxrow + 1,0,1,2)
self.grid.addWidget(reg_form, maxrow,0,1,4)
def submit(self) -> None: def submit(self) -> None:
@@ -191,40 +234,62 @@ class KitAdder(QWidget):
send kit to database send kit to database
""" """
# get form info # get form info
info, reagents = extract_form_info(self) info, reagents = self.parse_form()
logger.debug(f"kit info: {info}") # info, reagents = extract_form_info(self)
yml_type = {} info = {k:v for k,v in info.items() if k in [column.name for column in self.columns] + ['kit_name', 'used_for']}
try: logger.debug(f"kit info: {pformat(info)}")
yml_type['password'] = info['password'] logger.debug(f"kit reagents: {pformat(reagents)}")
except KeyError: info['reagent_types'] = reagents
pass # for reagent in reagents:
used = info['used_for'] # new_dict = {}
yml_type[used] = {} # for k,v in reagent.items():
yml_type[used]['kits'] = {} # if "_" in k:
yml_type[used]['kits'][info['kit_name']] = {} # key, sub_key = k.split("_")
yml_type[used]['kits'][info['kit_name']]['constant_cost'] = info["const_cost"] # if key not in new_dict.keys():
yml_type[used]['kits'][info['kit_name']]['mutable_cost_column'] = info["mut_cost_col"] # new_dict[key] = {}
yml_type[used]['kits'][info['kit_name']]['mutable_cost_sample'] = info["mut_cost_samp"] # logger.debug(f"Adding key {key}, {sub_key} and value {v} to {new_dict}")
yml_type[used]['kits'][info['kit_name']]['reagenttypes'] = reagents # new_dict[key][sub_key] = v
logger.debug(yml_type) # else:
# new_dict[k] = v
# info['reagent_types'].append(new_dict)
logger.debug(pformat(info))
# send to kit constructor # send to kit constructor
result = construct_kit_from_yaml(ctx=self.ctx, exp=yml_type) result = construct_kit_from_yaml(ctx=self.ctx, kit_dict=info)
msg = AlertPop(message=result['message'], status=result['status']) msg = AlertPop(message=result['message'], status=result['status'])
msg.exec() msg.exec()
self.__init__(self.ctx) self.__init__(self.ctx)
def parse_form(self) -> Tuple[dict, list]:
logger.debug(f"Hello from {self.__class__} parser!")
info = {}
reagents = []
widgets = [widget for widget in self.findChildren(QWidget) if widget.objectName() not in self.ignore and not isinstance(widget.parent(), ReagentTypeForm)]
for widget in widgets:
# logger.debug(f"Parsed widget: {widget.objectName()} of type {type(widget)} with parent {widget.parent()}")
match widget:
case ReagentTypeForm():
reagents.append(widget.parse_form())
case QLineEdit():
info[widget.objectName()] = widget.text()
case QComboBox():
info[widget.objectName()] = widget.currentText()
case QDateEdit():
info[widget.objectName()] = widget.date().toPyDate()
return info, reagents
class ReagentTypeForm(QWidget): class ReagentTypeForm(QWidget):
""" """
custom widget to add information about a new reagenttype custom widget to add information about a new reagenttype
""" """
def __init__(self, ctx:dict) -> None: def __init__(self, ctx:Settings) -> None:
super().__init__() super().__init__()
grid = QGridLayout() grid = QGridLayout()
self.setLayout(grid) self.setLayout(grid)
grid.addWidget(QLabel("Name (*Exactly* as it appears in the excel submission form):"),0,0) grid.addWidget(QLabel("Reagent Type Name"),0,0)
# Widget to get reagent info # Widget to get reagent info
self.reagent_getter = QComboBox() self.reagent_getter = QComboBox()
self.reagent_getter.setObjectName("name") 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 = lookup_reagent_types(ctx=ctx)
logger.debug(f"Looked up ReagentType names: {lookup}") logger.debug(f"Looked up ReagentType names: {lookup}")
@@ -233,10 +298,66 @@ class ReagentTypeForm(QWidget):
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)
# widget to get extension of life # widget to get extension of life
eol = QSpinBox() self.eol = QSpinBox()
eol.setObjectName('eol') self.eol.setObjectName('eol')
eol.setMinimum(0) self.eol.setMinimum(0)
grid.addWidget(eol, 0,3) grid.addWidget(self.eol, 0,3)
grid.addWidget(QLabel("Excel Location Sheet Name:"),1,0)
self.location_sheet_name = QLineEdit()
self.location_sheet_name.setObjectName("sheet")
self.location_sheet_name.setText("e.g. 'Reagent Info'")
grid.addWidget(self.location_sheet_name, 1,1)
for iii, item in enumerate(["Name", "Lot", "Expiry"]):
idx = iii + 2
grid.addWidget(QLabel(f"{item} Row:"), idx, 0)
row = QSpinBox()
row.setFixedWidth(50)
row.setObjectName(f'{item.lower()}_row')
row.setMinimum(0)
grid.addWidget(row, idx, 1)
grid.addWidget(QLabel(f"{item} Column:"), idx, 2)
col = QSpinBox()
col.setFixedWidth(50)
col.setObjectName(f'{item.lower()}_column')
col.setMinimum(0)
grid.addWidget(col, idx, 3)
self.setFixedHeight(175)
max_row = grid.rowCount()
self.r_button = QPushButton("Remove")
self.r_button.clicked.connect(self.remove)
grid.addWidget(self.r_button,max_row,0,1,1)
self.ignore = [None, "", "qt_spinbox_lineedit", "qt_scrollarea_viewport", "qt_scrollarea_hcontainer",
"qt_scrollarea_vcontainer", "submit_btn", "eol", "sheet", "rtname"
]
def remove(self):
self.setParent(None)
self.destroy()
def parse_form(self) -> dict:
logger.debug(f"Hello from {self.__class__} parser!")
info = {}
info['eol'] = self.eol.value()
info['sheet'] = self.location_sheet_name.text()
info['rtname'] = self.reagent_getter.currentText()
widgets = [widget for widget in self.findChildren(QWidget) if widget.objectName() not in self.ignore]
for widget in widgets:
logger.debug(f"Parsed widget: {widget.objectName()} of type {type(widget)} with parent {widget.parent()}")
match widget:
case QLineEdit():
info[widget.objectName()] = widget.text()
case QComboBox():
info[widget.objectName()] = widget.currentText()
case QDateEdit():
info[widget.objectName()] = widget.date().toPyDate()
case QSpinBox() | QDoubleSpinBox():
if "_" in widget.objectName():
key, sub_key = widget.objectName().split("_")
if key not in info.keys():
info[key] = {}
logger.debug(f"Adding key {key}, {sub_key} and value {widget.value()} to {info}")
info[key][sub_key] = widget.value()
return info
class ControlsDatePicker(QWidget): class ControlsDatePicker(QWidget):
""" """
@@ -336,3 +457,4 @@ class ParsedQLabel(QLabel):
self.setText(f"Parsed {output}") self.setText(f"Parsed {output}")
else: else:
self.setText(f"MISSING {output}") self.setText(f"MISSING {output}")

View File

@@ -8,6 +8,7 @@ from PyQt6.QtWidgets import (
from tools import jinja_template_loading from tools import jinja_template_loading
import logging import logging
from backend.db.functions import lookup_kit_types, lookup_submission_type from backend.db.functions import lookup_kit_types, lookup_submission_type
from typing import Literal
logger = logging.getLogger(f"submissions.{__name__}") logger = logging.getLogger(f"submissions.{__name__}")
@@ -36,7 +37,7 @@ class AlertPop(QMessageBox):
""" """
Dialog to show an alert. Dialog to show an alert.
""" """
def __init__(self, message:str, status:str) -> QMessageBox: def __init__(self, message:str, status:Literal['information', 'question', 'warning', 'critical']) -> QMessageBox:
super().__init__() super().__init__()
# select icon by string # select icon by string
icon = getattr(QMessageBox.Icon, status.title()) icon = getattr(QMessageBox.Icon, status.title())

View File

@@ -23,7 +23,7 @@ from xhtml2pdf import pisa
from pathlib import Path from pathlib import Path
import logging import logging
from .pop_ups import QuestionAsker, AlertPop from .pop_ups import QuestionAsker, AlertPop
from ..visualizations import make_plate_barcode, make_plate_map from ..visualizations import make_plate_barcode, make_plate_map, make_plate_map_html
from getpass import getuser from getpass import getuser
logger = logging.getLogger(f"submissions.{__name__}") logger = logging.getLogger(f"submissions.{__name__}")
@@ -265,18 +265,19 @@ class SubmissionDetails(QDialog):
if not check_if_app(): if not check_if_app():
self.base_dict['barcode'] = base64.b64encode(make_plate_barcode(self.base_dict['Plate Number'], width=120, height=30)).decode('utf-8') self.base_dict['barcode'] = base64.b64encode(make_plate_barcode(self.base_dict['Plate Number'], width=120, height=30)).decode('utf-8')
logger.debug(f"Hitpicking plate...") logger.debug(f"Hitpicking plate...")
plate_dicto = sub.hitpick_plate() self.plate_dicto = sub.hitpick_plate()
logger.debug(f"Making platemap...") logger.debug(f"Making platemap...")
platemap = make_plate_map(plate_dicto) self.base_dict['platemap'] = make_plate_map_html(self.plate_dicto)
logger.debug(f"platemap: {platemap}") # logger.debug(f"Platemap: {self.base_dict['platemap']}")
image_io = BytesIO() # logger.debug(f"platemap: {platemap}")
try: # image_io = BytesIO()
platemap.save(image_io, 'JPEG') # try:
except AttributeError: # platemap.save(image_io, 'JPEG')
logger.error(f"No plate map found for {sub.rsl_plate_num}") # except AttributeError:
self.base_dict['platemap'] = base64.b64encode(image_io.getvalue()).decode('utf-8') # logger.error(f"No plate map found for {sub.rsl_plate_num}")
template = env.get_template("submission_details.html") # self.base_dict['platemap'] = base64.b64encode(image_io.getvalue()).decode('utf-8')
self.html = template.render(sub=self.base_dict) self.template = env.get_template("submission_details.html")
self.html = self.template.render(sub=self.base_dict)
webview = QWebEngineView() webview = QWebEngineView()
webview.setMinimumSize(900, 500) webview.setMinimumSize(900, 500)
webview.setMaximumSize(900, 500) webview.setMaximumSize(900, 500)
@@ -290,6 +291,8 @@ class SubmissionDetails(QDialog):
btn.setParent(self) btn.setParent(self)
btn.setFixedWidth(900) btn.setFixedWidth(900)
btn.clicked.connect(self.export) btn.clicked.connect(self.export)
with open("test.html", "w") as f:
f.write(self.html)
def export(self): def export(self):
""" """
@@ -303,9 +306,18 @@ class SubmissionDetails(QDialog):
if fname.__str__() == ".": if fname.__str__() == ".":
logger.debug("Saving pdf was cancelled.") logger.debug("Saving pdf was cancelled.")
return return
del self.base_dict['platemap']
export_map = make_plate_map(self.plate_dicto)
image_io = BytesIO()
try:
export_map.save(image_io, 'JPEG')
except AttributeError:
logger.error(f"No plate map found")
self.base_dict['export_map'] = base64.b64encode(image_io.getvalue()).decode('utf-8')
self.html2 = self.template.render(sub=self.base_dict)
try: try:
with open(fname, "w+b") as f: with open(fname, "w+b") as f:
pisa.CreatePDF(self.html, dest=f) pisa.CreatePDF(self.html2, dest=f)
except PermissionError as e: except PermissionError as e:
logger.error(f"Error saving pdf: {e}") logger.error(f"Error saving pdf: {e}")
msg = QMessageBox() msg = QMessageBox()

View File

@@ -6,6 +6,7 @@ import difflib
from getpass import getuser from getpass import getuser
import inspect import inspect
import pprint import pprint
import re
import yaml import yaml
import json import json
from typing import Tuple, List from typing import Tuple, List
@@ -19,12 +20,12 @@ from PyQt6.QtWidgets import (
QMainWindow, QLabel, QWidget, QPushButton, QMainWindow, QLabel, QWidget, QPushButton,
QLineEdit, QComboBox, QDateEdit QLineEdit, QComboBox, QDateEdit
) )
from .all_window_functions import extract_form_info, select_open_file, select_save_file from .all_window_functions import select_open_file, select_save_file
from PyQt6.QtCore import QSignalBlocker from PyQt6.QtCore import QSignalBlocker
from backend.db.functions import ( from backend.db.functions import (
construct_submission_info, lookup_reagents, construct_kit_from_yaml, construct_org_from_yaml, get_control_subtypes, construct_submission_info, lookup_reagents, construct_kit_from_yaml, construct_org_from_yaml, get_control_subtypes,
update_subsampassoc_with_pcr, check_kit_integrity, update_last_used, lookup_organizations, lookup_kit_types, update_subsampassoc_with_pcr, check_kit_integrity, update_last_used, lookup_organizations, lookup_kit_types,
lookup_submissions, lookup_controls, lookup_samples, lookup_submission_sample_association, store_object lookup_submissions, lookup_controls, lookup_samples, lookup_submission_sample_association, store_object, lookup_submission_type
) )
from backend.excel.parser import SheetParser, PCRParser, SampleParser from backend.excel.parser import SheetParser, PCRParser, SampleParser
from backend.excel.reports import make_report_html, make_report_xlsx, convert_data_list_to_df from backend.excel.reports import make_report_html, make_report_xlsx, convert_data_list_to_df
@@ -139,11 +140,22 @@ def import_submission_function(obj:QMainWindow) -> Tuple[QMainWindow, dict|None]
logger.debug(f"{field}:\n\t{value}") logger.debug(f"{field}:\n\t{value}")
obj.samples = value obj.samples = value
continue continue
case 'submission_category':
add_widget = QComboBox()
cats = ['Diagnostic', "Surveillance", "Research"]
cats += [item.name for item in lookup_submission_type(ctx=obj.ctx)]
try:
cats.insert(0, cats.pop(cats.index(value['value'])))
except ValueError:
cats.insert(0, cats.pop(cats.index(pyd.submission_type['value'])))
add_widget.addItems(cats)
case "ctx": case "ctx":
continue continue
case 'reagents': case 'reagents':
# NOTE: This is now set to run when the extraction kit is updated. # NOTE: This is now set to run when the extraction kit is updated.
continue continue
case 'csv':
continue
case _: case _:
# anything else gets added in as a line edit # anything else gets added in as a line edit
add_widget = QLineEdit() add_widget = QLineEdit()
@@ -166,6 +178,7 @@ def import_submission_function(obj:QMainWindow) -> Tuple[QMainWindow, dict|None]
if "csv" in pyd.model_extra: if "csv" in pyd.model_extra:
obj.csv = pyd.model_extra['csv'] obj.csv = pyd.model_extra['csv']
logger.debug(f"All attributes of obj:\n{pprint.pformat(obj.__dict__)}") logger.debug(f"All attributes of obj:\n{pprint.pformat(obj.__dict__)}")
return obj, result return obj, result
def kit_reload_function(obj:QMainWindow) -> Tuple[QMainWindow, dict]: def kit_reload_function(obj:QMainWindow) -> Tuple[QMainWindow, dict]:
@@ -208,7 +221,7 @@ def kit_integrity_completion_function(obj:QMainWindow) -> Tuple[QMainWindow, dic
# get current kit being used # get current kit being used
obj.ext_kit = kit_widget.currentText() obj.ext_kit = kit_widget.currentText()
for item in obj.reagents: for item in obj.reagents:
obj.table_widget.formlayout.addWidget(ParsedQLabel({'parsed':True}, item.type, title=False, label_name=f"lot_{item.type}_label")) obj.table_widget.formlayout.addWidget(ParsedQLabel({'parsed':True}, item.type, title=False))
reagent = dict(type=item.type, lot=item.lot, exp=item.exp, name=item.name) reagent = dict(type=item.type, lot=item.lot, exp=item.exp, name=item.name)
add_widget = ImportReagent(ctx=obj.ctx, reagent=reagent, extraction_kit=obj.ext_kit) add_widget = ImportReagent(ctx=obj.ctx, reagent=reagent, extraction_kit=obj.ext_kit)
obj.table_widget.formlayout.addWidget(add_widget) obj.table_widget.formlayout.addWidget(add_widget)
@@ -218,7 +231,7 @@ def kit_integrity_completion_function(obj:QMainWindow) -> Tuple[QMainWindow, dic
result = dict(message=f"The submission you are importing is missing some reagents expected by the kit.\n\nIt looks like you are missing: {[item.type.upper() for item in obj.missing_reagents]}\n\nAlternatively, you may have set the wrong extraction kit.\n\nThe program will populate lists using existing reagents.\n\nPlease make sure you check the lots carefully!", status="Warning") result = dict(message=f"The submission you are importing is missing some reagents expected by the kit.\n\nIt looks like you are missing: {[item.type.upper() for item in obj.missing_reagents]}\n\nAlternatively, you may have set the wrong extraction kit.\n\nThe program will populate lists using existing reagents.\n\nPlease make sure you check the lots carefully!", status="Warning")
for item in obj.missing_reagents: for item in obj.missing_reagents:
# Add label that has parsed as False to show "MISSING" label. # Add label that has parsed as False to show "MISSING" label.
obj.table_widget.formlayout.addWidget(ParsedQLabel({'parsed':False}, item.type, title=False, label_name=f"missing_{item.type}_label")) obj.table_widget.formlayout.addWidget(ParsedQLabel({'parsed':False}, item.type, title=False))
# Set default parameters for the empty reagent. # Set default parameters for the empty reagent.
reagent = dict(type=item.type, lot=None, exp=date.today(), name=None) reagent = dict(type=item.type, lot=None, exp=date.today(), name=None)
# create and add widget # create and add widget
@@ -227,7 +240,7 @@ def kit_integrity_completion_function(obj:QMainWindow) -> Tuple[QMainWindow, dic
obj.table_widget.formlayout.addWidget(add_widget) obj.table_widget.formlayout.addWidget(add_widget)
# Add submit button to the form. # Add submit button to the form.
submit_btn = QPushButton("Submit") submit_btn = QPushButton("Submit")
submit_btn.setObjectName("lot_submit_btn") submit_btn.setObjectName("submit_btn")
obj.table_widget.formlayout.addWidget(submit_btn) obj.table_widget.formlayout.addWidget(submit_btn)
submit_btn.clicked.connect(obj.submit_new_sample) submit_btn.clicked.connect(obj.submit_new_sample)
return obj, result return obj, result
@@ -245,32 +258,37 @@ def submit_new_sample_function(obj:QMainWindow) -> Tuple[QMainWindow, dict]:
logger.debug(f"\n\nBeginning Submission\n\n") logger.debug(f"\n\nBeginning Submission\n\n")
result = None result = None
# extract info from the form widgets # extract info from the form widgets
info = extract_form_info(obj.table_widget.tab1) # info = extract_form_info(obj.table_widget.tab1)
# seperate out reagents # if isinstance(info, tuple):
reagents = {k.replace("lot_", ""):v for k,v in info.items() if k.startswith("lot_")} # logger.warning(f"Got tuple for info for some reason.")
info = {k:v for k,v in info.items() if not k.startswith("lot_")} # info = info[0]
# # seperate out reagents
# reagents = {k.replace("lot_", ""):v for k,v in info.items() if k.startswith("lot_")}
# info = {k:v for k,v in info.items() if not k.startswith("lot_")}
info, reagents = obj.table_widget.formwidget.parse_form()
logger.debug(f"Info: {info}") logger.debug(f"Info: {info}")
logger.debug(f"Reagents: {reagents}") logger.debug(f"Reagents: {reagents}")
parsed_reagents = [] parsed_reagents = []
# compare reagents in form to reagent database # compare reagents in form to reagent database
for reagent in reagents: for reagent in reagents:
# Lookup any existing reagent of this type with this lot number # Lookup any existing reagent of this type with this lot number
wanted_reagent = lookup_reagents(ctx=obj.ctx, lot_number=reagents[reagent], reagent_type=reagent) wanted_reagent = lookup_reagents(ctx=obj.ctx, lot_number=reagent['lot'], reagent_type=reagent['name'])
logger.debug(f"Looked up reagent: {wanted_reagent}") logger.debug(f"Looked up reagent: {wanted_reagent}")
# if reagent not found offer to add to database # if reagent not found offer to add to database
if wanted_reagent == None: if wanted_reagent == None:
r_lot = reagents[reagent] # r_lot = reagent[reagent]
dlg = QuestionAsker(title=f"Add {r_lot}?", message=f"Couldn't find reagent type {reagent.strip('Lot')}: {r_lot} in the database.\n\nWould you like to add it?") r_lot = reagent['lot']
dlg = QuestionAsker(title=f"Add {r_lot}?", message=f"Couldn't find reagent type {reagent['name'].strip('Lot')}: {r_lot} in the database.\n\nWould you like to add it?")
if dlg.exec(): if dlg.exec():
logger.debug(f"Looking through {pprint.pformat(obj.reagents)} for reagent {reagent}") logger.debug(f"Looking through {pprint.pformat(obj.reagents)} for reagent {reagent['name']}")
try: try:
picked_reagent = [item for item in obj.reagents if item.type == reagent][0] picked_reagent = [item for item in obj.reagents if item.type == reagent['name']][0]
except IndexError: except IndexError:
logger.error(f"Couldn't find {reagent} in obj.reagents. Checking missing reagents {pprint.pformat(obj.missing_reagents)}") logger.error(f"Couldn't find {reagent['name']} in obj.reagents. Checking missing reagents {pprint.pformat(obj.missing_reagents)}")
picked_reagent = [item for item in obj.missing_reagents if item.type == reagent][0] picked_reagent = [item for item in obj.missing_reagents if item.type == reagent['name']][0]
logger.debug(f"checking reagent: {reagent} in obj.reagents. Result: {picked_reagent}") logger.debug(f"checking reagent: {reagent['name']} in obj.reagents. Result: {picked_reagent}")
expiry_date = picked_reagent.exp expiry_date = picked_reagent.exp
wanted_reagent = obj.add_reagent(reagent_lot=r_lot, reagent_type=reagent.replace("lot_", ""), expiry=expiry_date, name=picked_reagent.name) wanted_reagent = obj.add_reagent(reagent_lot=r_lot, reagent_type=reagent['name'].replace("lot_", ""), expiry=expiry_date, name=picked_reagent.name)
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
logger.debug("Will not add reagent.") logger.debug("Will not add reagent.")
@@ -348,14 +366,13 @@ def generate_report_function(obj:QMainWindow) -> Tuple[QMainWindow, dict]:
Returns: Returns:
Tuple[QMainWindow, dict]: Collection of new main app window and result dict Tuple[QMainWindow, dict]: Collection of new main app window and result dict
""" """
result = None
# ask for date ranges # ask for date ranges
dlg = ReportDatePicker() dlg = ReportDatePicker()
if dlg.exec(): if dlg.exec():
info = extract_form_info(dlg) # info = extract_form_info(dlg)
info = dlg.parse_form()
logger.debug(f"Report info: {info}") logger.debug(f"Report info: {info}")
# find submissions based on date range # find submissions based on date range
# subs = lookup_submissions_by_date_range(ctx=obj.ctx, start_date=info['start_date'], end_date=info['end_date'])
subs = lookup_submissions(ctx=obj.ctx, start_date=info['start_date'], end_date=info['end_date']) subs = lookup_submissions(ctx=obj.ctx, 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]
@@ -542,6 +559,7 @@ def chart_maker_function(obj:QMainWindow) -> Tuple[QMainWindow, dict]:
def link_controls_function(obj:QMainWindow) -> Tuple[QMainWindow, dict]: def link_controls_function(obj:QMainWindow) -> Tuple[QMainWindow, dict]:
""" """
Link scraped controls to imported submissions. Link scraped controls to imported submissions.
NOTE: Depreciated due to improvements in controls scraper.
Args: Args:
obj (QMainWindow): original app window obj (QMainWindow): original app window
@@ -624,6 +642,8 @@ def link_extractions_function(obj:QMainWindow) -> Tuple[QMainWindow, dict]:
# sub = lookup_submission_by_rsl_num(ctx=obj.ctx, rsl_num=new_run['rsl_plate_num']) # sub = lookup_submission_by_rsl_num(ctx=obj.ctx, rsl_num=new_run['rsl_plate_num'])
sub = lookup_submissions(ctx=obj.ctx, rsl_number=new_run['rsl_plate_num']) sub = lookup_submissions(ctx=obj.ctx, rsl_number=new_run['rsl_plate_num'])
# If no such submission exists, move onto the next run # If no such submission exists, move onto the next run
if sub == None:
continue
try: try:
logger.debug(f"Found submission: {sub.rsl_plate_num}") logger.debug(f"Found submission: {sub.rsl_plate_num}")
count += 1 count += 1
@@ -687,6 +707,8 @@ def link_pcr_function(obj:QMainWindow) -> Tuple[QMainWindow, dict]:
# sub = lookup_submission_by_rsl_num(ctx=obj.ctx, rsl_num=new_run['rsl_plate_num']) # sub = lookup_submission_by_rsl_num(ctx=obj.ctx, rsl_num=new_run['rsl_plate_num'])
sub = lookup_submissions(ctx=obj.ctx, rsl_number=new_run['rsl_plate_num']) sub = lookup_submissions(ctx=obj.ctx, rsl_number=new_run['rsl_plate_num'])
# if imported submission doesn't exist move on to next run # if imported submission doesn't exist move on to next run
if sub == None:
continue
try: try:
logger.debug(f"Found submission: {sub.rsl_plate_num}") logger.debug(f"Found submission: {sub.rsl_plate_num}")
except AttributeError: except AttributeError:
@@ -838,11 +860,14 @@ def autofill_excel(obj:QMainWindow, xl_map:dict, reagents:List[dict], missing_re
new_info = [] new_info = []
logger.debug(f"Parsing from relevant info map: {pprint.pformat(relevant_info_map)}") logger.debug(f"Parsing from relevant info map: {pprint.pformat(relevant_info_map)}")
for item in relevant_info: for item in relevant_info:
new_item = {} try:
new_item['type'] = item new_item = {}
new_item['location'] = relevant_info_map[item] new_item['type'] = item
new_item['value'] = relevant_info[item] new_item['location'] = relevant_info_map[item]
new_info.append(new_item) new_item['value'] = relevant_info[item]
new_info.append(new_item)
except KeyError:
logger.error(f"Unable to fill in {item}, 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 # open a new workbook using openpyxl
@@ -888,17 +913,16 @@ def construct_first_strand_function(obj:QMainWindow) -> Tuple[QMainWindow, dict]
""" """
def get_plates(input_sample_number:str, plates:list) -> Tuple[int, str]: def get_plates(input_sample_number:str, plates:list) -> Tuple[int, str]:
logger.debug(f"Looking up {input_sample_number} in {plates}") logger.debug(f"Looking up {input_sample_number} in {plates}")
# samp = lookup_ww_sample_by_processing_number(ctx=obj.ctx, processing_number=input_sample_number)
samp = lookup_samples(ctx=obj.ctx, ww_processing_num=input_sample_number) samp = lookup_samples(ctx=obj.ctx, ww_processing_num=input_sample_number)
if samp == None: if samp == None:
# samp = lookup_sample_by_submitter_id(ctx=obj.ctx, submitter_id=input_sample_number)
samp = lookup_samples(ctx=obj.ctx, submitter_id=input_sample_number) samp = lookup_samples(ctx=obj.ctx, submitter_id=input_sample_number)
if samp == None:
return None, None
logger.debug(f"Got sample: {samp}") logger.debug(f"Got sample: {samp}")
# new_plates = [(iii+1, lookup_sub_samp_association_by_plate_sample(ctx=obj.ctx, rsl_sample_num=samp, rsl_plate_num=lookup_submission_by_rsl_num(ctx=obj.ctx, rsl_num=plate))) for iii, plate in enumerate(plates)]
new_plates = [(iii+1, lookup_submission_sample_association(ctx=obj.ctx, sample=samp, submission=plate)) for iii, plate in enumerate(plates)] new_plates = [(iii+1, lookup_submission_sample_association(ctx=obj.ctx, sample=samp, submission=plate)) for iii, plate in enumerate(plates)]
logger.debug(f"Associations: {pprint.pformat(new_plates)}") logger.debug(f"Associations: {pprint.pformat(new_plates)}")
try: try:
plate_num, plate = next(assoc for assoc in new_plates if assoc[1] is not None) plate_num, plate = next(assoc for assoc in new_plates if assoc[1])
except StopIteration: except StopIteration:
plate_num, plate = None, None plate_num, plate = None, None
logger.debug(f"Plate number {plate_num} is {plate}") logger.debug(f"Plate number {plate_num} is {plate}")
@@ -907,11 +931,19 @@ def construct_first_strand_function(obj:QMainWindow) -> Tuple[QMainWindow, dict]
xl = pd.ExcelFile(fname) xl = pd.ExcelFile(fname)
sprsr = SampleParser(ctx=obj.ctx, xl=xl, submission_type="First Strand") sprsr = SampleParser(ctx=obj.ctx, xl=xl, submission_type="First Strand")
_, samples = sprsr.parse_samples(generate=False) _, samples = sprsr.parse_samples(generate=False)
logger.debug(f"Samples: {pformat(samples)}")
logger.debug("Called first strand sample parser")
plates = sprsr.grab_plates() plates = sprsr.grab_plates()
logger.debug(f"Plates: {pformat(plates)}")
output_samples = [] output_samples = []
logger.debug(f"Samples: {pprint.pformat(samples)}") logger.debug(f"Samples: {pformat(samples)}")
old_plate_number = 1 old_plate_number = 1
for item in samples: for item in samples:
try:
item['well'] = re.search(r"\s\((.*)\)$", item['submitter_id']).groups()[0]
except AttributeError:
item['well'] = item
item['submitter_id'] = re.sub(r"\s\(.*\)$", "", str(item['submitter_id'])).strip()
new_dict = {} new_dict = {}
new_dict['sample'] = item['submitter_id'] new_dict['sample'] = item['submitter_id']
if item['submitter_id'] == "NTC1": if item['submitter_id'] == "NTC1":
@@ -967,6 +999,7 @@ def scrape_reagents(obj:QMainWindow, extraction_kit:str) -> Tuple[QMainWindow, d
logger.debug(f"Extraction kit: {extraction_kit}") logger.debug(f"Extraction kit: {extraction_kit}")
obj.reagents = [] obj.reagents = []
obj.missing_reagents = [] obj.missing_reagents = []
# Remove previous reagent widgets
[item.setParent(None) for item in obj.table_widget.formlayout.parentWidget().findChildren(QWidget) if item.objectName().startswith("lot_") or item.objectName().startswith("missing_")] [item.setParent(None) for item in obj.table_widget.formlayout.parentWidget().findChildren(QWidget) if item.objectName().startswith("lot_") or item.objectName().startswith("missing_")]
reagents = obj.prsr.parse_reagents(extraction_kit=extraction_kit) reagents = obj.prsr.parse_reagents(extraction_kit=extraction_kit)
logger.debug(f"Got reagents: {reagents}") logger.debug(f"Got reagents: {reagents}")

View File

@@ -2,7 +2,7 @@ from pathlib import Path
import sys import sys
from PIL import Image, ImageDraw, ImageFont from PIL import Image, ImageDraw, ImageFont
import numpy as np import numpy as np
from tools import check_if_app from tools import check_if_app, jinja_template_loading
import logging import logging
logger = logging.getLogger(f"submissions.{__name__}") logger = logging.getLogger(f"submissions.{__name__}")
@@ -81,4 +81,31 @@ def make_plate_map(sample_list:list) -> Image:
letter = row_dict[num-1] letter = row_dict[num-1]
y = (num * 100) - 10 y = (num * 100) - 10
draw.text((10, y), letter, (0,0,0),font=font) draw.text((10, y), letter, (0,0,0),font=font)
return new_img return new_img
def make_plate_map_html(sample_list:list, plate_rows:int=8, plate_columns=12) -> str:
try:
plate_num = sample_list[0]['plate_name']
except IndexError as e:
logger.error(f"Couldn't get a plate number. Will not make plate.")
return None
except TypeError as e:
logger.error(f"No samples for this plate. Nothing to do.")
return None
for sample in sample_list:
if sample['positive']:
sample['background_color'] = "#f10f07"
else:
sample['background_color'] = "#80cbc4"
output_samples = []
for column in range(1, plate_columns+1):
for row in range(1, plate_rows+1):
try:
well = [item for item in sample_list if item['row'] == row and item['column']==column][0]
except IndexError:
well = dict(name="", row=row, column=column, background_color="#ffffff")
output_samples.append(well)
env = jinja_template_loading()
template = env.get_template("plate_map.html")
html = template.render(samples=output_samples, PLATE_ROWS=plate_rows, PLATE_COLUMNS=plate_columns)
return html

View File

@@ -0,0 +1,17 @@
<div class="gallery" style="display: grid;grid-template-columns: repeat({{ PLATE_COLUMNS }}, 7.5vw);grid-template-rows: repeat({{ PLATE_ROWS }}, 7.5vw);grid-gap: 2px;">
{% for sample in samples %}
<div class="well" style="background-color: {{sample['background_color']}};
border: 1px solid #000;
padding: 20px;
grid-column-start: {{sample['column']}};
grid-column-end: {{sample['column']}};
grid-row-start: {{sample['row']}};
grid-row-end: {{sample['row']}};
display: flex;
">
<div class="tooltip" style="font-size: 0.5em; text-align: center; word-wrap: break-word;">{{ sample['name'] }}
<span class="tooltiptext">{{ sample['tooltip'] }}</span>
</div>
</div>
{% endfor %}
</div>

View File

@@ -1,6 +1,38 @@
<!doctype html> <!doctype html>
<html> <html>
<head> <head>
<style>
/* Tooltip container */
.tooltip {
position: relative;
display: inline-block;
border-bottom: 1px dotted black; /* If you want dots under the hoverable text */
}
/* Tooltip text */
.tooltip .tooltiptext {
visibility: hidden;
width: 120px;
background-color: black;
color: #fff;
text-align: center;
padding: 5px 0;
border-radius: 6px;
/* Position the tooltip text - see examples below! */
position: absolute;
z-index: 1;
bottom: 100%;
left: 50%;
margin-left: -60px;
}
/* Show the tooltip text when you mouse over the tooltip container */
.tooltip:hover .tooltiptext {
visibility: visible;
font-size: large;
}
</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'] %} {% set excluded = ['reagents', 'samples', 'controls', 'ext_info', 'pcr_info', 'comments', 'barcode', 'platemap'] %}
@@ -67,7 +99,11 @@
{% endif %} {% endif %}
{% if sub['platemap'] %} {% if sub['platemap'] %}
<h3><u>Plate map:</u></h3> <h3><u>Plate map:</u></h3>
<img height="300px" width="650px" src="data:image/jpeg;base64,{{ sub['platemap'] | safe }}"> {{ sub['platemap'] }}
{% endif %}
{% if sub['export_map'] %}
<h3><u>Plate map:</u></h3>
<img height="300px" width="650px" src="data:image/jpeg;base64,{{ sub['export_map'] | safe }}">
{% endif %} {% endif %}
</body> </body>
</html> </html>

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> Plates: {{ kit['plate_count'] }}, Samples: {{ kit['sample_count'] }}, Cost: {{ "${:,.2f}".format(kit['cost']) }}</p> <p> Runs: {{ kit['plate_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> Plates: {{ lab['total_plates'] }}, Samples: {{ lab['total_samples'] }}, Cost: {{ "${:,.2f}".format(lab['total_cost']) }}</p> <p> Runs: {{ lab['total_plates'] }}, Samples: {{ lab['total_samples'] }}, Cost: {{ "${:,.2f}".format(lab['total_cost']) }}</p>
<br> <br>
{% endfor %} {% endfor %}
</body> </body>

View File

@@ -36,6 +36,8 @@ main_aux_dir = Path.home().joinpath(f"{os_config_dir}/submissions")
CONFIGDIR = main_aux_dir.joinpath("config") CONFIGDIR = main_aux_dir.joinpath("config")
LOGDIR = main_aux_dir.joinpath("logs") LOGDIR = main_aux_dir.joinpath("logs")
row_map = {1:"A", 2:"B", 3:"C", 4:"D", 5:"E", 6:"F", 7:"G", 8:"H"}
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.
@@ -576,10 +578,11 @@ def jinja_template_loading():
if check_if_app(): if check_if_app():
loader_path = Path(sys._MEIPASS).joinpath("files", "templates") loader_path = Path(sys._MEIPASS).joinpath("files", "templates")
else: else:
loader_path = Path(__file__).parents[1].joinpath('templates').absolute().__str__() loader_path = Path(__file__).parents[1].joinpath('templates').absolute()#.__str__()
# jinja template loading # jinja template loading
loader = FileSystemLoader(loader_path) loader = FileSystemLoader(loader_path)
env = Environment(loader=loader) env = Environment(loader=loader)
env.globals['STATIC_PREFIX'] = loader_path.joinpath("static", "css")
return env return env
def check_is_power_user(ctx:Settings) -> bool: def check_is_power_user(ctx:Settings) -> bool:
@@ -632,4 +635,5 @@ def convert_well_to_row_column(input_str:str) -> Tuple[int, int]:
column = int(input_str[1:]) column = int(input_str[1:])
except IndexError: except IndexError:
return None, None return None, None
return row, column return row, column