diff --git a/src/submissions/backend/db/models/__init__.py b/src/submissions/backend/db/models/__init__.py index 113d2e0..cab8b02 100644 --- a/src/submissions/backend/db/models/__init__.py +++ b/src/submissions/backend/db/models/__init__.py @@ -2,7 +2,8 @@ Contains all models for sqlalchemy """ from __future__ import annotations -import sys, logging + +import sys, logging, json from dateutil.parser import parse from pandas import DataFrame @@ -565,15 +566,24 @@ class BaseClass(Base): check = False if check: continue - value = getattr(self, k) + try: + value = getattr(self, k) + except AttributeError: + continue match value: case datetime(): value = value.strftime("%Y-%m-%d %H:%M:%S") case _: pass - output[k] = value + output[k.strip("_")] = value return output + def show_details(self, obj): + logger.debug("Show Details") + from frontend.widgets.submission_details import SubmissionDetails + dlg = SubmissionDetails(parent=obj, sub=self) + if dlg.exec(): + pass class LogMixin(Base): tracking_exclusion: ClassVar = ['artic_technician', 'clientsubmissionsampleassociation', diff --git a/src/submissions/backend/db/models/kits.py b/src/submissions/backend/db/models/kits.py index a18fad5..2782a73 100644 --- a/src/submissions/backend/db/models/kits.py +++ b/src/submissions/backend/db/models/kits.py @@ -2,19 +2,16 @@ All kittype and reagent related models """ from __future__ import annotations -import json, zipfile, yaml, logging, re, sys +import zipfile, logging, re from operator import itemgetter -from pprint import pformat import numpy as np -from jinja2 import Template, TemplateNotFound from sqlalchemy import Column, String, TIMESTAMP, JSON, INTEGER, ForeignKey, Interval, Table, FLOAT, BLOB from sqlalchemy.orm import relationship, validates, Query from sqlalchemy.ext.associationproxy import association_proxy -from sqlalchemy.ext.hybrid import hybrid_property from datetime import date, datetime, timedelta -from tools import check_authorization, setup_lookup, Report, Result, check_regex_match, yaml_regex_creator, timezone, \ - jinja_template_loading, ctx +from tools import check_authorization, setup_lookup, Report, Result, check_regex_match, timezone, \ + jinja_template_loading from typing import List, Literal, Generator, Any, Tuple, TYPE_CHECKING from pandas import ExcelFile from pathlib import Path @@ -632,7 +629,7 @@ class Reagent(BaseClass, LogMixin): missing=False ) if full_data: - output['procedure'] = [sub.rsl_plate_num for sub in self.procedures] + output['procedure'] = [sub.rsl_plate_number for sub in self.procedures] output['excluded'] = ['missing', 'procedure', 'excluded', 'editable'] output['editable'] = ['lot', 'expiry'] return output @@ -743,7 +740,7 @@ class Reagent(BaseClass, LogMixin): role = ReagentRole.query(name=value, limit=1) case _: return - if role and role not in self.role: + if role and role not in self.reagentrole: self.reagentrole.append(role) return case "comment": @@ -1097,9 +1094,13 @@ class ProcedureType(BaseClass): id = Column(INTEGER, primary_key=True) name = Column(String(64)) reagent_map = Column(JSON) + info_map = Column(JSON) + sample_map = Column(JSON) + equipment_map = Column(JSON) plate_columns = Column(INTEGER, default=0) plate_rows = Column(INTEGER, default=0) allowed_result_methods = Column(JSON) + template_file = Column(BLOB) procedure = relationship("Procedure", back_populates="proceduretype") #: Concrete control of this type. @@ -1218,6 +1219,15 @@ class ProcedureType(BaseClass): plate_columns=self.plate_columns ) + def details_dict(self, **kwargs): + output = super().details_dict(**kwargs) + output['kittype'] = [item.details_dict() for item in output['kittype']] + # output['process'] = [item.details_dict() for item in output['process']] + output['equipment'] = [item.details_dict() for item in output['equipment']] + return output + + + def construct_dummy_procedure(self, run: Run|None=None): from backend.validators.pydant import PydProcedure if run: @@ -1277,6 +1287,7 @@ class Procedure(BaseClass): id = Column(INTEGER, primary_key=True) name = Column(String, unique=True) repeat = Column(INTEGER, nullable=False) + technician = Column(String(64)) #: name of processing tech(s) results = relationship("Results", back_populates="procedure", uselist=True) proceduretype_id = Column(INTEGER, ForeignKey("_proceduretype.id", ondelete="SET NULL", @@ -1372,7 +1383,7 @@ class Procedure(BaseClass): def add_results(self, obj, resultstype_name:str): logger.debug(f"Add Results! {resultstype_name}") - from frontend.widgets import results + from ...managers import results results_class = getattr(results, resultstype_name) rs = results_class(procedure=self, parent=obj) @@ -1412,8 +1423,8 @@ class Procedure(BaseClass): def add_comment(self, obj): logger.debug("Add Comment!") - def show_details(self, obj): - logger.debug("Show Details!") + # def show_details(self, obj): + # logger.debug("Show Details!") def delete(self, obj): logger.debug("Delete!") @@ -1423,10 +1434,25 @@ class Procedure(BaseClass): output['kittype'] = output['kittype'].details_dict() output['proceduretype'] = output['proceduretype'].details_dict() output['results'] = [result.details_dict() for result in output['results']] - output['sample'] = [sample.details_dict() for sample in output['sample']] + run_samples = [sample for sample in self.run.sample] + active_samples = [sample.details_dict() for sample in output['proceduresampleassociation'] + if sample.sample.sample_id in [s.sample_id for s in run_samples]] + for sample in active_samples: + sample['active'] = True + inactive_samples = [sample.details_dict() for sample in run_samples if sample.name not in [s['sample_id'] for s in active_samples]] + # logger.debug(f"Inactive samples:{pformat(inactive_samples)}") + for sample in inactive_samples: + sample['active'] = False + # output['sample'] = [sample.details_dict() for sample in output['runsampleassociation']] + output['sample'] = active_samples + inactive_samples + # output['sample'] = [sample.details_dict() for sample in output['sample']] output['reagent'] = [reagent.details_dict() for reagent in output['procedurereagentassociation']] output['equipment'] = [equipment.details_dict() for equipment in output['procedureequipmentassociation']] output['tips'] = [tips.details_dict() for tips in output['proceduretipsassociation']] + output['repeat'] = bool(output['repeat']) + output['excluded'] = ['id', "results", "proceduresampleassociation", "sample", "procedurereagentassociation", + "procedureequipmentassociation", "proceduretipsassociation", "reagent", "equipment", "tips", + "excluded"] return output class ProcedureTypeKitTypeAssociation(BaseClass): @@ -1814,7 +1840,7 @@ class ProcedureReagentAssociation(BaseClass): str: Representation of this RunReagentAssociation """ try: - return f"" + return f"" except AttributeError: logger.error(f"Reagent {self.reagent.lot} procedure association {self.reagent_id} has no procedure!") return f"" @@ -1887,9 +1913,9 @@ class ProcedureReagentAssociation(BaseClass): # NOTE: Figure out how to merge the misc_info if doing .update instead. relevant = {k: v for k, v in output.items() if k not in ['reagent']} output = output['reagent'].details_dict() - misc = output['_misc_info'] + misc = output['misc_info'] output.update(relevant) - output['_misc_info'] = misc + output['misc_info'] = misc output['results'] = [result.details_dict() for result in output['results']] return output @@ -2087,9 +2113,9 @@ class Equipment(BaseClass, LogMixin): asset_number=self.asset_number ) if full_data: - subs = [dict(plate=item.procedure.procedure.rsl_plate_num, process=item.process.name, + subs = [dict(plate=item.procedure.procedure.rsl_plate_number, process=item.process.name, sub_date=item.procedure.procedure.start_date) - if item.process else dict(plate=item.procedure.procedure.rsl_plate_num, process="NA") + if item.process else dict(plate=item.procedure.procedure.rsl_plate_number, process="NA") for item in self.equipmentprocedureassociation] output['procedure'] = sorted(subs, key=itemgetter("sub_date"), reverse=True) output['excluded'] = ['missing', 'procedure', 'excluded', 'editable'] @@ -2240,6 +2266,11 @@ class EquipmentRole(BaseClass): from backend.validators.omni_gui_objects import OmniEquipmentRole return OmniEquipmentRole(instance_object=self, name=self.name) + def details_dict(self, **kwargs): + output = super().details_dict(**kwargs) + output['equipment'] = [item.details_dict() for item in output['equipment']] + output['process'] = [item.details_dict() for item in output['process']] + return output class ProcedureEquipmentAssociation(BaseClass): """ @@ -2342,9 +2373,9 @@ class ProcedureEquipmentAssociation(BaseClass): # NOTE: Figure out how to merge the misc_info if doing .update instead. relevant = {k: v for k, v in output.items() if k not in ['equipment']} output = output['equipment'].details_dict() - misc = output['_misc_info'] + misc = output['misc_info'] output.update(relevant) - output['_misc_info'] = misc + output['misc_info'] = misc output['process'] = self.process.details_dict() return output @@ -2530,12 +2561,25 @@ class Process(BaseClass): name=self.name, ) if full_data: - subs = [dict(plate=sub.run.rsl_plate_num, equipment=sub.equipment.name, + subs = [dict(plate=sub.run.rsl_plate_number, equipment=sub.equipment.name, submitted_date=sub.run.clientsubmission.submitted_date) for sub in self.procedure] output['procedure'] = sorted(subs, key=itemgetter("submitted_date"), reverse=True) output['excluded'] = ['missing', 'procedure', 'excluded', 'editable'] return output + def to_pydantic(self): + from backend.validators.pydant import PydProcess + output = {} + for k, v in self.details_dict().items(): + if isinstance(v, list): + output[k] = [item.name for item in v] + elif issubclass(v.__class__, BaseClass): + output[k] = v.name + else: + output[k] = v + return PydProcess(**output) + + # @classproperty # def details_template(cls) -> Template: # """ @@ -2591,7 +2635,10 @@ class TipRole(BaseClass): @classmethod @setup_lookup - def query(cls, name: str | None = None, limit: int = 0, **kwargs) -> TipRole | List[TipRole]: + def query(cls, + name: str | None = None, + limit: int = 0, + **kwargs) -> TipRole | List[TipRole]: query = cls.__database_session__.query(cls) match name: case str(): @@ -2707,7 +2754,7 @@ class Tips(BaseClass, LogMixin): ) if full_data: subs = [ - dict(plate=item.procedure.procedure.rsl_plate_num, role=item.role_name, + dict(plate=item.procedure.procedure.rsl_plate_number, role=item.role_name, sub_date=item.procedure.procedure.clientsubmission.submitted_date) for item in self.tipsprocedureassociation] output['procedure'] = sorted(subs, key=itemgetter("sub_date"), reverse=True) @@ -2819,15 +2866,16 @@ class ProcedureTipsAssociation(BaseClass): # NOTE: Figure out how to merge the misc_info if doing .update instead. relevant = {k: v for k, v in output.items() if k not in ['tips']} output = output['tips'].details_dict() - misc = output['_misc_info'] + misc = output['misc_info'] output.update(relevant) - output['_misc_info'] = misc + output['misc_info'] = misc return output class Results(BaseClass): id = Column(INTEGER, primary_key=True) + result_type = Column(String(32)) result = Column(JSON) procedure_id = Column(INTEGER, ForeignKey("_procedure.id", ondelete='SET NULL', name="fk_RES_procedure_id")) diff --git a/src/submissions/backend/db/models/submissions.py b/src/submissions/backend/db/models/submissions.py index 9098695..c37b62e 100644 --- a/src/submissions/backend/db/models/submissions.py +++ b/src/submissions/backend/db/models/submissions.py @@ -26,7 +26,7 @@ from sqlite3 import OperationalError as SQLOperationalError, IntegrityError as S from openpyxl import Workbook from openpyxl.drawing.image import Image as OpenpyxlImage from tools import row_map, setup_lookup, jinja_template_loading, rreplace, row_keys, check_key_or_attr, Result, Report, \ - report_result, create_holidays_for_year, check_dictionary_inclusion_equality + report_result, create_holidays_for_year, check_dictionary_inclusion_equality, is_power_user from datetime import datetime, date from typing import List, Any, Tuple, Literal, Generator, Type, TYPE_CHECKING from pathlib import Path @@ -100,7 +100,7 @@ class ClientSubmission(BaseClass, LogMixin): Args: submission_type (str | models.SubmissionType | None, optional): Submission type of interest. Defaults to None. id (int | str | None, optional): Submission id in the database (limits results to 1). Defaults to None. - rsl_plate_num (str | None, optional): Submission name in the database (limits results to 1). Defaults to None. + rsl_plate_number (str | None, optional): Submission name in the database (limits results to 1). Defaults to None. start_date (date | str | int | None, optional): Beginning date to search by. Defaults to None. end_date (date | str | int | None, optional): Ending date to search by. Defaults to None. reagent (models.Reagent | str | None, optional): A reagent used in the procedure. Defaults to None. @@ -304,7 +304,7 @@ class ClientSubmission(BaseClass, LogMixin): samples = [sample.to_pydantic() for sample in self.clientsubmissionsampleassociation] checker = SampleChecker(parent=None, title="Create Run", samples=samples, clientsubmission=self) if checker.exec(): - run = Run(clientsubmission=self, rsl_plate_num=checker.rsl_plate_num) + run = Run(clientsubmission=self, rsl_plate_number=checker.rsl_plate_number) active_samples = [sample for sample in samples if sample.enabled] logger.debug(active_samples) for sample in active_samples: @@ -323,8 +323,12 @@ class ClientSubmission(BaseClass, LogMixin): def add_comment(self, obj): logger.debug("Add Comment") - def show_details(self, obj): - logger.debug("Show Details") + # def show_details(self, obj): + # logger.debug("Show Details") + # from frontend.widgets.submission_details import SubmissionDetails + # dlg = SubmissionDetails(parent=obj, sub=self) + # if dlg.exec(): + # pass def details_dict(self, **kwargs): output = super().details_dict(**kwargs) @@ -334,6 +338,11 @@ class ClientSubmission(BaseClass, LogMixin): output['run'] = [run.details_dict() for run in output['run']] output['sample'] = [sample.details_dict() for sample in output['clientsubmissionsampleassociation']] output['name'] = self.name + output['client_lab'] = output['clientlab'] + output['submission_type'] = output['submissiontype'] + output['excluded'] = ['run', "sample", "clientsubmissionsampleassociation", "excluded", + "expanded", 'clientlab', 'submissiontype', 'id'] + output['expanded'] = ["clientlab", "contact", "submissiontype"] return output @@ -343,7 +352,7 @@ class Run(BaseClass, LogMixin): """ id = Column(INTEGER, primary_key=True) #: primary key - rsl_plate_num = Column(String(32), unique=True, nullable=False) #: RSL name (e.g. RSL-22-0012) + rsl_plate_number = Column(String(32), unique=True, nullable=False) #: RSL name (e.g. RSL-22-0012) clientsubmission_id = Column(INTEGER, ForeignKey("_clientsubmission.id", ondelete="SET NULL", name="fk_BS_clientsub_id")) #: client lab id from _organizations) clientsubmission = relationship("ClientSubmission", back_populates="run") @@ -387,7 +396,7 @@ class Run(BaseClass, LogMixin): @hybrid_property def name(self): - return self.rsl_plate_num + return self.rsl_plate_number @classmethod def get_default_info(cls, *args, submissiontype: SubmissionType | None = None) -> dict: @@ -593,8 +602,23 @@ class Run(BaseClass, LogMixin): def details_dict(self, **kwargs): output = super().details_dict() - output['sample'] = [sample.details_dict() for sample in output['runsampleassociation']] + submission_samples = [sample for sample in self.clientsubmission.sample] + # logger.debug(f"Submission samples:{pformat(submission_samples)}") + active_samples = [sample.details_dict() for sample in output['runsampleassociation'] + if sample.sample.sample_id in [s.sample_id for s in submission_samples]] + # logger.debug(f"Active samples:{pformat(active_samples)}") + for sample in active_samples: + sample['active'] = True + inactive_samples = [sample.details_dict() for sample in submission_samples if sample.name not in [s['sample_id'] for s in active_samples]] + # logger.debug(f"Inactive samples:{pformat(inactive_samples)}") + for sample in inactive_samples: + sample['active'] = False + # output['sample'] = [sample.details_dict() for sample in output['runsampleassociation']] + output['sample'] = active_samples + inactive_samples output['procedure'] = [procedure.details_dict() for procedure in output['procedure']] + output['permission'] = is_power_user() + output['excluded'] = ['procedure', "runsampleassociation", 'excluded', 'expanded', 'sample', 'id', 'custom', 'permission'] + return output @classmethod @@ -890,10 +914,10 @@ class Run(BaseClass, LogMixin): field_value = dict(value=self.__getattribute__(key).name, missing=missing) case "plate_number": key = 'name' - field_value = dict(value=self.rsl_plate_num, missing=missing) + field_value = dict(value=self.rsl_plate_number, missing=missing) case "submitter_plate_number": key = "submitter_plate_id" - field_value = dict(value=self.submitter_plate_num, missing=missing) + field_value = dict(value=self.submitter_plate_number, missing=missing) case "id": continue case _: @@ -1168,8 +1192,8 @@ class Run(BaseClass, LogMixin): e: SQLIntegrityError or SQLOperationalError if problem with commit. """ from frontend.widgets.pop_ups import QuestionAsker - fname = self.__backup_path__.joinpath(f"{self.rsl_plate_num}-backup({date.today().strftime('%Y%m%d')})") - msg = QuestionAsker(title="Delete?", message=f"Are you sure you want to delete {self.rsl_plate_num}?\n") + fname = self.__backup_path__.joinpath(f"{self.rsl_plate_number}-backup({date.today().strftime('%Y%m%d')})") + msg = QuestionAsker(title="Delete?", message=f"Are you sure you want to delete {self.rsl_plate_number}?\n") if msg.exec(): try: # NOTE: backs up file as xlsx, same as export. @@ -1187,17 +1211,17 @@ class Run(BaseClass, LogMixin): except AttributeError: logger.error("App will not refresh data at this time.") - def show_details(self, obj): - """ - Creates Widget for showing procedure details. - - Args: - obj (Widget): Parent widget - """ - from frontend.widgets.submission_details import SubmissionDetails - dlg = SubmissionDetails(parent=obj, sub=self) - if dlg.exec(): - pass + # def show_details(self, obj): + # """ + # Creates Widget for showing procedure details. + # + # Args: + # obj (Widget): Parent widget + # """ + # from frontend.widgets.submission_details import SubmissionDetails + # dlg = SubmissionDetails(parent=obj, sub=self) + # if dlg.exec(): + # pass def edit(self, obj): """ @@ -1641,12 +1665,12 @@ class ClientSubmissionSampleAssociation(BaseClass): output = super().details_dict() # NOTE: Figure out how to merge the misc_info if doing .update instead. relevant = {k: v for k, v in output.items() if k not in ['sample']} - logger.debug(f"Relevant info from assoc output: {pformat(relevant)}") + # logger.debug(f"Relevant info from assoc output: {pformat(relevant)}") output = output['sample'].details_dict() - misc = output['_misc_info'] - logger.debug(f"Output from sample: {pformat(output)}") + misc = output['misc_info'] + # logger.debug(f"Output from sample: {pformat(output)}") output.update(relevant) - output['_misc_info'] = misc + output['misc_info'] = misc # output['sample'] = temp # output.update(output['sample'].details_dict()) return output @@ -1815,7 +1839,7 @@ class ClientSubmissionSampleAssociation(BaseClass): case ClientSubmission(): pass case str(): - clientsubmission = ClientSubmission.query(rsl_plate_num=clientsubmission) + clientsubmission = ClientSubmission.query(rsl_plate_number=clientsubmission) case _: raise ValueError() match sample: @@ -1879,7 +1903,7 @@ class RunSampleAssociation(BaseClass): def __repr__(self) -> str: try: - return f"<{self.__class__.__name__}({self.run.rsl_plate_num} & {self.sample.sample_id})" + return f"<{self.__class__.__name__}({self.run.rsl_plate_number} & {self.sample.sample_id})" except AttributeError as e: logger.error(f"Unable to construct __repr__ due to: {e}") return super().__repr__() @@ -1901,7 +1925,7 @@ class RunSampleAssociation(BaseClass): except KeyError as e: logger.error(f"Unable to find row {self.row} in row_map.") sample['Well'] = None - sample['plate_name'] = self.run.rsl_plate_num + sample['plate_name'] = self.run.rsl_plate_number sample['positive'] = False return sample @@ -1975,7 +1999,7 @@ class RunSampleAssociation(BaseClass): case Run(): query = query.filter(cls.run == run) case str(): - query = query.join(Run).filter(Run.rsl_plate_num == run) + query = query.join(Run).filter(Run.rsl_plate_number == run) case _: pass match sample: @@ -2060,12 +2084,12 @@ class RunSampleAssociation(BaseClass): output = super().details_dict() # NOTE: Figure out how to merge the misc_info if doing .update instead. relevant = {k: v for k, v in output.items() if k not in ['sample']} - logger.debug(f"Relevant info from assoc output: {pformat(relevant)}") + # logger.debug(f"Relevant info from assoc output: {pformat(relevant)}") output = output['sample'].details_dict() - misc = output['_misc_info'] - logger.debug(f"Output from sample: {pformat(output)}") + misc = output['misc_info'] + # logger.debug(f"Output from sample: {pformat(output)}") output.update(relevant) - output['_misc_info'] = misc + output['misc_info'] = misc return output class ProcedureSampleAssociation(BaseClass): @@ -2132,9 +2156,9 @@ class ProcedureSampleAssociation(BaseClass): # NOTE: Figure out how to merge the misc_info if doing .update instead. relevant = {k: v for k, v in output.items() if k not in ['sample']} output = output['sample'].details_dict() - misc = output['_misc_info'] + misc = output['misc_info'] output.update(relevant) - output['_misc_info'] = misc + output['misc_info'] = misc output['results'] = [result.details_dict() for result in output['results']] return output diff --git a/src/submissions/backend/excel/__init__.py b/src/submissions/backend/excel/__init__.py index 1dd3249..5df75cf 100644 --- a/src/submissions/backend/excel/__init__.py +++ b/src/submissions/backend/excel/__init__.py @@ -3,6 +3,6 @@ Contains pandas and openpyxl convenience functions for interacting with excel wo ''' from .parser import * -from backend.excel.parsers.submission_parser import * +from backend.excel.parsers.clientsubmission_parser import * from .reports import * from .writer import * diff --git a/src/submissions/backend/excel/parser.py b/src/submissions/backend/excel/parser.py index 3eb07a7..a710166 100644 --- a/src/submissions/backend/excel/parser.py +++ b/src/submissions/backend/excel/parser.py @@ -630,12 +630,12 @@ class PCRParser(object): return None if submission is None: self.submission_obj = Wastewater - rsl_plate_num = None + rsl_plate_number = None else: self.submission_obj = submission - rsl_plate_num = self.submission_obj.rsl_plate_num - self.samples = self.submission_obj.parse_pcr(xl=self.xl, rsl_plate_num=rsl_plate_num) - self.controls = self.submission_obj.parse_pcr_controls(xl=self.xl, rsl_plate_num=rsl_plate_num) + rsl_plate_number = self.submission_obj.rsl_plate_number + self.samples = self.submission_obj.parse_pcr(xl=self.xl, rsl_plate_number=rsl_plate_number) + self.controls = self.submission_obj.parse_pcr_controls(xl=self.xl, rsl_plate_number=rsl_plate_number) @property def pcr_info(self) -> dict: @@ -675,11 +675,11 @@ class ConcentrationParser(object): return None if run is None: self.submission_obj = Run() - rsl_plate_num = None + rsl_plate_number = None else: self.submission_obj = run - rsl_plate_num = self.submission_obj.rsl_plate_num - self.samples = self.submission_obj.parse_concentration(xl=self.xl, rsl_plate_num=rsl_plate_num) + rsl_plate_number = self.submission_obj.rsl_plate_number + self.samples = self.submission_obj.parse_concentration(xl=self.xl, rsl_plate_number=rsl_plate_number) # NOTE: Generified parsers below diff --git a/src/submissions/backend/excel/parsers/__init__.py b/src/submissions/backend/excel/parsers/__init__.py index d5593ac..0e0048a 100644 --- a/src/submissions/backend/excel/parsers/__init__.py +++ b/src/submissions/backend/excel/parsers/__init__.py @@ -1,14 +1,15 @@ """ """ +from __future__ import annotations import logging, re from pathlib import Path -from typing import Generator, Tuple -from openpyxl import load_workbook +from typing import Generator, Tuple, TYPE_CHECKING from pandas import DataFrame from backend.validators import pydant -from backend.db.models import Procedure -from dataclasses import dataclass +if TYPE_CHECKING: + from backend.db.models import ProcedureType + logger = logging.getLogger(f"submissions.{__name__}") @@ -30,7 +31,7 @@ class DefaultParser(object): return instance - def __init__(self, filepath: Path | str, procedure: Procedure|None=None, range_dict: dict | None = None, *args, **kwargs): + def __init__(self, filepath: Path | str, proceduretype: ProcedureType|None=None, range_dict: dict | None = None, *args, **kwargs): """ Args: @@ -40,7 +41,7 @@ class DefaultParser(object): *args (): **kwargs (): """ - self.procedure = procedure + self.proceduretype = proceduretype try: self._pyd_object = getattr(pydant, f"Pyd{self.__class__.__name__.replace('Parser', '')}") except AttributeError: @@ -58,6 +59,13 @@ class DefaultParser(object): data['filepath'] = self.filepath return self._pyd_object(**data) + @classmethod + def correct_procedure_type(cls, proceduretype: str | "ProcedureType"): + from backend.db.models import ProcedureType + if isinstance(proceduretype, str): + proceduretype = ProcedureType.query(name=proceduretype) + return proceduretype + class DefaultKEYVALUEParser(DefaultParser): @@ -90,7 +98,6 @@ class DefaultTABLEParser(DefaultParser): default_range_dict = [dict( header_row=20, - end_row=116, sheet="Sample List" )] @@ -98,15 +105,25 @@ class DefaultTABLEParser(DefaultParser): def parsed_info(self): for item in self.range_dict: list_worksheet = self.workbook[item['sheet']] - list_df = DataFrame([item for item in list_worksheet.values][item['header_row'] - 1:]) + if "end_row" in item.keys(): + list_df = DataFrame([item for item in list_worksheet.values][item['header_row'] - 1:item['end_row']-1]) + else: + list_df = DataFrame([item for item in list_worksheet.values][item['header_row'] - 1:]) list_df.columns = list_df.iloc[0] list_df = list_df[1:] list_df = list_df.dropna(axis=1, how='all') for ii, row in enumerate(list_df.iterrows()): - output = {key.lower().replace(" ", "_"): value for key, value in row[1].to_dict().items()} + output = {} + for key, value in row[1].to_dict().items(): + if isinstance(key, str): + key = key.lower().replace(" ", "_") + key = re.sub(r"_(\(.*\)|#)", "", key) + logger.debug(f"Row {ii} values: {key}: {value}") + output[key] = value yield output def to_pydantic(self, **kwargs): return [self._pyd_object(**output) for output in self.parsed_info] -from .submission_parser import * +from .clientsubmission_parser import * +from backend.excel.parsers.results_parsers.pcr_results_parser import * diff --git a/src/submissions/backend/excel/parsers/submission_parser.py b/src/submissions/backend/excel/parsers/clientsubmission_parser.py similarity index 74% rename from src/submissions/backend/excel/parsers/submission_parser.py rename to src/submissions/backend/excel/parsers/clientsubmission_parser.py index d44996a..ef7a86e 100644 --- a/src/submissions/backend/excel/parsers/submission_parser.py +++ b/src/submissions/backend/excel/parsers/clientsubmission_parser.py @@ -1,6 +1,7 @@ """ """ +from __future__ import annotations import logging from pathlib import Path from string import ascii_lowercase @@ -9,8 +10,9 @@ from typing import Generator from openpyxl.reader.excel import load_workbook from tools import row_keys -from backend.db.models import SubmissionType +# from backend.db.models import SubmissionType from . import DefaultKEYVALUEParser, DefaultTABLEParser +from backend.managers import procedures as procedure_managers logger = logging.getLogger(f"submissions.{__name__}") @@ -34,6 +36,7 @@ class SubmissionTyperMixin(object): @classmethod def get_subtype_from_regex(cls, filepath: Path): + from backend.db.models import SubmissionType regex = SubmissionType.regex m = regex.search(filepath.__str__()) try: @@ -45,7 +48,8 @@ class SubmissionTyperMixin(object): @classmethod def get_subtype_from_preparse(cls, filepath: Path): - parser = ClientSubmissionParser(filepath) + from backend.db.models import SubmissionType + parser = ClientSubmissionInfoParser(filepath) sub_type = next((value for k, value in parser.parsed_info if k == "submissiontype"), None) sub_type = SubmissionType.query(name=sub_type) if isinstance(sub_type, list): @@ -54,6 +58,7 @@ class SubmissionTyperMixin(object): @classmethod def get_subtype_from_properties(cls, filepath: Path): + from backend.db.models import SubmissionType wb = load_workbook(filepath) # NOTE: Gets first category in the metadata. categories = wb.properties.category.split(";") @@ -64,7 +69,7 @@ class SubmissionTyperMixin(object): return sub_type -class ClientSubmissionParser(DefaultKEYVALUEParser, SubmissionTyperMixin): +class ClientSubmissionInfoParser(DefaultKEYVALUEParser, SubmissionTyperMixin): """ Object for retrieving submitter info from "sample list" sheet """ @@ -78,13 +83,29 @@ class ClientSubmissionParser(DefaultKEYVALUEParser, SubmissionTyperMixin): )] def __init__(self, filepath: Path | str, *args, **kwargs): + from frontend.widgets.pop_ups import QuestionAsker self.submissiontype = self.retrieve_submissiontype(filepath=filepath) if "range_dict" not in kwargs: kwargs['range_dict'] = self.submissiontype.info_map super().__init__(filepath=filepath, **kwargs) + allowed_procedure_types = [item.name for item in self.submissiontype.proceduretype] + for name in allowed_procedure_types: + if name in self.workbook.sheetnames: + # TODO: check if run with name already exists + add_run = QuestionAsker(title="Add Run?", message="We've detected a sheet corresponding to an associated procedure type.\nWould you like to add a new run?") + if add_run.accepted: -class ClientSampleParser(DefaultTABLEParser, SubmissionTyperMixin): + # NOTE: recruit parser. + try: + manager = getattr(procedure_managers, name) + except AttributeError: + manager = procedure_managers.DefaultManager + self.manager = manager(proceduretype=name) + pass + + +class ClientSubmissionSampleParser(DefaultTABLEParser, SubmissionTyperMixin): """ Object for retrieving submitter samples from "sample list" sheet """ diff --git a/src/submissions/backend/excel/parsers/procedure_parsers/__init__.py b/src/submissions/backend/excel/parsers/procedure_parsers/__init__.py new file mode 100644 index 0000000..4107001 --- /dev/null +++ b/src/submissions/backend/excel/parsers/procedure_parsers/__init__.py @@ -0,0 +1,119 @@ +from __future__ import annotations +from pathlib import Path +from typing import TYPE_CHECKING + + +from backend.excel.parsers import DefaultTABLEParser, DefaultKEYVALUEParser +if TYPE_CHECKING: + from backend.db.models import ProcedureType + + +class DefaultInfoParser(DefaultKEYVALUEParser): + + default_range_dict = [dict( + start_row=1, + end_row=14, + key_column=1, + value_column=2, + sheet="" + )] + + def __init__(self, filepath: Path | str, proceduretype: "ProcedureType"|None=None, range_dict: dict | None = None, *args, **kwargs): + from backend.validators.pydant import PydProcedure + proceduretype = self.correct_procedure_type(proceduretype) + if not range_dict: + range_dict = proceduretype.info_map + if not range_dict: + range_dict = self.__class__.default_range_dict + for item in range_dict: + item['sheet'] = proceduretype.name + super().__init__(filepath=filepath, proceduretype=proceduretype, range_dict=range_dict, *args, **kwargs) + self._pyd_object = PydProcedure + + +class DefaultSampleParser(DefaultTABLEParser): + + default_range_dict = [dict( + header_row=41, + sheet="" + )] + + def __init__(self, filepath: Path | str, proceduretype: "ProcedureType"|None=None, range_dict: dict | None = None, *args, **kwargs): + from backend.validators.pydant import PydSample + proceduretype = self.correct_procedure_type(proceduretype) + if not range_dict: + range_dict = proceduretype.sample_map + if not range_dict: + range_dict = self.__class__.default_range_dict + for item in range_dict: + item['sheet'] = proceduretype.name + super().__init__(filepath=filepath, procedure=proceduretype, range_dict=range_dict, *args, **kwargs) + self._pyd_object = PydSample + + +class DefaultReagentParser(DefaultTABLEParser): + + default_range_dict = [dict( + header_row=17, + end_row=29, + sheet="" + )] + + def __init__(self, filepath: Path | str, proceduretype: "ProcedureType"|None=None, range_dict: dict | None = None, *args, **kwargs): + from backend.validators.pydant import PydReagent + proceduretype = self.correct_procedure_type(proceduretype) + if not range_dict: + range_dict = proceduretype.sample_map + if not range_dict: + range_dict = self.__class__.default_range_dict + for item in range_dict: + item['sheet'] = proceduretype.name + super().__init__(filepath=filepath, proceduretype=proceduretype, range_dict=range_dict, *args, **kwargs) + self._pyd_object = PydReagent + + @property + def parsed_info(self): + output = super().parsed_info + for item in output: + if not item['lot']: + continue + item['reagentrole'] = item['reagent_role'] + yield item + +class DefaultEquipmentParser(DefaultTABLEParser): + + default_range_dict = [dict( + header_row=32, + end_row=39, + sheet="" + )] + + def __init__(self, filepath: Path | str, proceduretype: "ProcedureType"|None=None, range_dict: dict | None = None, *args, **kwargs): + from backend.validators.pydant import PydEquipment + proceduretype = self.correct_procedure_type(proceduretype) + if not range_dict: + range_dict = proceduretype.sample_map + if not range_dict: + range_dict = self.__class__.default_range_dict + for item in range_dict: + item['sheet'] = proceduretype.name + super().__init__(filepath=filepath, proceduretype=proceduretype, range_dict=range_dict, *args, **kwargs) + self._pyd_object = PydEquipment + + @property + def parsed_info(self): + output = super().parsed_info + for item in output: + if not item['name']: + continue + from backend.db.models import Equipment, Process + from backend.validators.pydant import PydTips, PydProcess + eq = Equipment.query(name=item['name']) + item['asset_number'] = eq.asset_number + item['nickname'] = eq.nickname + process = Process.query(name=item['process']) + + if item['tips']: + item['tips'] = [PydTips(name=item['tips'], tiprole=process.tiprole[0].name)] + item['equipmentrole'] = item['equipment_role'] + yield item diff --git a/src/submissions/backend/excel/parsers/results_parsers/__init__.py b/src/submissions/backend/excel/parsers/results_parsers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/submissions/backend/excel/parsers/pcr_parser.py b/src/submissions/backend/excel/parsers/results_parsers/pcr_results_parser.py similarity index 94% rename from src/submissions/backend/excel/parsers/pcr_parser.py rename to src/submissions/backend/excel/parsers/results_parsers/pcr_results_parser.py index 05a832f..01b0720 100644 --- a/src/submissions/backend/excel/parsers/pcr_parser.py +++ b/src/submissions/backend/excel/parsers/results_parsers/pcr_results_parser.py @@ -1,19 +1,14 @@ """ """ -import logging, re, sys -from pprint import pformat -from pathlib import Path -from typing import Generator, Tuple - -from openpyxl import load_workbook - +import logging from backend.db.models import Run, Sample, Procedure, ProcedureSampleAssociation -from . import DefaultKEYVALUEParser, DefaultTABLEParser +from backend.excel.parsers import DefaultKEYVALUEParser, DefaultTABLEParser logger = logging.getLogger(f"submissions.{__name__}") - +# class PCRResultsParser(DefaultParser): +# pass class PCRInfoParser(DefaultKEYVALUEParser): default_range_dict = [dict( diff --git a/src/submissions/backend/excel/reports.py b/src/submissions/backend/excel/reports.py index 0134002..9f265fb 100644 --- a/src/submissions/backend/excel/reports.py +++ b/src/submissions/backend/excel/reports.py @@ -191,7 +191,7 @@ class TurnaroundMaker(ReportArchetype): tat_ok = days <= tat except TypeError: return {} - return dict(name=str(sub.rsl_plate_num), days=days, submitted_date=sub.submitted_date, + return dict(name=str(sub.rsl_plate_number), days=days, submitted_date=sub.submitted_date, completed_date=sub.completed_date, acceptable=tat_ok) diff --git a/src/submissions/backend/managers/__init__.py b/src/submissions/backend/managers/__init__.py new file mode 100644 index 0000000..1803afc --- /dev/null +++ b/src/submissions/backend/managers/__init__.py @@ -0,0 +1,22 @@ +import logging +from pathlib import Path +from backend.db.models import ProcedureType +from frontend.widgets.functions import select_open_file +from tools import get_application_from_parent + +logger = logging.getLogger(f"submissions.{__name__}") + +class DefaultManager(object): + + def __init__(self, proceduretype: ProcedureType, parent, fname: Path | str | None = None): + logger.debug(f"FName before correction: {fname}") + if isinstance(proceduretype, str): + proceduretype = ProcedureType.query(name=proceduretype) + self.proceduretype = proceduretype + if fname != "no_file": + if not fname: + self.fname = select_open_file(file_extension="xlsx", obj=get_application_from_parent(parent)) + elif isinstance(fname, str): + self.fname = Path(fname) + logger.debug(f"FName after correction: {fname}") + diff --git a/src/submissions/backend/managers/procedures/__init__.py b/src/submissions/backend/managers/procedures/__init__.py new file mode 100644 index 0000000..23a071c --- /dev/null +++ b/src/submissions/backend/managers/procedures/__init__.py @@ -0,0 +1,44 @@ +from __future__ import annotations +import logging +from backend.managers import DefaultManager +from typing import TYPE_CHECKING +from pathlib import Path +from backend.excel.parsers import procedure_parsers +if TYPE_CHECKING: + from backend.db.models import ProcedureType + +logger = logging.getLogger(f"submissions.{__name__}") + + +class DefaultProcedure(DefaultManager): + + def __init__(self, proceduretype: "ProcedureType"|str, parent, fname: Path | str | None = None): + + super().__init__(proceduretype=proceduretype, parent=parent, fname=fname) + try: + info_parser = getattr(procedure_parsers, f"{self.proceduretype.name.replace(' ', '')}InfoParser") + except AttributeError: + info_parser = procedure_parsers.DefaultInfoParser + self.info_parser = info_parser(filepath=fname, proceduretype=proceduretype) + try: + reagent_parser = getattr(procedure_parsers, f"{self.proceduretype.name.replace(' ', '')}ReagentParser") + except AttributeError: + reagent_parser = procedure_parsers.DefaultReagentParser + self.reagent_parser = reagent_parser(filepath=fname, proceduretype=proceduretype) + try: + sample_parser = getattr(procedure_parsers, f"{self.proceduretype.name.replace(' ', '')}SampleParser") + except AttributeError: + sample_parser = procedure_parsers.DefaultSampleParser + self.sample_parser = sample_parser(filepath=fname, proceduretype=proceduretype) + try: + equipment_parser = getattr(procedure_parsers, f"{self.proceduretype.name.replace(' ', '')}EquipmentParser") + except AttributeError: + equipment_parser = procedure_parsers.DefaultEquipmentParser + self.equipment_parser = equipment_parser(filepath=fname, proceduretype=proceduretype) + self.to_pydantic() + + def to_pydantic(self): + self.procedure = self.info_parser.to_pydantic() + self.reagents = self.reagent_parser.to_pydantic() + self.samples = self.sample_parser.to_pydantic() + self.equipment = self.equipment_parser.to_pydantic() \ No newline at end of file diff --git a/src/submissions/backend/managers/results/__init__.py b/src/submissions/backend/managers/results/__init__.py new file mode 100644 index 0000000..a63b489 --- /dev/null +++ b/src/submissions/backend/managers/results/__init__.py @@ -0,0 +1,21 @@ +import logging +from .. import DefaultManager +from backend.db.models import Procedure +from pathlib import Path +from frontend.widgets.functions import select_open_file +from tools import get_application_from_parent + +logger = logging.getLogger(f"submission.{__name__}") + +class DefaultResults(DefaultManager): + + def __init__(self, procedure: Procedure, parent, fname: Path | str | None = None): + logger.debug(f"FName before correction: {fname}") + self.procedure = procedure + if not fname: + self.fname = select_open_file(file_extension="xlsx", obj=get_application_from_parent(parent)) + elif isinstance(fname, str): + self.fname = Path(fname) + logger.debug(f"FName after correction: {fname}") + +from .pcr_results_manager import PCR diff --git a/src/submissions/frontend/widgets/results/pcr.py b/src/submissions/backend/managers/results/pcr_results_manager.py similarity index 52% rename from src/submissions/frontend/widgets/results/pcr.py rename to src/submissions/backend/managers/results/pcr_results_manager.py index c720260..2398d2e 100644 --- a/src/submissions/frontend/widgets/results/pcr.py +++ b/src/submissions/backend/managers/results/pcr_results_manager.py @@ -3,11 +3,8 @@ """ import logging from pathlib import Path -from backend.validators import PydResults -from backend.db.models import Procedure, Results -from backend.excel.parsers.pcr_parser import PCRSampleParser, PCRInfoParser -from frontend.widgets.functions import select_open_file -from tools import get_application_from_parent +from backend.db.models import Procedure +from backend.excel.parsers.results_parsers.pcr_results_parser import PCRSampleParser, PCRInfoParser from . import DefaultResults logger = logging.getLogger(f"submissions.{__name__}") @@ -15,25 +12,21 @@ logger = logging.getLogger(f"submissions.{__name__}") class PCR(DefaultResults): def __init__(self, procedure: Procedure, parent, fname:Path|str|None=None): - logger.debug(f"FName before correction: {fname}") - self.procedure = procedure - if not fname: - self.fname = select_open_file(file_extension="xlsx", obj=get_application_from_parent(parent)) - elif isinstance(fname, str): - self.fname = Path(fname) - logger.debug(f"FName after correction: {fname}") + super().__init__(procedure=procedure, parent=parent, fname=fname) self.info_parser = PCRInfoParser(filepath=self.fname, procedure=self.procedure) self.sample_parser = PCRSampleParser(filepath=self.fname, procedure=self.procedure) - self.build_procedure() + self.build_info() self.build_samples() - def build_procedure(self): + def build_info(self): procedure_info = self.info_parser.to_pydantic() + procedure_info.results_type = self.__class__.__name__ procedure_sql = procedure_info.to_sql() procedure_sql.save() def build_samples(self): samples = self.sample_parser.to_pydantic() for sample in samples: + sample.results_type = self.__class__.__name__ sql = sample.to_sql() sql.save() diff --git a/src/submissions/backend/validators/__init__.py b/src/submissions/backend/validators/__init__.py index 68f9a29..383b93d 100644 --- a/src/submissions/backend/validators/__init__.py +++ b/src/submissions/backend/validators/__init__.py @@ -59,8 +59,8 @@ class ClientSubmissionNamer(DefaultNamer): def get_subtype_from_preparse(self): - from backend.excel.parsers.submission_parser import ClientSubmissionParser - parser = ClientSubmissionParser(self.filepath) + from backend.excel.parsers.clientsubmission_parser import ClientSubmissionInfoParser + parser = ClientSubmissionInfoParser(self.filepath) sub_type = next((value for k, value in parser.parsed_info if k == "submissiontype"), None) sub_type = SubmissionType.query(name=sub_type) if isinstance(sub_type, list): diff --git a/src/submissions/backend/validators/pydant.py b/src/submissions/backend/validators/pydant.py index b7d2640..2a301be 100644 --- a/src/submissions/backend/validators/pydant.py +++ b/src/submissions/backend/validators/pydant.py @@ -262,6 +262,12 @@ class PydSample(PydBaseClass): pass return value + @field_validator("row", mode="before") + @classmethod + def str_to_int(cls, value): + if isinstance(value, str): + value = row_keys[value] + return value class PydTips(BaseModel): name: str @@ -298,7 +304,7 @@ class PydEquipment(BaseModel, extra='ignore'): asset_number: str name: str nickname: str | None - processes: List[str] | None + process: List[str] | None equipmentrole: str | None tips: List[PydTips] | None = Field(default=None) @@ -309,7 +315,7 @@ class PydEquipment(BaseModel, extra='ignore'): value = value.name return value - @field_validator('processes', mode='before') + @field_validator('process', mode='before') @classmethod def make_empty_list(cls, value): # if isinstance(value, dict): @@ -397,7 +403,7 @@ class PydSubmission(BaseModel, extra='allow'): filepath: Path submissiontype: dict | None submitter_plate_id: dict | None = Field(default=dict(value=None, missing=True), validate_default=True) - rsl_plate_num: dict | None = Field(default=dict(value=None, missing=True), validate_default=True) + rsl_plate_number: dict | None = Field(default=dict(value=None, missing=True), validate_default=True) submitted_date: dict | None = Field(default=dict(value=date.today(), missing=True), validate_default=True) clientlab: dict | None sample_count: dict | None @@ -517,14 +523,14 @@ class PydSubmission(BaseModel, extra='allow'): value['value'] = None return value - @field_validator("rsl_plate_num", mode='before') + @field_validator("rsl_plate_number", mode='before') @classmethod def rescue_rsl_number(cls, value): if value is None: return dict(value=None, missing=True) return value - @field_validator("rsl_plate_num") + @field_validator("rsl_plate_number") @classmethod def rsl_from_file(cls, value, values): sub_type = values.data['proceduretype']['value'] @@ -689,7 +695,7 @@ class PydSubmission(BaseModel, extra='allow'): # NOTE: this could also be done with default_factory self.submission_object = Run.find_polymorphic_subclass( polymorphic_identity=self.submission_type['value']) - self.namer = RSLNamer(self.rsl_plate_num['value'], submission_type=self.submission_type['value']) + self.namer = RSLNamer(self.rsl_plate_number['value'], submission_type=self.submission_type['value']) if run_custom: self.submission_object.custom_validation(pyd=self) @@ -796,7 +802,7 @@ class PydSubmission(BaseModel, extra='allow'): # logger.debug(f"Pydantic procedure type: {self.proceduretype['value']}") # logger.debug(f"Pydantic improved_dict: {pformat(dicto)}") instance, result = Run.query_or_create(submissiontype=self.submission_type['value'], - rsl_plate_num=self.rsl_plate_num['value']) + rsl_plate_number=self.rsl_plate_number['value']) # logger.debug(f"Created or queried instance: {instance}") if instance is None: report.add_result(Result(msg="Overwrite Cancelled.")) @@ -1266,13 +1272,13 @@ class PydEquipmentRole(BaseModel): class PydProcess(BaseModel, extra="allow"): name: str version: str = Field(default="1") - submissiontype: List[str] + proceduretype: List[str] equipment: List[str] equipmentrole: List[str] kittype: List[str] tiprole: List[str] - @field_validator("submissiontype", "equipment", "equipmentrole", "kittype", "tiprole", mode="before") + @field_validator("proceduretype", "equipment", "equipmentrole", "kittype", "tiprole", mode="before") @classmethod def enforce_list(cls, value): if not isinstance(value, list): @@ -1361,10 +1367,10 @@ class PydProcedure(PydBaseClass, arbitrary_types_allowed=True): else: procedure_type = None if values.data['run']: - run = values.data['run'].rsl_plate_num + run = values.data['run'].rsl_plate_number else: run = None - value['value'] = f"{procedure_type}-{run}" + value['value'] = f"{run}-{procedure_type}" value['missing'] = True return value @@ -1391,7 +1397,7 @@ class PydProcedure(PydBaseClass, arbitrary_types_allowed=True): if not value: if values.data['kittype']['value'] != cls.model_fields['kittype'].default['value']: kittype = KitType.query(name=values.data['kittype']['value']) - value = {item.name: item.reagents for item in kittype.reagentrole} + value = {item.name: item.reagent for item in kittype.reagentrole} return value def update_kittype_reagentroles(self, kittype: str | KitType): @@ -1545,11 +1551,13 @@ class PydClientSubmission(PydBaseClass): class PydResults(PydBaseClass, arbitrary_types_allowed=True): results: dict = Field(default={}) - img: None = Field(default=None) + results_type: str = Field(default="NA") + img: None | bytes = Field(default=None) parent: Procedure|ProcedureSampleAssociation|None = Field(default=None) def to_sql(self): - sql = Results(result=self.results) + sql = Results(results_type=self.results_type, result=self.results) + sql.image = self.img match self.parent: case ProcedureSampleAssociation(): sql.sampleprocedureassociation = self.parent diff --git a/src/submissions/frontend/widgets/pop_ups.py b/src/submissions/frontend/widgets/pop_ups.py index 638d1c7..5c4120a 100644 --- a/src/submissions/frontend/widgets/pop_ups.py +++ b/src/submissions/frontend/widgets/pop_ups.py @@ -8,8 +8,8 @@ from PyQt6.QtWidgets import ( from PyQt6.QtWebEngineWidgets import QWebEngineView from tools import jinja_template_loading import logging -from backend.db import models -from typing import Literal + +from typing import Literal, Any logger = logging.getLogger(f"submissions.{__name__}") @@ -70,7 +70,8 @@ class ObjectSelector(QDialog): dialog to input BaseClass type manually """ - def __init__(self, title: str, message: str, obj_type: str | type[models.BaseClass], values: list | None = None): + def __init__(self, title: str, message: str, obj_type: str | Any, values: list | None = None): + from backend.db import models super().__init__() self.setWindowTitle(title) self.widget = QComboBox() diff --git a/src/submissions/frontend/widgets/procedure_creation.py b/src/submissions/frontend/widgets/procedure_creation.py index c9c68aa..1336279 100644 --- a/src/submissions/frontend/widgets/procedure_creation.py +++ b/src/submissions/frontend/widgets/procedure_creation.py @@ -29,7 +29,7 @@ class ProcedureCreation(QDialog): super().__init__(parent) self.run = run self.proceduretype = proceduretype - self.setWindowTitle(f"New {proceduretype.name} for { run.rsl_plate_num }") + self.setWindowTitle(f"New {proceduretype.name} for { run.rsl_plate_number }") self.created_procedure = self.proceduretype.construct_dummy_procedure(run=self.run) self.created_procedure.update_kittype_reagentroles(kittype=self.created_procedure.possible_kits[0]) self.created_procedure.samples = self.run.constuct_sample_dicts_for_proceduretype(proceduretype=self.proceduretype) @@ -65,8 +65,8 @@ class ProcedureCreation(QDialog): template_name="procedure_creation", # css_in=['new_context_menu'], js_in=["procedure_form", "grid_drag", "context_menu"], - proceduretype=self.proceduretype.as_dict, - run=self.run.to_dict(), + proceduretype=self.proceduretype.details_dict(), + run=self.run.details_dict(), procedure=self.created_procedure.__dict__, plate_map=self.plate_map ) diff --git a/src/submissions/frontend/widgets/results/__init__.py b/src/submissions/frontend/widgets/results/__init__.py deleted file mode 100644 index 3e64eda..0000000 --- a/src/submissions/frontend/widgets/results/__init__.py +++ /dev/null @@ -1,7 +0,0 @@ - - -class DefaultResults(object): - - pass - -from .pcr import PCR \ No newline at end of file diff --git a/src/submissions/frontend/widgets/sample_checker.py b/src/submissions/frontend/widgets/sample_checker.py index 4ea0dee..39d5959 100644 --- a/src/submissions/frontend/widgets/sample_checker.py +++ b/src/submissions/frontend/widgets/sample_checker.py @@ -21,9 +21,9 @@ class SampleChecker(QDialog): def __init__(self, parent, title: str, samples: List[PydSample], clientsubmission: ClientSubmission|None=None): super().__init__(parent) if clientsubmission: - self.rsl_plate_num = RSLNamer.construct_new_plate_name(clientsubmission.to_dict()) + self.rsl_plate_number = RSLNamer.construct_new_plate_name(clientsubmission.to_dict()) else: - self.rsl_plate_num = clientsubmission + self.rsl_plate_number = clientsubmission self.samples = samples self.setWindowTitle(title) self.app = get_application_from_parent(parent) @@ -45,7 +45,7 @@ class SampleChecker(QDialog): except AttributeError as e: logger.error(f"Problem getting sample list: {e}") samples = [] - html = template.render(samples=samples, css=css, rsl_plate_num=self.rsl_plate_num) + html = template.render(samples=samples, css=css, rsl_plate_number=self.rsl_plate_number) self.webview.setHtml(html) QBtn = QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel self.buttonBox = QDialogButtonBox(QBtn) @@ -76,9 +76,9 @@ class SampleChecker(QDialog): item.__setattr__("enabled", enabled) @pyqtSlot(str) - def set_rsl_plate_num(self, rsl_plate_num: str): - logger.debug(f"RSL plate num: {rsl_plate_num}") - self.rsl_plate_num = rsl_plate_num + def set_rsl_plate_number(self, rsl_plate_number: str): + logger.debug(f"RSL plate num: {rsl_plate_number}") + self.rsl_plate_number = rsl_plate_number @property def formatted_list(self) -> List[dict]: diff --git a/src/submissions/frontend/widgets/submission_details.py b/src/submissions/frontend/widgets/submission_details.py index a6c7e76..1fe0682 100644 --- a/src/submissions/frontend/widgets/submission_details.py +++ b/src/submissions/frontend/widgets/submission_details.py @@ -50,17 +50,33 @@ class SubmissionDetails(QDialog): # NOTE: setup channel self.channel = QWebChannel() self.channel.registerObject('backend', self) - match sub: - case Run(): - self.run_details(run=sub) - self.rsl_plate_num = sub.rsl_plate_num - case Sample(): - self.sample_details(sample=sub) - case Reagent(): - self.reagent_details(reagent=sub) + # match sub: + # case Run(): + # self.run_details(run=sub) + # self.rsl_plate_number = sub.rsl_plate_number + # case Sample(): + # self.sample_details(sample=sub) + # case Reagent(): + # self.reagent_details(reagent=sub) # NOTE: Used to maintain javascript functions. + self.object_details(object=sub) self.webview.page().setWebChannel(self.channel) + def object_details(self, object): + details = object.details_dict() + template = object.details_template + template_path = Path(template.environment.loader.__getattribute__("searchpath")[0]) + with open(template_path.joinpath("css", "styles.css"), "r") as f: + css = f.read() + key = object.__class__.__name__.lower() + d = {key: details} + logger.debug(f"Using details: {d}") + html = template.render(**d, css=css) + self.webview.setHtml(html) + self.setWindowTitle(f"{object.__class__.__name__} Details - {object.name}") + + + def activate_export(self) -> None: """ Determines if export pdf should be active. @@ -213,7 +229,7 @@ class SubmissionDetails(QDialog): logger.debug(f"Submission details.") if isinstance(run, str): run = Run.query(name=run) - self.rsl_plate_num = run.rsl_plate_num + self.rsl_plate_number = run.rsl_plate_number self.base_dict = run.to_dict(full_data=True) # NOTE: don't want id self.base_dict['platemap'] = run.make_plate_map(sample_list=run.hitpicked) @@ -244,7 +260,7 @@ class SubmissionDetails(QDialog): run.completed_date = datetime.now() run.completed_date.replace(tzinfo=timezone) run.save() - self.run_details(run=self.rsl_plate_num) + self.run_details(run=self.rsl_plate_number) def save_pdf(self): """ @@ -264,7 +280,7 @@ class SubmissionComment(QDialog): super().__init__(parent) self.app = get_application_from_parent(parent) self.submission = submission - self.setWindowTitle(f"{self.submission.rsl_plate_num} Submission Comment") + self.setWindowTitle(f"{self.submission.rsl_plate_number} Submission Comment") # NOTE: create text field self.txt_editor = QTextEdit(self) self.txt_editor.setReadOnly(False) diff --git a/src/submissions/frontend/widgets/submission_table.py b/src/submissions/frontend/widgets/submission_table.py index 0ba0f12..3a3501e 100644 --- a/src/submissions/frontend/widgets/submission_table.py +++ b/src/submissions/frontend/widgets/submission_table.py @@ -161,7 +161,7 @@ class SubmissionsSheet(QTableView): for run in runs: new_run = dict( start_time=run[0].strip(), - rsl_plate_num=run[1].strip(), + rsl_plate_number=run[1].strip(), sample_count=run[2].strip(), status=run[3].strip(), experiment_name=run[4].strip(), @@ -213,7 +213,7 @@ class SubmissionsSheet(QTableView): for run in runs: new_run = dict( start_time=run[0].strip(), - rsl_plate_num=run[1].strip(), + rsl_plate_number=run[1].strip(), biomek_status=run[2].strip(), quant_status=run[3].strip(), experiment_name=run[4].strip(), @@ -379,7 +379,7 @@ class SubmissionsTree(QTreeView): query_str=submission['submitter_plate_id'], item_type=ClientSubmission )) - logger.debug(f"Added {submission_item}") + # logger.debug(f"Added {submission_item}") for run in submission['run']: # self.model.append_element_to_group(group_item=group_item, element=run) run_item = self.model.add_child(parent=submission_item, child=dict( @@ -387,14 +387,14 @@ class SubmissionsTree(QTreeView): query_str=run['plate_number'], item_type=Run )) - logger.debug(f"Added {run_item}") + # logger.debug(f"Added {run_item}") for procedure in run['procedures']: procedure_item = self.model.add_child(parent=run_item, child=dict( name=procedure['name'], query_str=procedure['name'], item_type=Procedure )) - logger.debug(f"Added {procedure_item}") + # logger.debug(f"Added {procedure_item}") def _populateTree(self, children, parent): for child in children: @@ -415,7 +415,6 @@ class SubmissionsTree(QTreeView): # id = id.sibling(id.row(), 1) indexes = self.selectedIndexes() dicto = next((item.data(1) for item in indexes if item.data(1))) - logger.debug(dicto) # try: # id = int(id.data()) # except ValueError: @@ -423,6 +422,7 @@ class SubmissionsTree(QTreeView): # Run.query(id=id).show_details(self) obj = dicto['item_type'].query(name=dicto['query_str'], limit=1) logger.debug(obj) + obj.show_details(obj) def link_extractions(self): pass diff --git a/src/submissions/frontend/widgets/submission_widget.py b/src/submissions/frontend/widgets/submission_widget.py index 6d0916b..dcd5286 100644 --- a/src/submissions/frontend/widgets/submission_widget.py +++ b/src/submissions/frontend/widgets/submission_widget.py @@ -10,7 +10,7 @@ from .functions import select_open_file, select_save_file import logging from pathlib import Path from tools import Report, Result, check_not_nan, main_form_style, report_result, get_application_from_parent -from backend.excel import ClientSubmissionParser, ClientSampleParser +from backend.excel.parsers.clientsubmission_parser import ClientSubmissionInfoParser, ClientSubmissionSampleParser from backend.validators import PydSubmission, PydReagent, PydClientSubmission, PydSample from backend.db import ( ClientLab, SubmissionType, Reagent, @@ -121,20 +121,20 @@ class SubmissionFormContainer(QWidget): return report # NOTE: create sheetparser using excel sheet and context from gui try: - self.clientsubmissionparser = ClientSubmissionParser(filepath=fname) + self.clientsubmissionparser = ClientSubmissionInfoParser(filepath=fname) except PermissionError: logger.error(f"Couldn't get permission to access file: {fname}") return except AttributeError: - self.clientsubmissionparser = ClientSubmissionParser(filepath=fname) + self.clientsubmissionparser = ClientSubmissionInfoParser(filepath=fname) try: # self.prsr = SheetParser(filepath=fname) - self.sampleparser = ClientSampleParser(filepath=fname) + self.sampleparser = ClientSubmissionSampleParser(filepath=fname) except PermissionError: logger.error(f"Couldn't get permission to access file: {fname}") return except AttributeError: - self.sampleparser = ClientSampleParser(filepath=fname) + self.sampleparser = ClientSubmissionSampleParser(filepath=fname) self.pydclientsubmission = self.clientsubmissionparser.to_pydantic() self.pydsamples = self.sampleparser.to_pydantic() # logger.debug(f"Samples: {pformat(self.pydclientsubmission.sample)}") @@ -368,7 +368,7 @@ class SubmissionFormWidget(QWidget): pass # NOTE: code 1: ask for overwrite case 1: - dlg = QuestionAsker(title=f"Review {base_submission.rsl_plate_num}?", message=trigger.msg) + dlg = QuestionAsker(title=f"Review {base_submission.rsl_plate_number}?", message=trigger.msg) if dlg.exec(): # NOTE: Do not add duplicate reagents. pass diff --git a/src/submissions/templates/clientsubmission_details.html b/src/submissions/templates/clientsubmission_details.html new file mode 100644 index 0000000..a7b2827 --- /dev/null +++ b/src/submissions/templates/clientsubmission_details.html @@ -0,0 +1,37 @@ +{% extends "details.html"%} + + + {% block head %} + {{ super() }} + ClientSubmission Details for {{ clientsubmission['name'] }} + {% endblock %} + + + {% block body %} +

Submission Details for {{ clientsubmission['name'] }}

+ {{ super() }} +

{% for key, value in clientsubmission.items() if key not in clientsubmission['excluded'] %} +     {{ key | replace("_", " ") | title | replace("Id", "ID") }}: {% if key=='cost' %}{% if clientsubmission['cost'] %} {{ "${:,.2f}".format(value) }}{% endif %}{% else %}{{ value }}{% endif %}
+ {% endfor %} +

+ + {% if clientsubmission['sample'] %} + +
+

{% for sample in clientsubmission['sample'] %} +     {{ sample['sample_id']}}
+ {% endfor %}

+
+ {% endif %} + {% if clientsubmission['run'] %} + +
+ {% for run in clientsubmission['run'] %} + {% with run=run, child=True %} + {% include "run_details.html" %} + {% endwith %} + {% endfor %} +
+ {% endif %} + {% endblock %} + diff --git a/src/submissions/templates/css/styles.css b/src/submissions/templates/css/styles.css index 305bf6d..54ef4d7 100644 --- a/src/submissions/templates/css/styles.css +++ b/src/submissions/templates/css/styles.css @@ -146,3 +146,36 @@ ul.no-bullets { background-color: pink; } +/* */ +.nested { + margin-left: 50px; + padding: 0 18px; + display: none; + overflow: hidden; + background-color: #f1f1f1; +} + +/* Style the button that is used to open and close the collapsible content */ +.collapsible { + background-color: #eee; + color: #444; + cursor: pointer; + padding: 18px; + width: 100%; + border: none; + text-align: left; + outline: none; + font-size: 15px; +} + +/* Add a background color to the button if it is clicked on (add the .active class with JS), and when you move the mouse over it (hover) */ +.active, .collapsible:hover { + background-color: #ccc; +} + +.unused { + color: red; + text-decoration-line: line-through; + text-decoration-color: red; +} + diff --git a/src/submissions/templates/details.html b/src/submissions/templates/details.html index 8edfce9..7145c36 100644 --- a/src/submissions/templates/details.html +++ b/src/submissions/templates/details.html @@ -1,4 +1,4 @@ - +{% if not child %} {% block head %} @@ -14,17 +14,41 @@ {% endblock %} +{% endif %} {% block body %} - {% endblock %} {% block signing_button %}{% endblock %} +{% if not child %} +{% endif %} {% block script %} +{% if not child %} + +{% endif %} {% for j in js%} + + {% endfor %} {% endblock %} - \ No newline at end of file +{% if not child %} + +{% endif %} \ No newline at end of file diff --git a/src/submissions/templates/procedure_creation.html b/src/submissions/templates/procedure_creation.html index 614c8f6..a8c0aec 100644 --- a/src/submissions/templates/procedure_creation.html +++ b/src/submissions/templates/procedure_creation.html @@ -28,13 +28,24 @@


{% for key, value in procedure['reagentrole'].items() %}
- + {% endfor %} {% endif %} + {% if proceduretype['equipment'] %} +


+ {% for equipmentrole in proceduretype['equipment'] %} +
+ + {% endfor %} + {% endif%}
diff --git a/src/submissions/templates/procedure_details.html b/src/submissions/templates/procedure_details.html index e9b8087..21a3ca4 100644 --- a/src/submissions/templates/procedure_details.html +++ b/src/submissions/templates/procedure_details.html @@ -1,4 +1,5 @@ {% extends "details.html" %} +{% if not child %} {% block head %} @@ -7,9 +8,31 @@ {% endblock %} +{% endif %} {% block body %} - - +

Procedure Details for {{ procedure['name'] }}

+ {{ super() }} +

{% for key, value in procedure.items() if key not in procedure['excluded'] %} +     {{ key | replace("_", " ") | title | replace("Pcr", "PCR") }}: {{ value }}
+ {% endfor %}

+ {% if procedure['results'] %} + +
+ {% for result in procedure['results'] %} +

{% for k, v in result['result'].items() %} + {{ key | replace("_", " ") | title | replace("Rsl", "RSL") }}: {{ value }}
+ {% endfor %}

+ {% endfor %} +
+ {% endif %} + {% if procedure['sample'] %} + +
+

{% for sample in procedure['sample'] %} +     {{ sample['sample_id']}}
+ {% endfor %}

+
+ {% endif %} {% endblock %} - \ No newline at end of file + diff --git a/src/submissions/templates/run_details.html b/src/submissions/templates/run_details.html index b9e15b0..8b14604 100644 --- a/src/submissions/templates/run_details.html +++ b/src/submissions/templates/run_details.html @@ -1,135 +1,59 @@ {% extends "details.html" %} - - - {% block head %} - {{ super() }} - Submission Details for {{ sub['plate_number'] }} - {% endblock %} - - - {% block body %} -

Submission Details for {{ sub['plate_number'] }}

   {% if sub['barcode'] %}{% endif %} - {{ super() }} -

{% for key, value in sub.items() if key not in sub['excluded'] %} -     {{ key | replace("_", " ") | title | replace("Pcr", "PCR") }}: {% if key=='cost' %}{% if sub['cost'] %} {{ "${:,.2f}".format(value) }}{% endif %}{% else %}{{ value }}{% endif %}
- {% endfor %} - {% if sub['custom'] %}{% for key, value in sub['custom'].items() %} -     {{ key | replace("_", " ") | title }}: {{ value }}
- {% endfor %}{% endif %}

- {% if sub['reagents'] %} -

Reagents:

-

{% for item in sub['reagents'] %} -     {{ item['role'] }}: {{ item['lot'] }} (EXP: {{ item['expiry'] }})
- {% endfor %}

- {% endif %} - {% if sub['equipment'] %} -

Equipment:

-

{% for item in sub['equipment'] %} -     {{ item['role'] }}: {{ item['name'] }} ({{ item['asset_number'] }}): {{ item['processes'][0]|replace('\n\t', '
        ') }}

- {% endfor %}

- {% endif %} - {% if sub['tips'] %} -

Tips:

-

{% for item in sub['tips'] %} -     {{ item['role'] }}: {{ item['name'] }} ({{ item['lot'] }})
- {% endfor %}

- {% endif %} - {% if sub['samples'] %} -

Samples:

-

{% for item in sub['samples'] %} -     {{ item['well'] }}:{% if item['organism'] %} {{ item['name'] }} - ({{ item['organism']|replace('\n\t', '
        ') }}){% else %} {{ item['name']|replace('\n\t', '
        ') }}{% endif %}

- {% endfor %}

- {% endif %} + + {% block head %} + {{ super() }} + Run Details for {{ run['rsl_plate_number'] }} + {% endblock %} + + - {% if sub['ext_info'] %} - {% for entry in sub['ext_info'] %} -

Extraction Status:

-

{% for key, value in entry.items() %} - {% if "column" in key %} -     {{ key|replace('_', ' ')|title() }}: {{ value }}uL
- {% else %} -     {{ key|replace('_', ' ')|title() }}: {{ value }}
- {% endif %} - {% endfor %}

- {% endfor %} - {% endif %} + {% block body %} +

Run Details for {{ run['rsl_plate_number'] }}

+ {{ super() }} +

{% for key, value in run.items() if key not in run['excluded'] %} +     {{ key | replace("_", " ") | title | replace("Rsl", "RSL") }}: {{ value }}
+ {% endfor %}

+ {% if run['sample'] %} + +

{% for sample in run['sample'] %} +     {{ sample['sample_id']}}
+ {% endfor %}

+ {% endif %} + {% if run['procedure'] %} + +
+ {% for procedure in run['procedure'] %} + {% with procedure=procedure, child=True %} + {% include "procedure_details.html" %} + {% endwith %} + {% endfor %} +
+ {% endif %} + {% endblock %} + {% block signing_button %} + + {% endblock %} +
- {% if sub['comment'] %} -

Comments:

-

{% for entry in sub['comment'] %} -      {{ entry['name'] }}:
{{ entry['text'] }}
- {{ entry['time'] }}
- {% endfor %}

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

Plate map:

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

Plate map:

- - {% endif %} - {% endblock %} - {% block signing_button %} - - {% endblock %} -
-
-
- + - {% block script %} - {{ super() }} - + {% endblock %} - for(let i = 0; i < reagentSelection.length; i++) { - reagentSelection[i].addEventListener("click", function() { - console.log(reagentSelection[i].id); - backend.reagent_details(reagentSelection[i].id, "{{ sub['extraction_kit'] }}"); - }) - } - var equipmentSelection = document.getElementsByClassName('equipment'); - - for(let i = 0; i < equipmentSelection.length; i++) { - equipmentSelection[i].addEventListener("click", function() { - console.log(equipmentSelection[i].id); - backend.equipment_details(equipmentSelection[i].id); - }) - } - - var processSelection = document.getElementsByClassName('process'); - - for(let i = 0; i < processSelection.length; i++) { - processSelection[i].addEventListener("click", function() { - console.log(processSelection[i].id); - backend.process_details(processSelection[i].id); - }) - } - - var tipsSelection = document.getElementsByClassName('tips'); - - for(let i = 0; i < tipsSelection.length; i++) { - tipsSelection[i].addEventListener("click", function() { - console.log(tipsSelection[i].id); - backend.tips_details(tipsSelection[i].id); - }) - } - - document.getElementById("sign_btn").addEventListener("click", function(){ - backend.sign_off("{{ sub['plate_number'] }}"); - }); - - {% endblock %} - - diff --git a/src/submissions/templates/sample_checker.html b/src/submissions/templates/sample_checker.html index a2c7146..b7612c5 100644 --- a/src/submissions/templates/sample_checker.html +++ b/src/submissions/templates/sample_checker.html @@ -10,8 +10,8 @@

Sample Checker


{% if rsl_plate_num %} -
- +
+ {% endif %}