From a9ce9514fc257a3b6a712fc5e7649ca1e21c21f2 Mon Sep 17 00:00:00 2001 From: Landon Wark Date: Fri, 10 Feb 2023 10:22:39 -0600 Subject: [PATCH] Commit pre-refactor for code cleanup. --- src/submissions/__init__.py | 2 +- src/submissions/backend/db/__init__.py | 2 +- .../backend/db/functions/__init__.py | 21 ++ src/submissions/backend/db/models/kits.py | 2 +- src/submissions/backend/excel/parser.py | 27 +- src/submissions/backend/excel/reports.py | 9 +- src/submissions/frontend/__init__.py | 79 +++--- .../frontend/custom_widgets/__init__.py | 233 +----------------- .../frontend/custom_widgets/pop_ups.py | 78 ++++++ .../frontend/custom_widgets/sub_details.py | 188 ++++++++++++++ src/submissions/frontend/functions.py | 31 +++ 11 files changed, 400 insertions(+), 272 deletions(-) create mode 100644 src/submissions/frontend/custom_widgets/pop_ups.py create mode 100644 src/submissions/frontend/custom_widgets/sub_details.py create mode 100644 src/submissions/frontend/functions.py diff --git a/src/submissions/__init__.py b/src/submissions/__init__.py index 71cd9b3..373711d 100644 --- a/src/submissions/__init__.py +++ b/src/submissions/__init__.py @@ -1,4 +1,4 @@ # __init__.py # Version of the realpython-reader package -__version__ = "1.2.2" +__version__ = "1.2.3" diff --git a/src/submissions/backend/db/__init__.py b/src/submissions/backend/db/__init__.py index 22424e9..48d062b 100644 --- a/src/submissions/backend/db/__init__.py +++ b/src/submissions/backend/db/__init__.py @@ -329,7 +329,7 @@ def lookup_all_submissions_by_type(ctx:dict, type:str|None=None) -> list[models. if type == None: subs = ctx['database_session'].query(models.BasicSubmission).all() else: - subs = ctx['database_session'].query(models.BasicSubmission).filter(models.BasicSubmission.submission_type==type).all() + subs = ctx['database_session'].query(models.BasicSubmission).filter(models.BasicSubmission.submission_type==type.lower().replace(" ", "_")).all() return subs def lookup_all_orgs(ctx:dict) -> list[models.Organization]: diff --git a/src/submissions/backend/db/functions/__init__.py b/src/submissions/backend/db/functions/__init__.py index e69de29..4b755c2 100644 --- a/src/submissions/backend/db/functions/__init__.py +++ b/src/submissions/backend/db/functions/__init__.py @@ -0,0 +1,21 @@ +from ..models import * +import logging + +logger = logging.getLogger(f"submissions.{__name__}") + +def check_kit_integrity(sub:BasicSubmission): + ext_kit_rtypes = [reagenttype.name for reagenttype in sub.extraction_kit.reagent_types] + logger.debug(f"Kit reagents: {ext_kit_rtypes}") + reagenttypes = [reagent.type.name for reagent in sub.reagents] + logger.debug(f"Submission reagents: {reagenttypes}") + check = set(ext_kit_rtypes) == set(reagenttypes) + logger.debug(f"Checking if reagents match kit contents: {check}") + common = list(set(ext_kit_rtypes).intersection(reagenttypes)) + logger.debug(f"common reagents types: {common}") + if check: + result = None + else: + result = {'message' : f"Couldn't verify reagents match listed kit components.\n\nIt looks like you are missing: {[x.upper for x in ext_kit_rtypes if x not in common]}\n\nAlternatively, you may have set the wrong extraction kit."} + return result + + diff --git a/src/submissions/backend/db/models/kits.py b/src/submissions/backend/db/models/kits.py index d7d9fa5..33fe867 100644 --- a/src/submissions/backend/db/models/kits.py +++ b/src/submissions/backend/db/models/kits.py @@ -78,7 +78,7 @@ class Reagent(Base): Returns: str: string representing this object's lot number """ - return self.lot + return str(self.lot) def to_sub_dict(self) -> dict: """ diff --git a/src/submissions/backend/excel/parser.py b/src/submissions/backend/excel/parser.py index 83e14b3..a3d1148 100644 --- a/src/submissions/backend/excel/parser.py +++ b/src/submissions/backend/excel/parser.py @@ -7,6 +7,7 @@ import re import numpy as np from datetime import date import uuid +from frontend.functions import check_not_nan logger = logging.getLogger(f"submissions.{__name__}") @@ -59,7 +60,8 @@ class SheetParser(object): def _parse_unknown(self) -> None: """ Dummy function to handle unknown excel structures - """ + """ + logger.error(f"Unknown excel workbook structure. Cannot parse.") self.sub = None @@ -96,11 +98,11 @@ class SheetParser(object): if ii == 11: continue logger.debug(f"Running reagent parse for {row[1]} with type {type(row[1])} and value: {row[2]} with type {type(row[2])}") - try: - check = not np.isnan(row[1]) - except TypeError: - check = True - if not isinstance(row[2], float) and check: + # try: + # check = not np.isnan(row[1]) + # except TypeError: + # check = True + if not isinstance(row[2], float) and check_not_nan(row[1]): # must be prefixed with 'lot_' to be recognized by gui try: reagent_type = row[1].replace(' ', '_').lower().strip() @@ -114,7 +116,18 @@ class SheetParser(object): logger.debug(f"Couldn't upperize {row[2]}, must be a number") output_var = row[2] logger.debug(f"Output variable is {output_var}") - self.sub[f"lot_{reagent_type}"] = output_var + # self.sub[f"lot_{reagent_type}"] = output_var + # update 2023-02-10 to above allowing generation of expiry date in adding reagent to db. + logger.debug(f"Expiry date for imported reagent: {row[3]}") + try: + check = not np.isnan(row[3]) + except TypeError: + check = True + if check: + expiry = row[3].date() + else: + expiry = date.today() + self.sub[f"lot_{reagent_type}"] = {'lot':output_var, 'exp':expiry} submission_info = self._parse_generic("Sample List") # iloc is [row][column] and the first row is set as header row so -2 diff --git a/src/submissions/backend/excel/reports.py b/src/submissions/backend/excel/reports.py index 33373b8..8ba6220 100644 --- a/src/submissions/backend/excel/reports.py +++ b/src/submissions/backend/excel/reports.py @@ -33,7 +33,9 @@ def make_report_xlsx(records:list[dict]) -> DataFrame: # put submissions with the same lab together df = df.sort_values("Submitting Lab") # aggregate cost and sample count columns - df2 = df.groupby(["Submitting Lab", "Extraction Kit"]).agg({'Cost': ['sum', 'count'], 'Sample Count':['sum']}) + df2 = df.groupby(["Submitting Lab", "Extraction Kit"]).agg({'Extraction Kit':'count', 'Cost': 'sum', 'Sample Count':'sum'}) + df2 = df2.rename(columns={"Extraction Kit": 'Kit Count'}) + logger.debug(f"Output daftaframe for xlsx: {df2.columns}") # apply formating to cost column # df2.iloc[:, (df2.columns.get_level_values(1)=='sum') & (df2.columns.get_level_values(0)=='Cost')] = df2.iloc[:, (df2.columns.get_level_values(1)=='sum') & (df2.columns.get_level_values(0)=='Cost')].applymap('${:,.2f}'.format) return df2 @@ -54,11 +56,14 @@ def make_report_html(df:DataFrame, start_date:date, end_date:date) -> str: """ old_lab = "" output = [] + logger.debug(f"Report DataFrame: {df}") for ii, row in enumerate(df.iterrows()): row = [item for item in row] + logger.debug(f"Row: {row}") + lab = row[0][0] logger.debug(f"Old lab: {old_lab}, Current lab: {lab}") - kit = dict(name=row[0][1], cost=row[1][('Cost', 'sum')], plate_count=int(row[1][('Cost', 'count')]), sample_count=int(row[1][('Sample Count', 'sum')])) + kit = dict(name=row[0][1], cost=row[1]['Cost'], plate_count=int(row[1]['Kit Count']), sample_count=int(row[1]['Sample Count'])) if lab == old_lab: output[ii-1]['kits'].append(kit) output[ii-1]['total_cost'] += kit['cost'] diff --git a/src/submissions/frontend/__init__.py b/src/submissions/frontend/__init__.py index 59387f9..81654ab 100644 --- a/src/submissions/frontend/__init__.py +++ b/src/submissions/frontend/__init__.py @@ -10,9 +10,7 @@ from PyQt6.QtWidgets import ( from PyQt6.QtGui import QAction from PyQt6.QtCore import QSignalBlocker from PyQt6.QtWebEngineWidgets import QWebEngineView - # import pandas as pd - from pathlib import Path import plotly import pandas as pd @@ -21,7 +19,7 @@ from xhtml2pdf import pisa # import plotly.express as px import yaml import pprint - +import numpy as np from backend.excel.parser import SheetParser from backend.excel.reports import convert_control_by_mode, convert_data_list_to_df from backend.db import (construct_submission_info, lookup_reagent, @@ -30,9 +28,13 @@ from backend.db import (construct_submission_info, lookup_reagent, get_all_Control_Types_names, create_kit_from_yaml, get_all_available_modes, get_all_controls_by_type, get_control_subtypes, lookup_all_submissions_by_type, get_all_controls, lookup_submission_by_rsl_num ) +from .functions import check_kit_integrity, check_not_nan from backend.excel.reports import make_report_xlsx, make_report_html + import numpy -from frontend.custom_widgets import AddReagentQuestion, AddReagentForm, SubmissionsSheet, ReportDatePicker, KitAdder, ControlsDatePicker, OverwriteSubQuestion +from frontend.custom_widgets.sub_details import SubmissionsSheet +from frontend.custom_widgets.pop_ups import AddReagentQuestion, OverwriteSubQuestion, AlertPop +from frontend.custom_widgets import AddReagentForm, ReportDatePicker, KitAdder, ControlsDatePicker import logging import difflib from getpass import getuser @@ -130,6 +132,7 @@ class App(QMainWindow): logger.debug(self.ctx) # initialize samples self.samples = [] + self.reagents = {} # set file dialog home_dir = str(Path(self.ctx["directory_path"])) fname = Path(QFileDialog.getOpenFileName(self, 'Open file', home_dir)[0]) @@ -180,14 +183,9 @@ class App(QMainWindow): # create label self.table_widget.formlayout.addWidget(QLabel(item.replace("_", " ").title())) # if extraction kit not available, all other values fail - if prsr.sub[item] == 'nan': - msg = QMessageBox() - # msg.setIcon(QMessageBox.critical) - msg.setText("Error") - msg.setInformativeText('You need to enter a value for extraction kit.') - msg.setWindowTitle("Error") + if np.isnan(prsr.sub[item]): + msg = AlertPop(message="Make sure to check your extraction kit!", status="warning") msg.exec() - break # create combobox to hold looked up kits add_widget = QComboBox() # lookup existing kits by 'submission_type' decided on by sheetparser @@ -216,13 +214,13 @@ class App(QMainWindow): query_var = item.replace("lot_", "") logger.debug(f"Query for: {query_var}") if isinstance(prsr.sub[item], numpy.float64): - logger.debug(f"{prsr.sub[item]} is a numpy float!") + logger.debug(f"{prsr.sub[item]['lot']} is a numpy float!") try: - prsr.sub[item] = int(prsr.sub[item]) + prsr.sub[item] = int(prsr.sub[item]['lot']) except ValueError: pass # query for reagents using type name from sheet and kit from sheet - logger.debug(f"Attempting lookup of reagents by type: {query_var} and kit: {prsr.sub['extraction_kit']}") + logger.debug(f"Attempting lookup of reagents by type: {query_var}") # below was lookup_reagent_by_type_name_and_kit_name, but I couldn't get it to work. relevant_reagents = [item.__str__() for item in lookup_regent_by_type_name(ctx=self.ctx, type_name=query_var)]#, kit_name=prsr.sub['extraction_kit'])] output_reg = [] @@ -235,15 +233,12 @@ class App(QMainWindow): relevant_reagents = output_reg logger.debug(f"Relevant reagents: {relevant_reagents}") # if reagent in sheet is not found insert it into items - if prsr.sub[item] not in relevant_reagents and prsr.sub[item] != 'nan': - try: - check = not numpy.isnan(prsr.sub[item]) - except TypeError: - check = True - if check: - relevant_reagents.insert(0, str(prsr.sub[item])) - logger.debug(f"Relevant reagents: {relevant_reagents}") + if str(prsr.sub[item]['lot']) not in relevant_reagents and prsr.sub[item]['lot'] != 'nan': + if check_not_nan(prsr.sub[item]['lot']): + relevant_reagents.insert(0, str(prsr.sub[item]['lot'])) + logger.debug(f"New relevant reagents: {relevant_reagents}") add_widget.addItems(relevant_reagents) + self.reagents[item] = prsr.sub[item] # TODO: make samples not appear in frame. case 'samples': # hold samples in 'self' until form submitted @@ -259,6 +254,7 @@ class App(QMainWindow): submit_btn = QPushButton("Submit") self.table_widget.formlayout.addWidget(submit_btn) submit_btn.clicked.connect(self.submit_new_sample) + logger.debug(f"Imported reagents: {self.reagents}") def submit_new_sample(self): """ @@ -278,7 +274,9 @@ class App(QMainWindow): if wanted_reagent == None: dlg = AddReagentQuestion(reagent_type=reagent, reagent_lot=reagents[reagent]) if dlg.exec(): - wanted_reagent = self.add_reagent(reagent_lot=reagents[reagent], reagent_type=reagent.replace("lot_", "")) + logger.debug(f"checking reagent: {reagent} in self.reagents. Result: {self.reagents[reagent]}") + expiry_date = self.reagents[reagent]['exp'] + wanted_reagent = self.add_reagent(reagent_lot=reagents[reagent], reagent_type=reagent.replace("lot_", ""), expiry=expiry_date) else: logger.debug("Will not add reagent.") if wanted_reagent != None: @@ -301,15 +299,17 @@ class App(QMainWindow): for reagent in parsed_reagents: base_submission.reagents.append(reagent) # base_submission.reagents_id = reagent.id + logger.debug("Checking kit integrity...") + kit_integrity = check_kit_integrity(base_submission) + if kit_integrity != None: + msg = AlertPop(message=kit_integrity['message'], status="critical") + msg.exec() + return logger.debug(f"Sending submission: {base_submission.rsl_plate_num} to database.") result = store_submission(ctx=self.ctx, base_submission=base_submission) - # check result of storing for issues + # # check result of storing for issues if result != None: - msg = QMessageBox() - # msg.setIcon(QMessageBox.critical) - msg.setText("Error") - msg.setInformativeText(result['message']) - msg.setWindowTitle("Error") + msg = AlertPop(result['message']) msg.exec() # update summary sheet self.table_widget.sub_wid.setData() @@ -318,7 +318,7 @@ class App(QMainWindow): item.setParent(None) - def add_reagent(self, reagent_lot:str|None=None, reagent_type:str|None=None): + def add_reagent(self, reagent_lot:str|None=None, reagent_type:str|None=None, expiry:date|None=None): """ Action to create new reagent in DB. @@ -332,7 +332,7 @@ class App(QMainWindow): if isinstance(reagent_lot, bool): reagent_lot = "" # create form - dlg = AddReagentForm(ctx=self.ctx, reagent_lot=reagent_lot, reagent_type=reagent_type) + dlg = AddReagentForm(ctx=self.ctx, reagent_lot=reagent_lot, reagent_type=reagent_type, expiry=expiry) if dlg.exec(): # extract form info labels, values = self.extract_form_info(dlg) @@ -341,7 +341,7 @@ class App(QMainWindow): # create reagent object reagent = construct_reagent(ctx=self.ctx, info_dict=info) # send reagent to db - store_reagent(ctx=self.ctx, reagent=reagent) + # store_reagent(ctx=self.ctx, reagent=reagent) return reagent @@ -417,19 +417,16 @@ class App(QMainWindow): # set_column(idx, idx, max_len) # set column width # colu = worksheet.column_dimensions["C"] # style = NamedStyle(name="custom_currency", number_format='Currency') - for cell in worksheet['C']: + for cell in worksheet['D']: # try: # check = int(cell.row) # except TypeError: # continue - if cell.row > 3: + if cell.row > 1: cell.style = 'Currency' writer.close() - - - def add_kit(self): """ Constructs new kit from yaml and adds to DB. @@ -477,7 +474,7 @@ class App(QMainWindow): # correct start date being more recent than end date and rerun if self.table_widget.datepicker.start_date.date() > self.table_widget.datepicker.end_date.date(): logger.warning("Start date after end date is not allowed!") - threemonthsago = self.table_widget.datepicker.end_date.date().addDays(-90) + threemonthsago = self.table_widget.datepicker.end_date.date().addDays(-60) # block signal that will rerun controls getter and set start date with QSignalBlocker(self.table_widget.datepicker.start_date) as blocker: self.table_widget.datepicker.start_date.setDate(threemonthsago) @@ -572,12 +569,14 @@ class App(QMainWindow): if " " in sample: logger.warning(f"There is not supposed to be a space in the sample name!!!") sample = sample.replace(" ", "") - if sample not in ac_list: + # if sample not in ac_list: + 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 diff > 0.955: + # if diff > 0.955: + if control.name.startswith(sample): logger.debug(f"Checking {sample} against {control.name}... {diff}") # if sample == control.name: logger.debug(f"Found match:\n\tSample: {sample}\n\tControl: {control.name}\n\tDifference: {diff}") diff --git a/src/submissions/frontend/custom_widgets/__init__.py b/src/submissions/frontend/custom_widgets/__init__.py index 5a059e0..5254a83 100644 --- a/src/submissions/frontend/custom_widgets/__init__.py +++ b/src/submissions/frontend/custom_widgets/__init__.py @@ -1,18 +1,17 @@ +from datetime import date from PyQt6.QtWidgets import ( QLabel, QVBoxLayout, QLineEdit, QComboBox, QDialog, - QDialogButtonBox, QDateEdit, QTableView, - QTextEdit, QSizePolicy, QWidget, + QDialogButtonBox, QDateEdit, QSizePolicy, QWidget, QGridLayout, QPushButton, QSpinBox, - QScrollBar, QScrollArea, QHBoxLayout, - QMessageBox, QFileDialog, QToolBar + QScrollBar, QHBoxLayout, + QMessageBox ) -from PyQt6.QtCore import Qt, QDate, QAbstractTableModel, QSize -from PyQt6.QtGui import QFontMetrics, QAction +from PyQt6.QtCore import Qt, QDate, QSize +# from PyQt6.QtGui import QFontMetrics, QAction -from backend.db import get_all_reagenttype_names, submissions_to_df, lookup_submission_by_id, lookup_all_sample_types, create_kit_from_yaml +from backend.db import get_all_reagenttype_names, lookup_all_sample_types, create_kit_from_yaml from jinja2 import Environment, FileSystemLoader -from xhtml2pdf import pisa import sys from pathlib import Path import logging @@ -26,55 +25,12 @@ else: loader = FileSystemLoader(loader_path) env = Environment(loader=loader) -class AddReagentQuestion(QDialog): - """ - dialog to ask about adding a new reagne to db - """ - def __init__(self, reagent_type:str, reagent_lot:str) -> None: - super().__init__() - - self.setWindowTitle(f"Add {reagent_lot}?") - - QBtn = QDialogButtonBox.StandardButton.Yes | QDialogButtonBox.StandardButton.No - - self.buttonBox = QDialogButtonBox(QBtn) - self.buttonBox.accepted.connect(self.accept) - self.buttonBox.rejected.connect(self.reject) - - self.layout = QVBoxLayout() - message = QLabel(f"Couldn't find reagent type {reagent_type.replace('_', ' ').title().strip('Lot')}: {reagent_lot} in the database.\nWould you like to add it?") - self.layout.addWidget(message) - self.layout.addWidget(self.buttonBox) - self.setLayout(self.layout) - - -class OverwriteSubQuestion(QDialog): - """ - dialog to ask about overwriting existing submission - """ - def __init__(self, message:str, rsl_plate_num:str) -> None: - super().__init__() - - self.setWindowTitle(f"Overwrite {rsl_plate_num}?") - - QBtn = QDialogButtonBox.StandardButton.Yes | QDialogButtonBox.StandardButton.No - - self.buttonBox = QDialogButtonBox(QBtn) - self.buttonBox.accepted.connect(self.accept) - self.buttonBox.rejected.connect(self.reject) - - self.layout = QVBoxLayout() - message = QLabel(message) - self.layout.addWidget(message) - self.layout.addWidget(self.buttonBox) - self.setLayout(self.layout) - class AddReagentForm(QDialog): """ dialog to add gather info about new reagent """ - def __init__(self, ctx:dict, reagent_lot:str|None, reagent_type:str|None) -> None: + def __init__(self, ctx:dict, reagent_lot:str|None, reagent_type:str|None, expiry:date|None=None) -> None: super().__init__() if reagent_lot == None: @@ -92,7 +48,10 @@ class AddReagentForm(QDialog): lot_input.setText(reagent_lot) # get expiry info exp_input = QDateEdit(calendarPopup=True) - exp_input.setDate(QDate.currentDate()) + if expiry == None: + exp_input.setDate(QDate.currentDate()) + else: + exp_input.setDate(expiry) # get reagent type info type_input = QComboBox() type_input.addItems([item.replace("_", " ").title() for item in get_all_reagenttype_names(ctx=ctx)]) @@ -117,172 +76,6 @@ class AddReagentForm(QDialog): self.setLayout(self.layout) - -class pandasModel(QAbstractTableModel): - """ - pandas model for inserting summary sheet into gui - """ - def __init__(self, data) -> None: - QAbstractTableModel.__init__(self) - self._data = data - - def rowCount(self, parent=None) -> int: - """ - does what it says - - Args: - parent (_type_, optional): _description_. Defaults to None. - - Returns: - int: number of rows in data - """ - return self._data.shape[0] - - def columnCount(self, parnet=None) -> int: - """ - does what it says - - Args: - parnet (_type_, optional): _description_. Defaults to None. - - Returns: - int: number of columns in data - """ - return self._data.shape[1] - - def data(self, index, role=Qt.ItemDataRole.DisplayRole) -> str|None: - if index.isValid(): - if role == Qt.ItemDataRole.DisplayRole: - return str(self._data.iloc[index.row(), index.column()]) - return None - - def headerData(self, col, orientation, role): - if orientation == Qt.Orientation.Horizontal and role == Qt.ItemDataRole.DisplayRole: - return self._data.columns[col] - return None - - -class SubmissionsSheet(QTableView): - """ - presents submission summary to user in tab1 - """ - def __init__(self, ctx:dict) -> None: - """ - initialize - - Args: - ctx (dict): settings passed from gui - """ - super().__init__() - self.ctx = ctx - self.setData() - self.resizeColumnsToContents() - self.resizeRowsToContents() - # self.clicked.connect(self.test) - self.doubleClicked.connect(self.show_details) - - def setData(self) -> None: - """ - sets data in model - """ - self.data = submissions_to_df(ctx=self.ctx) - self.model = pandasModel(self.data) - self.setModel(self.model) - # self.resize(800,600) - - def show_details(self) -> None: - """ - creates detailed data to show in seperate window - """ - index=(self.selectionModel().currentIndex()) - # logger.debug(index) - value=index.sibling(index.row(),0).data() - dlg = SubmissionDetails(ctx=self.ctx, id=value) - # dlg.show() - if dlg.exec(): - pass - - - - -class SubmissionDetails(QDialog): - """ - a window showing text details of submission - """ - def __init__(self, ctx:dict, id:int) -> None: - - super().__init__() - self.ctx = ctx - self.setWindowTitle("Submission Details") - - # create scrollable interior - interior = QScrollArea() - interior.setParent(self) - # get submision from db - data = lookup_submission_by_id(ctx=ctx, id=id) - self.base_dict = data.to_dict() - logger.debug(f"Base dict: {self.base_dict}") - # don't want id - del self.base_dict['id'] - # convert sub objects to dicts - self.base_dict['reagents'] = [item.to_sub_dict() for item in data.reagents] - self.base_dict['samples'] = [item.to_sub_dict() for item in data.samples] - # retrieve jinja template - template = env.get_template("submission_details.txt") - # render using object dict - text = template.render(sub=self.base_dict) - # create text field - txt_editor = QTextEdit(self) - txt_editor.setReadOnly(True) - txt_editor.document().setPlainText(text) - # resize - font = txt_editor.document().defaultFont() - fontMetrics = QFontMetrics(font) - textSize = fontMetrics.size(0, txt_editor.toPlainText()) - w = textSize.width() + 10 - h = textSize.height() + 10 - txt_editor.setMinimumSize(w, h) - txt_editor.setMaximumSize(w, h) - txt_editor.resize(w, h) - interior.resize(w,900) - txt_editor.setText(text) - interior.setWidget(txt_editor) - self.layout = QVBoxLayout() - self.setFixedSize(w, 900) - btn = QPushButton("Export PDF") - btn.setParent(self) - btn.setFixedWidth(w) - btn.clicked.connect(self.export) - - - # def _create_actions(self): - # self.exportAction = QAction("Export", self) - - - def export(self): - template = env.get_template("submission_details.html") - html = template.render(sub=self.base_dict) - # logger.debug(f"Submission details: {self.base_dict}") - home_dir = Path(self.ctx["directory_path"]).joinpath(f"Submission_Details_{self.base_dict['Plate Number']}.pdf").resolve().__str__() - fname = Path(QFileDialog.getSaveFileName(self, "Save File", home_dir, filter=".pdf")[0]) - # logger.debug(f"report output name: {fname}") - # df.to_excel(fname, engine='openpyxl') - if fname.__str__() == ".": - logger.debug("Saving pdf was cancelled.") - return - try: - with open(fname, "w+b") as f: - pisa.CreatePDF(html, dest=f) - except PermissionError as e: - logger.error(f"Error saving pdf: {e}") - msg = QMessageBox() - msg.setText("Permission Error") - msg.setInformativeText(f"Looks like {fname.__str__()} is open.\nPlease close it and try again.") - msg.setWindowTitle("Permission Error") - msg.exec() - - - class ReportDatePicker(QDialog): """ custom dialog to ask for report start/stop dates @@ -467,7 +260,7 @@ class ControlsDatePicker(QWidget): self.start_date = QDateEdit(calendarPopup=True) # start date is three month prior to end date by default - threemonthsago = QDate.currentDate().addDays(-90) + threemonthsago = QDate.currentDate().addDays(-60) self.start_date.setDate(threemonthsago) self.end_date = QDateEdit(calendarPopup=True) self.end_date.setDate(QDate.currentDate()) diff --git a/src/submissions/frontend/custom_widgets/pop_ups.py b/src/submissions/frontend/custom_widgets/pop_ups.py new file mode 100644 index 0000000..3045068 --- /dev/null +++ b/src/submissions/frontend/custom_widgets/pop_ups.py @@ -0,0 +1,78 @@ +# from datetime import date +from PyQt6.QtWidgets import ( + QLabel, QVBoxLayout, QDialog, + QDialogButtonBox, QMessageBox +) +# from PyQt6.QtCore import Qt, QDate, QSize +# from PyQt6.QtGui import QFontMetrics, QAction + +# from backend.db import get_all_reagenttype_names, lookup_all_sample_types, create_kit_from_yaml +from jinja2 import Environment, FileSystemLoader +import sys +from pathlib import Path +import logging + +logger = logging.getLogger(f"submissions.{__name__}") + +if getattr(sys, 'frozen', False): + loader_path = Path(sys._MEIPASS).joinpath("files", "templates") +else: + loader_path = Path(__file__).parents[2].joinpath('templates').absolute().__str__() +loader = FileSystemLoader(loader_path) +env = Environment(loader=loader) + + +class AddReagentQuestion(QDialog): + """ + dialog to ask about adding a new reagne to db + """ + def __init__(self, reagent_type:str, reagent_lot:str) -> QDialog: + super().__init__() + + self.setWindowTitle(f"Add {reagent_lot}?") + + QBtn = QDialogButtonBox.StandardButton.Yes | QDialogButtonBox.StandardButton.No + + self.buttonBox = QDialogButtonBox(QBtn) + self.buttonBox.accepted.connect(self.accept) + self.buttonBox.rejected.connect(self.reject) + + self.layout = QVBoxLayout() + message = QLabel(f"Couldn't find reagent type {reagent_type.replace('_', ' ').title().strip('Lot')}: {reagent_lot} in the database.\n\nWould you like to add it?") + self.layout.addWidget(message) + self.layout.addWidget(self.buttonBox) + self.setLayout(self.layout) + + +class OverwriteSubQuestion(QDialog): + """ + dialog to ask about overwriting existing submission + """ + def __init__(self, message:str, rsl_plate_num:str) -> QDialog: + super().__init__() + + self.setWindowTitle(f"Overwrite {rsl_plate_num}?") + + QBtn = QDialogButtonBox.StandardButton.Yes | QDialogButtonBox.StandardButton.No + + self.buttonBox = QDialogButtonBox(QBtn) + self.buttonBox.accepted.connect(self.accept) + self.buttonBox.rejected.connect(self.reject) + + self.layout = QVBoxLayout() + message = QLabel(message) + self.layout.addWidget(message) + self.layout.addWidget(self.buttonBox) + self.setLayout(self.layout) + + +class AlertPop(QMessageBox): + + def __init__(self, message:str, status:str) -> QMessageBox: + super().__init__() + icon = getattr(QMessageBox.Icon, status.title()) + self.setIcon(icon) + # msg.setText("Error") + self.setInformativeText(message) + self.setWindowTitle(status.title()) + diff --git a/src/submissions/frontend/custom_widgets/sub_details.py b/src/submissions/frontend/custom_widgets/sub_details.py new file mode 100644 index 0000000..a0e5247 --- /dev/null +++ b/src/submissions/frontend/custom_widgets/sub_details.py @@ -0,0 +1,188 @@ +from datetime import date +from PyQt6.QtWidgets import ( + QVBoxLayout, QDialog, QTableView, + QTextEdit, QPushButton, QScrollArea, + QMessageBox, QFileDialog +) +from PyQt6.QtCore import Qt, QAbstractTableModel +from PyQt6.QtGui import QFontMetrics + +from backend.db import submissions_to_df, lookup_submission_by_id, lookup_all_sample_types, create_kit_from_yaml +from jinja2 import Environment, FileSystemLoader +from xhtml2pdf import pisa +import sys +from pathlib import Path +import logging + +logger = logging.getLogger(f"submissions.{__name__}") + +if getattr(sys, 'frozen', False): + loader_path = Path(sys._MEIPASS).joinpath("files", "templates") +else: + loader_path = Path(__file__).parents[2].joinpath('templates').absolute().__str__() +loader = FileSystemLoader(loader_path) +env = Environment(loader=loader) + +class pandasModel(QAbstractTableModel): + """ + pandas model for inserting summary sheet into gui + """ + def __init__(self, data) -> None: + QAbstractTableModel.__init__(self) + self._data = data + + def rowCount(self, parent=None) -> int: + """ + does what it says + + Args: + parent (_type_, optional): _description_. Defaults to None. + + Returns: + int: number of rows in data + """ + return self._data.shape[0] + + def columnCount(self, parnet=None) -> int: + """ + does what it says + + Args: + parnet (_type_, optional): _description_. Defaults to None. + + Returns: + int: number of columns in data + """ + return self._data.shape[1] + + def data(self, index, role=Qt.ItemDataRole.DisplayRole) -> str|None: + if index.isValid(): + if role == Qt.ItemDataRole.DisplayRole: + return str(self._data.iloc[index.row(), index.column()]) + return None + + def headerData(self, col, orientation, role): + if orientation == Qt.Orientation.Horizontal and role == Qt.ItemDataRole.DisplayRole: + return self._data.columns[col] + return None + + +class SubmissionsSheet(QTableView): + """ + presents submission summary to user in tab1 + """ + def __init__(self, ctx:dict) -> None: + """ + initialize + + Args: + ctx (dict): settings passed from gui + """ + super().__init__() + self.ctx = ctx + self.setData() + self.resizeColumnsToContents() + self.resizeRowsToContents() + # self.clicked.connect(self.test) + self.doubleClicked.connect(self.show_details) + + def setData(self) -> None: + """ + sets data in model + """ + self.data = submissions_to_df(ctx=self.ctx) + self.model = pandasModel(self.data) + self.setModel(self.model) + # self.resize(800,600) + + def show_details(self) -> None: + """ + creates detailed data to show in seperate window + """ + index=(self.selectionModel().currentIndex()) + # logger.debug(index) + value=index.sibling(index.row(),0).data() + dlg = SubmissionDetails(ctx=self.ctx, id=value) + # dlg.show() + if dlg.exec(): + pass + + + + +class SubmissionDetails(QDialog): + """ + a window showing text details of submission + """ + def __init__(self, ctx:dict, id:int) -> None: + + super().__init__() + self.ctx = ctx + self.setWindowTitle("Submission Details") + + # create scrollable interior + interior = QScrollArea() + interior.setParent(self) + # get submision from db + data = lookup_submission_by_id(ctx=ctx, id=id) + self.base_dict = data.to_dict() + logger.debug(f"Base dict: {self.base_dict}") + # don't want id + del self.base_dict['id'] + # convert sub objects to dicts + self.base_dict['reagents'] = [item.to_sub_dict() for item in data.reagents] + self.base_dict['samples'] = [item.to_sub_dict() for item in data.samples] + # retrieve jinja template + template = env.get_template("submission_details.txt") + # render using object dict + text = template.render(sub=self.base_dict) + # create text field + txt_editor = QTextEdit(self) + txt_editor.setReadOnly(True) + txt_editor.document().setPlainText(text) + # resize + font = txt_editor.document().defaultFont() + fontMetrics = QFontMetrics(font) + textSize = fontMetrics.size(0, txt_editor.toPlainText()) + w = textSize.width() + 10 + h = textSize.height() + 10 + txt_editor.setMinimumSize(w, h) + txt_editor.setMaximumSize(w, h) + txt_editor.resize(w, h) + interior.resize(w,900) + txt_editor.setText(text) + interior.setWidget(txt_editor) + self.layout = QVBoxLayout() + self.setFixedSize(w, 900) + btn = QPushButton("Export PDF") + btn.setParent(self) + btn.setFixedWidth(w) + btn.clicked.connect(self.export) + + + # def _create_actions(self): + # self.exportAction = QAction("Export", self) + + + def export(self): + template = env.get_template("submission_details.html") + html = template.render(sub=self.base_dict) + # logger.debug(f"Submission details: {self.base_dict}") + home_dir = Path(self.ctx["directory_path"]).joinpath(f"Submission_Details_{self.base_dict['Plate Number']}.pdf").resolve().__str__() + fname = Path(QFileDialog.getSaveFileName(self, "Save File", home_dir, filter=".pdf")[0]) + # logger.debug(f"report output name: {fname}") + # df.to_excel(fname, engine='openpyxl') + if fname.__str__() == ".": + logger.debug("Saving pdf was cancelled.") + return + try: + with open(fname, "w+b") as f: + pisa.CreatePDF(html, dest=f) + except PermissionError as e: + logger.error(f"Error saving pdf: {e}") + msg = QMessageBox() + msg.setText("Permission Error") + msg.setInformativeText(f"Looks like {fname.__str__()} is open.\nPlease close it and try again.") + msg.setWindowTitle("Permission Error") + msg.exec() + diff --git a/src/submissions/frontend/functions.py b/src/submissions/frontend/functions.py new file mode 100644 index 0000000..593cf0d --- /dev/null +++ b/src/submissions/frontend/functions.py @@ -0,0 +1,31 @@ +# from ..models import * +from backend.db.models import * +import logging +import numpy as np + +logger = logging.getLogger(f"submissions.{__name__}") + +def check_kit_integrity(sub:BasicSubmission): + ext_kit_rtypes = [reagenttype.name for reagenttype in sub.extraction_kit.reagent_types] + logger.debug(f"Kit reagents: {ext_kit_rtypes}") + reagenttypes = [reagent.type.name for reagent in sub.reagents] + logger.debug(f"Submission reagents: {reagenttypes}") + check = set(ext_kit_rtypes) == set(reagenttypes) + logger.debug(f"Checking if reagents match kit contents: {check}") + common = list(set(ext_kit_rtypes).intersection(reagenttypes)) + logger.debug(f"common reagents types: {common}") + if check: + result = None + else: + result = {'message' : f"Couldn't verify reagents match listed kit components.\n\nIt looks like you are missing: {[x.upper() for x in ext_kit_rtypes if x not in common]}\n\nAlternatively, you may have set the wrong extraction kit."} + return result + + +def check_not_nan(cell_contents) -> bool: + try: + return not np.isnan(cell_contents) + except ValueError: + return True + except Exception as e: + logger.debug(f"Check encounteded unknown error: {e}") + return False \ No newline at end of file