From da714c355dff27b751990d58b1b995c1f9d2f229 Mon Sep 17 00:00:00 2001 From: Landon Wark Date: Tue, 14 Nov 2023 11:16:54 -0600 Subject: [PATCH] Large scale frontend refactor --- src/submissions/__main__.py | 3 +- src/submissions/backend/__init__.py | 5 +- src/submissions/backend/db/functions.py | 27 +- .../backend/db/models/submissions.py | 41 +- src/submissions/backend/excel/parser.py | 19 +- src/submissions/backend/excel/reports.py | 6 +- .../backend/validators/__init__.py | 6 +- src/submissions/backend/validators/pydant.py | 28 +- src/submissions/frontend/__init__.py | 439 +------- .../frontend/custom_widgets/__init__.py | 7 - .../frontend/custom_widgets/misc.py | 767 -------------- .../frontend/functions/__init__.py | 105 -- .../functions/all_window_functions.py | 102 -- .../functions/main_window_functions.py | 935 ------------------ .../functions/submission_functions.py | 0 .../frontend/visualizations/control_charts.py | 4 +- src/submissions/frontend/widgets/__init__.py | 13 + src/submissions/frontend/widgets/app.py | 412 ++++++++ .../frontend/widgets/controls_chart.py | 193 ++++ src/submissions/frontend/widgets/functions.py | 56 ++ .../frontend/widgets/kit_creator.py | 223 +++++ src/submissions/frontend/widgets/misc.py | 749 ++++++++++++++ .../{custom_widgets => widgets}/pop_ups.py | 0 .../submission_table.py} | 240 ++++- .../frontend/widgets/submission_widget.py | 747 ++++++++++++++ src/submissions/tools.py | 2 +- 26 files changed, 2682 insertions(+), 2447 deletions(-) delete mode 100644 src/submissions/frontend/custom_widgets/__init__.py delete mode 100644 src/submissions/frontend/custom_widgets/misc.py delete mode 100644 src/submissions/frontend/functions/__init__.py delete mode 100644 src/submissions/frontend/functions/all_window_functions.py delete mode 100644 src/submissions/frontend/functions/main_window_functions.py delete mode 100644 src/submissions/frontend/functions/submission_functions.py create mode 100644 src/submissions/frontend/widgets/__init__.py create mode 100644 src/submissions/frontend/widgets/app.py create mode 100644 src/submissions/frontend/widgets/controls_chart.py create mode 100644 src/submissions/frontend/widgets/functions.py create mode 100644 src/submissions/frontend/widgets/kit_creator.py create mode 100644 src/submissions/frontend/widgets/misc.py rename src/submissions/frontend/{custom_widgets => widgets}/pop_ups.py (100%) rename src/submissions/frontend/{custom_widgets/sub_details.py => widgets/submission_table.py} (61%) create mode 100644 src/submissions/frontend/widgets/submission_widget.py diff --git a/src/submissions/__main__.py b/src/submissions/__main__.py index 8c5dea5..3d1ef60 100644 --- a/src/submissions/__main__.py +++ b/src/submissions/__main__.py @@ -9,7 +9,8 @@ logger = setup_logger(verbosity=3) # create settings object ctx = get_config(None) from PyQt6.QtWidgets import QApplication -from frontend import App +# from frontend import App +from frontend.widgets.app import App if __name__ == '__main__': app = QApplication(['', '--no-sandbox']) diff --git a/src/submissions/backend/__init__.py b/src/submissions/backend/__init__.py index ab7c0c9..ee800e3 100644 --- a/src/submissions/backend/__init__.py +++ b/src/submissions/backend/__init__.py @@ -1,3 +1,6 @@ ''' Contains database, validators and excel operations. -''' \ No newline at end of file +''' +from .db import * +from .excel import * +from .validators import * \ No newline at end of file diff --git a/src/submissions/backend/db/functions.py b/src/submissions/backend/db/functions.py index 9fad02b..12d51d0 100644 --- a/src/submissions/backend/db/functions.py +++ b/src/submissions/backend/db/functions.py @@ -199,7 +199,7 @@ def update_subsampassoc_with_pcr(submission:BasicSubmission, sample:BasicSample, dict|None: result object """ # assoc = lookup_submission_sample_association(ctx, submission=submission, sample=sample) - assoc = SubmissionSampleAssociation.query(submission=submission, sample=sample) + assoc = SubmissionSampleAssociation.query(submission=submission, sample=sample, limit=1) for k,v in input_dict.items(): try: setattr(assoc, k, v) @@ -209,28 +209,3 @@ def update_subsampassoc_with_pcr(submission:BasicSubmission, sample:BasicSample, result = assoc.save() return result - -# def store_object(ctx:Settings, object) -> dict|None: -# """ -# Store an object in the database - -# Args: -# ctx (Settings): Settings object passed down from gui -# object (_type_): Object to be stored - -# Returns: -# dict|None: Result of action -# """ -# dbs = ctx.database_session -# dbs.merge(object) -# try: -# dbs.commit() -# 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.\n{e}", "status":"Critical"} -# except (SQLOperationalError, AlcOperationalError): -# logger.error(f"Hit an operational error: {e}") -# dbs.rollback() -# return {"message":"The database is locked for editing."} -# return None diff --git a/src/submissions/backend/db/models/submissions.py b/src/submissions/backend/db/models/submissions.py index 2a3adff..95d2d8c 100644 --- a/src/submissions/backend/db/models/submissions.py +++ b/src/submissions/backend/db/models/submissions.py @@ -310,9 +310,10 @@ class BasicSubmission(Base): return input_excel @classmethod - def enforce_name(cls, instr:str) -> str: + def enforce_name(cls, instr:str, data:dict|None=None) -> str: logger.debug(f"Hello from {cls.__mapper_args__['polymorphic_identity']} Enforcer!") - logger.debug(f"Attempting enforcement on {instr}") + logger.debug(f"Attempting enforcement on {instr} using data: {pformat(data)}") + # sys.exit() return instr @classmethod @@ -499,7 +500,7 @@ class BasicSubmission(Base): cls: _description_ """ code = 0 - msg = None + msg = "" disallowed = ["id"] if kwargs == {}: raise ValueError("Need to narrow down query or the first available instance will be returned.") @@ -636,9 +637,9 @@ class BacterialCulture(BasicSubmission): return input_excel @classmethod - def enforce_name(cls, instr:str) -> str: - outstr = super().enforce_name(instr=instr) - def construct() -> str: + def enforce_name(cls, instr:str, data:dict|None=None) -> str: + outstr = super().enforce_name(instr=instr, data=data) + def construct(data:dict|None=None) -> str: """ DEPRECIATED due to slowness. Search for the largest rsl number and increment by 1 @@ -765,18 +766,28 @@ class Wastewater(BasicSubmission): return samples @classmethod - def enforce_name(cls, instr:str) -> str: - outstr = super().enforce_name(instr=instr) - def construct(): - today = datetime.now() + def enforce_name(cls, instr:str, data:dict|None=None) -> str: + outstr = super().enforce_name(instr=instr, data=data) + def construct(data:dict|None=None): + if "submitted_date" in data.keys(): + if data['submitted_date']['value'] != None: + today = data['submitted_date']['value'] + else: + today = datetime.now() + else: + today = re.search(r"\d{4}(_|-)?\d{2}(_|-)?\d{2}", instr) + try: + today = parse(today.group()) + except AttributeError: + today = datetime.now() return f"RSL-WW-{today.year}{str(today.month).zfill(2)}{str(today.day).zfill(2)}" if outstr == None: - outstr = construct() + outstr = construct(data) try: outstr = re.sub(r"PCR(-|_)", "", outstr) except AttributeError as e: logger.error(f"Problem using regex: {e}") - outstr = construct() + outstr = construct(data) outstr = outstr.replace("RSLWW", "RSL-WW") outstr = re.sub(r"WW(\d{4})", r"WW-\1", outstr, flags=re.IGNORECASE) outstr = re.sub(r"(\d{4})-(\d{2})-(\d{2})", r"\1\2\3", outstr) @@ -848,9 +859,9 @@ class WastewaterArtic(BasicSubmission): return input_dict @classmethod - def enforce_name(cls, instr:str) -> str: - outstr = super().enforce_name(instr=instr) - def construct(): + def enforce_name(cls, instr:str, data:dict|None=None) -> str: + outstr = super().enforce_name(instr=instr, data=data) + def construct(data:dict|None=None): today = datetime.now() return f"RSL-AR-{today.year}{str(today.month).zfill(2)}{str(today.day).zfill(2)}" try: diff --git a/src/submissions/backend/excel/parser.py b/src/submissions/backend/excel/parser.py index e2a6ee7..43f99f3 100644 --- a/src/submissions/backend/excel/parser.py +++ b/src/submissions/backend/excel/parser.py @@ -2,7 +2,7 @@ contains parser object for pulling values from client generated submission sheets. ''' from getpass import getuser -import pprint +from pprint import pformat from typing import List import pandas as pd import numpy as np @@ -15,7 +15,7 @@ import re from datetime import date from dateutil.parser import parse, ParserError from tools import check_not_nan, convert_nans_to_nones, Settings -from frontend.custom_widgets.pop_ups import KitSelector + logger = logging.getLogger(f"submissions.{__name__}") @@ -70,7 +70,7 @@ class SheetParser(object): pass case _: self.sub[k] = v - logger.debug(f"Parser.sub after info scrape: {pprint.pformat(self.sub)}") + logger.debug(f"Parser.sub after info scrape: {pformat(self.sub)}") def parse_reagents(self, extraction_kit:str|None=None): """ @@ -100,6 +100,7 @@ class SheetParser(object): Returns: List[PydReagent]: List of reagents """ + from frontend.widgets.pop_ups import KitSelector if not check_not_nan(self.sub['extraction_kit']['value']): dlg = KitSelector(title="Kit Needed", message="At minimum a kit is needed. Please select one.") if dlg.exec(): @@ -117,7 +118,7 @@ class SheetParser(object): # kit = lookup_kit_types(ctx=self.ctx, name=self.sub['extraction_kit']['value']) kit = KitType.query(name=self.sub['extraction_kit']['value']) allowed_reagents = [item.name for item in kit.get_reagents()] - logger.debug(f"List of reagents for comparison with allowed_reagents: {pprint.pformat(self.sub['reagents'])}") + logger.debug(f"List of reagents for comparison with allowed_reagents: {pformat(self.sub['reagents'])}") # self.sub['reagents'] = [reagent for reagent in self.sub['reagents'] if reagent['value'].type in allowed_reagents] self.sub['reagents'] = [reagent for reagent in self.sub['reagents'] if reagent.type in allowed_reagents] @@ -133,7 +134,7 @@ class SheetParser(object): Returns: PydSubmission: output pydantic model """ - logger.debug(f"Submission dictionary coming into 'to_pydantic':\n{pprint.pformat(self.sub)}") + logger.debug(f"Submission dictionary coming into 'to_pydantic':\n{pformat(self.sub)}") psm = PydSubmission(filepath=self.filepath, **self.sub) # delattr(psm, "filepath") return psm @@ -145,7 +146,7 @@ class InfoParser(object): # self.ctx = ctx 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)}") + logger.debug(f"Info map for InfoParser: {pformat(self.map)}") def fetch_submission_info_map(self, submission_type:str|dict) -> dict: @@ -186,7 +187,7 @@ class InfoParser(object): continue if sheet in self.map[k]['sheets']: relevant[k] = v - logger.debug(f"relevant map for {sheet}: {pprint.pformat(relevant)}") + logger.debug(f"relevant map for {sheet}: {pformat(relevant)}") if relevant == {}: continue for item in relevant: @@ -236,7 +237,7 @@ class ReagentParser(object): df = self.xl.parse(sheet, header=None, dtype=object) df.replace({np.nan: None}, inplace = True) relevant = {k.strip():v for k,v in self.map.items() if sheet in self.map[k]['sheet']} - logger.debug(f"relevant map for {sheet}: {pprint.pformat(relevant)}") + logger.debug(f"relevant map for {sheet}: {pformat(relevant)}") if relevant == {}: continue for item in relevant: @@ -302,7 +303,7 @@ class SampleParser(object): logger.debug(f"Looking up submission type: {submission_type}") # submission_type = lookup_submission_type(ctx=self.ctx, name=submission_type) submission_type = SubmissionType.query(name=submission_type) - logger.debug(f"info_map: {pprint.pformat(submission_type.info_map)}") + logger.debug(f"info_map: {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 self.custom_sub_parser = BasicSubmission.find_polymorphic_subclass(polymorphic_identity=submission_type.name).parse_samples diff --git a/src/submissions/backend/excel/reports.py b/src/submissions/backend/excel/reports.py index 634533e..5347a08 100644 --- a/src/submissions/backend/excel/reports.py +++ b/src/submissions/backend/excel/reports.py @@ -6,7 +6,7 @@ import logging from datetime import date, timedelta import re from typing import Tuple -from tools import jinja_template_loading +from tools import jinja_template_loading, Settings logger = logging.getLogger(f"submissions.{__name__}") @@ -198,7 +198,7 @@ def get_unique_values_in_df_column(df: DataFrame, column_name: str) -> list: return sorted(df[column_name].unique()) -def drop_reruns_from_df(ctx:dict, df: DataFrame) -> DataFrame: +def drop_reruns_from_df(ctx:Settings, df: DataFrame) -> DataFrame: """ Removes semi-duplicates from dataframe after finding sequencing repeats. @@ -211,7 +211,7 @@ def drop_reruns_from_df(ctx:dict, df: DataFrame) -> DataFrame: """ if 'rerun_regex' in ctx: sample_names = get_unique_values_in_df_column(df, column_name="name") - rerun_regex = re.compile(fr"{ctx['rerun_regex']}") + rerun_regex = re.compile(fr"{ctx.rerun_regex}") for sample in sample_names: if rerun_regex.search(sample): first_run = re.sub(rerun_regex, "", sample) diff --git a/src/submissions/backend/validators/__init__.py b/src/submissions/backend/validators/__init__.py index d38d457..a73e16e 100644 --- a/src/submissions/backend/validators/__init__.py +++ b/src/submissions/backend/validators/__init__.py @@ -10,7 +10,7 @@ class RSLNamer(object): """ Object that will enforce proper formatting on RSL plate names. """ - def __init__(self, instr:str, sub_type:str|None=None): + def __init__(self, instr:str, sub_type:str|None=None, data:dict|None=None): self.submission_type = sub_type if self.submission_type == None: @@ -19,7 +19,7 @@ class RSLNamer(object): if self.submission_type != None: enforcer = BasicSubmission.find_polymorphic_subclass(polymorphic_identity=self.submission_type) self.parsed_name = self.retrieve_rsl_number(instr=instr, regex=enforcer.get_regex()) - self.parsed_name = enforcer.enforce_name(instr=self.parsed_name) + self.parsed_name = enforcer.enforce_name(instr=self.parsed_name, data=data) @classmethod def retrieve_submission_type(cls, instr:str|Path) -> str: @@ -58,7 +58,7 @@ class RSLNamer(object): except UnboundLocalError: check = True if check: - from frontend.custom_widgets import SubmissionTypeSelector + from frontend.widgets import SubmissionTypeSelector dlg = SubmissionTypeSelector(title="Couldn't parse submission type.", message="Please select submission type from list below.") if dlg.exec(): submission_type = dlg.parse_form() diff --git a/src/submissions/backend/validators/pydant.py b/src/submissions/backend/validators/pydant.py index 1dc638d..9788936 100644 --- a/src/submissions/backend/validators/pydant.py +++ b/src/submissions/backend/validators/pydant.py @@ -11,7 +11,7 @@ from . import RSLNamer from pathlib import Path import re import logging -from tools import check_not_nan, convert_nans_to_nones, jinja_template_loading +from tools import check_not_nan, convert_nans_to_nones, jinja_template_loading, Report, Result from backend.db.models import * from sqlalchemy.exc import StatementError from PyQt6.QtWidgets import QComboBox, QWidget @@ -86,8 +86,8 @@ class PydReagent(BaseModel): else: return values.data['type'] - def toSQL(self) -> Tuple[Reagent, dict]: - result = None + def toSQL(self) -> Tuple[Reagent, Report]: + report = Report() logger.debug(f"Reagent SQL constructor is looking up type: {self.type}, lot: {self.lot}") # reagent = lookup_reagents(ctx=self.ctx, lot_number=self.lot) reagent = Reagent.query(lot_number=self.lot) @@ -113,10 +113,10 @@ class PydReagent(BaseModel): reagent.name = value # add end-of-life extension from reagent type to expiry date # NOTE: this will now be done only in the reporting phase to account for potential changes in end-of-life extensions - return reagent, result + return reagent, report def toForm(self, parent:QWidget, extraction_kit:str) -> QComboBox: - from frontend.custom_widgets.misc import ReagentFormWidget + from frontend.widgets.submission_widget import ReagentFormWidget return ReagentFormWidget(parent=parent, reagent=self, extraction_kit=extraction_kit) class PydSample(BaseModel, extra='allow'): @@ -180,8 +180,9 @@ class PydSubmission(BaseModel, extra='allow'): submission_type: dict|None # For defaults submitter_plate_num: 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) submitted_date: dict|None + rsl_plate_num: dict|None = Field(default=dict(value=None, missing=True), validate_default=True) + # submitted_date: dict|None submitting_lab: dict|None sample_count: dict|None extraction_kit: dict|None @@ -243,7 +244,7 @@ class PydSubmission(BaseModel, extra='allow'): @field_validator("rsl_plate_num") @classmethod def rsl_from_file(cls, value, values): - logger.debug(f"RSL-plate initial value: {value['value']}") + logger.debug(f"RSL-plate initial value: {value['value']} and other values: {values.data}") sub_type = values.data['submission_type']['value'] if check_not_nan(value['value']): # if lookup_submissions(ctx=values.data['ctx'], rsl_number=value['value']) == None: @@ -256,7 +257,7 @@ class PydSubmission(BaseModel, extra='allow'): # return dict(value=output, missing=True) return value else: - output = RSLNamer(instr=values.data['filepath'].__str__(), sub_type=sub_type).parsed_name + output = RSLNamer(instr=values.data['filepath'].__str__(), sub_type=sub_type, data=values.data).parsed_name return dict(value=output, missing=True) @field_validator("technician", mode="before") @@ -346,12 +347,11 @@ class PydSubmission(BaseModel, extra='allow'): missing_reagents = [reagent for reagent in self.reagents if reagent.missing] return missing_info, missing_reagents - def toSQL(self): - code = 0 - msg = None - status = None + def toSQL(self) -> Tuple[BasicSubmission, Result]: + self.__dict__.update(self.model_extra) instance, code, msg = BasicSubmission.query_or_create(submission_type=self.submission_type['value'], rsl_plate_num=self.rsl_plate_num['value']) + result = Result(msg=msg, code=code) self.handle_duplicate_samples() logger.debug(f"Here's our list of duplicate removed samples: {self.samples}") for key, value in self.__dict__.items(): @@ -389,10 +389,10 @@ class PydSubmission(BaseModel, extra='allow'): except AttributeError as e: logger.debug(f"Something went wrong constructing instance {self.rsl_plate_num}: {e}") logger.debug(f"Constructed submissions message: {msg}") - return instance, {'code':code, 'message':msg, 'status':"Information"} + return instance, result def toForm(self, parent:QWidget): - from frontend.custom_widgets.misc import SubmissionFormWidget + from frontend.widgets.submission_widget import SubmissionFormWidget return SubmissionFormWidget(parent=parent, **self.improved_dict()) def autofill_excel(self, missing_only:bool=True): diff --git a/src/submissions/frontend/__init__.py b/src/submissions/frontend/__init__.py index 09cefb2..960d1ab 100644 --- a/src/submissions/frontend/__init__.py +++ b/src/submissions/frontend/__init__.py @@ -1,440 +1,5 @@ ''' Constructs main application. ''' -import sys -from PyQt6.QtWidgets import ( - QMainWindow, QToolBar, - QTabWidget, QWidget, QVBoxLayout, - QComboBox, QHBoxLayout, - QScrollArea -) -from PyQt6.QtCore import pyqtSignal -from PyQt6.QtGui import QAction -from PyQt6.QtWebEngineWidgets import QWebEngineView -from pathlib import Path -from backend.db.models import ControlType, Control -from backend.validators import PydSubmission, PydReagent -from .functions import ( - import_submission_function, kit_reload_function, kit_integrity_completion_function, - submit_new_sample_function, generate_report_function, add_kit_function, add_org_function, - controls_getter_function, chart_maker_function, link_controls_function, link_extractions_function, - link_pcr_function, autofill_excel, scrape_reagents, export_csv_function, import_pcr_results_function -) -from tools import check_if_app, Settings, Report -from frontend.custom_widgets import SubmissionsSheet, AlertPop, AddReagentForm, KitAdder, ControlsDatePicker -import logging -from datetime import date -import webbrowser -from pathlib import Path -from typing import List - -logger = logging.getLogger(f'submissions.{__name__}') -logger.info("Hello, I am a logger") - -class App(QMainWindow): - - def __init__(self, ctx: Settings = {}): - logger.debug(f"Initializing main window...") - super().__init__() - self.ctx = ctx - self.last_dir = ctx.directory_path - self.report = Report() - # indicate version and connected database in title bar - try: - self.title = f"Submissions App (v{ctx.package.__version__}) - {ctx.database_path}" - except (AttributeError, KeyError): - self.title = f"Submissions App" - # set initial app position and size - self.left = 0 - self.top = 0 - self.width = 1300 - self.height = 1000 - self.setWindowTitle(self.title) - self.setGeometry(self.left, self.top, self.width, self.height) - # insert tabs into main app - self.table_widget = AddSubForm(self) - self.setCentralWidget(self.table_widget) - # run initial setups - self._createActions() - self._createMenuBar() - self._createToolBar() - self._connectActions() - self._controls_getter() - self.show() - self.statusBar().showMessage('Ready', 5000) - - def _createMenuBar(self): - """ - adds items to menu bar - """ - logger.debug(f"Creating menu bar...") - menuBar = self.menuBar() - fileMenu = menuBar.addMenu("&File") - # Creating menus using a title - # methodsMenu = menuBar.addMenu("&Methods") - reportMenu = menuBar.addMenu("&Reports") - maintenanceMenu = menuBar.addMenu("&Monthly") - helpMenu = menuBar.addMenu("&Help") - helpMenu.addAction(self.helpAction) - helpMenu.addAction(self.docsAction) - fileMenu.addAction(self.importAction) - fileMenu.addAction(self.importPCRAction) - # methodsMenu.addAction(self.constructFS) - reportMenu.addAction(self.generateReportAction) - maintenanceMenu.addAction(self.joinExtractionAction) - maintenanceMenu.addAction(self.joinPCRAction) - - def _createToolBar(self): - """ - adds items to toolbar - """ - logger.debug(f"Creating toolbar...") - toolbar = QToolBar("My main toolbar") - self.addToolBar(toolbar) - toolbar.addAction(self.addReagentAction) - toolbar.addAction(self.addKitAction) - toolbar.addAction(self.addOrgAction) - - def _createActions(self): - """ - creates actions - """ - logger.debug(f"Creating actions...") - self.importAction = QAction("&Import Submission", self) - self.importPCRAction = QAction("&Import PCR Results", self) - self.addReagentAction = QAction("Add Reagent", self) - self.generateReportAction = QAction("Make Report", self) - self.addKitAction = QAction("Import Kit", self) - self.addOrgAction = QAction("Import Org", self) - self.joinExtractionAction = QAction("Link Extraction Logs") - self.joinPCRAction = QAction("Link PCR Logs") - self.helpAction = QAction("&About", self) - self.docsAction = QAction("&Docs", self) - # self.constructFS = QAction("Make First Strand", self) - - def _connectActions(self): - """ - connect menu and tool bar item to functions - """ - logger.debug(f"Connecting actions...") - self.importAction.triggered.connect(self.importSubmission) - self.importPCRAction.triggered.connect(self.importPCRResults) - self.addReagentAction.triggered.connect(self.add_reagent) - self.generateReportAction.triggered.connect(self.generateReport) - self.addKitAction.triggered.connect(self.add_kit) - self.addOrgAction.triggered.connect(self.add_org) - self.table_widget.control_typer.currentIndexChanged.connect(self._controls_getter) - self.table_widget.mode_typer.currentIndexChanged.connect(self._controls_getter) - self.table_widget.datepicker.start_date.dateChanged.connect(self._controls_getter) - self.table_widget.datepicker.end_date.dateChanged.connect(self._controls_getter) - self.joinExtractionAction.triggered.connect(self.linkExtractions) - self.joinPCRAction.triggered.connect(self.linkPCR) - self.helpAction.triggered.connect(self.showAbout) - self.docsAction.triggered.connect(self.openDocs) - # self.constructFS.triggered.connect(self.construct_first_strand) - self.table_widget.formwidget.import_drag.connect(self.importSubmission) - - def showAbout(self): - """ - Show the 'about' message - """ - output = f"Version: {self.ctx.package.__version__}\n\nAuthor: {self.ctx.package.__author__['name']} - {self.ctx.package.__author__['email']}\n\nCopyright: {self.ctx.package.__copyright__}" - about = AlertPop(message=output, status="information") - about.exec() - - def openDocs(self): - """ - Open the documentation html pages - """ - if check_if_app(): - url = Path(sys._MEIPASS).joinpath("files", "docs", "index.html") - else: - url = Path("docs\\build\\index.html").absolute() - logger.debug(f"Attempting to open {url}") - webbrowser.get('windows-default').open(f"file://{url.__str__()}") - - def result_reporter(self): - # def result_reporter(self, result:TypedDict[]|None=None): - """ - Report any anomolous results - if any - to the user - - Args: - result (dict | None, optional): The result from a function. Defaults to None. - """ - # logger.info(f"We got the result: {result}") - # if result != None: - # msg = AlertPop(message=result['message'], status=result['status']) - # msg.exec() - logger.debug(f"Running results reporter for: {self.report.results}") - if len(self.report.results) > 0: - logger.debug(f"We've got some results!") - for result in self.report.results: - logger.debug(f"Showing result: {result}") - if result != None: - alert = result.report() - if alert.exec(): - pass - self.report = Report() - else: - self.statusBar().showMessage("Action completed sucessfully.", 5000) - - def importSubmission(self, fname:Path|None=None): - """ - import submission from excel sheet into form - """ - # from .main_window_functions import import_submission_function - self.raise_() - self.activateWindow() - self = import_submission_function(self, fname) - logger.debug(f"Result from result reporter: {self.report.results}") - self.result_reporter() - - def kit_reload(self): - """ - Removes all reagents from form before running kit integrity completion. - """ - # from .main_window_functions import kit_reload_function - self = kit_reload_function(self) - self.result_reporter() - - def kit_integrity_completion(self): - """ - Performs check of imported reagents - NOTE: this will not change self.reagents which should be fine - since it's only used when looking up - """ - # from .main_window_functions import kit_integrity_completion_function - self = kit_integrity_completion_function(self) - self.result_reporter() - - def submit_new_sample(self): - """ - Attempt to add sample to database when 'submit' button clicked - """ - # from .main_window_functions import submit_new_sample_function - self, result = submit_new_sample_function(self) - self.result_reporter(result) - - def add_reagent(self, reagent_lot:str|None=None, reagent_type:str|None=None, expiry:date|None=None, name:str|None=None): - """ - Action to create new reagent in DB. - - Args: - reagent_lot (str | None, optional): Parsed reagent from import form. Defaults to None. - reagent_type (str | None, optional): Parsed reagent type from import form. Defaults to None. - - Returns: - models.Reagent: the constructed reagent object to add to submission - """ - if isinstance(reagent_lot, bool): - reagent_lot = "" - # create form - 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 = dlg.parse_form() - logger.debug(f"Reagent info: {info}") - # create reagent object - # reagent = construct_reagent(ctx=self.ctx, info_dict=info) - reagent = PydReagent(ctx=self.ctx, **info) - # send reagent to db - # store_reagent(ctx=self.ctx, reagent=reagent) - sqlobj, result = reagent.toSQL() - sqlobj.save() - # result = store_object(ctx=self.ctx, object=reagent.toSQL()[0]) - self.result_reporter(result=result) - return reagent - - def generateReport(self): - """ - Action to create a summary of sheet data per client - """ - # from .main_window_functions import generate_report_function - self, result = generate_report_function(self) - self.result_reporter(result) - - def add_kit(self): - """ - Constructs new kit from yaml and adds to DB. - """ - # from .main_window_functions import add_kit_function - self, result = add_kit_function(self) - self.result_reporter(result) - - def add_org(self): - """ - Constructs new kit from yaml and adds to DB. - """ - # from .main_window_functions import add_org_function - self, result = add_org_function(self) - self.result_reporter(result) - - def _controls_getter(self): - """ - Lookup controls from database and send to chartmaker - """ - # from .main_window_functions import controls_getter_function - self = controls_getter_function(self) - self.result_reporter() - - def _chart_maker(self): - """ - Creates plotly charts for webview - """ - # from .main_window_functions import chart_maker_function - self = chart_maker_function(self) - self.result_reporter() - - 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) - self.result_reporter(result) - - def linkExtractions(self): - """ - Links extraction logs from .csv files to relevant submissions. - """ - # from .main_window_functions import link_extractions_function - self, result = link_extractions_function(self) - self.result_reporter(result) - - def linkPCR(self): - """ - Links PCR logs from .csv files to relevant submissions. - """ - # from .main_window_functions import link_pcr_function - self, result = link_pcr_function(self) - self.result_reporter(result) - - def importPCRResults(self): - """ - Imports results exported from Design and Analysis .eds files - """ - # from .main_window_functions import import_pcr_results_function - self, result = import_pcr_results_function(self) - self.result_reporter(result) - - # def construct_first_strand(self): - # """ - # Converts first strand excel sheet to Biomek CSV - # """ - # from .main_window_functions import construct_first_strand_function - # self, result = construct_first_strand_function(self) - # self.result_reporter(result) - - def scrape_reagents(self, *args, **kwargs): - # from .main_window_functions import scrape_reagents - logger.debug(f"Args: {args}") - logger.debug(F"kwargs: {kwargs}") - self = scrape_reagents(self, args[0]) - self.kit_integrity_completion() - self.result_reporter() - - def export_csv(self, fname:Path|None=None): - from .main_window_functions import export_csv_function - export_csv_function(self, fname) - -class AddSubForm(QWidget): - - def __init__(self, parent:QWidget): - logger.debug(f"Initializating subform...") - super(QWidget, self).__init__(parent) - self.layout = QVBoxLayout(self) - self.parent = parent - # Initialize tab screen - self.tabs = QTabWidget() - self.tab1 = QWidget() - self.tab2 = QWidget() - self.tab3 = QWidget() - self.tabs.resize(300,200) - - # Add tabs - self.tabs.addTab(self.tab1,"Submissions") - self.tabs.addTab(self.tab2,"Controls") - self.tabs.addTab(self.tab3, "Add Kit") - # Create submission adder form - self.formwidget = SubmissionFormContainer(self) - self.formlayout = QVBoxLayout(self) - self.formwidget.setLayout(self.formlayout) - self.formwidget.setFixedWidth(300) - # Make scrollable interior for form - self.interior = QScrollArea(self.tab1) - self.interior.setWidgetResizable(True) - self.interior.setFixedWidth(325) - self.interior.setWidget(self.formwidget) - # Create sheet to hold existing submissions - self.sheetwidget = QWidget(self) - self.sheetlayout = QVBoxLayout(self) - self.sheetwidget.setLayout(self.sheetlayout) - self.sub_wid = SubmissionsSheet(parent.ctx) - self.sheetlayout.addWidget(self.sub_wid) - # Create layout of first tab to hold form and sheet - self.tab1.layout = QHBoxLayout(self) - self.tab1.setLayout(self.tab1.layout) - self.tab1.layout.addWidget(self.interior) - self.tab1.layout.addWidget(self.sheetwidget) - # create widgets for tab 2 - self.datepicker = ControlsDatePicker() - self.webengineview = QWebEngineView() - # set tab2 layout - self.tab2.layout = QVBoxLayout(self) - self.control_typer = QComboBox() - # fetch types of controls - # con_types = get_all_Control_Types_names(ctx=parent.ctx) - # con_types = [item.name for item in lookup_control_types(ctx=parent.ctx)] - con_types = [item.name for item in ControlType.query()] - self.control_typer.addItems(con_types) - # create custom widget to get types of analysis - self.mode_typer = QComboBox() - # mode_types = get_all_available_modes(ctx=parent.ctx) - # mode_types = lookup_modes(ctx=parent.ctx) - mode_types = Control.get_modes() - self.mode_typer.addItems(mode_types) - # create custom widget to get subtypes of analysis - self.sub_typer = QComboBox() - self.sub_typer.setEnabled(False) - # add widgets to tab2 layout - self.tab2.layout.addWidget(self.datepicker) - self.tab2.layout.addWidget(self.control_typer) - self.tab2.layout.addWidget(self.mode_typer) - self.tab2.layout.addWidget(self.sub_typer) - self.tab2.layout.addWidget(self.webengineview) - self.tab2.setLayout(self.tab2.layout) - # create custom widget to add new tabs - adder = KitAdder(parent_ctx=parent.ctx) - self.tab3.layout = QVBoxLayout(self) - self.tab3.layout.addWidget(adder) - self.tab3.setLayout(self.tab3.layout) - # add tabs to main widget - self.layout.addWidget(self.tabs) - self.setLayout(self.layout) - -class SubmissionFormContainer(QWidget): - - import_drag = pyqtSignal(Path) - - def __init__(self, parent: QWidget) -> None: - logger.debug(f"Setting form widget...") - super().__init__(parent) - # self.parent = parent - - self.setAcceptDrops(True) - - def dragEnterEvent(self, event): - if event.mimeData().hasUrls(): - event.accept() - else: - event.ignore() - - def dropEvent(self, event): - fname = Path([u.toLocalFile() for u in event.mimeData().urls()][0]) - app = self.parent().parent().parent().parent().parent().parent().parent - logger.debug(f"App: {app}") - app.last_dir = fname.parent - self.import_drag.emit(fname) - +from .widgets import * +from .visualizations import * \ No newline at end of file diff --git a/src/submissions/frontend/custom_widgets/__init__.py b/src/submissions/frontend/custom_widgets/__init__.py deleted file mode 100644 index f7f4021..0000000 --- a/src/submissions/frontend/custom_widgets/__init__.py +++ /dev/null @@ -1,7 +0,0 @@ -''' -Contains all custom generated PyQT6 derivative widgets. -''' - -from .misc import * -from .pop_ups import * -from .sub_details import * \ No newline at end of file diff --git a/src/submissions/frontend/custom_widgets/misc.py b/src/submissions/frontend/custom_widgets/misc.py deleted file mode 100644 index 425b407..0000000 --- a/src/submissions/frontend/custom_widgets/misc.py +++ /dev/null @@ -1,767 +0,0 @@ -''' -Contains miscellaneous widgets for frontend functions -''' -from datetime import date -from pprint import pformat -from PyQt6 import QtCore -from PyQt6.QtWidgets import ( - QLabel, QVBoxLayout, - QLineEdit, QComboBox, QDialog, - QDialogButtonBox, QDateEdit, QSizePolicy, QWidget, - QGridLayout, QPushButton, QSpinBox, QDoubleSpinBox, - QHBoxLayout, QScrollArea, QFormLayout -) -from PyQt6.QtCore import Qt, QDate, QSize, pyqtSignal -from tools import check_not_nan, jinja_template_loading, Settings -from backend.db.models import * -from sqlalchemy import FLOAT, INTEGER -import logging -import numpy as np -from .pop_ups import AlertPop, QuestionAsker -from backend.validators import PydReagent, PydKit, PydReagentType, PydSubmission -from typing import Tuple, List -from pprint import pformat -import difflib - - -logger = logging.getLogger(f"submissions.{__name__}") - -env = jinja_template_loading() - -class AddReagentForm(QDialog): - """ - dialog to add gather info about new reagent - """ - def __init__(self, ctx:dict, reagent_lot:str|None=None, reagent_type:str|None=None, expiry:date|None=None, reagent_name:str|None=None) -> None: - super().__init__() - self.ctx = ctx - if reagent_lot == None: - reagent_lot = reagent_type - - self.setWindowTitle("Add Reagent") - - QBtn = QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel - - self.buttonBox = QDialogButtonBox(QBtn) - self.buttonBox.accepted.connect(self.accept) - self.buttonBox.rejected.connect(self.reject) - # widget to get lot info - self.name_input = QComboBox() - self.name_input.setObjectName("name") - self.name_input.setEditable(True) - self.name_input.setCurrentText(reagent_name) - # self.name_input.setText(reagent_name) - self.lot_input = QLineEdit() - self.lot_input.setObjectName("lot") - self.lot_input.setText(reagent_lot) - # widget to get expiry info - self.exp_input = QDateEdit(calendarPopup=True) - self.exp_input.setObjectName('expiry') - # if expiry is not passed in from gui, use today - if expiry == None: - self.exp_input.setDate(QDate.currentDate()) - else: - self.exp_input.setDate(expiry) - # widget to get reagent type info - self.type_input = QComboBox() - self.type_input.setObjectName('type') - # self.type_input.addItems([item.name for item in lookup_reagent_types(ctx=ctx)]) - self.type_input.addItems([item.name for item in ReagentType.query()]) - logger.debug(f"Trying to find index of {reagent_type}") - # convert input to user friendly string? - try: - reagent_type = reagent_type.replace("_", " ").title() - except AttributeError: - reagent_type = None - # set parsed reagent type to top of list - index = self.type_input.findText(reagent_type, Qt.MatchFlag.MatchEndsWith) - if index >= 0: - self.type_input.setCurrentIndex(index) - self.layout = QVBoxLayout() - self.layout.addWidget(QLabel("Name:")) - self.layout.addWidget(self.name_input) - self.layout.addWidget(QLabel("Lot:")) - self.layout.addWidget(self.lot_input) - self.layout.addWidget(QLabel("Expiry:\n(use exact date on reagent.\nEOL will be calculated from kit automatically)")) - self.layout.addWidget(self.exp_input) - self.layout.addWidget(QLabel("Type:")) - self.layout.addWidget(self.type_input) - self.layout.addWidget(self.buttonBox) - 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 - """ - logger.debug(self.type_input.currentText()) - self.name_input.clear() - # lookup = lookup_reagents(ctx=self.ctx, reagent_type=self.type_input.currentText()) - lookup = Reagent.query(reagent_type=self.type_input.currentText()) - self.name_input.addItems(list(set([item.name for item in lookup]))) - -class ReportDatePicker(QDialog): - """ - custom dialog to ask for report start/stop dates - """ - def __init__(self) -> None: - super().__init__() - - self.setWindowTitle("Select Report Date Range") - # make confirm/reject buttons - QBtn = QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel - self.buttonBox = QDialogButtonBox(QBtn) - self.buttonBox.accepted.connect(self.accept) - self.buttonBox.rejected.connect(self.reject) - # widgets to ask for dates - self.start_date = QDateEdit(calendarPopup=True) - self.start_date.setObjectName("start_date") - self.start_date.setDate(QDate.currentDate()) - self.end_date = QDateEdit(calendarPopup=True) - self.end_date.setObjectName("end_date") - self.end_date.setDate(QDate.currentDate()) - self.layout = QVBoxLayout() - self.layout.addWidget(QLabel("Start Date")) - self.layout.addWidget(self.start_date) - self.layout.addWidget(QLabel("End Date")) - self.layout.addWidget(self.end_date) - 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 - """ - 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) - scrollContent.setLayout(self.grid) - # insert submit button at top - self.submit_btn = QPushButton("Submit") - self.grid.addWidget(self.submit_btn,0,0,1,1) - self.grid.addWidget(QLabel("Kit Name:"),2,0) - # widget to get kit name - kit_name = QLineEdit() - kit_name.setObjectName("kit_name") - self.grid.addWidget(kit_name,2,1) - 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([item.name for item in lookup_submission_type(ctx=parent_ctx)]) - used_for.addItems([item.name for item in SubmissionType.query()]) - used_for.setEditable(True) - self.grid.addWidget(used_for,3,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.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: - """ - insert new reagent type row - """ - # get bottommost row - maxrow = self.grid.rowCount() - 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,0,1,4) - - - - def submit(self) -> None: - """ - send kit to database - """ - # get form info - 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 - logger.debug(pformat(info)) - # send to kit constructor - kit = PydKit(name=info['kit_name']) - for reagent in info['reagent_types']: - uses = { - info['used_for']: - {'sheet':reagent['sheet'], - 'name':reagent['name'], - 'lot':reagent['lot'], - 'expiry':reagent['expiry'] - }} - kit.reagent_types.append(PydReagentType(name=reagent['rtname'], eol_ext=reagent['eol'], uses=uses)) - logger.debug(f"Output pyd object: {kit.__dict__}") - # result = construct_kit_from_yaml(ctx=self.ctx, kit_dict=info) - sqlobj, result = kit.toSQL(self.ctx) - sqlobj.save() - 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:Settings) -> None: - super().__init__() - grid = QGridLayout() - self.setLayout(grid) - grid.addWidget(QLabel("Reagent Type Name"),0,0) - # Widget to get reagent info - self.reagent_getter = QComboBox() - self.reagent_getter.setObjectName("rtname") - # lookup all reagent type names from db - # lookup = lookup_reagent_types(ctx=ctx) - lookup = ReagentType.query() - logger.debug(f"Looked up ReagentType names: {lookup}") - self.reagent_getter.addItems([item.__str__() for item in lookup]) - self.reagent_getter.setEditable(True) - grid.addWidget(self.reagent_getter,0,1) - grid.addWidget(QLabel("Extension of Life (months):"),0,2) - # widget to get extension of life - 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): - """ - custom widget to pick start and end dates for controls graphs - """ - def __init__(self) -> None: - super().__init__() - - self.start_date = QDateEdit(calendarPopup=True) - # start date is two months prior to end date by default - twomonthsago = QDate.currentDate().addDays(-60) - self.start_date.setDate(twomonthsago) - self.end_date = QDateEdit(calendarPopup=True) - self.end_date.setDate(QDate.currentDate()) - self.layout = QHBoxLayout() - self.layout.addWidget(QLabel("Start Date")) - self.layout.addWidget(self.start_date) - self.layout.addWidget(QLabel("End Date")) - self.layout.addWidget(self.end_date) - self.setLayout(self.layout) - self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) - - def sizeHint(self) -> QSize: - return QSize(80,20) - -class FirstStrandSalvage(QDialog): - - def __init__(self, ctx:Settings, submitter_id:str, rsl_plate_num:str|None=None) -> None: - super().__init__() - if rsl_plate_num == None: - rsl_plate_num = "" - self.setWindowTitle("Add Reagent") - - QBtn = QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel - - self.buttonBox = QDialogButtonBox(QBtn) - self.buttonBox.accepted.connect(self.accept) - self.buttonBox.rejected.connect(self.reject) - self.submitter_id_input = QLineEdit() - self.submitter_id_input.setText(submitter_id) - self.rsl_plate_num = QLineEdit() - self.rsl_plate_num.setText(rsl_plate_num) - self.row_letter = QComboBox() - self.row_letter.addItems(['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H']) - self.row_letter.setEditable(False) - self.column_number = QComboBox() - self.column_number.addItems(['1', '2', '3', '4', '5', '6', '7', '8', '9', '10', '11', '12']) - self.column_number.setEditable(False) - self.layout = QFormLayout() - self.layout.addRow(self.tr("&Sample Number:"), self.submitter_id_input) - self.layout.addRow(self.tr("&Plate Number:"), self.rsl_plate_num) - self.layout.addRow(self.tr("&Source Row:"), self.row_letter) - self.layout.addRow(self.tr("&Source Column:"), self.column_number) - self.layout.addWidget(self.buttonBox) - self.setLayout(self.layout) - - def parse_form(self): - return dict(plate=self.rsl_plate_num.text(), submitter_id=self.submitter_id_input.text(), well=f"{self.row_letter.currentText()}{self.column_number.currentText()}") - -class FirstStrandPlateList(QDialog): - - def __init__(self, ctx:Settings) -> None: - super().__init__() - self.setWindowTitle("First Strand Plates") - - QBtn = QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel - - self.buttonBox = QDialogButtonBox(QBtn) - self.buttonBox.accepted.connect(self.accept) - self.buttonBox.rejected.connect(self.reject) - # ww = [item.rsl_plate_num for item in lookup_submissions(ctx=ctx, submission_type="Wastewater")] - ww = [item.rsl_plate_num for item in BasicSubmission.query(submission_type="Wastewater")] - self.plate1 = QComboBox() - self.plate2 = QComboBox() - self.plate3 = QComboBox() - self.layout = QFormLayout() - for ii, plate in enumerate([self.plate1, self.plate2, self.plate3]): - plate.addItems(ww) - self.layout.addRow(self.tr(f"&Plate {ii+1}:"), plate) - self.layout.addWidget(self.buttonBox) - self.setLayout(self.layout) - - def parse_form(self): - output = [] - for plate in [self.plate1, self.plate2, self.plate3]: - output.append(plate.currentText()) - return output - -class ReagentFormWidget(QWidget): - - def __init__(self, parent:QWidget, reagent:PydReagent, extraction_kit:str): - super().__init__(parent) - # self.setParent(parent) - self.reagent = reagent - self.extraction_kit = extraction_kit - # self.ctx = reagent.ctx - layout = QVBoxLayout() - self.label = self.ReagentParsedLabel(reagent=reagent) - layout.addWidget(self.label) - self.lot = self.ReagentLot(reagent=reagent, extraction_kit=extraction_kit) - layout.addWidget(self.lot) - # Remove spacing between reagents - layout.setContentsMargins(0,0,0,0) - self.setLayout(layout) - self.setObjectName(reagent.name) - self.missing = reagent.missing - # If changed set self.missing to True and update self.label - self.lot.currentTextChanged.connect(self.updated) - - def parse_form(self) -> Tuple[PydReagent, dict]: - lot = self.lot.currentText() - # wanted_reagent = lookup_reagents(ctx=self.ctx, lot_number=lot, reagent_type=self.reagent.type) - wanted_reagent = Reagent.query(lot_number=lot, reagent_type=self.reagent.type) - # if reagent doesn't exist in database, off to add it (uses App.add_reagent) - if wanted_reagent == None: - dlg = QuestionAsker(title=f"Add {lot}?", message=f"Couldn't find reagent type {self.reagent.type}: {lot} in the database.\n\nWould you like to add it?") - if dlg.exec(): - wanted_reagent = self.parent().parent().parent().parent().parent().parent().parent().parent().parent.add_reagent(reagent_lot=lot, reagent_type=self.reagent.type, expiry=self.reagent.expiry, name=self.reagent.name) - return wanted_reagent, None - else: - # In this case we will have an empty reagent and the submission will fail kit integrity check - logger.debug("Will not add reagent.") - return None, dict(message="Failed integrity check", status="critical") - else: - # Since this now gets passed in directly from the parser -> pyd -> form and the parser gets the name - # from the db, it should no longer be necessary to query the db with reagent/kit, but with rt name directly. - # rt = lookup_reagent_types(ctx=self.ctx, name=self.reagent.type) - # rt = lookup_reagent_types(ctx=self.ctx, kit_type=self.extraction_kit, reagent=wanted_reagent) - rt = ReagentType.query(name=self.reagent.type) - if rt == None: - # rt = lookup_reagent_types(ctx=self.ctx, kit_type=self.extraction_kit, reagent=wanted_reagent) - rt = ReagentType.query(kit_type=self.extraction_kit, reagent=wanted_reagent) - return PydReagent(name=wanted_reagent.name, lot=wanted_reagent.lot, type=rt.name, expiry=wanted_reagent.expiry, parsed=not self.missing), None - - def updated(self): - self.missing = True - self.label.updated(self.reagent.type) - - - class ReagentParsedLabel(QLabel): - - def __init__(self, reagent:PydReagent): - super().__init__() - try: - check = not reagent.missing - except: - check = False - self.setObjectName(f"{reagent.type}_label") - if check: - self.setText(f"Parsed {reagent.type}") - else: - self.setText(f"MISSING {reagent.type}") - - def updated(self, reagent_type:str): - self.setText(f"UPDATED {reagent_type}") - - class ReagentLot(QComboBox): - - def __init__(self, reagent, extraction_kit:str) -> None: - super().__init__() - # self.ctx = reagent.ctx - self.setEditable(True) - # if reagent.parsed: - # pass - logger.debug(f"Attempting lookup of reagents by type: {reagent.type}") - # below was lookup_reagent_by_type_name_and_kit_name, but I couldn't get it to work. - # lookup = lookup_reagents(ctx=self.ctx, reagent_type=reagent.type) - lookup = Reagent.query(reagent_type=reagent.type) - relevant_reagents = [item.__str__() for item in lookup] - output_reg = [] - for rel_reagent in relevant_reagents: - # extract strings from any sets. - if isinstance(rel_reagent, set): - for thing in rel_reagent: - output_reg.append(thing) - elif isinstance(rel_reagent, str): - output_reg.append(rel_reagent) - relevant_reagents = output_reg - # if reagent in sheet is not found insert it into the front of relevant reagents so it shows - logger.debug(f"Relevant reagents for {reagent.lot}: {relevant_reagents}") - if str(reagent.lot) not in relevant_reagents: - if check_not_nan(reagent.lot): - relevant_reagents.insert(0, str(reagent.lot)) - else: - # TODO: look up the last used reagent of this type in the database - # looked_up_rt = lookup_reagenttype_kittype_association(ctx=self.ctx, reagent_type=reagent.type, kit_type=extraction_kit) - looked_up_rt = KitTypeReagentTypeAssociation.query(reagent_type=reagent.type, kit_type=extraction_kit) - try: - # looked_up_reg = lookup_reagents(ctx=self.ctx, lot_number=looked_up_rt.last_used) - looked_up_reg = Reagent.query(lot_number=looked_up_rt.last_used) - except AttributeError: - looked_up_reg = None - logger.debug(f"Because there was no reagent listed for {reagent.lot}, we will insert the last lot used: {looked_up_reg}") - if looked_up_reg != None: - relevant_reagents.remove(str(looked_up_reg.lot)) - relevant_reagents.insert(0, str(looked_up_reg.lot)) - else: - if len(relevant_reagents) > 1: - logger.debug(f"Found {reagent.lot} in relevant reagents: {relevant_reagents}. Moving to front of list.") - idx = relevant_reagents.index(str(reagent.lot)) - logger.debug(f"The index we got for {reagent.lot} in {relevant_reagents} was {idx}") - moved_reag = relevant_reagents.pop(idx) - relevant_reagents.insert(0, moved_reag) - else: - logger.debug(f"Found {reagent.lot} in relevant reagents: {relevant_reagents}. But no need to move due to short list.") - logger.debug(f"New relevant reagents: {relevant_reagents}") - self.setObjectName(f"lot_{reagent.type}") - self.addItems(relevant_reagents) - -class SubmissionFormWidget(QWidget): - - def __init__(self, parent: QWidget, **kwargs) -> None: - super().__init__(parent) - # self.ignore = [None, "", "qt_spinbox_lineedit", "qt_scrollarea_viewport", "qt_scrollarea_hcontainer", - # "qt_scrollarea_vcontainer", "submit_btn" - # ] - self.ignore = ['filepath', 'samples', 'reagents', 'csv', 'ctx'] - layout = QVBoxLayout() - for k, v in kwargs.items(): - if k not in self.ignore: - add_widget = self.create_widget(key=k, value=v, submission_type=kwargs['submission_type']) - if add_widget != None: - layout.addWidget(add_widget) - else: - setattr(self, k, v) - - self.setLayout(layout) - - def create_widget(self, key:str, value:dict, submission_type:str|None=None): - if key not in self.ignore: - return self.InfoItem(self, key=key, value=value, submission_type=submission_type) - return None - - def clear_form(self): - for item in self.findChildren(QWidget): - item.setParent(None) - - def find_widgets(self, object_name:str|None=None) -> List[QWidget]: - query = self.findChildren(QWidget) - if object_name != None: - query = [widget for widget in query if widget.objectName()==object_name] - return query - - def parse_form(self) -> PydSubmission: - logger.debug(f"Hello from form parser!") - info = {} - reagents = [] - if hasattr(self, 'csv'): - info['csv'] = self.csv - # samples = self.parent().parent.parent.samples - # filepath = self.parent().parent.parent.pyd.filepath - # logger.debug(f"Using samples: {pformat(samples)}") - # widgets = [widget for widget in self.findChildren(QWidget) if widget.objectName() not in self.ignore] - # widgets = [widget for widget in self.findChildren(QWidget)] - # for widget in widgets: - for widget in self.findChildren(QWidget): - logger.debug(f"Parsed widget of type {type(widget)}") - match widget: - case ReagentFormWidget(): - reagent, _ = widget.parse_form() - reagents.append(reagent) - case self.InfoItem(): - field, value = widget.parse_form() - if field != None: - info[field] = value - # case ImportReagent(): - # reagent = dict(name=widget.objectName().replace("lot_", ""), lot=widget.currentText(), type=None, expiry=None) - # # ctx: self.SubmissionContinerWidget.AddSubForm - # reagents.append(PydReagent(ctx=self.parent.parent.ctx, **reagent)) - # case QLineEdit(): - # info[widget.objectName()] = dict(value=widget.text()) - # case QComboBox(): - # info[widget.objectName()] = dict(value=widget.currentText()) - # case QDateEdit(): - # info[widget.objectName()] = dict(value=widget.date().toPyDate()) - logger.debug(f"Info: {pformat(info)}") - logger.debug(f"Reagents: {pformat(reagents)}") - app = self.parent().parent().parent().parent().parent().parent().parent().parent - submission = PydSubmission(ctx=app.ctx, filepath=self.filepath, reagents=reagents, samples=self.samples, **info) - return submission - - class InfoItem(QWidget): - - def __init__(self, parent: QWidget, key:str, value:dict, submission_type:str|None=None) -> None: - super().__init__(parent) - layout = QVBoxLayout() - self.label = self.ParsedQLabel(key=key, value=value) - self.input: QWidget = self.set_widget(parent=self, key=key, value=value, submission_type=submission_type['value']) - self.setObjectName(key) - try: - self.missing:bool = value['missing'] - except (TypeError, KeyError): - self.missing:bool = False - if self.input != None: - layout.addWidget(self.label) - layout.addWidget(self.input) - layout.setContentsMargins(0,0,0,0) - self.setLayout(layout) - match self.input: - case QComboBox(): - self.input.currentTextChanged.connect(self.update_missing) - case QDateEdit(): - self.input.dateChanged.connect(self.update_missing) - case QLineEdit(): - self.input.textChanged.connect(self.update_missing) - - def parse_form(self): - match self.input: - case QLineEdit(): - value = self.input.text() - case QComboBox(): - value = self.input.currentText() - case QDateEdit(): - value = self.input.date().toPyDate() - case _: - return None, None - return self.input.objectName(), dict(value=value, missing=self.missing) - - def set_widget(self, parent: QWidget, key:str, value:dict, submission_type:str|None=None) -> QWidget: - try: - value = value['value'] - except (TypeError, KeyError): - pass - obj = parent.parent().parent() - logger.debug(f"Creating widget for: {key}") - match key: - case 'submitting_lab': - add_widget = QComboBox() - # lookup organizations suitable for submitting_lab (ctx: self.InfoItem.SubmissionFormWidget.SubmissionFormContainer.AddSubForm ) - # labs = [item.__str__() for item in lookup_organizations(ctx=obj.ctx)] - labs = [item.__str__() for item in Organization.query()] - # try to set closest match to top of list - try: - labs = difflib.get_close_matches(value, labs, len(labs), 0) - except (TypeError, ValueError): - pass - # set combobox values to lookedup values - add_widget.addItems(labs) - case 'extraction_kit': - # if extraction kit not available, all other values fail - if not check_not_nan(value): - msg = AlertPop(message="Make sure to check your extraction kit in the excel sheet!", status="warning") - msg.exec() - # create combobox to hold looked up kits - add_widget = QComboBox() - # lookup existing kits by 'submission_type' decided on by sheetparser - logger.debug(f"Looking up kits used for {submission_type}") - # uses = [item.__str__() for item in lookup_kit_types(ctx=obj.ctx, used_for=submission_type)] - uses = [item.__str__() for item in KitType.query(used_for=submission_type)] - obj.uses = uses - logger.debug(f"Kits received for {submission_type}: {uses}") - if check_not_nan(value): - logger.debug(f"The extraction kit in parser was: {value}") - uses.insert(0, uses.pop(uses.index(value))) - obj.ext_kit = value - else: - logger.error(f"Couldn't find {obj.prsr.sub['extraction_kit']}") - obj.ext_kit = uses[0] - add_widget.addItems(uses) - - # Run reagent scraper whenever extraction kit is changed. - # add_widget.currentTextChanged.connect(obj.scrape_reagents) - case 'submitted_date': - # uses base calendar - add_widget = QDateEdit(calendarPopup=True) - # sets submitted date based on date found in excel sheet - try: - add_widget.setDate(value) - # if not found, use today - except: - add_widget.setDate(date.today()) - case 'submission_category': - add_widget = QComboBox() - cats = ['Diagnostic', "Surveillance", "Research"] - # cats += [item.name for item in lookup_submission_type(ctx=obj.ctx)] - cats += [item.name for item in SubmissionType.query()] - try: - cats.insert(0, cats.pop(cats.index(value))) - except ValueError: - cats.insert(0, cats.pop(cats.index(submission_type))) - add_widget.addItems(cats) - case _: - # anything else gets added in as a line edit - add_widget = QLineEdit() - logger.debug(f"Setting widget text to {str(value).replace('_', ' ')}") - add_widget.setText(str(value).replace("_", " ")) - if add_widget != None: - add_widget.setObjectName(key) - add_widget.setParent(parent) - - return add_widget - - def update_missing(self): - self.missing = True - self.label.updated(self.objectName()) - - class ParsedQLabel(QLabel): - - def __init__(self, key:str, value:dict, title:bool=True, label_name:str|None=None): - super().__init__() - try: - check = not value['missing'] - except: - check = True - if label_name != None: - self.setObjectName(label_name) - else: - self.setObjectName(f"{key}_label") - if title: - output = key.replace('_', ' ').title() - else: - output = key.replace('_', ' ') - if check: - self.setText(f"Parsed {output}") - else: - self.setText(f"MISSING {output}") - - def updated(self, key:str, title:bool=True): - if title: - output = key.replace('_', ' ').title() - else: - output = key.replace('_', ' ') - self.setText(f"UPDATED {output}") - diff --git a/src/submissions/frontend/functions/__init__.py b/src/submissions/frontend/functions/__init__.py deleted file mode 100644 index 5c2d395..0000000 --- a/src/submissions/frontend/functions/__init__.py +++ /dev/null @@ -1,105 +0,0 @@ -''' -functions used by all windows in the application's frontend -''' -from pathlib import Path -import logging -from PyQt6.QtWidgets import QMainWindow, QFileDialog - -logger = logging.getLogger(f"submissions.{__name__}") - -def select_open_file(obj:QMainWindow, file_extension:str) -> Path: - """ - File dialog to select a file to read from - - Args: - obj (QMainWindow): Original main app window to be parent - file_extension (str): file extension - - Returns: - Path: Path of file to be opened - """ - try: - # home_dir = Path(obj.ctx.directory_path).resolve().__str__() - home_dir = obj.last_dir.resolve().__str__() - except FileNotFoundError: - home_dir = Path.home().resolve().__str__() - fname = Path(QFileDialog.getOpenFileName(obj, 'Open file', home_dir, filter = f"{file_extension}(*.{file_extension})")[0]) - # fname = Path(QFileDialog.getOpenFileName(obj, 'Open file', filter = f"{file_extension}(*.{file_extension})")[0]) - obj.last_file = fname - return fname - -def select_save_file(obj:QMainWindow, default_name:str, extension:str) -> Path: - """ - File dialog to select a file to write to - - Args: - obj (QMainWindow): Original main app window to be parent - default_name (str): default base file name - extension (str): file extension - - Returns: - Path: Path of file to be opened - """ - try: - # home_dir = Path(obj.ctx.directory_path).joinpath(default_name).resolve().__str__() - home_dir = obj.last_dir.joinpath(default_name).resolve().__str__() - except FileNotFoundError: - home_dir = Path.home().joinpath(default_name).resolve().__str__() - fname = Path(QFileDialog.getSaveFileName(obj, "Save File", home_dir, filter = f"{extension}(*.{extension})")[0]) - # fname = Path(QFileDialog.getSaveFileName(obj, "Save File", filter = f"{extension}(*.{extension})")[0]) - obj.last_dir = fname.parent - return fname - -# 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 - -# Returns: -# dict: dictionary of objectName:text items -# """ - -# from frontend.custom_widgets import ReagentTypeForm -# dicto = {} -# reagents = [] -# logger.debug(f"Object type: {type(object)}") -# # grab all widgets in form -# try: -# all_children = object.layout.parentWidget().findChildren(QWidget) -# except AttributeError: -# all_children = object.layout().parentWidget().findChildren(QWidget) -# for item in all_children: -# logger.debug(f"Looking at: {item.objectName()}: {type(item)}") -# match item: -# case QLineEdit(): -# dicto[item.objectName()] = item.text() -# case QComboBox(): -# dicto[item.objectName()] = item.currentText() -# case QDateEdit(): -# dicto[item.objectName()] = item.date().toPyDate() -# case QSpinBox() | QDoubleSpinBox(): -# dicto[item.objectName()] = item.value() -# case ReagentTypeForm(): -# reagent = extract_form_info(item) -# logger.debug(f"Reagent found: {reagent}") -# 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 - - -from .main_window_functions import * -from .submission_functions import * \ No newline at end of file diff --git a/src/submissions/frontend/functions/all_window_functions.py b/src/submissions/frontend/functions/all_window_functions.py deleted file mode 100644 index 6d32b5c..0000000 --- a/src/submissions/frontend/functions/all_window_functions.py +++ /dev/null @@ -1,102 +0,0 @@ -''' -functions used by all windows in the application's frontend -NOTE: Depreciated. Moved to functions.__init__ -''' -from pathlib import Path -import logging -from PyQt6.QtWidgets import QMainWindow, QFileDialog - -logger = logging.getLogger(f"submissions.{__name__}") - -def select_open_file(obj:QMainWindow, file_extension:str) -> Path: - """ - File dialog to select a file to read from - - Args: - obj (QMainWindow): Original main app window to be parent - file_extension (str): file extension - - Returns: - Path: Path of file to be opened - """ - try: - # home_dir = Path(obj.ctx.directory_path).resolve().__str__() - home_dir = obj.last_dir.resolve().__str__() - except FileNotFoundError: - home_dir = Path.home().resolve().__str__() - fname = Path(QFileDialog.getOpenFileName(obj, 'Open file', home_dir, filter = f"{file_extension}(*.{file_extension})")[0]) - # fname = Path(QFileDialog.getOpenFileName(obj, 'Open file', filter = f"{file_extension}(*.{file_extension})")[0]) - obj.last_file = fname - return fname - -def select_save_file(obj:QMainWindow, default_name:str, extension:str) -> Path: - """ - File dialog to select a file to write to - - Args: - obj (QMainWindow): Original main app window to be parent - default_name (str): default base file name - extension (str): file extension - - Returns: - Path: Path of file to be opened - """ - try: - # home_dir = Path(obj.ctx.directory_path).joinpath(default_name).resolve().__str__() - home_dir = obj.last_dir.joinpath(default_name).resolve().__str__() - except FileNotFoundError: - home_dir = Path.home().joinpath(default_name).resolve().__str__() - fname = Path(QFileDialog.getSaveFileName(obj, "Save File", home_dir, filter = f"{extension}(*.{extension})")[0]) - # fname = Path(QFileDialog.getSaveFileName(obj, "Save File", filter = f"{extension}(*.{extension})")[0]) - obj.last_dir = fname.parent - return fname - -# 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 - -# Returns: -# dict: dictionary of objectName:text items -# """ - -# from frontend.custom_widgets import ReagentTypeForm -# dicto = {} -# reagents = [] -# logger.debug(f"Object type: {type(object)}") -# # grab all widgets in form -# try: -# all_children = object.layout.parentWidget().findChildren(QWidget) -# except AttributeError: -# all_children = object.layout().parentWidget().findChildren(QWidget) -# for item in all_children: -# logger.debug(f"Looking at: {item.objectName()}: {type(item)}") -# match item: -# case QLineEdit(): -# dicto[item.objectName()] = item.text() -# case QComboBox(): -# dicto[item.objectName()] = item.currentText() -# case QDateEdit(): -# dicto[item.objectName()] = item.date().toPyDate() -# case QSpinBox() | QDoubleSpinBox(): -# dicto[item.objectName()] = item.value() -# case ReagentTypeForm(): -# reagent = extract_form_info(item) -# logger.debug(f"Reagent found: {reagent}") -# 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/functions/main_window_functions.py b/src/submissions/frontend/functions/main_window_functions.py deleted file mode 100644 index 1ea6196..0000000 --- a/src/submissions/frontend/functions/main_window_functions.py +++ /dev/null @@ -1,935 +0,0 @@ -''' -contains operations used by multiple widgets. -''' -from datetime import date -import difflib -import inspect -from pprint import pformat -import yaml -import json -from typing import Tuple, List -from openpyxl import load_workbook -from openpyxl.utils import get_column_letter -from xhtml2pdf import pisa -import pandas as pd -from backend.db.models import * -import logging -from PyQt6.QtWidgets import QMainWindow, QPushButton -# from .all_window_functions import select_open_file, select_save_file -from . import select_open_file, select_save_file -from PyQt6.QtCore import QSignalBlocker -from backend.db.functions import ( - get_control_subtypes, update_subsampassoc_with_pcr, check_kit_integrity, update_last_used -) -from backend.excel.parser import SheetParser, PCRParser -from backend.excel.reports import make_report_html, make_report_xlsx, convert_data_list_to_df -from backend.validators import PydSubmission, PydKit -from tools import Report, Result -from frontend.custom_widgets.pop_ups import AlertPop, QuestionAsker -from frontend.custom_widgets import ReportDatePicker -from frontend.visualizations.control_charts import create_charts, construct_html -from pathlib import Path -from frontend.custom_widgets.misc import ReagentFormWidget - -logger = logging.getLogger(f"submissions.{__name__}") - -def import_submission_function(obj:QMainWindow, fname:Path|None=None) -> Tuple[QMainWindow, dict|None]: - """ - Import a new submission to the app window - - Args: - obj (QMainWindow): original app window - - Returns: - Tuple[QMainWindow, dict|None]: Collection of new main app window and result dict - """ - logger.debug(f"\n\nStarting Import...\n\n") - report = Report() - # logger.debug(obj.ctx) - # initialize samples - try: - obj.form.setParent(None) - except AttributeError: - pass - obj.samples = [] - obj.missing_info = [] - # set file dialog - if isinstance(fname, bool) or fname == None: - fname = select_open_file(obj, file_extension="xlsx") - logger.debug(f"Attempting to parse file: {fname}") - if not fname.exists(): - # result = dict(message=f"File {fname.__str__()} not found.", status="critical") - report.add_result(Result(msg=f"File {fname.__str__()} not found.", status="critical")) - obj.report.add_result(report) - return obj - # create sheetparser using excel sheet and context from gui - try: - obj.prsr = SheetParser(ctx=obj.ctx, filepath=fname) - except PermissionError: - logger.error(f"Couldn't get permission to access file: {fname}") - return obj - try: - logger.debug(f"Submission dictionary:\n{pformat(obj.prsr.sub)}") - obj.pyd = obj.prsr.to_pydantic() - logger.debug(f"Pydantic result: \n\n{pformat(obj.pyd)}\n\n") - except Exception as e: - report.add_result(Result(msg=f"Problem creating pydantic model:\n\n{e}", status="critical")) - obj.report.add_result(report) - return obj - obj.form = obj.pyd.toForm(parent=obj) - obj.table_widget.formlayout.addWidget(obj.form) - kit_widget = obj.form.find_widgets(object_name="extraction_kit")[0].input - logger.debug(f"Kitwidget {kit_widget}") - obj.scrape_reagents(kit_widget.currentText()) - kit_widget.currentTextChanged.connect(obj.scrape_reagents) - # compare obj.reagents with expected reagents in kit - if obj.prsr.sample_result != None: - msg = AlertPop(message=obj.prsr.sample_result, status="WARNING") - msg.exec() - obj.report.add_result(report) - logger.debug(f"Outgoing report: {obj.report.results}") - logger.debug(f"All attributes of obj:\n{pformat(obj.__dict__)}") - return obj - -def kit_reload_function(obj:QMainWindow) -> Tuple[QMainWindow, dict]: - """ - Reload the fields in the form - - Args: - obj (QMainWindow): original app window - - Returns: - Tuple[QMainWindow, dict]: Collection of new main app window and result dict - """ - report = Report() - # for item in obj.table_widget.formlayout.parentWidget().findChildren(QWidget): - logger.debug(f"Attempting to clear {obj.form.find_widgets()}") - for item in obj.form.find_widgets(): - if isinstance(item, ReagentFormWidget): - item.setParent(None) - obj = kit_integrity_completion_function(obj) - obj.report.add_result(report) - logger.debug(f"Outgoing report: {obj.report.results}") - return obj - -def kit_integrity_completion_function(obj:QMainWindow) -> Tuple[QMainWindow, dict]: - """ - Compare kit contents to parsed contents - - Args: - obj (QMainWindow): The original app window - - Returns: - Tuple[QMainWindow, dict]: Collection of new main app window and result dict - """ - report = Report() - missing_reagents = [] - logger.debug(inspect.currentframe().f_back.f_code.co_name) - # find the widget that contains kit info - kit_widget = obj.form.find_widgets(object_name="extraction_kit")[0].input - logger.debug(f"Kit selector: {kit_widget}") - # get current kit being used - obj.ext_kit = kit_widget.currentText() - # for reagent in obj.pyd.reagents: - for reagent in obj.form.reagents: - add_widget = ReagentFormWidget(parent=obj.table_widget.formwidget, reagent=reagent, extraction_kit=obj.ext_kit) - add_widget.setParent(obj.form) - obj.form.layout().addWidget(add_widget) - if reagent.missing: - missing_reagents.append(reagent) - logger.debug(f"Checking integrity of {obj.ext_kit}") - # TODO: put check_kit_integrity here instead of what's here? - # see if there are any missing reagents - if len(missing_reagents) > 0: - result = Result(msg=f"""The submission you are importing is missing some reagents expected by the kit.\n\n - It looks like you are missing: {[item.type.upper() for item in missing_reagents]}\n\n - Alternatively, 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!""".replace(" ", ""), status="Warning") - report.add_result(result) - if hasattr(obj.pyd, "csv"): - export_csv_btn = QPushButton("Export CSV") - export_csv_btn.setObjectName("export_csv_btn") - obj.form.layout().addWidget(export_csv_btn) - export_csv_btn.clicked.connect(obj.export_csv) - submit_btn = QPushButton("Submit") - submit_btn.setObjectName("submit_btn") - obj.form.layout().addWidget(submit_btn) - submit_btn.clicked.connect(obj.submit_new_sample) - obj.report.add_result(report) - logger.debug(f"Outgoing report: {obj.report.results}") - return obj - -def submit_new_sample_function(obj:QMainWindow) -> Tuple[QMainWindow, dict]: - """ - Parse forms and add sample to the database. - - Args: - obj (QMainWindow): original app window - - Returns: - Tuple[QMainWindow, dict]: Collection of new main app window and result dict - """ - logger.debug(f"\n\nBeginning Submission\n\n") - report = Report() - obj.pyd: PydSubmission = obj.form.parse_form() - logger.debug(f"Submission: {pformat(obj.pyd)}") - logger.debug("Checking kit integrity...") - result = check_kit_integrity(sub=obj.pyd) - report.add_result(result) - if len(result.results) > 0: - obj.report.add_result(report) - return obj - base_submission, result = obj.pyd.toSQL() - # check output message for issues - match result['code']: - # code 0: everything is fine. - case 0: - result = None - # code 1: ask for overwrite - case 1: - dlg = QuestionAsker(title=f"Review {base_submission.rsl_plate_num}?", message=result['message']) - if dlg.exec(): - # Do not add duplicate reagents. - # base_submission.reagents = [] - result = None - else: - obj.ctx.database_session.rollback() - return obj, dict(message="Overwrite cancelled", status="Information") - # code 2: No RSL plate number given - case 2: - return obj, dict(message=result['message'], status='critical') - case _: - pass - # add reagents to submission object - for reagent in base_submission.reagents: - update_last_used(reagent=reagent, kit=base_submission.extraction_kit) - logger.debug(f"Here is the final submission: {pformat(base_submission.__dict__)}") - logger.debug(f"Parsed reagents: {pformat(base_submission.reagents)}") - logger.debug(f"Sending submission: {base_submission.rsl_plate_num} to database.") - base_submission.save() - # update summary sheet - obj.table_widget.sub_wid.setData() - # reset form - obj.form.setParent(None) - logger.debug(f"All attributes of obj: {pformat(obj.__dict__)}") - wkb = obj.pyd.autofill_excel() - if wkb != None: - fname = select_save_file(obj=obj, default_name=obj.pyd.construct_filename(), extension="xlsx") - try: - wkb.save(filename=fname.__str__()) - except PermissionError: - logger.error("Hit a permission error when saving workbook. Cancelled?") - if hasattr(obj.pyd, 'csv'): - dlg = QuestionAsker("Export CSV?", "Would you like to export the csv file?") - if dlg.exec(): - fname = select_save_file(obj, f"{obj.pyd.construct_filename()}.csv", extension="csv") - try: - obj.pyd.csv.to_csv(fname.__str__(), index=False) - except PermissionError: - logger.debug(f"Could not get permissions to {fname}. Possibly the request was cancelled.") - return obj, result - -def generate_report_function(obj:QMainWindow) -> Tuple[QMainWindow, dict]: - """ - Generate a summary of activities for a time period - - Args: - obj (QMainWindow): original app window - - Returns: - Tuple[QMainWindow, dict]: Collection of new main app window and result dict - """ - # ask for date ranges - dlg = ReportDatePicker() - if dlg.exec(): - info = dlg.parse_form() - logger.debug(f"Report info: {info}") - # find submissions based on date range - subs = BasicSubmission.query(start_date=info['start_date'], end_date=info['end_date']) - # convert each object to dict - records = [item.report_dict() for item in subs] - # make dataframe from record dictionaries - detailed_df, summary_df = make_report_xlsx(records=records) - html = make_report_html(df=summary_df, start_date=info['start_date'], end_date=info['end_date']) - # get save location of report - fname = select_save_file(obj=obj, default_name=f"Submissions_Report_{info['start_date']}-{info['end_date']}.pdf", extension="pdf") - with open(fname, "w+b") as f: - pisa.CreatePDF(html, dest=f) - writer = pd.ExcelWriter(fname.with_suffix(".xlsx"), engine='openpyxl') - summary_df.to_excel(writer, sheet_name="Report") - detailed_df.to_excel(writer, sheet_name="Details", index=False) - worksheet = writer.sheets['Report'] - for idx, col in enumerate(summary_df): # loop through all columns - series = summary_df[col] - max_len = max(( - series.astype(str).map(len).max(), # len of largest item - len(str(series.name)) # len of column name/header - )) + 20 # adding a little extra space - try: - worksheet.column_dimensions[get_column_letter(idx)].width = max_len - except ValueError: - pass - for cell in worksheet['D']: - if cell.row > 1: - cell.style = 'Currency' - writer.close() - return obj, None - -def add_kit_function(obj:QMainWindow) -> Tuple[QMainWindow, dict]: - """ - Add a new kit to the database. - - Args: - obj (QMainWindow): original app window - - Returns: - Tuple[QMainWindow, dict]: Collection of new main app window and result dict - """ - result = None - # setup file dialog to find yaml file - fname = select_open_file(obj, file_extension="yml") - assert fname.exists() - # read yaml file - try: - with open(fname.__str__(), "r") as stream: - try: - exp = yaml.load(stream, Loader=yaml.Loader) - except yaml.YAMLError as exc: - logger.error(f'Error reading yaml file {fname}: {exc}') - return {} - except PermissionError: - return - # send to kit creator function - result = PydKit(**exp) - return obj, result - -def add_org_function(obj:QMainWindow) -> Tuple[QMainWindow, dict]: - """ - Add a new organization to the database. - - Args: - obj (QMainWindow): original app window - - Returns: - Tuple[QMainWindow, dict]: Collection of new main app window and result dict - """ - result = None - # setup file dialog to find yaml flie - fname = select_open_file(obj, extension="yml") - assert fname.exists() - # read yaml file - try: - with open(fname.__str__(), "r") as stream: - try: - org = yaml.load(stream, Loader=yaml.Loader) - except yaml.YAMLError as exc: - logger.error(f'Error reading yaml file {fname}: {exc}') - return obj, dict(message=f"There was a problem reading yaml file {fname.__str__()}", status="critical") - except PermissionError: - return obj, result - # send to kit creator function - result = construct_org_from_yaml(ctx=obj.ctx, org=org) - return obj, result - -def controls_getter_function(obj:QMainWindow) -> Tuple[QMainWindow, dict]: - """ - Get controls based on start/end dates - - Args: - obj (QMainWindow): original app window - - Returns: - Tuple[QMainWindow, dict]: Collection of new main app window and result dict - """ - report = Report() - # subtype defaults to disabled - try: - obj.table_widget.sub_typer.disconnect() - except TypeError: - pass - # correct start date being more recent than end date and rerun - if obj.table_widget.datepicker.start_date.date() > obj.table_widget.datepicker.end_date.date(): - logger.warning("Start date after end date is not allowed!") - threemonthsago = obj.table_widget.datepicker.end_date.date().addDays(-60) - # block signal that will rerun controls getter and set start date - # Without triggering this function again - with QSignalBlocker(obj.table_widget.datepicker.start_date) as blocker: - obj.table_widget.datepicker.start_date.setDate(threemonthsago) - obj._controls_getter() - obj.report.add_result(report) - return obj - # convert to python useable date objects - obj.start_date = obj.table_widget.datepicker.start_date.date().toPyDate() - obj.end_date = obj.table_widget.datepicker.end_date.date().toPyDate() - obj.con_type = obj.table_widget.control_typer.currentText() - obj.mode = obj.table_widget.mode_typer.currentText() - obj.table_widget.sub_typer.clear() - # lookup subtypes - sub_types = get_control_subtypes(type=obj.con_type, mode=obj.mode) - # sub_types = lookup_controls(ctx=obj.ctx, control_type=obj.con_type) - if sub_types != []: - # block signal that will rerun controls getter and update sub_typer - with QSignalBlocker(obj.table_widget.sub_typer) as blocker: - obj.table_widget.sub_typer.addItems(sub_types) - obj.table_widget.sub_typer.setEnabled(True) - obj.table_widget.sub_typer.currentTextChanged.connect(obj._chart_maker) - else: - obj.table_widget.sub_typer.clear() - obj.table_widget.sub_typer.setEnabled(False) - obj._chart_maker() - obj.report.add_result(report) - return obj - -def chart_maker_function(obj:QMainWindow) -> Tuple[QMainWindow, dict]: - """ - Create html chart for controls reporting - - Args: - obj (QMainWindow): original app window - - Returns: - Tuple[QMainWindow, dict]: Collection of new main app window and result dict - """ - report = Report() - logger.debug(f"Control getter context: \n\tControl type: {obj.con_type}\n\tMode: {obj.mode}\n\tStart Date: {obj.start_date}\n\tEnd Date: {obj.end_date}") - # set the subtype for kraken - if obj.table_widget.sub_typer.currentText() == "": - obj.subtype = None - else: - obj.subtype = obj.table_widget.sub_typer.currentText() - logger.debug(f"Subtype: {obj.subtype}") - # query all controls using the type/start and end dates from the gui - # controls = get_all_controls_by_type(ctx=obj.ctx, con_type=obj.con_type, start_date=obj.start_date, end_date=obj.end_date) - # controls = lookup_controls(ctx=obj.ctx, control_type=obj.con_type, start_date=obj.start_date, end_date=obj.end_date) - controls = Control.query(control_type=obj.con_type, start_date=obj.start_date, end_date=obj.end_date) - # if no data found from query set fig to none for reporting in webview - if controls == None: - fig = None - else: - # change each control to list of dictionaries - data = [control.convert_by_mode(mode=obj.mode) for control in controls] - # flatten data to one dimensional list - data = [item for sublist in data for item in sublist] - logger.debug(f"Control objects going into df conversion: {type(data)}") - if data == []: - return obj, dict(status="Critical", message="No data found for controls in given date range.") - # send to dataframe creator - df = convert_data_list_to_df(input=data, subtype=obj.subtype) - if obj.subtype == None: - title = obj.mode - else: - title = f"{obj.mode} - {obj.subtype}" - # send dataframe to chart maker - fig = create_charts(ctx=obj.ctx, df=df, ytitle=title) - logger.debug(f"Updating figure...") - # construct html for webview - html = construct_html(figure=fig) - logger.debug(f"The length of html code is: {len(html)}") - obj.table_widget.webengineview.setHtml(html) - obj.table_widget.webengineview.update() - logger.debug("Figure updated... I hope.") - obj.report.add_result(report) - return obj - -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 - - Returns: - Tuple[QMainWindow, dict]: Collection of new main app window and result dict - """ - result = None - # all_bcs = lookup_all_submissions_by_type(obj.ctx, "Bacterial Culture") - # all_bcs = lookup_submissions(ctx=obj.ctx, submission_type="Bacterial Culture") - all_bcs = BasicSubmission.query(submission_type="Bacterial Culture") - logger.debug(all_bcs) - # all_controls = get_all_controls(obj.ctx) - # all_controls = lookup_controls(ctx=obj.ctx) - all_controls = Control.query() - ac_list = [control.name for control in all_controls] - count = 0 - for bcs in all_bcs: - logger.debug(f"Running for {bcs.rsl_plate_num}") - logger.debug(f"Here is the current control: {[control.name for control in bcs.controls]}") - samples = [sample.submitter_id for sample in bcs.samples] - logger.debug(bcs.controls) - for sample in samples: - # replace below is a stopgap method because some dingus decided to add spaces in some of the ATCC49... so it looks like "ATCC 49"... - if " " in sample: - logger.warning(f"There is not supposed to be a space in the sample name!!!") - sample = sample.replace(" ", "") - if not any([ac.startswith(sample) for ac in ac_list]): - continue - else: - for control in all_controls: - diff = difflib.SequenceMatcher(a=sample, b=control.name).ratio() - if control.name.startswith(sample): - logger.debug(f"Checking {sample} against {control.name}... {diff}") - logger.debug(f"Found match:\n\tSample: {sample}\n\tControl: {control.name}\n\tDifference: {diff}") - if control in bcs.controls: - logger.debug(f"{control.name} already in {bcs.rsl_plate_num}, skipping") - continue - else: - logger.debug(f"Adding {control.name} to {bcs.rsl_plate_num} as control") - bcs.controls.append(control) - control.submission = bcs - control.submission_id = bcs.id - obj.ctx.database_session.add(control) - count += 1 - obj.ctx.database_session.add(bcs) - logger.debug(f"Here is the new control: {[control.name for control in bcs.controls]}") - result = dict(message=f"We added {count} controls to bacterial cultures.", status="information") - logger.debug(result) - obj.ctx.database_session.commit() - return obj, result - -def link_extractions_function(obj:QMainWindow) -> Tuple[QMainWindow, dict]: - """ - Link extractions from runlogs to imported submissions - - Args: - obj (QMainWindow): original app window - - Returns: - Tuple[QMainWindow, dict]: Collection of new main app window and result dict - """ - result = None - fname = select_open_file(obj, file_extension="csv") - with open(fname.__str__(), 'r') as f: - # split csv on commas - runs = [col.strip().split(",") for col in f.readlines()] - count = 0 - for run in runs: - new_run = dict( - start_time=run[0].strip(), - rsl_plate_num=run[1].strip(), - sample_count=run[2].strip(), - status=run[3].strip(), - experiment_name=run[4].strip(), - end_time=run[5].strip() - ) - # elution columns are item 6 in the comma split list to the end - for ii in range(6, len(run)): - new_run[f"column{str(ii-5)}_vol"] = run[ii] - # Lookup imported submissions - # sub = lookup_submission_by_rsl_num(ctx=obj.ctx, rsl_num=new_run['rsl_plate_num']) - # sub = lookup_submissions(ctx=obj.ctx, rsl_number=new_run['rsl_plate_num']) - sub = BasicSubmission.query(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 - except AttributeError: - continue - if sub.extraction_info != None: - existing = json.loads(sub.extraction_info) - else: - existing = None - # Check if the new info already exists in the imported submission - try: - if json.dumps(new_run) in sub.extraction_info: - logger.debug(f"Looks like we already have that info.") - continue - except TypeError: - pass - # Update or create the extraction info - if existing != None: - try: - logger.debug(f"Updating {type(existing)}: {existing} with {type(new_run)}: {new_run}") - existing.append(new_run) - logger.debug(f"Setting: {existing}") - sub.extraction_info = json.dumps(existing) - except TypeError: - logger.error(f"Error updating!") - sub.extraction_info = json.dumps([new_run]) - logger.debug(f"Final ext info for {sub.rsl_plate_num}: {sub.extraction_info}") - else: - sub.extraction_info = json.dumps([new_run]) - obj.ctx.database_session.add(sub) - obj.ctx.database_session.commit() - result = dict(message=f"We added {count} logs to the database.", status='information') - return obj, result - -def link_pcr_function(obj:QMainWindow) -> Tuple[QMainWindow, dict]: - """ - Link PCR data from run logs to an imported submission - - Args: - obj (QMainWindow): original app window - - Returns: - Tuple[QMainWindow, dict]: Collection of new main app window and result dict - """ - result = None - fname = select_open_file(obj, file_extension="csv") - with open(fname.__str__(), 'r') as f: - # split csv rows on comma - runs = [col.strip().split(",") for col in f.readlines()] - count = 0 - for run in runs: - new_run = dict( - start_time=run[0].strip(), - rsl_plate_num=run[1].strip(), - biomek_status=run[2].strip(), - quant_status=run[3].strip(), - experiment_name=run[4].strip(), - end_time=run[5].strip() - ) - # lookup imported submission - # sub = lookup_submission_by_rsl_num(ctx=obj.ctx, rsl_num=new_run['rsl_plate_num']) - # sub = lookup_submissions(ctx=obj.ctx, rsl_number=new_run['rsl_plate_num']) - sub = BasicSubmission.query(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: - continue - # check if pcr_info already exists - if hasattr(sub, 'pcr_info') and sub.pcr_info != None: - existing = json.loads(sub.pcr_info) - else: - existing = None - # check if this entry already exists in imported submission - try: - if json.dumps(new_run) in sub.pcr_info: - logger.debug(f"Looks like we already have that info.") - continue - else: - count += 1 - except TypeError: - logger.error(f"No json to dump") - if existing != None: - try: - logger.debug(f"Updating {type(existing)}: {existing} with {type(new_run)}: {new_run}") - existing.append(new_run) - logger.debug(f"Setting: {existing}") - sub.pcr_info = json.dumps(existing) - except TypeError: - logger.error(f"Error updating!") - sub.pcr_info = json.dumps([new_run]) - logger.debug(f"Final ext info for {sub.rsl_plate_num}: {sub.pcr_info}") - else: - sub.pcr_info = json.dumps([new_run]) - obj.ctx.database_session.add(sub) - obj.ctx.database_session.commit() - result = dict(message=f"We added {count} logs to the database.", status='information') - return obj, result - -def import_pcr_results_function(obj:QMainWindow) -> Tuple[QMainWindow, dict]: - """ - Import Quant-studio PCR data to an imported submission - - Args: - obj (QMainWindow): original app window - - Returns: - Tuple[QMainWindow, dict]: Collection of new main app window and result dict - """ - result = None - fname = select_open_file(obj, file_extension="xlsx") - parser = PCRParser(filepath=fname) - logger.debug(f"Attempting lookup for {parser.plate_num}") - # sub = lookup_submission_by_rsl_num(ctx=obj.ctx, rsl_num=parser.plate_num) - sub = BasicSubmission.query(rsl_number=parser.plate_num) - try: - logger.debug(f"Found submission: {sub.rsl_plate_num}") - except AttributeError: - # If no plate is found, may be because this is a repeat. Lop off the '-1' or '-2' and repeat - logger.error(f"Submission of number {parser.plate_num} not found. Attempting rescue of plate repeat.") - parser.plate_num = "-".join(parser.plate_num.split("-")[:-1]) - # sub = lookup_submission_by_rsl_num(ctx=obj.ctx, rsl_num=parser.plate_num) - # sub = lookup_submissions(ctx=obj.ctx, rsl_number=parser.plate_num) - sub = BasicSubmission.query(rsl_number=parser.plate_num) - try: - logger.debug(f"Found submission: {sub.rsl_plate_num}") - except AttributeError: - logger.error(f"Rescue of {parser.plate_num} failed.") - return obj, dict(message="Couldn't find a submission with that RSL number.", status="warning") - # Check if PCR info already exists - if hasattr(sub, 'pcr_info') and sub.pcr_info != None: - existing = json.loads(sub.pcr_info) - else: - existing = None - if existing != None: - # update pcr_info - try: - logger.debug(f"Updating {type(existing)}: {existing} with {type(parser.pcr)}: {parser.pcr}") - if json.dumps(parser.pcr) not in sub.pcr_info: - existing.append(parser.pcr) - logger.debug(f"Setting: {existing}") - sub.pcr_info = json.dumps(existing) - except TypeError: - logger.error(f"Error updating!") - sub.pcr_info = json.dumps([parser.pcr]) - logger.debug(f"Final pcr info for {sub.rsl_plate_num}: {sub.pcr_info}") - else: - sub.pcr_info = json.dumps([parser.pcr]) - obj.ctx.database_session.add(sub) - logger.debug(f"Existing {type(sub.pcr_info)}: {sub.pcr_info}") - logger.debug(f"Inserting {type(json.dumps(parser.pcr))}: {json.dumps(parser.pcr)}") - obj.ctx.database_session.commit() - logger.debug(f"Got {len(parser.samples)} samples to update!") - logger.debug(f"Parser samples: {parser.samples}") - for sample in sub.samples: - logger.debug(f"Running update on: {sample}") - try: - sample_dict = [item for item in parser.samples if item['sample']==sample.rsl_number][0] - except IndexError: - continue - update_subsampassoc_with_pcr(submission=sub, sample=sample, input_dict=sample_dict) - - result = dict(message=f"We added PCR info to {sub.rsl_plate_num}.", status='information') - return obj, result - -def autofill_excel(obj:QMainWindow, xl_map:dict, reagents:List[dict], missing_reagents:List[str], info:dict, missing_info:List[str]): - """ - Automatically fills in excel cells with submission info. - - Args: - obj (QMainWindow): Original main app window - xl_map (dict): Map of where each item goes in the excel workbook. - reagents (List[dict]): All reagents placed in the submission form. - missing_reagents (List[str]): Reagents that are required for the kit that were not present. - info (dict): Dictionary of misc info from submission - missing_info (List[str]): Plate info missing from the excel sheet. - """ - # logger.debug(reagents) - - logger.debug(f"Here is the info dict coming in:\n{pprint.pformat(info)}") - logger.debug(f"Here are the missing reagents:\n{missing_reagents}") - logger.debug(f"Here are the missing info:\n{missing_info}") - logger.debug(f"Here is the xl map: {pprint.pformat(xl_map)}") - # pare down the xl map to only the missing data. - relevant_reagent_map = {k:v for k,v in xl_map.items() if k in [reagent.type for reagent in missing_reagents]} - # pare down reagents to only what's missing - logger.debug(f"Checking {[item['type'] for item in reagents]} against {[reagent.type for reagent in missing_reagents]}") - relevant_reagents = [item for item in reagents if item['type'] in [reagent.type for reagent in missing_reagents]] - logger.debug(f"Here are the relevant reagents: {pprint.pformat(relevant_reagents)}") - # hacky manipulation of submission type so it looks better. - # pare down info to just what's missing - relevant_info_map = {k:v for k,v in xl_map['info'].items() if k in missing_info and k != 'samples'} - relevant_info = {k:v for k,v in info.items() if k in missing_info} - logger.debug(f"Here is the relevant info: {pprint.pformat(relevant_info)}") - # construct new objects to put into excel sheets: - new_reagents = [] - logger.debug(f"Parsing from relevant reagent map: {pprint.pformat(relevant_reagent_map)}") - for reagent in relevant_reagents: - new_reagent = {} - new_reagent['type'] = reagent['type'] - new_reagent['lot'] = relevant_reagent_map[new_reagent['type']]['lot'] - new_reagent['lot']['value'] = reagent['lot'] - new_reagent['expiry'] = relevant_reagent_map[new_reagent['type']]['expiry'] - new_reagent['expiry']['value'] = reagent['expiry'] - new_reagent['sheet'] = relevant_reagent_map[new_reagent['type']]['sheet'] - # name is only present for Bacterial Culture - try: - new_reagent['name'] = relevant_reagent_map[new_reagent['type']]['name'] - new_reagent['name']['value'] = reagent['name'] - except Exception as e: - logger.error(f"Couldn't get name due to {e}") - new_reagents.append(new_reagent) - # construct new info objects to put into excel sheets - new_info = [] - logger.debug(f"Parsing from relevant info map: {pprint.pformat(relevant_info_map)}") - for item in relevant_info: - 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 - workbook = load_workbook(obj.prsr.xl.io) - # get list of sheet names - sheets = workbook.sheetnames - # logger.debug(workbook.sheetnames) - for sheet in sheets: - # open sheet - worksheet=workbook[sheet] - # Get relevant reagents for that sheet - sheet_reagents = [item for item in new_reagents if sheet in item['sheet']] - for reagent in sheet_reagents: - logger.debug(f"Attempting to write lot {reagent['lot']['value']} in: row {reagent['lot']['row']}, column {reagent['lot']['column']}") - worksheet.cell(row=reagent['lot']['row'], column=reagent['lot']['column'], value=reagent['lot']['value']) - logger.debug(f"Attempting to write expiry {reagent['expiry']['value']} in: row {reagent['expiry']['row']}, column {reagent['expiry']['column']}") - worksheet.cell(row=reagent['expiry']['row'], column=reagent['expiry']['column'], value=reagent['expiry']['value']) - try: - logger.debug(f"Attempting to write name {reagent['name']['value']} in: row {reagent['name']['row']}, column {reagent['name']['column']}") - worksheet.cell(row=reagent['name']['row'], column=reagent['name']['column'], value=reagent['name']['value']) - except Exception as e: - logger.error(f"Could not write name {reagent['name']['value']} due to {e}") - # Get relevant info for that sheet - sheet_info = [item for item in new_info if sheet in item['location']['sheets']] - for item in sheet_info: - logger.debug(f"Attempting: {item['type']}") - worksheet.cell(row=item['location']['row'], column=item['location']['column'], value=item['value']) - # Hacky way to pop in 'signed by' - # custom_parser = get_polymorphic_subclass(BasicSubmission, info['submission_type']) - custom_parser = BasicSubmission.find_polymorphic_subclass(polymorphic_identity=info['submission_type']) - workbook = custom_parser.custom_autofill(workbook) - fname = select_save_file(obj=obj, default_name=info['rsl_plate_num'], extension="xlsx") - workbook.save(filename=fname.__str__()) - -# def construct_first_strand_function(obj:QMainWindow) -> Tuple[QMainWindow, dict]: -# """ -# Generates a csv file from client submitted xlsx file. -# NOTE: Depreciated, now folded into import Artic. - -# Args: -# obj (QMainWindow): Main application - -# Returns: -# Tuple[QMainWindow, dict]: Updated main application and result -# """ -# def get_plates(input_sample_number:str, plates:list) -> Tuple[int, str]: -# logger.debug(f"Looking up {input_sample_number} in {plates}") -# # samp = lookup_samples(ctx=obj.ctx, ww_processing_num=input_sample_number) -# samp = BasicSample.query(ww_processing_num=input_sample_number) -# if samp == None: -# # samp = lookup_samples(ctx=obj.ctx, submitter_id=input_sample_number) -# samp = BasicSample.query(submitter_id=input_sample_number) -# if samp == None: -# return None, None -# logger.debug(f"Got sample: {samp}") -# # new_plates = [(iii+1, lookup_submission_sample_association(ctx=obj.ctx, sample=samp, submission=plate)) for iii, plate in enumerate(plates)] -# new_plates = [(iii+1, SubmissionSampleAssociation.query(sample=samp, submission=plate)) for iii, plate in enumerate(plates)] -# logger.debug(f"Associations: {pformat(new_plates)}") -# try: -# 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}") -# return plate_num, plate -# fname = select_open_file(obj=obj, file_extension="xlsx") -# xl = pd.ExcelFile(fname) -# sprsr = SampleParser(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() -# # Fix no plates found in form. -# if plates == []: -# dlg = FirstStrandPlateList(ctx=obj.ctx) -# if dlg.exec(): -# plates = dlg.parse_form() -# plates = list(set(plates)) -# logger.debug(f"Plates: {pformat(plates)}") -# output_samples = [] -# logger.debug(f"Samples: {pformat(samples)}") -# old_plate_number = 1 -# old_plate = '' -# for item in samples: -# try: -# item['well'] = re.search(r"\s\((.*)\)$", item['submitter_id']).groups()[0] -# except AttributeError: -# pass -# item['submitter_id'] = re.sub(r"\s\(.*\)$", "", str(item['submitter_id'])).strip() -# new_dict = {} -# new_dict['sample'] = item['submitter_id'] -# plate_num, plate = get_plates(input_sample_number=new_dict['sample'], plates=plates) -# if plate_num == None: -# plate_num = str(old_plate_number) + "*" -# else: -# old_plate_number = plate_num -# logger.debug(f"Got plate number: {plate_num}, plate: {plate}") -# if item['submitter_id'] == "NTC1": -# new_dict['destination_row'] = 8 -# new_dict['destination_column'] = 2 -# new_dict['plate_number'] = 'control' -# new_dict['plate'] = None -# output_samples.append(new_dict) -# continue -# elif item['submitter_id'] == "NTC2": -# new_dict['destination_row'] = 8 -# new_dict['destination_column'] = 5 -# new_dict['plate_number'] = 'control' -# new_dict['plate'] = None -# output_samples.append(new_dict) -# continue -# else: -# new_dict['destination_row'] = item['row'] -# new_dict['destination_column'] = item['column'] -# new_dict['plate_number'] = plate_num -# # Fix plate association not found -# if plate == None: -# dlg = FirstStrandSalvage(ctx=obj.ctx, submitter_id=item['submitter_id'], rsl_plate_num=old_plate) -# if dlg.exec(): -# item.update(dlg.parse_form()) -# try: -# new_dict['source_row'], new_dict['source_column'] = convert_well_to_row_column(item['well']) -# except KeyError: -# pass -# else: -# new_dict['plate'] = plate.submission.rsl_plate_num -# new_dict['source_row'] = plate.row -# new_dict['source_column'] = plate.column -# old_plate = plate.submission.rsl_plate_num -# output_samples.append(new_dict) -# df = pd.DataFrame.from_records(output_samples) -# df.sort_values(by=['destination_column', 'destination_row'], ascending=True, inplace=True) -# columnsTitles = ['sample', 'destination_column', 'destination_row', 'plate_number', 'plate', "source_column", 'source_row'] -# df = df.reindex(columns=columnsTitles) -# ofname = select_save_file(obj=obj, default_name=f"First Strand {date.today()}", extension="csv") -# df.to_csv(ofname, index=False) -# return obj, None - -def scrape_reagents(obj:QMainWindow, extraction_kit:str) -> Tuple[QMainWindow, dict]: - """ - Extracted scrape reagents function that will run when - form 'extraction_kit' widget is updated. - - Args: - obj (QMainWindow): updated main application - extraction_kit (str): name of extraction kit (in 'extraction_kit' widget) - - Returns: - Tuple[QMainWindow, dict]: Updated application and result - """ - report = Report() - logger.debug(f"Extraction kit: {extraction_kit}") - # obj.reagents = [] - # obj.missing_reagents = [] - # Remove previous reagent widgets - try: - old_reagents = obj.form.find_widgets() - except AttributeError: - logger.error(f"Couldn't find old reagents.") - old_reagents = [] - # logger.debug(f"\n\nAttempting to clear: {old_reagents}\n\n") - for reagent in old_reagents: - if isinstance(reagent, ReagentFormWidget) or isinstance(reagent, QPushButton): - reagent.setParent(None) - # reagents = obj.prsr.parse_reagents(extraction_kit=extraction_kit) - # logger.debug(f"Got reagents: {reagents}") - # for reagent in obj.prsr.sub['reagents']: - # # create label - # if reagent.parsed: - # obj.reagents.append(reagent) - # else: - # obj.missing_reagents.append(reagent) - obj.form.reagents = obj.prsr.sub['reagents'] - # logger.debug(f"Imported reagents: {obj.reagents}") - # logger.debug(f"Missing reagents: {obj.missing_reagents}") - obj.report.add_result(report) - logger.debug(f"Outgoing report: {obj.report.results}") - return obj - -def export_csv_function(obj:QMainWindow, fname:Path|None=None): - if isinstance(fname, bool) or fname == None: - fname = select_save_file(obj=obj, default_name=obj.pyd.construct_filename(), extension="csv") - try: - obj.pyd.csv.to_csv(fname.__str__(), index=False) - except PermissionError: - logger.debug(f"Could not get permissions to {fname}. Possibly the request was cancelled.") \ No newline at end of file diff --git a/src/submissions/frontend/functions/submission_functions.py b/src/submissions/frontend/functions/submission_functions.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/submissions/frontend/visualizations/control_charts.py b/src/submissions/frontend/visualizations/control_charts.py index 364cebc..7c5a8be 100644 --- a/src/submissions/frontend/visualizations/control_charts.py +++ b/src/submissions/frontend/visualizations/control_charts.py @@ -51,7 +51,7 @@ def create_charts(ctx:dict, df:pd.DataFrame, ytitle:str|None=None) -> Figure: df = df.sort_values(by=sorts, ascending=ascending) logger.debug(df[df.isna().any(axis=1)]) # actual chart construction is done by - fig = construct_chart(ctx=ctx, df=df, modes=modes, ytitle=ytitle) + fig = construct_chart(df=df, modes=modes, ytitle=ytitle) return fig @@ -153,7 +153,7 @@ def output_figures(settings:dict, figs:list, group_name:str): -def construct_chart(ctx:dict, df:pd.DataFrame, modes:list, ytitle:str|None=None) -> Figure: +def construct_chart(df:pd.DataFrame, modes:list, ytitle:str|None=None) -> Figure: """ Creates a plotly chart for controls from a pandas dataframe diff --git a/src/submissions/frontend/widgets/__init__.py b/src/submissions/frontend/widgets/__init__.py new file mode 100644 index 0000000..757816e --- /dev/null +++ b/src/submissions/frontend/widgets/__init__.py @@ -0,0 +1,13 @@ +''' +Contains all custom generated PyQT6 derivative widgets. +''' + +# from .app import App +from .functions import * +from .misc import * +from .pop_ups import * +from .submission_table import * +from .submission_widget import * +from .controls_chart import * +from .kit_creator import * +from .app import App \ No newline at end of file diff --git a/src/submissions/frontend/widgets/app.py b/src/submissions/frontend/widgets/app.py new file mode 100644 index 0000000..6af7e59 --- /dev/null +++ b/src/submissions/frontend/widgets/app.py @@ -0,0 +1,412 @@ +''' +Constructs main application. +TODO: Complete. +''' +import sys +from PyQt6.QtWidgets import ( + QTabWidget, QWidget, QVBoxLayout, + QHBoxLayout, QScrollArea, QMainWindow, + QToolBar +) +from PyQt6.QtGui import QAction +from pathlib import Path +from backend.validators import PydReagent +# from frontend.functions import ( +# add_kit_function, add_org_function, link_controls_function, export_csv_function +# ) +from tools import check_if_app, Settings, Report +from .pop_ups import AlertPop +from .misc import AddReagentForm +import logging +from datetime import date +import webbrowser +from .submission_table import SubmissionsSheet +from .submission_widget import SubmissionFormContainer +from .controls_chart import ControlsViewer +from .kit_creator import KitAdder +import webbrowser + +logger = logging.getLogger(f'submissions.{__name__}') +logger.info("Hello, I am a logger") + +class App(QMainWindow): + + def __init__(self, ctx: Settings = None): + logger.debug(f"Initializing main window...") + super().__init__() + self.ctx = ctx + self.last_dir = ctx.directory_path + self.report = Report() + # indicate version and connected database in title bar + try: + self.title = f"Submissions App (v{ctx.package.__version__}) - {ctx.database_path}" + except (AttributeError, KeyError): + self.title = f"Submissions App" + # set initial app position and size + self.left = 0 + self.top = 0 + self.width = 1300 + self.height = 1000 + self.setWindowTitle(self.title) + self.setGeometry(self.left, self.top, self.width, self.height) + # insert tabs into main app + self.table_widget = AddSubForm(self) + self.setCentralWidget(self.table_widget) + # run initial setups + self._createActions() + self._createMenuBar() + self._createToolBar() + self._connectActions() + # self._controls_getter() + self.show() + self.statusBar().showMessage('Ready', 5000) + + def _createMenuBar(self): + """ + adds items to menu bar + """ + logger.debug(f"Creating menu bar...") + menuBar = self.menuBar() + fileMenu = menuBar.addMenu("&File") + # Creating menus using a title + # methodsMenu = menuBar.addMenu("&Methods") + reportMenu = menuBar.addMenu("&Reports") + maintenanceMenu = menuBar.addMenu("&Monthly") + helpMenu = menuBar.addMenu("&Help") + helpMenu.addAction(self.helpAction) + helpMenu.addAction(self.docsAction) + fileMenu.addAction(self.importAction) + fileMenu.addAction(self.importPCRAction) + # methodsMenu.addAction(self.constructFS) + reportMenu.addAction(self.generateReportAction) + maintenanceMenu.addAction(self.joinExtractionAction) + maintenanceMenu.addAction(self.joinPCRAction) + + def _createToolBar(self): + """ + adds items to toolbar + """ + logger.debug(f"Creating toolbar...") + toolbar = QToolBar("My main toolbar") + self.addToolBar(toolbar) + toolbar.addAction(self.addReagentAction) + toolbar.addAction(self.addKitAction) + toolbar.addAction(self.addOrgAction) + + def _createActions(self): + """ + creates actions + """ + logger.debug(f"Creating actions...") + self.importAction = QAction("&Import Submission", self) + self.importPCRAction = QAction("&Import PCR Results", self) + self.addReagentAction = QAction("Add Reagent", self) + self.generateReportAction = QAction("Make Report", self) + self.addKitAction = QAction("Import Kit", self) + self.addOrgAction = QAction("Import Org", self) + self.joinExtractionAction = QAction("Link Extraction Logs") + self.joinPCRAction = QAction("Link PCR Logs") + self.helpAction = QAction("&About", self) + self.docsAction = QAction("&Docs", self) + # self.constructFS = QAction("Make First Strand", self) + + def _connectActions(self): + """ + connect menu and tool bar item to functions + """ + logger.debug(f"Connecting actions...") + self.importAction.triggered.connect(self.table_widget.formwidget.importSubmission) + self.importPCRAction.triggered.connect(self.table_widget.formwidget.import_pcr_results) + self.addReagentAction.triggered.connect(self.add_reagent) + self.generateReportAction.triggered.connect(self.table_widget.sub_wid.generate_report) + # self.addKitAction.triggered.connect(self.add_kit) + # self.addOrgAction.triggered.connect(self.add_org) + self.joinExtractionAction.triggered.connect(self.table_widget.sub_wid.link_extractions) + self.joinPCRAction.triggered.connect(self.table_widget.sub_wid.link_pcr) + self.helpAction.triggered.connect(self.showAbout) + self.docsAction.triggered.connect(self.openDocs) + # self.constructFS.triggered.connect(self.construct_first_strand) + # self.table_widget.formwidget.import_drag.connect(self.importSubmission) + + def showAbout(self): + """ + Show the 'about' message + """ + output = f"Version: {self.ctx.package.__version__}\n\nAuthor: {self.ctx.package.__author__['name']} - {self.ctx.package.__author__['email']}\n\nCopyright: {self.ctx.package.__copyright__}" + about = AlertPop(message=output, status="information") + about.exec() + + def openDocs(self): + """ + Open the documentation html pages + """ + if check_if_app(): + url = Path(sys._MEIPASS).joinpath("files", "docs", "index.html") + else: + url = Path("docs\\build\\index.html").absolute() + logger.debug(f"Attempting to open {url}") + webbrowser.get('windows-default').open(f"file://{url.__str__()}") + + def result_reporter(self): + # def result_reporter(self, result:TypedDict[]|None=None): + """ + Report any anomolous results - if any - to the user + + Args: + result (dict | None, optional): The result from a function. Defaults to None. + """ + # logger.info(f"We got the result: {result}") + # if result != None: + # msg = AlertPop(message=result['message'], status=result['status']) + # msg.exec() + logger.debug(f"Running results reporter for: {self.report.results}") + if len(self.report.results) > 0: + logger.debug(f"We've got some results!") + for result in self.report.results: + logger.debug(f"Showing result: {result}") + if result != None: + alert = result.report() + if alert.exec(): + pass + self.report = Report() + else: + self.statusBar().showMessage("Action completed sucessfully.", 5000) + + # def importSubmission(self, fname:Path|None=None): + # """ + # import submission from excel sheet into form + # """ + # # from .main_window_functions import import_submission_function + # self.raise_() + # self.activateWindow() + # self = import_submission_function(self, fname) + # logger.debug(f"Result from result reporter: {self.report.results}") + # self.result_reporter() + + # def kit_reload(self): + # """ + # Removes all reagents from form before running kit integrity completion. + # """ + # # from .main_window_functions import kit_reload_function + # self = kit_reload_function(self) + # self.result_reporter() + + # def kit_integrity_completion(self): + # """ + # Performs check of imported reagents + # NOTE: this will not change self.reagents which should be fine + # since it's only used when looking up + # """ + # # from .main_window_functions import kit_integrity_completion_function + # self = kit_integrity_completion_function(self) + # self.result_reporter() + + # def submit_new_sample(self): + # """ + # Attempt to add sample to database when 'submit' button clicked + # """ + # # from .main_window_functions import submit_new_sample_function + # self = submit_new_sample_function(self) + # self.result_reporter() + + def add_reagent(self, reagent_lot:str|None=None, reagent_type:str|None=None, expiry:date|None=None, name:str|None=None): + """ + Action to create new reagent in DB. + + Args: + reagent_lot (str | None, optional): Parsed reagent from import form. Defaults to None. + reagent_type (str | None, optional): Parsed reagent type from import form. Defaults to None. + + Returns: + models.Reagent: the constructed reagent object to add to submission + """ + report = Report() + if isinstance(reagent_lot, bool): + reagent_lot = "" + # create form + 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 = dlg.parse_form() + logger.debug(f"Reagent info: {info}") + # create reagent object + # reagent = construct_reagent(ctx=self.ctx, info_dict=info) + reagent = PydReagent(ctx=self.ctx, **info) + # send reagent to db + # store_reagent(ctx=self.ctx, reagent=reagent) + sqlobj, result = reagent.toSQL() + sqlobj.save() + # result = store_object(ctx=self.ctx, object=reagent.toSQL()[0]) + report.add_result(result) + self.result_reporter() + return reagent + + # def generateReport(self): + # """ + # Action to create a summary of sheet data per client + # """ + # # from .main_window_functions import generate_report_function + # self, result = generate_report_function(self) + # self.result_reporter(result) + + # def add_kit(self): + # """ + # Constructs new kit from yaml and adds to DB. + # """ + # # from .main_window_functions import add_kit_function + # self, result = add_kit_function(self) + # self.result_reporter(result) + + # def add_org(self): + # """ + # Constructs new kit from yaml and adds to DB. + # """ + # # from .main_window_functions import add_org_function + # self, result = add_org_function(self) + # self.result_reporter(result) + + # def _controls_getter(self): + # """ + # Lookup controls from database and send to chartmaker + # """ + # # from .main_window_functions import controls_getter_function + # self = controls_getter_function(self) + # self.result_reporter() + + # def _chart_maker(self): + # """ + # Creates plotly charts for webview + # """ + # # from .main_window_functions import chart_maker_function + # self = chart_maker_function(self) + # self.result_reporter() + + # 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) + # self.result_reporter(result) + + # def linkExtractions(self): + # """ + # Links extraction logs from .csv files to relevant submissions. + # """ + # # from .main_window_functions import link_extractions_function + # self, result = link_extractions_function(self) + # self.result_reporter(result) + + # def linkPCR(self): + # """ + # Links PCR logs from .csv files to relevant submissions. + # """ + # # from .main_window_functions import link_pcr_function + # self, result = link_pcr_function(self) + # self.result_reporter(result) + + # def importPCRResults(self): + # """ + # Imports results exported from Design and Analysis .eds files + # """ + # # from .main_window_functions import import_pcr_results_function + # self, result = import_pcr_results_function(self) + # self.result_reporter(result) + + # def construct_first_strand(self): + # """ + # Converts first strand excel sheet to Biomek CSV + # """ + # from .main_window_functions import construct_first_strand_function + # self, result = construct_first_strand_function(self) + # self.result_reporter(result) + + # def scrape_reagents(self, *args, **kwargs): + # # from .main_window_functions import scrape_reagents + # logger.debug(f"Args: {args}") + # logger.debug(F"kwargs: {kwargs}") + # self = scrape_reagents(self, args[0]) + # self.kit_integrity_completion() + # self.result_reporter() + + # def export_csv(self, fname:Path|None=None): + # # from .main_window_functions import export_csv_function + # export_csv_function(self, fname) + +class AddSubForm(QWidget): + + def __init__(self, parent:QWidget): + logger.debug(f"Initializating subform...") + super(QWidget, self).__init__(parent) + self.layout = QVBoxLayout(self) + self.parent = parent + # Initialize tab screen + self.tabs = QTabWidget() + self.tab1 = QWidget() + self.tab2 = QWidget() + self.tab3 = QWidget() + self.tabs.resize(300,200) + # Add tabs + self.tabs.addTab(self.tab1,"Submissions") + self.tabs.addTab(self.tab2,"Controls") + self.tabs.addTab(self.tab3, "Add Kit") + # Create submission adder form + self.formwidget = SubmissionFormContainer(self) + self.formlayout = QVBoxLayout(self) + self.formwidget.setLayout(self.formlayout) + self.formwidget.setFixedWidth(300) + # Make scrollable interior for form + self.interior = QScrollArea(self.tab1) + self.interior.setWidgetResizable(True) + self.interior.setFixedWidth(325) + self.interior.setWidget(self.formwidget) + # Create sheet to hold existing submissions + self.sheetwidget = QWidget(self) + self.sheetlayout = QVBoxLayout(self) + self.sheetwidget.setLayout(self.sheetlayout) + self.sub_wid = SubmissionsSheet(parent=parent) + self.sheetlayout.addWidget(self.sub_wid) + # Create layout of first tab to hold form and sheet + self.tab1.layout = QHBoxLayout(self) + self.tab1.setLayout(self.tab1.layout) + self.tab1.layout.addWidget(self.interior) + self.tab1.layout.addWidget(self.sheetwidget) + # create widgets for tab 2 + # self.datepicker = ControlsDatePicker() + # self.webengineview = QWebEngineView() + # set tab2 layout + self.tab2.layout = QVBoxLayout(self) + # self.control_typer = QComboBox() + # fetch types of controls + # con_types = get_all_Control_Types_names(ctx=parent.ctx) + # con_types = [item.name for item in lookup_control_types(ctx=parent.ctx)] + # con_types = [item.name for item in ControlType.query()] + # self.control_typer.addItems(con_types) + # create custom widget to get types of analysis + # self.mode_typer = QComboBox() + # mode_types = get_all_available_modes(ctx=parent.ctx) + # mode_types = lookup_modes(ctx=parent.ctx) + # mode_types = Control.get_modes() + # self.mode_typer.addItems(mode_types) + # create custom widget to get subtypes of analysis + # self.sub_typer = QComboBox() + # self.sub_typer.setEnabled(False) + # add widgets to tab2 layout + # self.tab2.layout.addWidget(self.datepicker) + # self.tab2.layout.addWidget(self.control_typer) + # self.tab2.layout.addWidget(self.mode_typer) + # self.tab2.layout.addWidget(self.sub_typer) + # self.tab2.layout.addWidget(self.webengineview) + self.controls_viewer = ControlsViewer(self) + self.tab2.layout.addWidget(self.controls_viewer) + self.tab2.setLayout(self.tab2.layout) + # create custom widget to add new tabs + adder = KitAdder(self) + self.tab3.layout = QVBoxLayout(self) + self.tab3.layout.addWidget(adder) + self.tab3.setLayout(self.tab3.layout) + # add tabs to main widget + self.layout.addWidget(self.tabs) + self.setLayout(self.layout) \ No newline at end of file diff --git a/src/submissions/frontend/widgets/controls_chart.py b/src/submissions/frontend/widgets/controls_chart.py new file mode 100644 index 0000000..855d65b --- /dev/null +++ b/src/submissions/frontend/widgets/controls_chart.py @@ -0,0 +1,193 @@ +from PyQt6.QtWebEngineWidgets import QWebEngineView +from PyQt6.QtWidgets import ( + QWidget, QVBoxLayout, QComboBox, QHBoxLayout, + QDateEdit, QLabel, QSizePolicy +) +from PyQt6.QtCore import QSignalBlocker +from backend.db import ControlType, Control, get_control_subtypes +from PyQt6.QtCore import QDate, QSize +import logging +from tools import Report, Result +from backend.excel.reports import convert_data_list_to_df +from frontend.visualizations.control_charts import create_charts, construct_html + +logger = logging.getLogger(f"submissions.{__name__}") + +class ControlsViewer(QWidget): + + def __init__(self, parent: QWidget) -> None: + super().__init__(parent) + self.app = self.parent().parent + print(f"\n\n{self.app}\n\n") + self.report = Report() + self.datepicker = ControlsDatePicker() + self.webengineview = QWebEngineView() + # set tab2 layout + self.layout = QVBoxLayout(self) + self.control_typer = QComboBox() + # fetch types of controls + # con_types = get_all_Control_Types_names(ctx=parent.ctx) + # con_types = [item.name for item in lookup_control_types(ctx=parent.ctx)] + con_types = [item.name for item in ControlType.query()] + self.control_typer.addItems(con_types) + # create custom widget to get types of analysis + self.mode_typer = QComboBox() + # mode_types = get_all_available_modes(ctx=parent.ctx) + # mode_types = lookup_modes(ctx=parent.ctx) + mode_types = Control.get_modes() + self.mode_typer.addItems(mode_types) + # create custom widget to get subtypes of analysis + self.sub_typer = QComboBox() + self.sub_typer.setEnabled(False) + # add widgets to tab2 layout + self.layout.addWidget(self.datepicker) + self.layout.addWidget(self.control_typer) + self.layout.addWidget(self.mode_typer) + self.layout.addWidget(self.sub_typer) + self.layout.addWidget(self.webengineview) + self.setLayout(self.layout) + self.controls_getter() + self.control_typer.currentIndexChanged.connect(self.controls_getter) + self.mode_typer.currentIndexChanged.connect(self.controls_getter) + self.datepicker.start_date.dateChanged.connect(self.controls_getter) + self.datepicker.end_date.dateChanged.connect(self.controls_getter) + + def controls_getter(self): + """ + Lookup controls from database and send to chartmaker + """ + # from .main_window_functions import controls_getter_function + self.controls_getter_function() + # self.result_reporter() + + def chart_maker(self): + """ + Creates plotly charts for webview + """ + # from .main_window_functions import chart_maker_function + self.chart_maker_function() + # self.result_reporter() + + def controls_getter_function(self): + """ + Get controls based on start/end dates + + Args: + obj (QMainWindow): original app window + + Returns: + Tuple[QMainWindow, dict]: Collection of new main app window and result dict + """ + report = Report() + # subtype defaults to disabled + try: + self.sub_typer.disconnect() + except TypeError: + pass + # correct start date being more recent than end date and rerun + if self.datepicker.start_date.date() > self.datepicker.end_date.date(): + logger.warning("Start date after end date is not allowed!") + threemonthsago = self.datepicker.end_date.date().addDays(-60) + # block signal that will rerun controls getter and set start date + # Without triggering this function again + with QSignalBlocker(self.datepicker.start_date) as blocker: + self.datepicker.start_date.setDate(threemonthsago) + self.controls_getter() + self.report.add_result(report) + return + # convert to python useable date objects + self.start_date = self.datepicker.start_date.date().toPyDate() + self.end_date = self.datepicker.end_date.date().toPyDate() + self.con_type = self.control_typer.currentText() + self.mode = self.mode_typer.currentText() + self.sub_typer.clear() + # lookup subtypes + sub_types = get_control_subtypes(type=self.con_type, mode=self.mode) + # sub_types = lookup_controls(ctx=obj.ctx, control_type=obj.con_type) + if sub_types != []: + # block signal that will rerun controls getter and update sub_typer + with QSignalBlocker(self.sub_typer) as blocker: + self.sub_typer.addItems(sub_types) + self.sub_typer.setEnabled(True) + self.sub_typer.currentTextChanged.connect(self.chart_maker) + else: + self.sub_typer.clear() + self.sub_typer.setEnabled(False) + self.chart_maker() + self.report.add_result(report) + + + def chart_maker_function(self): + """ + Create html chart for controls reporting + + Args: + obj (QMainWindow): original app window + + Returns: + Tuple[QMainWindow, dict]: Collection of new main app window and result dict + """ + report = Report() + logger.debug(f"Control getter context: \n\tControl type: {self.con_type}\n\tMode: {self.mode}\n\tStart Date: {self.start_date}\n\tEnd Date: {self.end_date}") + # set the subtype for kraken + if self.sub_typer.currentText() == "": + self.subtype = None + else: + self.subtype = self.sub_typer.currentText() + logger.debug(f"Subtype: {self.subtype}") + # query all controls using the type/start and end dates from the gui + # controls = get_all_controls_by_type(ctx=obj.ctx, con_type=obj.con_type, start_date=obj.start_date, end_date=obj.end_date) + # controls = lookup_controls(ctx=obj.ctx, control_type=obj.con_type, start_date=obj.start_date, end_date=obj.end_date) + controls = Control.query(control_type=self.con_type, start_date=self.start_date, end_date=self.end_date) + # if no data found from query set fig to none for reporting in webview + if controls == None: + fig = None + else: + # change each control to list of dictionaries + data = [control.convert_by_mode(mode=self.mode) for control in controls] + # flatten data to one dimensional list + data = [item for sublist in data for item in sublist] + logger.debug(f"Control objects going into df conversion: {type(data)}") + if data == []: + self.report.add_result(Result(status="Critical", msg="No data found for controls in given date range.")) + return + # send to dataframe creator + df = convert_data_list_to_df(input=data, subtype=self.subtype) + if self.subtype == None: + title = self.mode + else: + title = f"{self.mode} - {self.subtype}" + # send dataframe to chart maker + fig = create_charts(ctx=self.app.ctx, df=df, ytitle=title) + logger.debug(f"Updating figure...") + # construct html for webview + html = construct_html(figure=fig) + logger.debug(f"The length of html code is: {len(html)}") + self.webengineview.setHtml(html) + self.webengineview.update() + logger.debug("Figure updated... I hope.") + self.report.add_result(report) + +class ControlsDatePicker(QWidget): + """ + custom widget to pick start and end dates for controls graphs + """ + def __init__(self) -> None: + super().__init__() + + self.start_date = QDateEdit(calendarPopup=True) + # start date is two months prior to end date by default + twomonthsago = QDate.currentDate().addDays(-60) + self.start_date.setDate(twomonthsago) + self.end_date = QDateEdit(calendarPopup=True) + self.end_date.setDate(QDate.currentDate()) + self.layout = QHBoxLayout() + self.layout.addWidget(QLabel("Start Date")) + self.layout.addWidget(self.start_date) + self.layout.addWidget(QLabel("End Date")) + self.layout.addWidget(self.end_date) + self.setLayout(self.layout) + self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) + + def sizeHint(self) -> QSize: + return QSize(80,20) diff --git a/src/submissions/frontend/widgets/functions.py b/src/submissions/frontend/widgets/functions.py new file mode 100644 index 0000000..18e27fd --- /dev/null +++ b/src/submissions/frontend/widgets/functions.py @@ -0,0 +1,56 @@ +''' +functions used by all windows in the application's frontend +NOTE: Depreciated. Moved to functions.__init__ +''' +from pathlib import Path +import logging +from PyQt6.QtWidgets import QMainWindow, QFileDialog + +logger = logging.getLogger(f"submissions.{__name__}") + +def select_open_file(obj:QMainWindow, file_extension:str) -> Path: + """ + File dialog to select a file to read from + + Args: + obj (QMainWindow): Original main app window to be parent + file_extension (str): file extension + + Returns: + Path: Path of file to be opened + """ + try: + # home_dir = Path(obj.ctx.directory_path).resolve().__str__() + home_dir = obj.last_dir.resolve().__str__() + except FileNotFoundError: + home_dir = Path.home().resolve().__str__() + except AttributeError: + home_dir = obj.app.last_dir.resolve().__str__() + fname = Path(QFileDialog.getOpenFileName(obj, 'Open file', home_dir, filter = f"{file_extension}(*.{file_extension})")[0]) + # fname = Path(QFileDialog.getOpenFileName(obj, 'Open file', filter = f"{file_extension}(*.{file_extension})")[0]) + obj.last_file = fname + return fname + +def select_save_file(obj:QMainWindow, default_name:str, extension:str) -> Path: + """ + File dialog to select a file to write to + + Args: + obj (QMainWindow): Original main app window to be parent + default_name (str): default base file name + extension (str): file extension + + Returns: + Path: Path of file to be opened + """ + try: + # home_dir = Path(obj.ctx.directory_path).joinpath(default_name).resolve().__str__() + home_dir = obj.last_dir.joinpath(default_name).resolve().__str__() + except FileNotFoundError: + home_dir = Path.home().joinpath(default_name).resolve().__str__() + except AttributeError: + home_dir = obj.app.last_dir.joinpath(default_name).resolve().__str__() + fname = Path(QFileDialog.getSaveFileName(obj, "Save File", home_dir, filter = f"{extension}(*.{extension})")[0]) + # fname = Path(QFileDialog.getSaveFileName(obj, "Save File", filter = f"{extension}(*.{extension})")[0]) + obj.last_dir = fname.parent + return fname \ No newline at end of file diff --git a/src/submissions/frontend/widgets/kit_creator.py b/src/submissions/frontend/widgets/kit_creator.py new file mode 100644 index 0000000..6fa4e3a --- /dev/null +++ b/src/submissions/frontend/widgets/kit_creator.py @@ -0,0 +1,223 @@ +from PyQt6.QtWidgets import ( + QWidget, QVBoxLayout, QScrollArea, + QGridLayout, QPushButton, QLabel, + QLineEdit, QComboBox, QDoubleSpinBox, + QSpinBox, QDateEdit +) +from sqlalchemy import FLOAT, INTEGER +from backend.db import SubmissionTypeKitTypeAssociation, SubmissionType, ReagentType +from backend.validators import PydReagentType, PydKit +import logging +from pprint import pformat +from tools import Report, Result +from typing import Tuple + +logger = logging.getLogger(f"submissions.{__name__}") + + +class KitAdder(QWidget): + """ + dialog to get information to add kit + """ + def __init__(self, parent) -> None: + super().__init__(parent) + # self.ctx = parent_ctx + self.report = Report() + self.app = parent.parent + main_box = QVBoxLayout(self) + scroll = QScrollArea(self) + main_box.addWidget(scroll) + scroll.setWidgetResizable(True) + scrollContent = QWidget(scroll) + self.grid = QGridLayout() + # 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) + self.grid.addWidget(QLabel("Kit Name:"),2,0) + # widget to get kit name + kit_name = QLineEdit() + kit_name.setObjectName("kit_name") + self.grid.addWidget(kit_name,2,1) + 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([item.name for item in lookup_submission_type(ctx=parent_ctx)]) + used_for.addItems([item.name for item in SubmissionType.query()]) + used_for.setEditable(True) + self.grid.addWidget(used_for,3,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.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: + """ + insert new reagent type row + """ + print(self.app) + # get bottommost row + maxrow = self.grid.rowCount() + reg_form = ReagentTypeForm() + reg_form.setObjectName(f"ReagentForm_{maxrow}") + # self.grid.addWidget(reg_form, maxrow + 1,0,1,2) + self.grid.addWidget(reg_form, maxrow,0,1,4) + + def submit(self) -> None: + """ + send kit to database + """ + report = Report() + # get form info + 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 + logger.debug(pformat(info)) + # send to kit constructor + kit = PydKit(name=info['kit_name']) + for reagent in info['reagent_types']: + uses = { + info['used_for']: + {'sheet':reagent['sheet'], + 'name':reagent['name'], + 'lot':reagent['lot'], + 'expiry':reagent['expiry'] + }} + kit.reagent_types.append(PydReagentType(name=reagent['rtname'], eol_ext=reagent['eol'], uses=uses)) + logger.debug(f"Output pyd object: {kit.__dict__}") + # result = construct_kit_from_yaml(ctx=self.ctx, kit_dict=info) + sqlobj, result = kit.toSQL(self.ctx) + report.add_result(result=result) + sqlobj.save() + + self.__init__(self.parent()) + + 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) -> None: + super().__init__() + grid = QGridLayout() + self.setLayout(grid) + grid.addWidget(QLabel("Reagent Type Name"),0,0) + # Widget to get reagent info + self.reagent_getter = QComboBox() + self.reagent_getter.setObjectName("rtname") + # lookup all reagent type names from db + # lookup = lookup_reagent_types(ctx=ctx) + lookup = ReagentType.query() + logger.debug(f"Looked up ReagentType names: {lookup}") + self.reagent_getter.addItems([item.__str__() for item in lookup]) + self.reagent_getter.setEditable(True) + grid.addWidget(self.reagent_getter,0,1) + grid.addWidget(QLabel("Extension of Life (months):"),0,2) + # widget to get extension of life + 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 diff --git a/src/submissions/frontend/widgets/misc.py b/src/submissions/frontend/widgets/misc.py new file mode 100644 index 0000000..83f7891 --- /dev/null +++ b/src/submissions/frontend/widgets/misc.py @@ -0,0 +1,749 @@ +''' +Contains miscellaneous widgets for frontend functions +''' +from datetime import date +from pprint import pformat +from PyQt6 import QtCore +from PyQt6.QtWidgets import ( + QLabel, QVBoxLayout, + QLineEdit, QComboBox, QDialog, + QDialogButtonBox, QDateEdit, QSizePolicy, QWidget, + QGridLayout, QPushButton, QSpinBox, QDoubleSpinBox, + QHBoxLayout, QScrollArea, QFormLayout +) +from PyQt6.QtCore import Qt, QDate, QSize, pyqtSignal +from tools import check_not_nan, jinja_template_loading, Settings, Result +from backend.db.models import * +from sqlalchemy import FLOAT, INTEGER +import logging +import numpy as np +from .pop_ups import AlertPop, QuestionAsker +from backend.validators import PydReagent, PydKit, PydReagentType, PydSubmission +from typing import Tuple, List +from pprint import pformat +import difflib + + +logger = logging.getLogger(f"submissions.{__name__}") + +env = jinja_template_loading() + +class AddReagentForm(QDialog): + """ + dialog to add gather info about new reagent + """ + def __init__(self, ctx:dict, reagent_lot:str|None=None, reagent_type:str|None=None, expiry:date|None=None, reagent_name:str|None=None) -> None: + super().__init__() + self.ctx = ctx + if reagent_lot == None: + reagent_lot = reagent_type + + self.setWindowTitle("Add Reagent") + + QBtn = QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel + + self.buttonBox = QDialogButtonBox(QBtn) + self.buttonBox.accepted.connect(self.accept) + self.buttonBox.rejected.connect(self.reject) + # widget to get lot info + self.name_input = QComboBox() + self.name_input.setObjectName("name") + self.name_input.setEditable(True) + self.name_input.setCurrentText(reagent_name) + # self.name_input.setText(reagent_name) + self.lot_input = QLineEdit() + self.lot_input.setObjectName("lot") + self.lot_input.setText(reagent_lot) + # widget to get expiry info + self.exp_input = QDateEdit(calendarPopup=True) + self.exp_input.setObjectName('expiry') + # if expiry is not passed in from gui, use today + if expiry == None: + self.exp_input.setDate(QDate.currentDate()) + else: + self.exp_input.setDate(expiry) + # widget to get reagent type info + self.type_input = QComboBox() + self.type_input.setObjectName('type') + # self.type_input.addItems([item.name for item in lookup_reagent_types(ctx=ctx)]) + self.type_input.addItems([item.name for item in ReagentType.query()]) + logger.debug(f"Trying to find index of {reagent_type}") + # convert input to user friendly string? + try: + reagent_type = reagent_type.replace("_", " ").title() + except AttributeError: + reagent_type = None + # set parsed reagent type to top of list + index = self.type_input.findText(reagent_type, Qt.MatchFlag.MatchEndsWith) + if index >= 0: + self.type_input.setCurrentIndex(index) + self.layout = QVBoxLayout() + self.layout.addWidget(QLabel("Name:")) + self.layout.addWidget(self.name_input) + self.layout.addWidget(QLabel("Lot:")) + self.layout.addWidget(self.lot_input) + self.layout.addWidget(QLabel("Expiry:\n(use exact date on reagent.\nEOL will be calculated from kit automatically)")) + self.layout.addWidget(self.exp_input) + self.layout.addWidget(QLabel("Type:")) + self.layout.addWidget(self.type_input) + self.layout.addWidget(self.buttonBox) + 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 + """ + logger.debug(self.type_input.currentText()) + self.name_input.clear() + # lookup = lookup_reagents(ctx=self.ctx, reagent_type=self.type_input.currentText()) + lookup = Reagent.query(reagent_type=self.type_input.currentText()) + self.name_input.addItems(list(set([item.name for item in lookup]))) + +class ReportDatePicker(QDialog): + """ + custom dialog to ask for report start/stop dates + """ + def __init__(self) -> None: + super().__init__() + + self.setWindowTitle("Select Report Date Range") + # make confirm/reject buttons + QBtn = QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel + self.buttonBox = QDialogButtonBox(QBtn) + self.buttonBox.accepted.connect(self.accept) + self.buttonBox.rejected.connect(self.reject) + # widgets to ask for dates + self.start_date = QDateEdit(calendarPopup=True) + self.start_date.setObjectName("start_date") + self.start_date.setDate(QDate.currentDate()) + self.end_date = QDateEdit(calendarPopup=True) + self.end_date.setObjectName("end_date") + self.end_date.setDate(QDate.currentDate()) + self.layout = QVBoxLayout() + self.layout.addWidget(QLabel("Start Date")) + self.layout.addWidget(self.start_date) + self.layout.addWidget(QLabel("End Date")) + self.layout.addWidget(self.end_date) + 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 +# """ +# def __init__(self) -> 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) +# scrollContent.setLayout(self.grid) +# # insert submit button at top +# self.submit_btn = QPushButton("Submit") +# self.grid.addWidget(self.submit_btn,0,0,1,1) +# self.grid.addWidget(QLabel("Kit Name:"),2,0) +# # widget to get kit name +# kit_name = QLineEdit() +# kit_name.setObjectName("kit_name") +# self.grid.addWidget(kit_name,2,1) +# 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([item.name for item in lookup_submission_type(ctx=parent_ctx)]) +# used_for.addItems([item.name for item in SubmissionType.query()]) +# used_for.setEditable(True) +# self.grid.addWidget(used_for,3,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.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: +# """ +# insert new reagent type row +# """ +# # get bottommost row +# maxrow = self.grid.rowCount() +# reg_form = ReagentTypeForm() +# reg_form.setObjectName(f"ReagentForm_{maxrow}") +# # self.grid.addWidget(reg_form, maxrow + 1,0,1,2) +# self.grid.addWidget(reg_form, maxrow,0,1,4) + + + +# def submit(self) -> None: +# """ +# send kit to database +# """ +# # get form info +# 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 +# logger.debug(pformat(info)) +# # send to kit constructor +# kit = PydKit(name=info['kit_name']) +# for reagent in info['reagent_types']: +# uses = { +# info['used_for']: +# {'sheet':reagent['sheet'], +# 'name':reagent['name'], +# 'lot':reagent['lot'], +# 'expiry':reagent['expiry'] +# }} +# kit.reagent_types.append(PydReagentType(name=reagent['rtname'], eol_ext=reagent['eol'], uses=uses)) +# logger.debug(f"Output pyd object: {kit.__dict__}") +# # result = construct_kit_from_yaml(ctx=self.ctx, kit_dict=info) +# sqlobj, result = kit.toSQL(self.ctx) +# sqlobj.save() +# 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) -> None: +# super().__init__() +# grid = QGridLayout() +# self.setLayout(grid) +# grid.addWidget(QLabel("Reagent Type Name"),0,0) +# # Widget to get reagent info +# self.reagent_getter = QComboBox() +# self.reagent_getter.setObjectName("rtname") +# # lookup all reagent type names from db +# # lookup = lookup_reagent_types(ctx=ctx) +# lookup = ReagentType.query() +# logger.debug(f"Looked up ReagentType names: {lookup}") +# self.reagent_getter.addItems([item.__str__() for item in lookup]) +# self.reagent_getter.setEditable(True) +# grid.addWidget(self.reagent_getter,0,1) +# grid.addWidget(QLabel("Extension of Life (months):"),0,2) +# # widget to get extension of life +# 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): +# """ +# custom widget to pick start and end dates for controls graphs +# """ +# def __init__(self) -> None: +# super().__init__() + +# self.start_date = QDateEdit(calendarPopup=True) +# # start date is two months prior to end date by default +# twomonthsago = QDate.currentDate().addDays(-60) +# self.start_date.setDate(twomonthsago) +# self.end_date = QDateEdit(calendarPopup=True) +# self.end_date.setDate(QDate.currentDate()) +# self.layout = QHBoxLayout() +# self.layout.addWidget(QLabel("Start Date")) +# self.layout.addWidget(self.start_date) +# self.layout.addWidget(QLabel("End Date")) +# self.layout.addWidget(self.end_date) +# self.setLayout(self.layout) +# self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) + +# def sizeHint(self) -> QSize: +# return QSize(80,20) + +class FirstStrandSalvage(QDialog): + + def __init__(self, ctx:Settings, submitter_id:str, rsl_plate_num:str|None=None) -> None: + super().__init__() + if rsl_plate_num == None: + rsl_plate_num = "" + self.setWindowTitle("Add Reagent") + + QBtn = QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel + + self.buttonBox = QDialogButtonBox(QBtn) + self.buttonBox.accepted.connect(self.accept) + self.buttonBox.rejected.connect(self.reject) + self.submitter_id_input = QLineEdit() + self.submitter_id_input.setText(submitter_id) + self.rsl_plate_num = QLineEdit() + self.rsl_plate_num.setText(rsl_plate_num) + self.row_letter = QComboBox() + self.row_letter.addItems(['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H']) + self.row_letter.setEditable(False) + self.column_number = QComboBox() + self.column_number.addItems(['1', '2', '3', '4', '5', '6', '7', '8', '9', '10', '11', '12']) + self.column_number.setEditable(False) + self.layout = QFormLayout() + self.layout.addRow(self.tr("&Sample Number:"), self.submitter_id_input) + self.layout.addRow(self.tr("&Plate Number:"), self.rsl_plate_num) + self.layout.addRow(self.tr("&Source Row:"), self.row_letter) + self.layout.addRow(self.tr("&Source Column:"), self.column_number) + self.layout.addWidget(self.buttonBox) + self.setLayout(self.layout) + + def parse_form(self): + return dict(plate=self.rsl_plate_num.text(), submitter_id=self.submitter_id_input.text(), well=f"{self.row_letter.currentText()}{self.column_number.currentText()}") + +class FirstStrandPlateList(QDialog): + + def __init__(self, ctx:Settings) -> None: + super().__init__() + self.setWindowTitle("First Strand Plates") + + QBtn = QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel + + self.buttonBox = QDialogButtonBox(QBtn) + self.buttonBox.accepted.connect(self.accept) + self.buttonBox.rejected.connect(self.reject) + # ww = [item.rsl_plate_num for item in lookup_submissions(ctx=ctx, submission_type="Wastewater")] + ww = [item.rsl_plate_num for item in BasicSubmission.query(submission_type="Wastewater")] + self.plate1 = QComboBox() + self.plate2 = QComboBox() + self.plate3 = QComboBox() + self.layout = QFormLayout() + for ii, plate in enumerate([self.plate1, self.plate2, self.plate3]): + plate.addItems(ww) + self.layout.addRow(self.tr(f"&Plate {ii+1}:"), plate) + self.layout.addWidget(self.buttonBox) + self.setLayout(self.layout) + + def parse_form(self): + output = [] + for plate in [self.plate1, self.plate2, self.plate3]: + output.append(plate.currentText()) + return output + +# class ReagentFormWidget(QWidget): + +# def __init__(self, parent:QWidget, reagent:PydReagent, extraction_kit:str): +# super().__init__(parent) +# # self.setParent(parent) +# self.reagent = reagent +# self.extraction_kit = extraction_kit +# # self.ctx = reagent.ctx +# layout = QVBoxLayout() +# self.label = self.ReagentParsedLabel(reagent=reagent) +# layout.addWidget(self.label) +# self.lot = self.ReagentLot(reagent=reagent, extraction_kit=extraction_kit) +# layout.addWidget(self.lot) +# # Remove spacing between reagents +# layout.setContentsMargins(0,0,0,0) +# self.setLayout(layout) +# self.setObjectName(reagent.name) +# self.missing = reagent.missing +# # If changed set self.missing to True and update self.label +# self.lot.currentTextChanged.connect(self.updated) + +# def parse_form(self) -> Tuple[PydReagent, dict]: +# lot = self.lot.currentText() +# # wanted_reagent = lookup_reagents(ctx=self.ctx, lot_number=lot, reagent_type=self.reagent.type) +# wanted_reagent = Reagent.query(lot_number=lot, reagent_type=self.reagent.type) +# # if reagent doesn't exist in database, off to add it (uses App.add_reagent) +# if wanted_reagent == None: +# dlg = QuestionAsker(title=f"Add {lot}?", message=f"Couldn't find reagent type {self.reagent.type}: {lot} in the database.\n\nWould you like to add it?") +# if dlg.exec(): +# wanted_reagent = self.parent().parent().parent().parent().parent().parent().parent().parent().parent.add_reagent(reagent_lot=lot, reagent_type=self.reagent.type, expiry=self.reagent.expiry, name=self.reagent.name) +# return wanted_reagent, None +# else: +# # In this case we will have an empty reagent and the submission will fail kit integrity check +# logger.debug("Will not add reagent.") +# return None, Result(msg="Failed integrity check", status="Critical") +# else: +# # Since this now gets passed in directly from the parser -> pyd -> form and the parser gets the name +# # from the db, it should no longer be necessary to query the db with reagent/kit, but with rt name directly. +# # rt = lookup_reagent_types(ctx=self.ctx, name=self.reagent.type) +# # rt = lookup_reagent_types(ctx=self.ctx, kit_type=self.extraction_kit, reagent=wanted_reagent) +# rt = ReagentType.query(name=self.reagent.type) +# if rt == None: +# # rt = lookup_reagent_types(ctx=self.ctx, kit_type=self.extraction_kit, reagent=wanted_reagent) +# rt = ReagentType.query(kit_type=self.extraction_kit, reagent=wanted_reagent) +# return PydReagent(name=wanted_reagent.name, lot=wanted_reagent.lot, type=rt.name, expiry=wanted_reagent.expiry, parsed=not self.missing), None + +# def updated(self): +# self.missing = True +# self.label.updated(self.reagent.type) + + +# class ReagentParsedLabel(QLabel): + +# def __init__(self, reagent:PydReagent): +# super().__init__() +# try: +# check = not reagent.missing +# except: +# check = False +# self.setObjectName(f"{reagent.type}_label") +# if check: +# self.setText(f"Parsed {reagent.type}") +# else: +# self.setText(f"MISSING {reagent.type}") + +# def updated(self, reagent_type:str): +# self.setText(f"UPDATED {reagent_type}") + +# class ReagentLot(QComboBox): + +# def __init__(self, reagent, extraction_kit:str) -> None: +# super().__init__() +# # self.ctx = reagent.ctx +# self.setEditable(True) +# # if reagent.parsed: +# # pass +# logger.debug(f"Attempting lookup of reagents by type: {reagent.type}") +# # below was lookup_reagent_by_type_name_and_kit_name, but I couldn't get it to work. +# # lookup = lookup_reagents(ctx=self.ctx, reagent_type=reagent.type) +# lookup = Reagent.query(reagent_type=reagent.type) +# relevant_reagents = [item.__str__() for item in lookup] +# output_reg = [] +# for rel_reagent in relevant_reagents: +# # extract strings from any sets. +# if isinstance(rel_reagent, set): +# for thing in rel_reagent: +# output_reg.append(thing) +# elif isinstance(rel_reagent, str): +# output_reg.append(rel_reagent) +# relevant_reagents = output_reg +# # if reagent in sheet is not found insert it into the front of relevant reagents so it shows +# logger.debug(f"Relevant reagents for {reagent.lot}: {relevant_reagents}") +# if str(reagent.lot) not in relevant_reagents: +# if check_not_nan(reagent.lot): +# relevant_reagents.insert(0, str(reagent.lot)) +# else: +# # TODO: look up the last used reagent of this type in the database +# # looked_up_rt = lookup_reagenttype_kittype_association(ctx=self.ctx, reagent_type=reagent.type, kit_type=extraction_kit) +# looked_up_rt = KitTypeReagentTypeAssociation.query(reagent_type=reagent.type, kit_type=extraction_kit) +# try: +# # looked_up_reg = lookup_reagents(ctx=self.ctx, lot_number=looked_up_rt.last_used) +# looked_up_reg = Reagent.query(lot_number=looked_up_rt.last_used) +# except AttributeError: +# looked_up_reg = None +# logger.debug(f"Because there was no reagent listed for {reagent.lot}, we will insert the last lot used: {looked_up_reg}") +# if looked_up_reg != None: +# relevant_reagents.remove(str(looked_up_reg.lot)) +# relevant_reagents.insert(0, str(looked_up_reg.lot)) +# else: +# if len(relevant_reagents) > 1: +# logger.debug(f"Found {reagent.lot} in relevant reagents: {relevant_reagents}. Moving to front of list.") +# idx = relevant_reagents.index(str(reagent.lot)) +# logger.debug(f"The index we got for {reagent.lot} in {relevant_reagents} was {idx}") +# moved_reag = relevant_reagents.pop(idx) +# relevant_reagents.insert(0, moved_reag) +# else: +# logger.debug(f"Found {reagent.lot} in relevant reagents: {relevant_reagents}. But no need to move due to short list.") +# logger.debug(f"New relevant reagents: {relevant_reagents}") +# self.setObjectName(f"lot_{reagent.type}") +# self.addItems(relevant_reagents) + +# class SubmissionFormWidget(QWidget): + +# def __init__(self, parent: QWidget, **kwargs) -> None: +# super().__init__(parent) +# # self.ignore = [None, "", "qt_spinbox_lineedit", "qt_scrollarea_viewport", "qt_scrollarea_hcontainer", +# # "qt_scrollarea_vcontainer", "submit_btn" +# # ] +# self.ignore = ['filepath', 'samples', 'reagents', 'csv', 'ctx'] +# layout = QVBoxLayout() +# for k, v in kwargs.items(): +# if k not in self.ignore: +# add_widget = self.create_widget(key=k, value=v, submission_type=kwargs['submission_type']) +# if add_widget != None: +# layout.addWidget(add_widget) +# else: +# setattr(self, k, v) + +# self.setLayout(layout) + +# def create_widget(self, key:str, value:dict, submission_type:str|None=None): +# if key not in self.ignore: +# return self.InfoItem(self, key=key, value=value, submission_type=submission_type) +# return None + +# def clear_form(self): +# for item in self.findChildren(QWidget): +# item.setParent(None) + +# def find_widgets(self, object_name:str|None=None) -> List[QWidget]: +# query = self.findChildren(QWidget) +# if object_name != None: +# query = [widget for widget in query if widget.objectName()==object_name] +# return query + +# def parse_form(self) -> PydSubmission: +# logger.debug(f"Hello from form parser!") +# info = {} +# reagents = [] +# if hasattr(self, 'csv'): +# info['csv'] = self.csv +# for widget in self.findChildren(QWidget): +# # logger.debug(f"Parsed widget of type {type(widget)}") +# match widget: +# case ReagentFormWidget(): +# reagent, _ = widget.parse_form() +# if reagent != None: +# reagents.append(reagent) +# case self.InfoItem(): +# field, value = widget.parse_form() +# if field != None: +# info[field] = value +# logger.debug(f"Info: {pformat(info)}") +# logger.debug(f"Reagents: {pformat(reagents)}") +# # app = self.parent().parent().parent().parent().parent().parent().parent().parent +# submission = PydSubmission(filepath=self.filepath, reagents=reagents, samples=self.samples, **info) +# return submission + +# class InfoItem(QWidget): + +# def __init__(self, parent: QWidget, key:str, value:dict, submission_type:str|None=None) -> None: +# super().__init__(parent) +# layout = QVBoxLayout() +# self.label = self.ParsedQLabel(key=key, value=value) +# self.input: QWidget = self.set_widget(parent=self, key=key, value=value, submission_type=submission_type['value']) +# self.setObjectName(key) +# try: +# self.missing:bool = value['missing'] +# except (TypeError, KeyError): +# self.missing:bool = True +# if self.input != None: +# layout.addWidget(self.label) +# layout.addWidget(self.input) +# layout.setContentsMargins(0,0,0,0) +# self.setLayout(layout) +# match self.input: +# case QComboBox(): +# self.input.currentTextChanged.connect(self.update_missing) +# case QDateEdit(): +# self.input.dateChanged.connect(self.update_missing) +# case QLineEdit(): +# self.input.textChanged.connect(self.update_missing) + +# def parse_form(self): +# match self.input: +# case QLineEdit(): +# value = self.input.text() +# case QComboBox(): +# value = self.input.currentText() +# case QDateEdit(): +# value = self.input.date().toPyDate() +# case _: +# return None, None +# return self.input.objectName(), dict(value=value, missing=self.missing) + +# def set_widget(self, parent: QWidget, key:str, value:dict, submission_type:str|None=None) -> QWidget: +# try: +# value = value['value'] +# except (TypeError, KeyError): +# pass +# obj = parent.parent().parent() +# logger.debug(f"Creating widget for: {key}") +# match key: +# case 'submitting_lab': +# add_widget = QComboBox() +# # lookup organizations suitable for submitting_lab (ctx: self.InfoItem.SubmissionFormWidget.SubmissionFormContainer.AddSubForm ) +# labs = [item.__str__() for item in Organization.query()] +# # try to set closest match to top of list +# try: +# labs = difflib.get_close_matches(value, labs, len(labs), 0) +# except (TypeError, ValueError): +# pass +# # set combobox values to lookedup values +# add_widget.addItems(labs) +# case 'extraction_kit': +# # if extraction kit not available, all other values fail +# if not check_not_nan(value): +# msg = AlertPop(message="Make sure to check your extraction kit in the excel sheet!", status="warning") +# msg.exec() +# # create combobox to hold looked up kits +# add_widget = QComboBox() +# # lookup existing kits by 'submission_type' decided on by sheetparser +# logger.debug(f"Looking up kits used for {submission_type}") +# uses = [item.__str__() for item in KitType.query(used_for=submission_type)] +# obj.uses = uses +# logger.debug(f"Kits received for {submission_type}: {uses}") +# if check_not_nan(value): +# logger.debug(f"The extraction kit in parser was: {value}") +# uses.insert(0, uses.pop(uses.index(value))) +# obj.ext_kit = value +# else: +# logger.error(f"Couldn't find {obj.prsr.sub['extraction_kit']}") +# obj.ext_kit = uses[0] +# add_widget.addItems(uses) +# # Run reagent scraper whenever extraction kit is changed. +# # add_widget.currentTextChanged.connect(obj.scrape_reagents) +# case 'submitted_date': +# # uses base calendar +# add_widget = QDateEdit(calendarPopup=True) +# # sets submitted date based on date found in excel sheet +# try: +# add_widget.setDate(value) +# # if not found, use today +# except: +# add_widget.setDate(date.today()) +# case 'submission_category': +# add_widget = QComboBox() +# cats = ['Diagnostic', "Surveillance", "Research"] +# # cats += [item.name for item in lookup_submission_type(ctx=obj.ctx)] +# cats += [item.name for item in SubmissionType.query()] +# try: +# cats.insert(0, cats.pop(cats.index(value))) +# except ValueError: +# cats.insert(0, cats.pop(cats.index(submission_type))) +# add_widget.addItems(cats) +# case _: +# # anything else gets added in as a line edit +# add_widget = QLineEdit() +# logger.debug(f"Setting widget text to {str(value).replace('_', ' ')}") +# add_widget.setText(str(value).replace("_", " ")) +# if add_widget != None: +# add_widget.setObjectName(key) +# add_widget.setParent(parent) + +# return add_widget + +# def update_missing(self): +# self.missing = True +# self.label.updated(self.objectName()) + +# class ParsedQLabel(QLabel): + +# def __init__(self, key:str, value:dict, title:bool=True, label_name:str|None=None): +# super().__init__() +# try: +# check = not value['missing'] +# except: +# check = True +# if label_name != None: +# self.setObjectName(label_name) +# else: +# self.setObjectName(f"{key}_label") +# if title: +# output = key.replace('_', ' ').title() +# else: +# output = key.replace('_', ' ') +# if check: +# self.setText(f"Parsed {output}") +# else: +# self.setText(f"MISSING {output}") + +# def updated(self, key:str, title:bool=True): +# if title: +# output = key.replace('_', ' ').title() +# else: +# output = key.replace('_', ' ') +# self.setText(f"UPDATED {output}") + diff --git a/src/submissions/frontend/custom_widgets/pop_ups.py b/src/submissions/frontend/widgets/pop_ups.py similarity index 100% rename from src/submissions/frontend/custom_widgets/pop_ups.py rename to src/submissions/frontend/widgets/pop_ups.py diff --git a/src/submissions/frontend/custom_widgets/sub_details.py b/src/submissions/frontend/widgets/submission_table.py similarity index 61% rename from src/submissions/frontend/custom_widgets/sub_details.py rename to src/submissions/frontend/widgets/submission_table.py index eea8990..bcea319 100644 --- a/src/submissions/frontend/custom_widgets/sub_details.py +++ b/src/submissions/frontend/widgets/submission_table.py @@ -17,15 +17,19 @@ from PyQt6.QtCore import Qt, QAbstractTableModel, QSortFilterProxyModel from PyQt6.QtGui import QAction, QCursor, QPixmap, QPainter from backend.db.functions import submissions_to_df from backend.db.models import BasicSubmission -from backend.excel import make_hitpicks -from tools import check_if_app, Settings +from backend.excel import make_hitpicks, make_report_html, make_report_xlsx +from tools import check_if_app, Settings, Report, Result from tools import jinja_template_loading 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, make_plate_map_html +from .functions import select_save_file, select_open_file +from .misc import ReportDatePicker +import pandas as pd from getpass import getuser +import json logger = logging.getLogger(f"submissions.{__name__}") @@ -79,15 +83,17 @@ class SubmissionsSheet(QTableView): """ presents submission summary to user in tab1 """ - def __init__(self, ctx:Settings) -> None: + def __init__(self, parent) -> None: """ initialize Args: ctx (dict): settings passed from gui """ - super().__init__() - self.ctx = ctx + super().__init__(parent) + self.app = self.parent() + # self.ctx = ctx + self.report = Report() self.setData() self.resizeColumnsToContents() self.resizeRowsToContents() @@ -110,14 +116,14 @@ class SubmissionsSheet(QTableView): proxyModel.setSourceModel(pandasModel(self.data)) self.setModel(proxyModel) - def show_details(self) -> None: """ creates detailed data to show in seperate window """ + logger.debug(f"Sheet.app: {self.app}") index = (self.selectionModel().currentIndex()) value = index.sibling(index.row(),0).data() - dlg = SubmissionDetails(ctx=self.ctx, id=value) + dlg = SubmissionDetails(parent=self, id=value) if dlg.exec(): pass @@ -245,15 +251,210 @@ class SubmissionsSheet(QTableView): image.show() except Exception as e: logger.error(f"Could not show image: {e}.") - + + def link_extractions(self): + self.link_extractions_function() + self.app.report.add_result(self.report) + self.report = Report() + self.app.result_reporter() + + def link_extractions_function(self): + """ + Link extractions from runlogs to imported submissions + + Args: + obj (QMainWindow): original app window + + Returns: + Tuple[QMainWindow, dict]: Collection of new main app window and result dict + """ + fname = select_open_file(self, file_extension="csv") + with open(fname.__str__(), 'r') as f: + # split csv on commas + runs = [col.strip().split(",") for col in f.readlines()] + count = 0 + for run in runs: + new_run = dict( + start_time=run[0].strip(), + rsl_plate_num=run[1].strip(), + sample_count=run[2].strip(), + status=run[3].strip(), + experiment_name=run[4].strip(), + end_time=run[5].strip() + ) + # elution columns are item 6 in the comma split list to the end + for ii in range(6, len(run)): + new_run[f"column{str(ii-5)}_vol"] = run[ii] + # Lookup imported submissions + # sub = lookup_submission_by_rsl_num(ctx=obj.ctx, rsl_num=new_run['rsl_plate_num']) + # sub = lookup_submissions(ctx=obj.ctx, rsl_number=new_run['rsl_plate_num']) + sub = BasicSubmission.query(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 + except AttributeError: + continue + if sub.extraction_info != None: + existing = json.loads(sub.extraction_info) + else: + existing = None + # Check if the new info already exists in the imported submission + try: + if json.dumps(new_run) in sub.extraction_info: + logger.debug(f"Looks like we already have that info.") + continue + except TypeError: + pass + # Update or create the extraction info + if existing != None: + try: + logger.debug(f"Updating {type(existing)}: {existing} with {type(new_run)}: {new_run}") + existing.append(new_run) + logger.debug(f"Setting: {existing}") + sub.extraction_info = json.dumps(existing) + except TypeError: + logger.error(f"Error updating!") + sub.extraction_info = json.dumps([new_run]) + logger.debug(f"Final ext info for {sub.rsl_plate_num}: {sub.extraction_info}") + else: + sub.extraction_info = json.dumps([new_run]) + sub.save() + self.report.add_result(Result(msg=f"We added {count} logs to the database.", status='Information')) + + def link_pcr(self): + self.link_pcr_function() + self.app.report.add_result(self.report) + self.report = Report() + self.app.result_reporter() + + def link_pcr_function(self): + """ + Link PCR data from run logs to an imported submission + + Args: + obj (QMainWindow): original app window + + Returns: + Tuple[QMainWindow, dict]: Collection of new main app window and result dict + """ + fname = select_open_file(self, file_extension="csv") + with open(fname.__str__(), 'r') as f: + # split csv rows on comma + runs = [col.strip().split(",") for col in f.readlines()] + count = 0 + for run in runs: + new_run = dict( + start_time=run[0].strip(), + rsl_plate_num=run[1].strip(), + biomek_status=run[2].strip(), + quant_status=run[3].strip(), + experiment_name=run[4].strip(), + end_time=run[5].strip() + ) + # lookup imported submission + # sub = lookup_submission_by_rsl_num(ctx=obj.ctx, rsl_num=new_run['rsl_plate_num']) + # sub = lookup_submissions(ctx=obj.ctx, rsl_number=new_run['rsl_plate_num']) + sub = BasicSubmission.query(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: + continue + # check if pcr_info already exists + if hasattr(sub, 'pcr_info') and sub.pcr_info != None: + existing = json.loads(sub.pcr_info) + else: + existing = None + # check if this entry already exists in imported submission + try: + if json.dumps(new_run) in sub.pcr_info: + logger.debug(f"Looks like we already have that info.") + continue + else: + count += 1 + except TypeError: + logger.error(f"No json to dump") + if existing != None: + try: + logger.debug(f"Updating {type(existing)}: {existing} with {type(new_run)}: {new_run}") + existing.append(new_run) + logger.debug(f"Setting: {existing}") + sub.pcr_info = json.dumps(existing) + except TypeError: + logger.error(f"Error updating!") + sub.pcr_info = json.dumps([new_run]) + logger.debug(f"Final ext info for {sub.rsl_plate_num}: {sub.pcr_info}") + else: + sub.pcr_info = json.dumps([new_run]) + sub.save() + self.report.add_result(Result(msg=f"We added {count} logs to the database.", status='Information')) + + def generate_report(self): + self.generate_report_function() + self.app.report.add_result(self.report) + self.report = Report() + self.app.result_reporter() + + def generate_report_function(self): + """ + Generate a summary of activities for a time period + + Args: + obj (QMainWindow): original app window + + Returns: + Tuple[QMainWindow, dict]: Collection of new main app window and result dict + """ + report = Report() + # ask for date ranges + dlg = ReportDatePicker() + if dlg.exec(): + info = dlg.parse_form() + logger.debug(f"Report info: {info}") + # find submissions based on date range + subs = BasicSubmission.query(start_date=info['start_date'], end_date=info['end_date']) + # convert each object to dict + records = [item.report_dict() for item in subs] + # make dataframe from record dictionaries + detailed_df, summary_df = make_report_xlsx(records=records) + html = make_report_html(df=summary_df, start_date=info['start_date'], end_date=info['end_date']) + # get save location of report + fname = select_save_file(obj=self, default_name=f"Submissions_Report_{info['start_date']}-{info['end_date']}.pdf", extension="pdf") + with open(fname, "w+b") as f: + pisa.CreatePDF(html, dest=f) + writer = pd.ExcelWriter(fname.with_suffix(".xlsx"), engine='openpyxl') + summary_df.to_excel(writer, sheet_name="Report") + detailed_df.to_excel(writer, sheet_name="Details", index=False) + worksheet = writer.sheets['Report'] + for idx, col in enumerate(summary_df): # loop through all columns + series = summary_df[col] + max_len = max(( + series.astype(str).map(len).max(), # len of largest item + len(str(series.name)) # len of column name/header + )) + 20 # adding a little extra space + try: + worksheet.column_dimensions[get_column_letter(idx)].width = max_len + except ValueError: + pass + for cell in worksheet['D']: + if cell.row > 1: + cell.style = 'Currency' + writer.close() + self.report.add_result(report) + class SubmissionDetails(QDialog): """ a window showing text details of submission """ - def __init__(self, ctx:dict, id:int) -> None: + def __init__(self, parent, id:int) -> None: - super().__init__() - self.ctx = ctx + super().__init__(parent) + # self.ctx = ctx self.setWindowTitle("Submission Details") # create scrollable interior interior = QScrollArea() @@ -300,14 +501,15 @@ class SubmissionDetails(QDialog): """ Renders submission to html, then creates and saves .pdf file to user selected file. """ - try: - home_dir = Path(self.ctx.directory_path).joinpath(f"Submission_Details_{self.base_dict['Plate Number']}.pdf").resolve().__str__() - except FileNotFoundError: - home_dir = Path.home().resolve().__str__() - fname = Path(QFileDialog.getSaveFileName(self, "Save File", home_dir, filter=".pdf")[0]) - if fname.__str__() == ".": - logger.debug("Saving pdf was cancelled.") - return + # try: + # home_dir = Path(self.ctx.directory_path).joinpath(f"Submission_Details_{self.base_dict['Plate Number']}.pdf").resolve().__str__() + # except FileNotFoundError: + # home_dir = Path.home().resolve().__str__() + # fname = Path(QFileDialog.getSaveFileName(self, "Save File", home_dir, filter=".pdf")[0]) + # if fname.__str__() == ".": + # logger.debug("Saving pdf was cancelled.") + # return + fname = select_save_file(obj=self, default_name=self.base_dict['Plate Number'], extension="pdf") del self.base_dict['platemap'] export_map = make_plate_map(self.plate_dicto) image_io = BytesIO() diff --git a/src/submissions/frontend/widgets/submission_widget.py b/src/submissions/frontend/widgets/submission_widget.py new file mode 100644 index 0000000..c9fbdaf --- /dev/null +++ b/src/submissions/frontend/widgets/submission_widget.py @@ -0,0 +1,747 @@ +from PyQt6.QtWidgets import ( + QWidget, QPushButton, QVBoxLayout, + QComboBox, QDateEdit, QLineEdit, QLabel +) +from PyQt6.QtCore import pyqtSignal +from pathlib import Path +from . import select_open_file, select_save_file +import logging +from pathlib import Path +from tools import Report, Result, check_not_nan +from backend.excel.parser import SheetParser, PCRParser +from backend.validators import PydSubmission, PydReagent +from backend.db import ( + check_kit_integrity, update_last_used, KitType, Organization, SubmissionType, Reagent, + ReagentType, KitTypeReagentTypeAssociation, BasicSubmission, update_subsampassoc_with_pcr +) +from pprint import pformat +from .pop_ups import QuestionAsker, AlertPop +# from .misc import ReagentFormWidget +from typing import List, Tuple +import difflib +from datetime import date +import inspect +import json + + +logger = logging.getLogger(f"submissions.{__name__}") + +class SubmissionFormContainer(QWidget): + + import_drag = pyqtSignal(Path) + + def __init__(self, parent: QWidget) -> None: + logger.debug(f"Setting form widget...") + super().__init__(parent) + self.app = self.parent().parent#().parent().parent().parent().parent().parent + self.report = Report() + # self.parent = parent + self.setAcceptDrops(True) + self.import_drag.connect(self.importSubmission) + + def dragEnterEvent(self, event): + if event.mimeData().hasUrls(): + event.accept() + else: + event.ignore() + + def dropEvent(self, event): + fname = Path([u.toLocalFile() for u in event.mimeData().urls()][0]) + + logger.debug(f"App: {self.app}") + self.app.last_dir = fname.parent + self.import_drag.emit(fname) + + def importSubmission(self, fname:Path|None=None): + """ + import submission from excel sheet into form + """ + # from .main_window_functions import import_submission_function + self.app.raise_() + self.app.activateWindow() + self.import_submission_function(fname) + logger.debug(f"Result from result reporter: {self.report.results}") + self.app.report.add_result(self.report) + self.report = Report() + self.app.result_reporter() + + def scrape_reagents(self, *args, **kwargs): + # from .main_window_functions import scrape_reagents + # logger.debug(f"Args: {args}") + # logger.debug(F"kwargs: {kwargs}") + print(f"\n\n{inspect.stack()[1].function}\n\n") + self.scrape_reagents_function(args[0]) + self.kit_integrity_completion() + self.app.report.add_result(self.report) + self.report = Report() + match inspect.stack()[1].function: + case "import_submission_function": + pass + case _: + self.app.result_reporter() + + # def kit_reload_function(self): + # """ + # Reload the fields in the form + + # Args: + # obj (QMainWindow): original app window + + # Returns: + # Tuple[QMainWindow, dict]: Collection of new main app window and result dict + # """ + # report = Report() + # # for item in obj.table_widget.formlayout.parentWidget().findChildren(QWidget): + # logger.debug(f"Attempting to clear {obj.form.find_widgets()}") + # for item in self.form.find_widgets(): + # if isinstance(item, ReagentFormWidget): + # item.setParent(None) + # self.kit_integrity_completion_function() + # self.report.add_result(report) + + + + def kit_integrity_completion(self): + """ + Performs check of imported reagents + NOTE: this will not change self.reagents which should be fine + since it's only used when looking up + """ + # from .main_window_functions import kit_integrity_completion_function + self.kit_integrity_completion_function() + self.app.report.add_result(self.report) + self.report = Report() + match inspect.stack()[1].function: + case "import_submission_function": + pass + case _: + self.app.result_reporter() + + def submit_new_sample(self): + """ + Attempt to add sample to database when 'submit' button clicked + """ + # from .main_window_functions import submit_new_sample_function + self.submit_new_sample_function() + self.app.report.add_result(self.report) + self.report = Report() + self.app.result_reporter() + + def export_csv(self, fname:Path|None=None): + # from .main_window_functions import export_csv_function + self.export_csv_function(fname) + + def import_submission_function(self, fname:Path|None=None): + """ + Import a new submission to the app window + + Args: + obj (QMainWindow): original app window + + Returns: + Tuple[QMainWindow, dict|None]: Collection of new main app window and result dict + """ + logger.debug(f"\n\nStarting Import...\n\n") + report = Report() + # logger.debug(obj.ctx) + # initialize samples + try: + self.form.setParent(None) + except AttributeError: + pass + self.samples = [] + self.missing_info = [] + # set file dialog + if isinstance(fname, bool) or fname == None: + fname = select_open_file(self, file_extension="xlsx") + logger.debug(f"Attempting to parse file: {fname}") + if not fname.exists(): + # result = dict(message=f"File {fname.__str__()} not found.", status="critical") + report.add_result(Result(msg=f"File {fname.__str__()} not found.", status="critical")) + self.report.add_result(report) + return + # create sheetparser using excel sheet and context from gui + try: + self.prsr = SheetParser(ctx=self.ctx, filepath=fname) + except PermissionError: + logger.error(f"Couldn't get permission to access file: {fname}") + return + except AttributeError: + self.prsr = SheetParser(ctx=self.app.ctx, filepath=fname) + try: + logger.debug(f"Submission dictionary:\n{pformat(self.prsr.sub)}") + self.pyd = self.prsr.to_pydantic() + logger.debug(f"Pydantic result: \n\n{pformat(self.pyd)}\n\n") + except Exception as e: + report.add_result(Result(msg=f"Problem creating pydantic model:\n\n{e}", status="Critical")) + self.report.add_result(report) + return + self.form = self.pyd.toForm(parent=self) + self.layout().addWidget(self.form) + kit_widget = self.form.find_widgets(object_name="extraction_kit")[0].input + logger.debug(f"Kitwidget {kit_widget}") + self.scrape_reagents(kit_widget.currentText()) + kit_widget.currentTextChanged.connect(self.scrape_reagents) + # compare obj.reagents with expected reagents in kit + if self.prsr.sample_result != None: + report.add_result(msg=self.prsr.sample_result, status="Warning") + self.report.add_result(report) + logger.debug(f"Outgoing report: {self.report.results}") + logger.debug(f"All attributes of submission container:\n{pformat(self.__dict__)}") + + def scrape_reagents_function(self, extraction_kit:str): + """ + Extracted scrape reagents function that will run when + form 'extraction_kit' widget is updated. + + Args: + obj (QMainWindow): updated main application + extraction_kit (str): name of extraction kit (in 'extraction_kit' widget) + + Returns: + Tuple[QMainWindow, dict]: Updated application and result + """ + report = Report() + logger.debug(f"Extraction kit: {extraction_kit}") + # obj.reagents = [] + # obj.missing_reagents = [] + # Remove previous reagent widgets + try: + old_reagents = self.form.find_widgets() + except AttributeError: + logger.error(f"Couldn't find old reagents.") + old_reagents = [] + # logger.debug(f"\n\nAttempting to clear: {old_reagents}\n\n") + for reagent in old_reagents: + if isinstance(reagent, ReagentFormWidget) or isinstance(reagent, QPushButton): + reagent.setParent(None) + # reagents = obj.prsr.parse_reagents(extraction_kit=extraction_kit) + # logger.debug(f"Got reagents: {reagents}") + # for reagent in obj.prsr.sub['reagents']: + # # create label + # if reagent.parsed: + # obj.reagents.append(reagent) + # else: + # obj.missing_reagents.append(reagent) + self.form.reagents = self.prsr.sub['reagents'] + # logger.debug(f"Imported reagents: {obj.reagents}") + # logger.debug(f"Missing reagents: {obj.missing_reagents}") + self.report.add_result(report) + logger.debug(f"Outgoing report: {self.report.results}") + + def kit_integrity_completion_function(self): + """ + Compare kit contents to parsed contents + + Args: + obj (QMainWindow): The original app window + + Returns: + Tuple[QMainWindow, dict]: Collection of new main app window and result dict + """ + report = Report() + missing_reagents = [] + # logger.debug(inspect.currentframe().f_back.f_code.co_name) + # find the widget that contains kit info + kit_widget = self.form.find_widgets(object_name="extraction_kit")[0].input + logger.debug(f"Kit selector: {kit_widget}") + # get current kit being used + self.ext_kit = kit_widget.currentText() + # for reagent in obj.pyd.reagents: + for reagent in self.form.reagents: + add_widget = ReagentFormWidget(parent=self, reagent=reagent, extraction_kit=self.ext_kit) + # add_widget.setParent(sub_form_container.form) + self.form.layout().addWidget(add_widget) + if reagent.missing: + missing_reagents.append(reagent) + logger.debug(f"Checking integrity of {self.ext_kit}") + # TODO: put check_kit_integrity here instead of what's here? + # see if there are any missing reagents + if len(missing_reagents) > 0: + result = Result(msg=f"""The submission you are importing is missing some reagents expected by the kit.\n\n + It looks like you are missing: {[item.type.upper() for item in missing_reagents]}\n\n + Alternatively, 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!""".replace(" ", ""), status="Warning") + report.add_result(result) + if hasattr(self.pyd, "csv"): + export_csv_btn = QPushButton("Export CSV") + export_csv_btn.setObjectName("export_csv_btn") + self.form.layout().addWidget(export_csv_btn) + export_csv_btn.clicked.connect(self.export_csv) + submit_btn = QPushButton("Submit") + submit_btn.setObjectName("submit_btn") + self.form.layout().addWidget(submit_btn) + submit_btn.clicked.connect(self.submit_new_sample) + self.report.add_result(report) + logger.debug(f"Outgoing report: {self.report.results}") + + def submit_new_sample_function(self) -> QWidget: + """ + Parse forms and add sample to the database. + + Args: + obj (QMainWindow): original app window + + Returns: + Tuple[QMainWindow, dict]: Collection of new main app window and result dict + """ + logger.debug(f"\n\nBeginning Submission\n\n") + report = Report() + self.pyd: PydSubmission = self.form.parse_form() + logger.debug(f"Submission: {pformat(self.pyd)}") + logger.debug("Checking kit integrity...") + result = check_kit_integrity(sub=self.pyd) + report.add_result(result) + if len(result.results) > 0: + self.report.add_result(report) + return + base_submission, result = self.pyd.toSQL() + # check output message for issues + match result.code: + # code 0: everything is fine. + case 0: + self.report.add_result(None) + # code 1: ask for overwrite + case 1: + dlg = QuestionAsker(title=f"Review {base_submission.rsl_plate_num}?", message=result['message']) + if dlg.exec(): + # Do not add duplicate reagents. + # base_submission.reagents = [] + result = None + else: + self.app.ctx.database_session.rollback() + self.report.add_result(Result(msg="Overwrite cancelled", status="Information")) + return + # code 2: No RSL plate number given + case 2: + self.report.add_result(result) + return + case _: + pass + # add reagents to submission object + for reagent in base_submission.reagents: + update_last_used(reagent=reagent, kit=base_submission.extraction_kit) + logger.debug(f"Here is the final submission: {pformat(base_submission.__dict__)}") + logger.debug(f"Parsed reagents: {pformat(base_submission.reagents)}") + logger.debug(f"Sending submission: {base_submission.rsl_plate_num} to database.") + base_submission.save() + # update summary sheet + self.app.table_widget.sub_wid.setData() + # reset form + self.form.setParent(None) + logger.debug(f"All attributes of obj: {pformat(self.__dict__)}") + wkb = self.pyd.autofill_excel() + if wkb != None: + fname = select_save_file(obj=self, default_name=self.pyd.construct_filename(), extension="xlsx") + try: + wkb.save(filename=fname.__str__()) + except PermissionError: + logger.error("Hit a permission error when saving workbook. Cancelled?") + if hasattr(self.pyd, 'csv'): + dlg = QuestionAsker("Export CSV?", "Would you like to export the csv file?") + if dlg.exec(): + fname = select_save_file(self, f"{self.pyd.construct_filename()}.csv", extension="csv") + try: + self.pyd.csv.to_csv(fname.__str__(), index=False) + except PermissionError: + logger.debug(f"Could not get permissions to {fname}. Possibly the request was cancelled.") + self.report.add_result(report) + + def export_csv_function(self, fname:Path|None=None): + if isinstance(fname, bool) or fname == None: + fname = select_save_file(obj=self, default_name=self.pyd.construct_filename(), extension="csv") + try: + self.pyd.csv.to_csv(fname.__str__(), index=False) + except PermissionError: + logger.debug(f"Could not get permissions to {fname}. Possibly the request was cancelled.") + + def import_pcr_results(self): + self.import_pcr_results_function() + self.app.report.add_result(self.report) + self.report = Report() + self.app.result_reporter() + + def import_pcr_results_function(self): + """ + Import Quant-studio PCR data to an imported submission + + Args: + obj (QMainWindow): original app window + + Returns: + Tuple[QMainWindow, dict]: Collection of new main app window and result dict + """ + report = Report() + fname = select_open_file(self, file_extension="xlsx") + parser = PCRParser(filepath=fname) + logger.debug(f"Attempting lookup for {parser.plate_num}") + # sub = lookup_submission_by_rsl_num(ctx=obj.ctx, rsl_num=parser.plate_num) + sub = BasicSubmission.query(rsl_number=parser.plate_num) + try: + logger.debug(f"Found submission: {sub.rsl_plate_num}") + except AttributeError: + # If no plate is found, may be because this is a repeat. Lop off the '-1' or '-2' and repeat + logger.error(f"Submission of number {parser.plate_num} not found. Attempting rescue of plate repeat.") + parser.plate_num = "-".join(parser.plate_num.split("-")[:-1]) + # sub = lookup_submission_by_rsl_num(ctx=obj.ctx, rsl_num=parser.plate_num) + # sub = lookup_submissions(ctx=obj.ctx, rsl_number=parser.plate_num) + sub = BasicSubmission.query(rsl_number=parser.plate_num) + try: + logger.debug(f"Found submission: {sub.rsl_plate_num}") + except AttributeError: + logger.error(f"Rescue of {parser.plate_num} failed.") + # return obj, dict(message="Couldn't find a submission with that RSL number.", status="warning") + self.report.add_result(Result(msg="Couldn't find a submission with that RSL number.", status="Warning")) + return + # Check if PCR info already exists + if hasattr(sub, 'pcr_info') and sub.pcr_info != None: + existing = json.loads(sub.pcr_info) + else: + existing = None + if existing != None: + # update pcr_info + try: + logger.debug(f"Updating {type(existing)}: {existing} with {type(parser.pcr)}: {parser.pcr}") + if json.dumps(parser.pcr) not in sub.pcr_info: + existing.append(parser.pcr) + logger.debug(f"Setting: {existing}") + sub.pcr_info = json.dumps(existing) + except TypeError: + logger.error(f"Error updating!") + sub.pcr_info = json.dumps([parser.pcr]) + logger.debug(f"Final pcr info for {sub.rsl_plate_num}: {sub.pcr_info}") + else: + sub.pcr_info = json.dumps([parser.pcr]) + # obj.ctx.database_session.add(sub) + logger.debug(f"Existing {type(sub.pcr_info)}: {sub.pcr_info}") + logger.debug(f"Inserting {type(json.dumps(parser.pcr))}: {json.dumps(parser.pcr)}") + sub.save() + logger.debug(f"Got {len(parser.samples)} samples to update!") + logger.debug(f"Parser samples: {parser.samples}") + for sample in sub.samples: + logger.debug(f"Running update on: {sample}") + try: + sample_dict = [item for item in parser.samples if item['sample']==sample.rsl_number][0] + except IndexError: + continue + update_subsampassoc_with_pcr(submission=sub, sample=sample, input_dict=sample_dict) + self.report.add_result(Result(msg=f"We added PCR info to {sub.rsl_plate_num}.", status='Information')) + # return obj, result + +class SubmissionFormWidget(QWidget): + + def __init__(self, parent: QWidget, **kwargs) -> None: + super().__init__(parent) + # self.ignore = [None, "", "qt_spinbox_lineedit", "qt_scrollarea_viewport", "qt_scrollarea_hcontainer", + # "qt_scrollarea_vcontainer", "submit_btn" + # ] + self.ignore = ['filepath', 'samples', 'reagents', 'csv', 'ctx'] + layout = QVBoxLayout() + for k, v in kwargs.items(): + if k not in self.ignore: + add_widget = self.create_widget(key=k, value=v, submission_type=kwargs['submission_type']) + if add_widget != None: + layout.addWidget(add_widget) + else: + setattr(self, k, v) + + self.setLayout(layout) + + def create_widget(self, key:str, value:dict, submission_type:str|None=None): + if key not in self.ignore: + return self.InfoItem(self, key=key, value=value, submission_type=submission_type) + return None + + def clear_form(self): + for item in self.findChildren(QWidget): + item.setParent(None) + + def find_widgets(self, object_name:str|None=None) -> List[QWidget]: + query = self.findChildren(QWidget) + if object_name != None: + query = [widget for widget in query if widget.objectName()==object_name] + return query + + def parse_form(self) -> PydSubmission: + logger.debug(f"Hello from form parser!") + info = {} + reagents = [] + if hasattr(self, 'csv'): + info['csv'] = self.csv + for widget in self.findChildren(QWidget): + # logger.debug(f"Parsed widget of type {type(widget)}") + match widget: + case ReagentFormWidget(): + reagent, _ = widget.parse_form() + if reagent != None: + reagents.append(reagent) + case self.InfoItem(): + field, value = widget.parse_form() + if field != None: + info[field] = value + logger.debug(f"Info: {pformat(info)}") + logger.debug(f"Reagents: {pformat(reagents)}") + # app = self.parent().parent().parent().parent().parent().parent().parent().parent + submission = PydSubmission(filepath=self.filepath, reagents=reagents, samples=self.samples, **info) + return submission + + class InfoItem(QWidget): + + def __init__(self, parent: QWidget, key:str, value:dict, submission_type:str|None=None) -> None: + super().__init__(parent) + layout = QVBoxLayout() + self.label = self.ParsedQLabel(key=key, value=value) + self.input: QWidget = self.set_widget(parent=self, key=key, value=value, submission_type=submission_type['value']) + self.setObjectName(key) + try: + self.missing:bool = value['missing'] + except (TypeError, KeyError): + self.missing:bool = True + if self.input != None: + layout.addWidget(self.label) + layout.addWidget(self.input) + layout.setContentsMargins(0,0,0,0) + self.setLayout(layout) + match self.input: + case QComboBox(): + self.input.currentTextChanged.connect(self.update_missing) + case QDateEdit(): + self.input.dateChanged.connect(self.update_missing) + case QLineEdit(): + self.input.textChanged.connect(self.update_missing) + + def parse_form(self): + match self.input: + case QLineEdit(): + value = self.input.text() + case QComboBox(): + value = self.input.currentText() + case QDateEdit(): + value = self.input.date().toPyDate() + case _: + return None, None + return self.input.objectName(), dict(value=value, missing=self.missing) + + def set_widget(self, parent: QWidget, key:str, value:dict, submission_type:str|None=None) -> QWidget: + try: + value = value['value'] + except (TypeError, KeyError): + pass + obj = parent.parent().parent() + logger.debug(f"Creating widget for: {key}") + match key: + case 'submitting_lab': + add_widget = QComboBox() + # lookup organizations suitable for submitting_lab (ctx: self.InfoItem.SubmissionFormWidget.SubmissionFormContainer.AddSubForm ) + labs = [item.__str__() for item in Organization.query()] + # try to set closest match to top of list + try: + labs = difflib.get_close_matches(value, labs, len(labs), 0) + except (TypeError, ValueError): + pass + # set combobox values to lookedup values + add_widget.addItems(labs) + case 'extraction_kit': + # if extraction kit not available, all other values fail + if not check_not_nan(value): + msg = AlertPop(message="Make sure to check your extraction kit in the excel sheet!", status="warning") + msg.exec() + # create combobox to hold looked up kits + add_widget = QComboBox() + # lookup existing kits by 'submission_type' decided on by sheetparser + logger.debug(f"Looking up kits used for {submission_type}") + uses = [item.__str__() for item in KitType.query(used_for=submission_type)] + obj.uses = uses + logger.debug(f"Kits received for {submission_type}: {uses}") + if check_not_nan(value): + logger.debug(f"The extraction kit in parser was: {value}") + uses.insert(0, uses.pop(uses.index(value))) + obj.ext_kit = value + else: + logger.error(f"Couldn't find {obj.prsr.sub['extraction_kit']}") + obj.ext_kit = uses[0] + add_widget.addItems(uses) + # Run reagent scraper whenever extraction kit is changed. + # add_widget.currentTextChanged.connect(obj.scrape_reagents) + case 'submitted_date': + # uses base calendar + add_widget = QDateEdit(calendarPopup=True) + # sets submitted date based on date found in excel sheet + try: + add_widget.setDate(value) + # if not found, use today + except: + add_widget.setDate(date.today()) + case 'submission_category': + add_widget = QComboBox() + cats = ['Diagnostic', "Surveillance", "Research"] + # cats += [item.name for item in lookup_submission_type(ctx=obj.ctx)] + cats += [item.name for item in SubmissionType.query()] + try: + cats.insert(0, cats.pop(cats.index(value))) + except ValueError: + cats.insert(0, cats.pop(cats.index(submission_type))) + add_widget.addItems(cats) + case _: + # anything else gets added in as a line edit + add_widget = QLineEdit() + logger.debug(f"Setting widget text to {str(value).replace('_', ' ')}") + add_widget.setText(str(value).replace("_", " ")) + if add_widget != None: + add_widget.setObjectName(key) + add_widget.setParent(parent) + + return add_widget + + def update_missing(self): + self.missing = True + self.label.updated(self.objectName()) + + class ParsedQLabel(QLabel): + + def __init__(self, key:str, value:dict, title:bool=True, label_name:str|None=None): + super().__init__() + try: + check = not value['missing'] + except: + check = True + if label_name != None: + self.setObjectName(label_name) + else: + self.setObjectName(f"{key}_label") + if title: + output = key.replace('_', ' ').title() + else: + output = key.replace('_', ' ') + if check: + self.setText(f"Parsed {output}") + else: + self.setText(f"MISSING {output}") + + def updated(self, key:str, title:bool=True): + if title: + output = key.replace('_', ' ').title() + else: + output = key.replace('_', ' ') + self.setText(f"UPDATED {output}") + +class ReagentFormWidget(QWidget): + + def __init__(self, parent:QWidget, reagent:PydReagent, extraction_kit:str): + super().__init__(parent) + # self.setParent(parent) + self.reagent = reagent + self.extraction_kit = extraction_kit + # self.ctx = reagent.ctx + layout = QVBoxLayout() + self.label = self.ReagentParsedLabel(reagent=reagent) + layout.addWidget(self.label) + self.lot = self.ReagentLot(reagent=reagent, extraction_kit=extraction_kit) + layout.addWidget(self.lot) + # Remove spacing between reagents + layout.setContentsMargins(0,0,0,0) + self.setLayout(layout) + self.setObjectName(reagent.name) + self.missing = reagent.missing + # If changed set self.missing to True and update self.label + self.lot.currentTextChanged.connect(self.updated) + + def parse_form(self) -> Tuple[PydReagent, dict]: + lot = self.lot.currentText() + # wanted_reagent = lookup_reagents(ctx=self.ctx, lot_number=lot, reagent_type=self.reagent.type) + wanted_reagent = Reagent.query(lot_number=lot, reagent_type=self.reagent.type) + # if reagent doesn't exist in database, off to add it (uses App.add_reagent) + if wanted_reagent == None: + dlg = QuestionAsker(title=f"Add {lot}?", message=f"Couldn't find reagent type {self.reagent.type}: {lot} in the database.\n\nWould you like to add it?") + if dlg.exec(): + wanted_reagent = self.parent().parent().parent().parent().parent().parent().parent().parent().parent.add_reagent(reagent_lot=lot, reagent_type=self.reagent.type, expiry=self.reagent.expiry, name=self.reagent.name) + return wanted_reagent, None + else: + # In this case we will have an empty reagent and the submission will fail kit integrity check + logger.debug("Will not add reagent.") + return None, Result(msg="Failed integrity check", status="Critical") + else: + # Since this now gets passed in directly from the parser -> pyd -> form and the parser gets the name + # from the db, it should no longer be necessary to query the db with reagent/kit, but with rt name directly. + # rt = lookup_reagent_types(ctx=self.ctx, name=self.reagent.type) + # rt = lookup_reagent_types(ctx=self.ctx, kit_type=self.extraction_kit, reagent=wanted_reagent) + rt = ReagentType.query(name=self.reagent.type) + if rt == None: + # rt = lookup_reagent_types(ctx=self.ctx, kit_type=self.extraction_kit, reagent=wanted_reagent) + rt = ReagentType.query(kit_type=self.extraction_kit, reagent=wanted_reagent) + return PydReagent(name=wanted_reagent.name, lot=wanted_reagent.lot, type=rt.name, expiry=wanted_reagent.expiry, parsed=not self.missing), None + + def updated(self): + self.missing = True + self.label.updated(self.reagent.type) + + class ReagentParsedLabel(QLabel): + + def __init__(self, reagent:PydReagent): + super().__init__() + try: + check = not reagent.missing + except: + check = False + self.setObjectName(f"{reagent.type}_label") + if check: + self.setText(f"Parsed {reagent.type}") + else: + self.setText(f"MISSING {reagent.type}") + + def updated(self, reagent_type:str): + self.setText(f"UPDATED {reagent_type}") + + class ReagentLot(QComboBox): + + def __init__(self, reagent, extraction_kit:str) -> None: + super().__init__() + # self.ctx = reagent.ctx + self.setEditable(True) + # if reagent.parsed: + # pass + logger.debug(f"Attempting lookup of reagents by type: {reagent.type}") + # below was lookup_reagent_by_type_name_and_kit_name, but I couldn't get it to work. + # lookup = lookup_reagents(ctx=self.ctx, reagent_type=reagent.type) + lookup = Reagent.query(reagent_type=reagent.type) + relevant_reagents = [item.__str__() for item in lookup] + output_reg = [] + for rel_reagent in relevant_reagents: + # extract strings from any sets. + if isinstance(rel_reagent, set): + for thing in rel_reagent: + output_reg.append(thing) + elif isinstance(rel_reagent, str): + output_reg.append(rel_reagent) + relevant_reagents = output_reg + # if reagent in sheet is not found insert it into the front of relevant reagents so it shows + logger.debug(f"Relevant reagents for {reagent.lot}: {relevant_reagents}") + if str(reagent.lot) not in relevant_reagents: + if check_not_nan(reagent.lot): + relevant_reagents.insert(0, str(reagent.lot)) + else: + # TODO: look up the last used reagent of this type in the database + # looked_up_rt = lookup_reagenttype_kittype_association(ctx=self.ctx, reagent_type=reagent.type, kit_type=extraction_kit) + looked_up_rt = KitTypeReagentTypeAssociation.query(reagent_type=reagent.type, kit_type=extraction_kit) + try: + # looked_up_reg = lookup_reagents(ctx=self.ctx, lot_number=looked_up_rt.last_used) + looked_up_reg = Reagent.query(lot_number=looked_up_rt.last_used) + except AttributeError: + looked_up_reg = None + logger.debug(f"Because there was no reagent listed for {reagent.lot}, we will insert the last lot used: {looked_up_reg}") + if looked_up_reg != None: + relevant_reagents.remove(str(looked_up_reg.lot)) + relevant_reagents.insert(0, str(looked_up_reg.lot)) + else: + if len(relevant_reagents) > 1: + logger.debug(f"Found {reagent.lot} in relevant reagents: {relevant_reagents}. Moving to front of list.") + idx = relevant_reagents.index(str(reagent.lot)) + logger.debug(f"The index we got for {reagent.lot} in {relevant_reagents} was {idx}") + moved_reag = relevant_reagents.pop(idx) + relevant_reagents.insert(0, moved_reag) + else: + logger.debug(f"Found {reagent.lot} in relevant reagents: {relevant_reagents}. But no need to move due to short list.") + logger.debug(f"New relevant reagents: {relevant_reagents}") + self.setObjectName(f"lot_{reagent.type}") + self.addItems(relevant_reagents) + diff --git a/src/submissions/tools.py b/src/submissions/tools.py index 52b5d18..acf83fb 100644 --- a/src/submissions/tools.py +++ b/src/submissions/tools.py @@ -478,7 +478,7 @@ class Result(BaseModel): self.owner = inspect.stack()[1].function def report(self): - from frontend.custom_widgets.misc import AlertPop + from frontend.widgets.misc import AlertPop return AlertPop(message=self.msg, status=self.status, owner=self.owner) class Report(BaseModel):