diff --git a/CHANGELOG.md b/CHANGELOG.md index 5b0d213..66bac9b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/README.md b/README.md index 28287b2..988ea4b 100644 --- a/README.md +++ b/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: diff --git a/TODO.md b/TODO.md index 1f96d17..b81a32e 100644 --- a/TODO.md +++ b/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. diff --git a/alembic.ini b/alembic.ini index 7de27cb..b0c1125 100644 --- a/alembic.ini +++ b/alembic.ini @@ -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] diff --git a/alembic/versions/b95478ffb4a3_adding_submission_category.py b/alembic/versions/b95478ffb4a3_adding_submission_category.py new file mode 100644 index 0000000..7300bdb --- /dev/null +++ b/alembic/versions/b95478ffb4a3_adding_submission_category.py @@ -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 ### diff --git a/src/submissions/__init__.py b/src/submissions/__init__.py index dfc4eef..807685c 100644 --- a/src/submissions/__init__.py +++ b/src/submissions/__init__.py @@ -4,7 +4,7 @@ from pathlib import Path # Version of the realpython-reader package __project__ = "submissions" -__version__ = "202309.4b" +__version__ = "202310.1b" __author__ = {"name":"Landon Wark", "email":"Landon.Wark@phac-aspc.gc.ca"} __copyright__ = "2022-2023, Government of Canada" diff --git a/src/submissions/backend/db/functions/__init__.py b/src/submissions/backend/db/functions/__init__.py index b436a55..b967e87 100644 --- a/src/submissions/backend/db/functions/__init__.py +++ b/src/submissions/backend/db/functions/__init__.py @@ -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() diff --git a/src/submissions/backend/db/functions/constructions.py b/src/submissions/backend/db/functions/constructions.py index db0c8e4..78ab32f 100644 --- a/src/submissions/backend/db/functions/constructions.py +++ b/src/submissions/backend/db/functions/constructions.py @@ -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: diff --git a/src/submissions/backend/db/functions/lookups.py b/src/submissions/backend/db/functions/lookups.py index aa767e8..d4b7c5a 100644 --- a/src/submissions/backend/db/functions/lookups.py +++ b/src/submissions/backend/db/functions/lookups.py @@ -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]: diff --git a/src/submissions/backend/db/functions/misc.py b/src/submissions/backend/db/functions/misc.py index c354fb8..8448626 100644 --- a/src/submissions/backend/db/functions/misc.py +++ b/src/submissions/backend/db/functions/misc.py @@ -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 + + \ No newline at end of file diff --git a/src/submissions/backend/db/models/__init__.py b/src/submissions/backend/db/models/__init__.py index 553b693..089294b 100644 --- a/src/submissions/backend/db/models/__init__.py +++ b/src/submissions/backend/db/models/__init__.py @@ -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__}") diff --git a/src/submissions/backend/db/models/kits.py b/src/submissions/backend/db/models/kits.py index 639a5f5..532923d 100644 --- a/src/submissions/backend/db/models/kits.py +++ b/src/submissions/backend/db/models/kits.py @@ -332,4 +332,7 @@ class SubmissionTypeKitTypeAssociation(Base): self.constant_cost = 0.00 def __repr__(self) -> str: - return f" 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}
+ 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"
- ct N1: {'{:.2f}'.format(assoc.ct_n1)} ({assoc.n1_status})
- 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): diff --git a/src/submissions/backend/excel/parser.py b/src/submissions/backend/excel/parser.py index 80d3063..172f571 100644 --- a/src/submissions/backend/excel/parser.py +++ b/src/submissions/backend/excel/parser.py @@ -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]: """ diff --git a/src/submissions/backend/excel/reports.py b/src/submissions/backend/excel/reports.py index 4a0d37a..ef5d4bf 100644 --- a/src/submissions/backend/excel/reports.py +++ b/src/submissions/backend/excel/reports.py @@ -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 diff --git a/src/submissions/backend/pydant/__init__.py b/src/submissions/backend/pydant/__init__.py index 75223ba..bbb4c1f 100644 --- a/src/submissions/backend/pydant/__init__.py +++ b/src/submissions/backend/pydant/__init__.py @@ -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 + diff --git a/src/submissions/frontend/__init__.py b/src/submissions/frontend/__init__.py index 2ba299d..2dfd969 100644 --- a/src/submissions/frontend/__init__.py +++ b/src/submissions/frontend/__init__.py @@ -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 + diff --git a/src/submissions/frontend/all_window_functions.py b/src/submissions/frontend/all_window_functions.py index 23c8cf1..e6e959c 100644 --- a/src/submissions/frontend/all_window_functions.py +++ b/src/submissions/frontend/all_window_functions.py @@ -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 diff --git a/src/submissions/frontend/custom_widgets/misc.py b/src/submissions/frontend/custom_widgets/misc.py index cb88040..5b41139 100644 --- a/src/submissions/frontend/custom_widgets/misc.py +++ b/src/submissions/frontend/custom_widgets/misc.py @@ -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}") + diff --git a/src/submissions/frontend/custom_widgets/pop_ups.py b/src/submissions/frontend/custom_widgets/pop_ups.py index a714e57..243d8b9 100644 --- a/src/submissions/frontend/custom_widgets/pop_ups.py +++ b/src/submissions/frontend/custom_widgets/pop_ups.py @@ -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()) diff --git a/src/submissions/frontend/custom_widgets/sub_details.py b/src/submissions/frontend/custom_widgets/sub_details.py index 0b2cacd..afaa3af 100644 --- a/src/submissions/frontend/custom_widgets/sub_details.py +++ b/src/submissions/frontend/custom_widgets/sub_details.py @@ -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() diff --git a/src/submissions/frontend/main_window_functions.py b/src/submissions/frontend/main_window_functions.py index 6518625..c6caaf1 100644 --- a/src/submissions/frontend/main_window_functions.py +++ b/src/submissions/frontend/main_window_functions.py @@ -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}") diff --git a/src/submissions/frontend/visualizations/plate_map.py b/src/submissions/frontend/visualizations/plate_map.py index 19352c4..0a67e27 100644 --- a/src/submissions/frontend/visualizations/plate_map.py +++ b/src/submissions/frontend/visualizations/plate_map.py @@ -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 \ No newline at end of file + 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 \ No newline at end of file diff --git a/src/submissions/templates/plate_map.html b/src/submissions/templates/plate_map.html new file mode 100644 index 0000000..dd16ab7 --- /dev/null +++ b/src/submissions/templates/plate_map.html @@ -0,0 +1,17 @@ + \ No newline at end of file diff --git a/src/submissions/templates/submission_details.html b/src/submissions/templates/submission_details.html index b72bb14..223c16e 100644 --- a/src/submissions/templates/submission_details.html +++ b/src/submissions/templates/submission_details.html @@ -1,6 +1,38 @@ + Submission Details for {{ sub['Plate Number'] }} {% set excluded = ['reagents', 'samples', 'controls', 'ext_info', 'pcr_info', 'comments', 'barcode', 'platemap'] %} @@ -67,7 +99,11 @@ {% endif %} {% if sub['platemap'] %}

Plate map:

- + {{ sub['platemap'] }} + {% endif %} + {% if sub['export_map'] %} +

Plate map:

+ {% endif %} \ No newline at end of file diff --git a/src/submissions/templates/summary_report.html b/src/submissions/templates/summary_report.html index 7cf63d7..2d91cdb 100644 --- a/src/submissions/templates/summary_report.html +++ b/src/submissions/templates/summary_report.html @@ -12,10 +12,10 @@

{{ lab['lab'] }}:

{% for kit in lab['kits'] %}

{{ kit['name'] }}

-

Plates: {{ kit['plate_count'] }}, Samples: {{ kit['sample_count'] }}, Cost: {{ "${:,.2f}".format(kit['cost']) }}

+

Runs: {{ kit['plate_count'] }}, Samples: {{ kit['sample_count'] }}, Cost: {{ "${:,.2f}".format(kit['cost']) }}

{% endfor %}

Lab total:

-

Plates: {{ lab['total_plates'] }}, Samples: {{ lab['total_samples'] }}, Cost: {{ "${:,.2f}".format(lab['total_cost']) }}

+

Runs: {{ lab['total_plates'] }}, Samples: {{ lab['total_samples'] }}, Cost: {{ "${:,.2f}".format(lab['total_cost']) }}


{% endfor %} diff --git a/src/submissions/tools/__init__.py b/src/submissions/tools/__init__.py index b1b76f5..d43f450 100644 --- a/src/submissions/tools/__init__.py +++ b/src/submissions/tools/__init__.py @@ -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 \ No newline at end of file + return row, column +