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
- Updated KitAdder to add location info as well.
- Extraction kit can now be updated after import.
- 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:
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:

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] Clean up db.functions.
- [ ] Make kits easier to add.
- [ ] Clean up & document code... again.
- [x] Make kits easier to add.
- [x] Clean up & document code... again.
- Including paring down the logging.debugs
- Also including reducing number of functions in db.functions
- [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
; 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\python\submissions\tests\test_assets\submissions_test.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
[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
__project__ = "submissions"
__version__ = "202309.4b"
__version__ = "202310.1b"
__author__ = {"name":"Landon Wark", "email":"Landon.Wark@phac-aspc.gc.ca"}
__copyright__ = "2022-2023, Government of Canada"

View File

@@ -76,10 +76,10 @@ def store_object(ctx:Settings, object) -> dict|None:
dbs.merge(object)
try:
dbs.commit()
except (sqlite3.IntegrityError, sqlalchemy.exc.IntegrityError) as e:
except (SQLIntegrityError, AlcIntegrityError) as e:
logger.debug(f"Hit an integrity error : {e}")
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):
logger.error(f"Hit an operational error: {e}")
dbs.rollback()

View File

@@ -10,6 +10,7 @@ from datetime import date, timedelta
from dateutil.parser import parse
from typing import Tuple
from sqlalchemy.exc import IntegrityError, SAWarning
from . import store_object
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.
"""
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:
sample_instance = sample['sample']
else:
@@ -174,7 +175,7 @@ def construct_samples(ctx:Settings, instance:models.BasicSubmission, samples:Lis
try:
assoc = getattr(models, f"{sample_query}Association")
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 = assoc(submission=instance, sample=sample_instance, row=sample['row'], column=sample['column'])
instance.submission_sample_associations.append(assoc)
@@ -189,7 +190,7 @@ def construct_samples(ctx:Settings, instance:models.BasicSubmission, samples:Lis
continue
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
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):
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"}
# iterate through keys in dict
for type in exp:
# A submission type may use multiple kits.
for kt in exp[type]['kits']:
logger.debug(f"Looking up submission type: {type}")
# submission_type = lookup_submissiontype_by_name(ctx=ctx, type_name=type)
submission_type = lookup_submission_type(ctx=ctx, name=type)
logger.debug(f"Looked up submission type: {submission_type}")
kit = models.KitType(name=kt)
kt_st_assoc = models.SubmissionTypeKitTypeAssociation(kit_type=kit, submission_type=submission_type)
kt_st_assoc.constant_cost = exp[type]["kits"][kt]["constant_cost"]
kt_st_assoc.mutable_cost_column = exp[type]["kits"][kt]["mutable_cost_column"]
kt_st_assoc.mutable_cost_sample = exp[type]["kits"][kt]["mutable_cost_sample"]
kit.kit_submissiontype_associations.append(kt_st_assoc)
# A kit contains multiple reagent types.
for r in exp[type]['kits'][kt]['reagenttypes']:
# check if reagent type already exists.
r = massage_common_reagents(r)
look_up = ctx.database_session.query(models.ReagentType).filter(models.ReagentType.name==r).first()
if look_up == None:
rt = models.ReagentType(name=r.strip(), eol_ext=timedelta(30*exp[type]['kits'][kt]['reagenttypes'][r]['eol_ext']), last_used="")
else:
rt = look_up
assoc = models.KitTypeReagentTypeAssociation(kit_type=kit, reagent_type=rt, uses={})
ctx.database_session.add(rt)
kit.kit_reagenttype_associations.append(assoc)
logger.debug(f"Kit construction reagent type: {rt.__dict__}")
logger.debug(f"Kit construction kit: {kit.__dict__}")
ctx.database_session.add(kit)
ctx.database_session.commit()
submission_type = lookup_submission_type(ctx=ctx, name=kit_dict['used_for'])
logger.debug(f"Looked up submission type: {kit_dict['used_for']} and got {submission_type}")
kit = models.KitType(name=kit_dict["kit_name"])
kt_st_assoc = models.SubmissionTypeKitTypeAssociation(kit_type=kit, submission_type=submission_type)
for k,v in kit_dict.items():
if k not in ["reagent_types", "kit_name", "used_for"]:
kt_st_assoc.set_attrib(k, v)
kit.kit_submissiontype_associations.append(kt_st_assoc)
# A kit contains multiple reagent types.
for r in kit_dict['reagent_types']:
# check if reagent type already exists.
logger.debug(f"Constructing reagent type: {r}")
rtname = massage_common_reagents(r['rtname'])
# look_up = ctx.database_session.query(models.ReagentType).filter(models.ReagentType.name==rtname).first()
look_up = lookup_reagent_types(name=rtname)
if look_up == None:
rt = models.ReagentType(name=rtname.strip(), eol_ext=timedelta(30*r['eol']))
else:
rt = look_up
uses = {kit_dict['used_for']:{k:v for k,v in r.items() if k not in ['eol']}}
assoc = models.KitTypeReagentTypeAssociation(kit_type=kit, reagent_type=rt, uses=uses)
# ctx.database_session.add(rt)
store_object(ctx=ctx, object=rt)
kit.kit_reagenttype_associations.append(assoc)
logger.debug(f"Kit construction reagent type: {rt.__dict__}")
logger.debug(f"Kit construction kit: {kit.__dict__}")
store_object(ctx=ctx, object=kit)
return {'code':0, 'message':'Kit has been added', 'status': 'information'}
def construct_org_from_yaml(ctx:Settings, org:dict) -> dict:

View File

@@ -209,7 +209,11 @@ def lookup_submissions(ctx:Settings,
match rsl_number:
case str():
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(model.rsl_plate_num==rsl_number)
limit = 1
@@ -306,6 +310,7 @@ def lookup_controls(ctx:Settings,
control_type:models.ControlType|str|None=None,
start_date:date|str|int|None=None,
end_date:date|str|int|None=None,
control_name:str|None=None,
limit:int=0
) -> models.Control|List[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")
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))
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)
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)
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
'''
from typing import Any
from sqlalchemy.orm import declarative_base
from sqlalchemy.orm import declarative_base, DeclarativeMeta
import logging
from pprint import pformat
Base = declarative_base()
Base: DeclarativeMeta = declarative_base()
metadata = Base.metadata
logger = logging.getLogger(f"submissions.{__name__}")

View File

@@ -332,4 +332,7 @@ class SubmissionTypeKitTypeAssociation(Base):
self.constant_cost = 0.00
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
from pandas import Timestamp
from dateutil.parser import parse
import re
import pandas as pd
from tools import row_map
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.
uploaded_by = Column(String(32)) #: user name of person who submitted the submission to the database.
comment = Column(JSON)
submission_category = Column(String(64))
submission_sample_associations = relationship(
"SubmissionSampleAssociation",
@@ -83,7 +87,7 @@ class BasicSubmission(Base):
dict: dictionary used in submissions summary and details
"""
# 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:
sub_lab = self.submitting_lab.name
except AttributeError:
@@ -125,6 +129,7 @@ class BasicSubmission(Base):
"id": self.id,
"Plate Number": self.rsl_plate_num,
"Submission Type": self.submission_type_name,
"Submission Category": self.submission_category,
"Submitter Plate Number": self.submitter_plate_num,
"Submitted Date": self.submitted_date.strftime("%Y-%m-%d"),
"Submitting Lab": sub_lab,
@@ -232,6 +237,34 @@ class BasicSubmission(Base):
else:
continue
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
@@ -252,7 +285,7 @@ class BacterialCulture(BasicSubmission):
output = super().to_dict(full_data=full_data)
if full_data:
output['controls'] = [item.to_sub_dict() for item in self.controls]
return output
return output
class Wastewater(BasicSubmission):
"""
@@ -278,6 +311,23 @@ class Wastewater(BasicSubmission):
output['Technician'] = f"Enr: {self.technician}, Ext: {self.ext_technician}, PCR: {self.pcr_technician}"
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):
"""
derivative submission type for artic wastewater
@@ -303,6 +353,25 @@ class WastewaterArtic(BasicSubmission):
except Exception as 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):
"""
Base of basic sample which polymorphs into BCSample and WWSample
@@ -364,26 +433,31 @@ class BasicSample(Base):
Returns:
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 = {}
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:
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['name'] = self.submitter_id
return sample
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:
dict: dictionary of sample id, row and column in elution plate
"""
# 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):
"""
@@ -445,42 +519,24 @@ class WastewaterSample(BasicSample):
value = self.submitter_id
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:
"""
Outputs a dictionary of locations if sample is positive
Outputs a dictionary usable for html plate maps. Extends parent method.
Returns:
dict: dictionary of sample id, row and column in elution plate
"""
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
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:
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
class BacterialCultureSample(BasicSample):

View File

@@ -6,7 +6,7 @@ import pprint
from typing import List
import pandas as pd
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
import logging
from collections import OrderedDict
@@ -91,12 +91,11 @@ class SheetParser(object):
Pulls basic information from the excel sheet
"""
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()}"
try:
custom_parser = getattr(self, parser_query)
info = custom_parser(info)
except AttributeError:
logger.error(f"Couldn't find submission parser: {parser_query}")
# parser_query = f"parse_{self.sub['submission_type']['value'].replace(' ', '_').lower()}"
# custom_parser = getattr(self, parser_query)
# except AttributeError:
# logger.error(f"Couldn't find submission parser: {parser_query}")
for k,v in info.items():
match k:
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()
def parse_bacterial_culture(self, input_dict) -> dict:
"""
Update submission dictionary with type specific information
# def parse_bacterial_culture(self, input_dict) -> dict:
# """
# Update submission dictionary with type specific information
Args:
input_dict (dict): Input sample dictionary
# Args:
# input_dict (dict): Input sample dictionary
Returns:
dict: Updated sample dictionary
"""
return input_dict
# Returns:
# dict: Updated sample dictionary
# """
# return input_dict
def parse_wastewater(self, input_dict) -> dict:
"""
Update submission dictionary with type specific information
# def parse_wastewater(self, input_dict) -> dict:
# """
# Update submission dictionary with type specific information
Args:
input_dict (dict): Input sample dictionary
# Args:
# input_dict (dict): Input sample dictionary
Returns:
dict: Updated sample dictionary
"""
return input_dict
# Returns:
# dict: Updated sample dictionary
# """
# return input_dict
def parse_wastewater_artic(self, input_dict:dict) -> dict:
"""
Update submission dictionary with type specific information
# def parse_wastewater_artic(self, input_dict:dict) -> dict:
# """
# Update submission dictionary with type specific information
Args:
input_dict (dict): Input sample dictionary
# Args:
# input_dict (dict): Input sample dictionary
Returns:
dict: Updated sample dictionary
"""
return input_dict
# Returns:
# dict: Updated sample dictionary
# """
# return input_dict
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.xl = xl
logger.debug(f"Info map for InfoParser: {pprint.pformat(self.map)}")
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_submission_type(ctx=self.ctx, name=submission_type['value'])
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
def parse_info(self) -> dict:
@@ -263,7 +265,13 @@ class InfoParser(object):
continue
else:
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):
@@ -351,6 +359,7 @@ class SampleParser(object):
submission_type = lookup_submission_type(ctx=self.ctx, name=submission_type)
logger.debug(f"info_map: {pprint.pformat(submission_type.info_map)}")
sample_info_map = submission_type.info_map['samples']
self.custom_parser = get_polymorphic_subclass(models.BasicSubmission, submission_type.name).parse_samples
return sample_info_map
def construct_plate_map(self, plate_map_location:dict) -> pd.DataFrame:
@@ -473,12 +482,12 @@ class SampleParser(object):
except KeyError:
translated_dict[k] = convert_nans_to_nones(v)
translated_dict['sample_type'] = f"{self.submission_type} Sample"
parser_query = f"parse_{translated_dict['sample_type'].replace(' ', '_').lower()}"
try:
custom_parser = getattr(self, parser_query)
translated_dict = custom_parser(translated_dict)
except AttributeError:
logger.error(f"Couldn't get custom parser: {parser_query}")
# parser_query = f"parse_{translated_dict['sample_type'].replace(' ', '_').lower()}"
# try:
# custom_parser = getattr(self, parser_query)
translated_dict = self.custom_parser(translated_dict)
# except AttributeError:
# logger.error(f"Couldn't get custom parser: {parser_query}")
if generate:
new_samples.append(self.generate_sample_object(translated_dict))
else:
@@ -502,7 +511,7 @@ class SampleParser(object):
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=input_dict['submitter_id'])
instance = lookup_samples(ctx=self.ctx, 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()
@@ -516,63 +525,63 @@ class SampleParser(object):
return dict(sample=instance, row=input_dict['row'], column=input_dict['column'])
def parse_bacterial_culture_sample(self, input_dict:dict) -> dict:
"""
Update sample dictionary with bacterial culture specific information
# def parse_bacterial_culture_sample(self, input_dict:dict) -> dict:
# """
# Update sample dictionary with bacterial culture specific information
Args:
input_dict (dict): Input sample dictionary
# Args:
# input_dict (dict): Input sample dictionary
Returns:
dict: Updated sample dictionary
"""
logger.debug("Called bacterial culture sample parser")
return input_dict
# Returns:
# dict: Updated sample dictionary
# """
# logger.debug("Called bacterial culture sample parser")
# return input_dict
def parse_wastewater_sample(self, input_dict:dict) -> dict:
"""
Update sample dictionary with wastewater specific information
# def parse_wastewater_sample(self, input_dict:dict) -> dict:
# """
# Update sample dictionary with wastewater specific information
Args:
input_dict (dict): Input sample dictionary
# Args:
# input_dict (dict): Input sample dictionary
Returns:
dict: Updated sample dictionary
"""
logger.debug(f"Called wastewater sample parser")
return input_dict
# Returns:
# dict: Updated sample dictionary
# """
# logger.debug(f"Called wastewater sample parser")
# return input_dict
def parse_wastewater_artic_sample(self, input_dict:dict) -> dict:
"""
Update sample dictionary with artic specific information
# def parse_wastewater_artic_sample(self, input_dict:dict) -> dict:
# """
# Update sample dictionary with artic specific information
Args:
input_dict (dict): Input sample dictionary
# Args:
# input_dict (dict): Input sample dictionary
Returns:
dict: Updated sample dictionary
"""
logger.debug("Called wastewater artic sample parser")
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
# Returns:
# dict: Updated sample dictionary
# """
# logger.debug("Called wastewater artic sample parser")
# 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
def parse_first_strand_sample(self, input_dict:dict) -> dict:
"""
Update sample dictionary with first strand specific information
# def parse_first_strand_sample(self, input_dict:dict) -> dict:
# """
# Update sample dictionary with first strand specific information
Args:
input_dict (dict): Input sample dictionary
# Args:
# input_dict (dict): Input sample dictionary
Returns:
dict: Updated sample dictionary
"""
logger.debug("Called first strand sample parser")
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()
return input_dict
# Returns:
# dict: Updated sample dictionary
# """
# logger.debug("Called first strand sample parser")
# 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()
# return input_dict
def grab_plates(self) -> List[str]:
"""

View File

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

View File

@@ -86,6 +86,7 @@ class PydSubmission(BaseModel, extra=Extra.allow):
sample_count: dict|None
extraction_kit: dict|None
technician: dict|None
submission_category: dict|None = Field(default=dict(value=None, parsed=False), validate_default=True)
reagents: List[dict] = []
samples: List[Any]
@@ -205,3 +206,11 @@ class PydSubmission(BaseModel, extra=Extra.allow):
return dict(value=value, parsed=True)
else:
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.
'''
from pprint import pformat
import sys
from typing import Tuple
from PyQt6.QtWidgets import (
QMainWindow, QToolBar,
QTabWidget, QWidget, QVBoxLayout,
QComboBox, QHBoxLayout,
QScrollArea
QScrollArea, QLineEdit, QDateEdit,
QSpinBox
)
from PyQt6.QtCore import Qt
from PyQt6.QtGui import QAction
from PyQt6.QtWebEngineWidgets import QWebEngineView
from pathlib import Path
from backend.db import (
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 frontend.custom_widgets import SubmissionsSheet, AlertPop, AddReagentForm, KitAdder, ControlsDatePicker
from frontend.custom_widgets import SubmissionsSheet, AlertPop, AddReagentForm, KitAdder, ControlsDatePicker, ImportReagent
import logging
from datetime import date
import webbrowser
@@ -51,7 +55,9 @@ class App(QMainWindow):
self._createToolBar()
self._connectActions()
self._controls_getter()
# self.status_bar = self.statusBar()
self.show()
self.statusBar().showMessage('Ready', 5000)
def _createMenuBar(self):
@@ -73,7 +79,7 @@ class App(QMainWindow):
fileMenu.addAction(self.importPCRAction)
methodsMenu.addAction(self.constructFS)
reportMenu.addAction(self.generateReportAction)
maintenanceMenu.addAction(self.joinControlsAction)
# maintenanceMenu.addAction(self.joinControlsAction)
maintenanceMenu.addAction(self.joinExtractionAction)
maintenanceMenu.addAction(self.joinPCRAction)
@@ -99,7 +105,7 @@ class App(QMainWindow):
self.generateReportAction = QAction("Make Report", self)
self.addKitAction = QAction("Import Kit", self)
self.addOrgAction = QAction("Import Org", self)
self.joinControlsAction = QAction("Link Controls")
# self.joinControlsAction = QAction("Link Controls")
self.joinExtractionAction = QAction("Link Extraction Logs")
self.joinPCRAction = QAction("Link PCR Logs")
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.datepicker.start_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.joinPCRAction.triggered.connect(self.linkPCR)
self.helpAction.triggered.connect(self.showAbout)
@@ -149,6 +155,7 @@ class App(QMainWindow):
webbrowser.get('windows-default').open(f"file://{url.__str__()}")
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
@@ -158,6 +165,8 @@ class App(QMainWindow):
if result != None:
msg = AlertPop(message=result['message'], status=result['status'])
msg.exec()
else:
self.statusBar().showMessage("Action completed sucessfully.", 5000)
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)
if dlg.exec():
# extract form info
info = extract_form_info(dlg)
# info = extract_form_info(dlg)
info = dlg.parse_form()
logger.debug(f"Reagent info: {info}")
# create reagent object
reagent = construct_reagent(ctx=self.ctx, info_dict=info)
# send reagent to db
# store_reagent(ctx=self.ctx, reagent=reagent)
result = store_object(ctx=self.ctx, object=reagent)
self.result_reporter(result=result)
return reagent
def generateReport(self):
@@ -263,6 +274,7 @@ class App(QMainWindow):
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)
@@ -327,7 +339,7 @@ class AddSubForm(QWidget):
self.tabs.addTab(self.tab2,"Controls")
self.tabs.addTab(self.tab3, "Add Kit")
# Create submission adder form
self.formwidget = QWidget(self)
self.formwidget = SubmissionFormWidget(self)
self.formlayout = QVBoxLayout(self)
self.formwidget.setLayout(self.formlayout)
self.formwidget.setFixedWidth(300)
@@ -381,3 +393,32 @@ class AddSubForm(QWidget):
self.layout.addWidget(self.tabs)
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:
"""
retrieves object names and values from form
DEPRECIATED. Replaced by individual form parser methods.
Args:
object (_type_): the form widget
@@ -64,7 +65,7 @@ def extract_form_info(object) -> dict:
from frontend.custom_widgets import ReagentTypeForm
dicto = {}
reagents = {}
reagents = []
logger.debug(f"Object type: {type(object)}")
# grab all widgets in form
try:
@@ -85,8 +86,17 @@ def extract_form_info(object) -> dict:
case ReagentTypeForm():
reagent = extract_form_info(item)
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
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 != {}:
return dicto, reagents
return dicto

View File

@@ -2,22 +2,25 @@
Contains miscellaneous widgets for frontend functions
'''
from datetime import date
from pprint import pformat
from PyQt6.QtWidgets import (
QLabel, QVBoxLayout,
QLineEdit, QComboBox, QDialog,
QDialogButtonBox, QDateEdit, QSizePolicy, QWidget,
QGridLayout, QPushButton, QSpinBox, QDoubleSpinBox,
QHBoxLayout
QHBoxLayout, QScrollArea
)
from PyQt6.QtCore import Qt, QDate, QSize
from tools import check_not_nan, jinja_template_loading, Settings
from ..all_window_functions import extract_form_info
from backend.db import construct_kit_from_yaml, \
from backend.db.functions import construct_kit_from_yaml, \
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 numpy as np
from .pop_ups import AlertPop
from backend.pydant import PydReagent
from typing import Tuple
logger = logging.getLogger(f"submissions.{__name__}")
@@ -84,6 +87,12 @@ class AddReagentForm(QDialog):
self.setLayout(self.layout)
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):
"""
Updates reagent names form field with examples from reagent type
@@ -121,6 +130,9 @@ class ReportDatePicker(QDialog):
self.layout.addWidget(self.buttonBox)
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):
"""
dialog to get information to add kit
@@ -128,8 +140,14 @@ class KitAdder(QWidget):
def __init__(self, parent_ctx:Settings) -> None:
super().__init__()
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.setLayout(self.grid)
# self.setLayout(self.grid)
scrollContent.setLayout(self.grid)
# insert submit button at top
self.submit_btn = QPushButton("Submit")
self.grid.addWidget(self.submit_btn,0,0,1,1)
@@ -138,42 +156,65 @@ class KitAdder(QWidget):
kit_name = QLineEdit()
kit_name.setObjectName("kit_name")
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
used_for = QComboBox()
used_for.setObjectName("used_for")
# 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.setEditable(True)
self.grid.addWidget(used_for,3,1)
# set cost per run
self.grid.addWidget(QLabel("Constant cost per full plate (plates, work hours, etc.):"),4,0)
# 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)
# Get all fields in SubmissionTypeKitTypeAssociation
self.columns = [item for item in SubmissionTypeKitTypeAssociation.__table__.columns if len(item.foreign_keys) == 0]
for iii, column in enumerate(self.columns):
idx = iii + 4
# convert field name to human readable.
field_name = column.name.replace("_", " ").title()
self.grid.addWidget(QLabel(field_name),idx,0)
match column.type:
case FLOAT():
add_widget = QDoubleSpinBox()
add_widget.setMinimum(0)
add_widget.setMaximum(9999)
case INTEGER():
add_widget = QSpinBox()
add_widget.setMinimum(0)
add_widget.setMaximum(9999)
case _:
add_widget = QLineEdit()
add_widget.setObjectName(column.name)
self.grid.addWidget(add_widget, idx,1)
# self.grid.addWidget(QLabel("Constant cost per full plate (plates, work hours, etc.):"),4,0)
# # 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
self.add_RT_btn = QPushButton("Add Reagent Type")
self.grid.addWidget(self.add_RT_btn)
self.add_RT_btn.clicked.connect(self.add_RT)
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:
"""
@@ -181,9 +222,11 @@ class KitAdder(QWidget):
"""
# get bottommost row
maxrow = self.grid.rowCount()
reg_form = ReagentTypeForm(parent_ctx=self.ctx)
reg_form = ReagentTypeForm(ctx=self.ctx)
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:
@@ -191,40 +234,62 @@ class KitAdder(QWidget):
send kit to database
"""
# get form info
info, reagents = extract_form_info(self)
logger.debug(f"kit info: {info}")
yml_type = {}
try:
yml_type['password'] = info['password']
except KeyError:
pass
used = info['used_for']
yml_type[used] = {}
yml_type[used]['kits'] = {}
yml_type[used]['kits'][info['kit_name']] = {}
yml_type[used]['kits'][info['kit_name']]['constant_cost'] = info["const_cost"]
yml_type[used]['kits'][info['kit_name']]['mutable_cost_column'] = info["mut_cost_col"]
yml_type[used]['kits'][info['kit_name']]['mutable_cost_sample'] = info["mut_cost_samp"]
yml_type[used]['kits'][info['kit_name']]['reagenttypes'] = reagents
logger.debug(yml_type)
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']}
logger.debug(f"kit info: {pformat(info)}")
logger.debug(f"kit reagents: {pformat(reagents)}")
info['reagent_types'] = reagents
# for reagent in reagents:
# new_dict = {}
# for k,v in reagent.items():
# if "_" in k:
# key, sub_key = k.split("_")
# if key not in new_dict.keys():
# new_dict[key] = {}
# logger.debug(f"Adding key {key}, {sub_key} and value {v} to {new_dict}")
# new_dict[key][sub_key] = v
# else:
# new_dict[k] = v
# info['reagent_types'].append(new_dict)
logger.debug(pformat(info))
# 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.exec()
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):
"""
custom widget to add information about a new reagenttype
"""
def __init__(self, ctx:dict) -> None:
def __init__(self, ctx:Settings) -> None:
super().__init__()
grid = QGridLayout()
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
self.reagent_getter = QComboBox()
self.reagent_getter.setObjectName("name")
self.reagent_getter.setObjectName("rtname")
# lookup all reagent type names from db
lookup = lookup_reagent_types(ctx=ctx)
logger.debug(f"Looked up ReagentType names: {lookup}")
@@ -233,10 +298,66 @@ class ReagentTypeForm(QWidget):
grid.addWidget(self.reagent_getter,0,1)
grid.addWidget(QLabel("Extension of Life (months):"),0,2)
# widget to get extension of life
eol = QSpinBox()
eol.setObjectName('eol')
eol.setMinimum(0)
grid.addWidget(eol, 0,3)
self.eol = QSpinBox()
self.eol.setObjectName('eol')
self.eol.setMinimum(0)
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):
"""
@@ -336,3 +457,4 @@ class ParsedQLabel(QLabel):
self.setText(f"Parsed {output}")
else:
self.setText(f"MISSING {output}")

View File

@@ -8,6 +8,7 @@ from PyQt6.QtWidgets import (
from tools import jinja_template_loading
import logging
from backend.db.functions import lookup_kit_types, lookup_submission_type
from typing import Literal
logger = logging.getLogger(f"submissions.{__name__}")
@@ -36,7 +37,7 @@ class AlertPop(QMessageBox):
"""
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__()
# select icon by string
icon = getattr(QMessageBox.Icon, status.title())

View File

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

View File

@@ -6,6 +6,7 @@ import difflib
from getpass import getuser
import inspect
import pprint
import re
import yaml
import json
from typing import Tuple, List
@@ -19,12 +20,12 @@ from PyQt6.QtWidgets import (
QMainWindow, QLabel, QWidget, QPushButton,
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 backend.db.functions import (
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,
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.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}")
obj.samples = value
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":
continue
case 'reagents':
# NOTE: This is now set to run when the extraction kit is updated.
continue
case 'csv':
continue
case _:
# anything else gets added in as a line edit
add_widget = QLineEdit()
@@ -166,6 +178,7 @@ def import_submission_function(obj:QMainWindow) -> Tuple[QMainWindow, dict|None]
if "csv" in pyd.model_extra:
obj.csv = pyd.model_extra['csv']
logger.debug(f"All attributes of obj:\n{pprint.pformat(obj.__dict__)}")
return obj, result
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
obj.ext_kit = kit_widget.currentText()
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)
add_widget = ImportReagent(ctx=obj.ctx, reagent=reagent, extraction_kit=obj.ext_kit)
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")
for item in obj.missing_reagents:
# 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.
reagent = dict(type=item.type, lot=None, exp=date.today(), name=None)
# 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)
# Add submit button to the form.
submit_btn = QPushButton("Submit")
submit_btn.setObjectName("lot_submit_btn")
submit_btn.setObjectName("submit_btn")
obj.table_widget.formlayout.addWidget(submit_btn)
submit_btn.clicked.connect(obj.submit_new_sample)
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")
result = None
# extract info from the form widgets
info = extract_form_info(obj.table_widget.tab1)
# 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 = extract_form_info(obj.table_widget.tab1)
# if isinstance(info, tuple):
# logger.warning(f"Got tuple for info for some reason.")
# 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"Reagents: {reagents}")
parsed_reagents = []
# compare reagents in form to reagent database
for reagent in reagents:
# 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}")
# if reagent not found offer to add to database
if wanted_reagent == None:
r_lot = reagents[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[reagent]
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():
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:
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:
logger.error(f"Couldn't find {reagent} 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]
logger.debug(f"checking reagent: {reagent} in obj.reagents. Result: {picked_reagent}")
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['name']][0]
logger.debug(f"checking reagent: {reagent['name']} in obj.reagents. Result: {picked_reagent}")
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:
# In this case we will have an empty reagent and the submission will fail kit integrity check
logger.debug("Will not add reagent.")
@@ -348,14 +366,13 @@ def generate_report_function(obj:QMainWindow) -> Tuple[QMainWindow, dict]:
Returns:
Tuple[QMainWindow, dict]: Collection of new main app window and result dict
"""
result = None
# ask for date ranges
dlg = ReportDatePicker()
if dlg.exec():
info = extract_form_info(dlg)
# info = extract_form_info(dlg)
info = dlg.parse_form()
logger.debug(f"Report info: {info}")
# 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'])
# convert each object to dict
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]:
"""
Link scraped controls to imported submissions.
NOTE: Depreciated due to improvements in controls scraper.
Args:
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_submissions(ctx=obj.ctx, rsl_number=new_run['rsl_plate_num'])
# If no such submission exists, move onto the next run
if sub == None:
continue
try:
logger.debug(f"Found submission: {sub.rsl_plate_num}")
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_submissions(ctx=obj.ctx, rsl_number=new_run['rsl_plate_num'])
# if imported submission doesn't exist move on to next run
if sub == None:
continue
try:
logger.debug(f"Found submission: {sub.rsl_plate_num}")
except AttributeError:
@@ -838,11 +860,14 @@ def autofill_excel(obj:QMainWindow, xl_map:dict, reagents:List[dict], missing_re
new_info = []
logger.debug(f"Parsing from relevant info map: {pprint.pformat(relevant_info_map)}")
for item in relevant_info:
new_item = {}
new_item['type'] = item
new_item['location'] = relevant_info_map[item]
new_item['value'] = relevant_info[item]
new_info.append(new_item)
try:
new_item = {}
new_item['type'] = item
new_item['location'] = relevant_info_map[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 info: {new_info}")
# 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]:
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)
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)
if samp == None:
return None, None
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)]
logger.debug(f"Associations: {pprint.pformat(new_plates)}")
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:
plate_num, plate = None, None
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)
sprsr = SampleParser(ctx=obj.ctx, xl=xl, submission_type="First Strand")
_, samples = sprsr.parse_samples(generate=False)
logger.debug(f"Samples: {pformat(samples)}")
logger.debug("Called first strand sample parser")
plates = sprsr.grab_plates()
logger.debug(f"Plates: {pformat(plates)}")
output_samples = []
logger.debug(f"Samples: {pprint.pformat(samples)}")
logger.debug(f"Samples: {pformat(samples)}")
old_plate_number = 1
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['sample'] = item['submitter_id']
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}")
obj.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_")]
reagents = obj.prsr.parse_reagents(extraction_kit=extraction_kit)
logger.debug(f"Got reagents: {reagents}")

View File

@@ -2,7 +2,7 @@ from pathlib import Path
import sys
from PIL import Image, ImageDraw, ImageFont
import numpy as np
from tools import check_if_app
from tools import check_if_app, jinja_template_loading
import logging
logger = logging.getLogger(f"submissions.{__name__}")
@@ -81,4 +81,31 @@ def make_plate_map(sample_list:list) -> Image:
letter = row_dict[num-1]
y = (num * 100) - 10
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>
<html>
<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>
</head>
{% set excluded = ['reagents', 'samples', 'controls', 'ext_info', 'pcr_info', 'comments', 'barcode', 'platemap'] %}
@@ -67,7 +99,11 @@
{% endif %}
{% if sub['platemap'] %}
<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 %}
</body>
</html>

View File

@@ -12,10 +12,10 @@
<h3><u>{{ lab['lab'] }}:</u></h3>
{% for kit in lab['kits'] %}
<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 %}
<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>
{% endfor %}
</body>

View File

@@ -36,6 +36,8 @@ main_aux_dir = Path.home().joinpath(f"{os_config_dir}/submissions")
CONFIGDIR = main_aux_dir.joinpath("config")
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:
"""
Check to ensure excel sheet cell contents are not blank.
@@ -576,10 +578,11 @@ def jinja_template_loading():
if check_if_app():
loader_path = Path(sys._MEIPASS).joinpath("files", "templates")
else:
loader_path = Path(__file__).parents[1].joinpath('templates').absolute().__str__()
loader_path = Path(__file__).parents[1].joinpath('templates').absolute()#.__str__()
# jinja template loading
loader = FileSystemLoader(loader_path)
env = Environment(loader=loader)
env.globals['STATIC_PREFIX'] = loader_path.joinpath("static", "css")
return env
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:])
except IndexError:
return None, None
return row, column
return row, column