Increased robustness of form parsers.
This commit is contained in:
@@ -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.
|
||||
|
||||
|
||||
11
README.md
11
README.md
@@ -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:
|
||||
|
||||
|
||||
9
TODO.md
9
TODO.md
@@ -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.
|
||||
|
||||
@@ -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]
|
||||
|
||||
32
alembic/versions/b95478ffb4a3_adding_submission_category.py
Normal file
32
alembic/versions/b95478ffb4a3_adding_submission_category.py
Normal 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 ###
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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]:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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__}")
|
||||
|
||||
@@ -333,3 +333,6 @@ class SubmissionTypeKitTypeAssociation(Base):
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<SubmissionTypeKitTypeAssociation({self.submission_type.name})"
|
||||
|
||||
def set_attrib(self, name, value):
|
||||
self.__setattr__(name, value)
|
||||
@@ -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,
|
||||
@@ -233,6 +238,34 @@ class BasicSubmission(Base):
|
||||
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
|
||||
|
||||
class BacterialCulture(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):
|
||||
|
||||
@@ -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):
|
||||
@@ -207,6 +206,7 @@ class InfoParser(object):
|
||||
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:
|
||||
"""
|
||||
Gets location of basic info from the submission_type object in the database.
|
||||
@@ -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]:
|
||||
"""
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}")
|
||||
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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}")
|
||||
|
||||
@@ -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__}")
|
||||
@@ -82,3 +82,30 @@ def make_plate_map(sample_list:list) -> Image:
|
||||
y = (num * 100) - 10
|
||||
draw.text((10, y), letter, (0,0,0),font=font)
|
||||
return new_img
|
||||
|
||||
def make_plate_map_html(sample_list:list, plate_rows:int=8, plate_columns=12) -> str:
|
||||
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
|
||||
17
src/submissions/templates/plate_map.html
Normal file
17
src/submissions/templates/plate_map.html
Normal 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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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:
|
||||
@@ -633,3 +636,4 @@ def convert_well_to_row_column(input_str:str) -> Tuple[int, int]:
|
||||
except IndexError:
|
||||
return None, None
|
||||
return row, column
|
||||
|
||||
|
||||
Reference in New Issue
Block a user