From c460e5eeca48614078b015a5d100d828822988ab Mon Sep 17 00:00:00 2001 From: lwark Date: Wed, 3 Jul 2024 15:13:55 -0500 Subject: [PATCH] post documentation and code clean-up. --- src/submissions/backend/db/__init__.py | 1 + .../backend/db/models/submissions.py | 32 +++---- src/submissions/backend/excel/__init__.py | 3 +- src/submissions/backend/excel/parser.py | 92 ++++++++++++++---- src/submissions/backend/excel/reports.py | 10 ++ src/submissions/backend/excel/writer.py | 56 ++++++++++- .../backend/validators/__init__.py | 3 + src/submissions/backend/validators/pydant.py | 93 ++++++++++++++----- src/submissions/frontend/widgets/app.py | 12 ++- .../frontend/widgets/controls_chart.py | 35 +++---- .../frontend/widgets/equipment_usage.py | 7 +- .../frontend/widgets/gel_checker.py | 34 +++---- .../frontend/widgets/kit_creator.py | 18 ++-- src/submissions/frontend/widgets/misc.py | 61 ++---------- src/submissions/frontend/widgets/pop_ups.py | 10 +- .../frontend/widgets/sample_search.py | 24 ++++- .../frontend/widgets/submission_details.py | 15 ++- .../frontend/widgets/submission_table.py | 56 +++++------ .../widgets/submission_type_creator.py | 3 +- .../frontend/widgets/submission_widget.py | 17 ++-- src/submissions/tools.py | 77 ++++++++++++--- 21 files changed, 421 insertions(+), 238 deletions(-) diff --git a/src/submissions/backend/db/__init__.py b/src/submissions/backend/db/__init__.py index 61b60d2..5abf531 100644 --- a/src/submissions/backend/db/__init__.py +++ b/src/submissions/backend/db/__init__.py @@ -9,6 +9,7 @@ def set_sqlite_pragma(dbapi_connection, connection_record): """ *should* allow automatic creation of foreign keys in the database I have no idea how it actually works. + Listens for connect and then turns on foreign keys? Args: dbapi_connection (_type_): _description_ diff --git a/src/submissions/backend/db/models/submissions.py b/src/submissions/backend/db/models/submissions.py index 97d8937..b8141d3 100644 --- a/src/submissions/backend/db/models/submissions.py +++ b/src/submissions/backend/db/models/submissions.py @@ -1581,12 +1581,12 @@ class WastewaterArtic(BasicSubmission): dict: dictionary used in submissions summary """ output = super().to_dict(full_data=full_data, backup=backup, report=report) + if report: + return output if self.artic_technician in [None, "None"]: output['artic_technician'] = self.technician else: output['artic_technician'] = self.artic_technician - if report: - return output output['gel_info'] = self.gel_info output['gel_image_path'] = self.gel_image output['dna_core_submission_number'] = self.dna_core_submission_number @@ -2253,6 +2253,12 @@ class BasicSample(BaseClass): @classmethod def get_searchables(cls): + """ + Delivers a list of fields that can be used in fuzzy search. + + Returns: + List[str]: List of fields. + """ return [dict(label="Submitter ID", field="submitter_id")] @classmethod @@ -2381,22 +2387,14 @@ class WastewaterSample(BasicSample): output_dict["submitter_id"] = output_dict['ww_full_sample_id'] return output_dict - def get_previous_ww_submission(self, current_artic_submission: WastewaterArtic): - try: - plates = [item['plate'] for item in current_artic_submission.source_plates] - except TypeError as e: - logger.error(f"source_plates must not be present") - plates = [item.rsl_plate_num for item in - self.submissions[:self.submissions.index(current_artic_submission)]] - subs = [sub for sub in self.submissions if sub.rsl_plate_num in plates] - # logger.debug(f"Submissions: {subs}") - try: - return subs[-1] - except IndexError: - return None - @classmethod - def get_searchables(cls): + def get_searchables(cls) -> List[str]: + """ + Delivers a list of fields that can be used in fuzzy search. Extends parent. + + Returns: + List[str]: List of fields. + """ searchables = super().get_searchables() for item in ["ww_processing_num", "ww_full_sample_id", "rsl_number"]: label = item.strip("ww_").replace("_", " ").replace("rsl", "RSL").title() diff --git a/src/submissions/backend/excel/__init__.py b/src/submissions/backend/excel/__init__.py index 6fa938e..b09ca53 100644 --- a/src/submissions/backend/excel/__init__.py +++ b/src/submissions/backend/excel/__init__.py @@ -1,6 +1,7 @@ ''' -Contains pandas convenience functions for interacting with excel workbooks +Contains pandas and openpyxl convenience functions for interacting with excel workbooks ''' from .reports import * from .parser import * +from .writer import * diff --git a/src/submissions/backend/excel/parser.py b/src/submissions/backend/excel/parser.py index 16e74bc..bc3ad8b 100644 --- a/src/submissions/backend/excel/parser.py +++ b/src/submissions/backend/excel/parser.py @@ -1,5 +1,5 @@ ''' -contains parser object for pulling values from client generated submission sheets. +contains parser objects for pulling values from client generated submission sheets. ''' import sys from copy import copy @@ -78,7 +78,7 @@ class SheetParser(object): def parse_reagents(self, extraction_kit: str | None = None): """ - Pulls reagent info from the excel sheet + Calls reagent parser class to pull info from the excel sheet Args: extraction_kit (str | None, optional): Relevant extraction kit for reagent map. Defaults to None. @@ -91,16 +91,22 @@ class SheetParser(object): def parse_samples(self): """ - Pulls sample info from the excel sheet + Calls sample parser to pull info from the excel sheet """ parser = SampleParser(xl=self.xl, submission_type=self.submission_type) self.sub['samples'] = parser.reconcile_samples() def parse_equipment(self): + """ + Calls equipment parser to pull info from the excel sheet + """ parser = EquipmentParser(xl=self.xl, submission_type=self.submission_type) self.sub['equipment'] = parser.parse_equipment() def parse_tips(self): + """ + Calls tips parser to pull info from the excel sheet + """ parser = TipParser(xl=self.xl, submission_type=self.submission_type) self.sub['tips'] = parser.parse_tips() @@ -160,8 +166,16 @@ class SheetParser(object): class InfoParser(object): - + """ + Object to parse generic info from excel sheet. + """ def __init__(self, xl: Workbook, submission_type: str|SubmissionType, sub_object: BasicSubmission|None=None): + """ + Args: + xl (Workbook): Openpyxl workbook from submitted excel file. + submission_type (str | SubmissionType): Type of submission expected (Wastewater, Bacterial Culture, etc.) + sub_object (BasicSubmission | None, optional): Submission object holding methods. Defaults to None. + """ logger.info(f"\n\nHello from InfoParser!\n\n") if isinstance(submission_type, str): submission_type = SubmissionType.query(name=submission_type) @@ -221,6 +235,7 @@ class InfoParser(object): new['name'] = k relevant.append(new) # logger.debug(f"relevant map for {sheet}: {pformat(relevant)}") + # NOTE: make sure relevant is not an empty list. if not relevant: continue for item in relevant: @@ -231,6 +246,7 @@ class InfoParser(object): case "submission_type": value, missing = is_missing(value) value = value.title() + # NOTE: is field a JSON? case thing if thing in self.sub_object.jsons(): value, missing = is_missing(value) if missing: continue @@ -248,12 +264,23 @@ class InfoParser(object): dicto[item['name']] = dict(value=value, missing=missing) except (KeyError, IndexError): continue + # Return after running the parser components held in submission object. return self.sub_object.custom_info_parser(input_dict=dicto, xl=self.xl) class ReagentParser(object): + """ + Object to pull reagents from excel sheet. + """ def __init__(self, xl: Workbook, submission_type: str, extraction_kit: str, sub_object:BasicSubmission|None=None): + """ + Args: + xl (Workbook): Openpyxl workbook from submitted excel file. + submission_type (str): Type of submission expected (Wastewater, Bacterial Culture, etc.) + extraction_kit (str): Extraction kit used. + sub_object (BasicSubmission | None, optional): Submission object holding methods. Defaults to None. + """ # logger.debug("\n\nHello from ReagentParser!\n\n") self.submission_type_obj = submission_type self.sub_object = sub_object @@ -284,7 +311,7 @@ class ReagentParser(object): pass return reagent_map - def parse_reagents(self) -> List[PydReagent]: + def parse_reagents(self) -> List[dict]: """ Extracts reagent information from the excel form. @@ -312,7 +339,7 @@ class ReagentParser(object): comment = "" except (KeyError, IndexError): listo.append( - PydReagent(role=item.strip(), lot=None, expiry=None, name=None, comment="", missing=True)) + dict(role=item.strip(), lot=None, expiry=None, name=None, comment="", missing=True)) continue # NOTE: If the cell is blank tell the PydReagent if check_not_nan(lot): @@ -336,17 +363,17 @@ class ReagentParser(object): class SampleParser(object): """ - object to pull data for samples in excel sheet and construct individual sample objects + Object to pull data for samples in excel sheet and construct individual sample objects """ def __init__(self, xl: Workbook, submission_type: SubmissionType, sample_map: dict | None = None, sub_object:BasicSubmission|None=None) -> None: """ - convert sample sub-dataframe to dictionary of records - Args: - df (pd.DataFrame): input sample dataframe - elution_map (pd.DataFrame | None, optional): optional map of elution plate. Defaults to None. - """ + xl (Workbook): Openpyxl workbook from submitted excel file. + submission_type (SubmissionType): Type of submission expected (Wastewater, Bacterial Culture, etc.) + sample_map (dict | None, optional): Locations in database where samples are found. Defaults to None. + sub_object (BasicSubmission | None, optional): Submission object holding methods. Defaults to None. + """ # logger.debug("\n\nHello from SampleParser!\n\n") self.samples = [] self.xl = xl @@ -383,10 +410,13 @@ class SampleParser(object): sample_info_map = sample_map return sample_info_map - def parse_plate_map(self): + def parse_plate_map(self) -> List[dict]: """ Parse sample location/name from plate map - """ + + Returns: + List[dict]: List of sample ids and locations. + """ invalids = [0, "0", "EMPTY"] smap = self.sample_info_map['plate_map'] ws = self.xl[smap['sheet']] @@ -412,7 +442,11 @@ class SampleParser(object): def parse_lookup_table(self) -> List[dict]: """ Parse misc info from lookup table. - """ + + Returns: + List[dict]: List of basic sample info. + """ + lmap = self.sample_info_map['lookup_table'] ws = self.xl[lmap['sheet']] lookup_samples = [] @@ -460,7 +494,13 @@ class SampleParser(object): new_samples.append(PydSample(**translated_dict)) return result, new_samples - def reconcile_samples(self): + def reconcile_samples(self) -> List[dict]: + """ + Merges sample info from lookup table and plate map. + + Returns: + List[dict]: Reconciled samples + """ # TODO: Move to pydantic validator? if self.plate_map_samples is None or self.lookup_samples is None: self.samples = self.lookup_samples or self.plate_map_samples @@ -504,8 +544,15 @@ class SampleParser(object): class EquipmentParser(object): - + """ + Object to pull data for equipment in excel sheet + """ def __init__(self, xl: Workbook, submission_type: str|SubmissionType) -> None: + """ + Args: + xl (Workbook): Openpyxl workbook from submitted excel file. + submission_type (str | SubmissionType): Type of submission expected (Wastewater, Bacterial Culture, etc.) + """ if isinstance(submission_type, str): submission_type = SubmissionType.query(name=submission_type) self.submission_type = submission_type @@ -582,8 +629,15 @@ class EquipmentParser(object): class TipParser(object): - + """ + Object to pull data for tips in excel sheet + """ def __init__(self, xl: Workbook, submission_type: str|SubmissionType) -> None: + """ + Args: + xl (Workbook): Openpyxl workbook from submitted excel file. + submission_type (str | SubmissionType): Type of submission expected (Wastewater, Bacterial Culture, etc.) + """ if isinstance(submission_type, str): submission_type = SubmissionType.query(name=submission_type) self.submission_type = submission_type @@ -644,8 +698,6 @@ class PCRParser(object): def __init__(self, filepath: Path | None=None, submission: BasicSubmission | None=None) -> None: """ - Initializes object. - Args: filepath (Path | None, optional): file to parse. Defaults to None. """ diff --git a/src/submissions/backend/excel/reports.py b/src/submissions/backend/excel/reports.py index b637e27..13bdfbd 100644 --- a/src/submissions/backend/excel/reports.py +++ b/src/submissions/backend/excel/reports.py @@ -90,6 +90,13 @@ class ReportMaker(object): return html def write_report(self, filename: Path | str, obj: QWidget | None = None): + """ + Writes info to files. + + Args: + filename (Path | str): Basename of output file + obj (QWidget | None, optional): Parent object. Defaults to None. + """ if isinstance(filename, str): filename = Path(filename) filename = filename.absolute() @@ -108,6 +115,9 @@ class ReportMaker(object): self.writer.close() def fix_up_xl(self): + """ + Handles formatting of xl file. + """ # logger.debug(f"Updating worksheet") worksheet: Worksheet = self.writer.sheets['Report'] for idx, col in enumerate(self.summary_df, start=1): # loop through all columns diff --git a/src/submissions/backend/excel/writer.py b/src/submissions/backend/excel/writer.py index 9bc8027..8208700 100644 --- a/src/submissions/backend/excel/writer.py +++ b/src/submissions/backend/excel/writer.py @@ -1,3 +1,6 @@ +''' +contains writer objects for pushing values to submission sheet templates. +''' import logging from copy import copy from operator import itemgetter @@ -27,8 +30,9 @@ class SheetWriter(object): def __init__(self, submission: PydSubmission, missing_only: bool = False): """ Args: - filepath (Path | None, optional): file path to excel sheet. Defaults to None. - """ + submission (PydSubmission): Object containing submission information. + missing_only (bool, optional): Whether to only fill in missing values. Defaults to False. + """ self.sub = OrderedDict(submission.improved_dict()) for k, v in self.sub.items(): match k: @@ -116,6 +120,13 @@ class InfoWriter(object): def __init__(self, xl: Workbook, submission_type: SubmissionType | str, info_dict: dict, sub_object: BasicSubmission | None = None): + """ + Args: + xl (Workbook): Openpyxl workbook from submitted excel file. + submission_type (SubmissionType | str): Type of submission expected (Wastewater, Bacterial Culture, etc.) + info_dict (dict): Dictionary of information to write. + sub_object (BasicSubmission | None, optional): Submission object containing methods. Defaults to None. + """ logger.debug(f"Info_dict coming into InfoWriter: {pformat(info_dict)}") if isinstance(submission_type, str): submission_type = SubmissionType.query(name=submission_type) @@ -186,6 +197,13 @@ class ReagentWriter(object): def __init__(self, xl: Workbook, submission_type: SubmissionType | str, extraction_kit: KitType | str, reagent_list: list): + """ + Args: + xl (Workbook): Openpyxl workbook from submitted excel file. + submission_type (SubmissionType | str): Type of submission expected (Wastewater, Bacterial Culture, etc.) + extraction_kit (KitType | str): Extraction kit used. + reagent_list (list): List of reagent dicts to be written to excel. + """ self.xl = xl if isinstance(submission_type, str): submission_type = SubmissionType.query(name=submission_type) @@ -245,8 +263,13 @@ class SampleWriter(object): """ object to write sample data into excel file """ - def __init__(self, xl: Workbook, submission_type: SubmissionType | str, sample_list: list): + """ + Args: + xl (Workbook): Openpyxl workbook from submitted excel file. + submission_type (SubmissionType | str): Type of submission expected (Wastewater, Bacterial Culture, etc.) + sample_list (list): List of sample dictionaries to be written to excel file. + """ if isinstance(submission_type, str): submission_type = SubmissionType.query(name=submission_type) self.submission_type = submission_type @@ -303,6 +326,12 @@ class EquipmentWriter(object): """ def __init__(self, xl: Workbook, submission_type: SubmissionType | str, equipment_list: list): + """ + Args: + xl (Workbook): Openpyxl workbook from submitted excel file. + submission_type (SubmissionType | str): Type of submission expected (Wastewater, Bacterial Culture, etc.) + equipment_list (list): List of equipment dictionaries to write to excel file. + """ if isinstance(submission_type, str): submission_type = SubmissionType.query(name=submission_type) self.submission_type = submission_type @@ -385,6 +414,12 @@ class TipWriter(object): """ def __init__(self, xl: Workbook, submission_type: SubmissionType | str, tips_list: list): + """ + Args: + xl (Workbook): Openpyxl workbook from submitted excel file. + submission_type (SubmissionType | str): Type of submission expected (Wastewater, Bacterial Culture, etc.) + tips_list (list): List of tip dictionaries to write to the excel file. + """ if isinstance(submission_type, str): submission_type = SubmissionType.query(name=submission_type) self.submission_type = submission_type @@ -460,8 +495,15 @@ class TipWriter(object): class DocxWriter(object): + """ + Object to render + """ def __init__(self, base_dict: dict): + """ + Args: + base_dict (dict): dictionary of info to be written to template. + """ self.sub_obj = BasicSubmission.find_polymorphic_subclass(polymorphic_identity=base_dict['submission_type']) env = jinja_template_loading() temp_name = f"{base_dict['submission_type'].replace(' ', '').lower()}_subdocument.docx" @@ -506,7 +548,13 @@ class DocxWriter(object): output.append(contents) return output - def create_merged_template(self, *args): + def create_merged_template(self, *args) -> BytesIO: + """ + Appends submission specific information + + Returns: + BytesIO: Merged docx template + """ merged_document = Document() output = BytesIO() for index, file in enumerate(args): diff --git a/src/submissions/backend/validators/__init__.py b/src/submissions/backend/validators/__init__.py index 556793d..66eb726 100644 --- a/src/submissions/backend/validators/__init__.py +++ b/src/submissions/backend/validators/__init__.py @@ -1,3 +1,6 @@ +''' +Contains all validators +''' import logging, re import sys from pathlib import Path diff --git a/src/submissions/backend/validators/pydant.py b/src/submissions/backend/validators/pydant.py index 8ea5504..f4c9fb7 100644 --- a/src/submissions/backend/validators/pydant.py +++ b/src/submissions/backend/validators/pydant.py @@ -3,21 +3,18 @@ Contains pydantic models and accompanying validators ''' from __future__ import annotations import sys -from operator import attrgetter -import uuid, re, logging -from pydantic import BaseModel, field_validator, Field, model_validator, PrivateAttr +import uuid, re, logging, csv +from pydantic import BaseModel, field_validator, Field, model_validator from datetime import date, datetime, timedelta from dateutil.parser import parse from dateutil.parser import ParserError from typing import List, Tuple, Literal from . import RSLNamer from pathlib import Path -from tools import check_not_nan, convert_nans_to_nones, Report, Result, row_map +from tools import check_not_nan, convert_nans_to_nones, Report, Result from backend.db.models import * from sqlalchemy.exc import StatementError, IntegrityError from PyQt6.QtWidgets import QWidget -from openpyxl import load_workbook, Workbook -from io import BytesIO logger = logging.getLogger(f"submissions.{__name__}") @@ -106,20 +103,17 @@ class PydReagent(BaseModel): return values.data['role'] def improved_dict(self) -> dict: + """ + Constructs a dictionary consisting of model.fields and model.extras + + Returns: + dict: Information dictionary + """ try: extras = list(self.model_extra.keys()) except AttributeError: extras = [] fields = list(self.model_fields.keys()) + extras - # output = {} - # for k in fields: - # value = getattr(self, k) - # match value: - # case date(): - # value = value.strftime("%Y-%m-%d") - # case _: - # pass - # output[k] = value return {k: getattr(self, k) for k in fields} def toSQL(self, submission: BasicSubmission | str = None) -> Tuple[Reagent, SubmissionReagentAssociation, Report]: @@ -142,7 +136,7 @@ class PydReagent(BaseModel): if isinstance(value, dict): value = value['value'] # logger.debug(f"Reagent info item for {key}: {value}") - # set fields based on keys in dictionary + # NOTE: set fields based on keys in dictionary match key: case "lot": reagent.lot = value.upper() @@ -177,11 +171,9 @@ class PydReagent(BaseModel): assoc = None # 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, assoc, report - class PydSample(BaseModel, extra='allow'): submitter_id: str sample_type: str @@ -220,6 +212,12 @@ class PydSample(BaseModel, extra='allow'): return str(value) def improved_dict(self) -> dict: + """ + Constructs a dictionary consisting of model.fields and model.extras + + Returns: + dict: Information dictionary + """ fields = list(self.model_fields.keys()) + list(self.model_extra.keys()) return {k: getattr(self, k) for k in fields} @@ -249,7 +247,6 @@ class PydSample(BaseModel, extra='allow'): if isinstance(submission, str): submission = BasicSubmission.query(rsl_plate_num=submission) assoc_type = submission.submission_type_name - # assoc_type = self.sample_type.replace("Sample", "").strip() for row, column, aid, submission_rank in zip(self.row, self.column, self.assoc_id, self.submission_rank): # logger.debug(f"Looking up association with identity: ({submission.submission_type_name} Association)") # logger.debug(f"Looking up association with identity: ({assoc_type} Association)") @@ -268,6 +265,12 @@ class PydSample(BaseModel, extra='allow'): return instance, out_associations, report def improved_dict(self) -> dict: + """ + Constructs a dictionary consisting of model.fields and model.extras + + Returns: + dict: Information dictionary + """ try: extras = list(self.model_extra.keys()) except AttributeError: @@ -281,7 +284,16 @@ class PydTips(BaseModel): lot: str|None = Field(default=None) role: str - def to_sql(self, submission:BasicSubmission): + def to_sql(self, submission:BasicSubmission) -> SubmissionTipsAssociation: + """ + Con + + Args: + submission (BasicSubmission): A submission object to associate tips represented here. + + Returns: + SubmissionTipsAssociation: Association between queried tips and submission + """ tips = Tips.query(name=self.name, lot=self.lot, limit=1) assoc = SubmissionTipsAssociation(submission=submission, tips=tips, role_name=self.role) return assoc @@ -348,6 +360,12 @@ class PydEquipment(BaseModel, extra='ignore'): return equipment, assoc def improved_dict(self) -> dict: + """ + Constructs a dictionary consisting of model.fields and model.extras + + Returns: + dict: Information dictionary + """ try: extras = list(self.model_extra.keys()) except AttributeError: @@ -619,7 +637,14 @@ class PydSubmission(BaseModel, extra='allow'): self.submission_object = BasicSubmission.find_polymorphic_subclass( polymorphic_identity=self.submission_type['value']) - def set_attribute(self, key, value): + def set_attribute(self, key:str, value): + """ + Better handling of attribute setting. + + Args: + key (str): Name of field to set + value (_type_): Value to set field to. + """ self.__setattr__(name=key, value=value) def handle_duplicate_samples(self): @@ -775,15 +800,14 @@ class PydSubmission(BaseModel, extra='allow'): except AttributeError: instance.run_cost = 0 # logger.debug(f"Calculated base run cost of: {instance.run_cost}") - # Apply any discounts that are applicable for client and kit. + # NOTE: Apply any discounts that are applicable for client and kit. try: # logger.debug("Checking and applying discounts...") discounts = [item.amount for item in Discount.query(kit_type=instance.extraction_kit, organization=instance.submitting_lab)] # logger.debug(f"We got discounts: {discounts}") if len(discounts) > 0: - discounts = sum(discounts) - instance.run_cost = instance.run_cost - discounts + instance.run_cost = instance.run_cost - sum(discounts) except Exception as e: logger.error(f"An unknown exception occurred when calculating discounts: {e}") # We need to make sure there's a proper rsl plate number @@ -808,7 +832,13 @@ class PydSubmission(BaseModel, extra='allow'): from frontend.widgets.submission_widget import SubmissionFormWidget return SubmissionFormWidget(parent=parent, submission=self) - def to_writer(self): + def to_writer(self) -> "SheetWriter": + """ + Sends data here to the sheet writer. + + Returns: + SheetWriter: Sheetwriter object that will perform writing. + """ from backend.excel.writer import SheetWriter return SheetWriter(self) @@ -866,6 +896,19 @@ class PydSubmission(BaseModel, extra='allow'): status="Warning") report.add_result(result) return output_reagents, report + + def export_csv(self, filename:Path|str): + try: + worksheet = self.csv + except AttributeError: + logger.error("No csv found.") + return + if isinstance(filename, str): + filename = Path(filename) + with open(filename, 'w', newline="") as f: + c = csv.writer(f) + for r in worksheet.rows: + c.writerow([cell.value for cell in r]) class PydContact(BaseModel): diff --git a/src/submissions/frontend/widgets/app.py b/src/submissions/frontend/widgets/app.py index 1048797..410d122 100644 --- a/src/submissions/frontend/widgets/app.py +++ b/src/submissions/frontend/widgets/app.py @@ -62,7 +62,7 @@ class App(QMainWindow): # logger.debug(f"Creating menu bar...") menuBar = self.menuBar() fileMenu = menuBar.addMenu("&File") - # Creating menus using a title + # NOTE: Creating menus using a title methodsMenu = menuBar.addMenu("&Methods") reportMenu = menuBar.addMenu("&Reports") maintenanceMenu = menuBar.addMenu("&Monthly") @@ -70,7 +70,6 @@ class App(QMainWindow): helpMenu.addAction(self.helpAction) helpMenu.addAction(self.docsAction) fileMenu.addAction(self.importAction) - # fileMenu.addAction(self.importPCRAction) methodsMenu.addAction(self.searchLog) methodsMenu.addAction(self.searchSample) reportMenu.addAction(self.generateReportAction) @@ -94,7 +93,6 @@ class App(QMainWindow): """ # 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) @@ -112,7 +110,6 @@ class App(QMainWindow): """ # 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.table_widget.formwidget.add_reagent) self.generateReportAction.triggered.connect(self.table_widget.sub_wid.generate_report) self.joinExtractionAction.triggered.connect(self.table_widget.sub_wid.link_extractions) @@ -166,12 +163,17 @@ class App(QMainWindow): dlg.exec() def runSampleSearch(self): + """ + Create a search for samples. + """ dlg = SearchBox(self) dlg.exec() def backup_database(self): + """ + Copies the database into the backup directory the first time it is opened every month. + """ month = date.today().strftime("%Y-%m") - # day = date.today().strftime("%Y-%m-%d") # logger.debug(f"Here is the db directory: {self.ctx.database_path}") # logger.debug(f"Here is the backup directory: {self.ctx.backup_path}") current_month_bak = Path(self.ctx.backup_path).joinpath(f"submissions_backup-{month}").resolve().with_suffix(".db") diff --git a/src/submissions/frontend/widgets/controls_chart.py b/src/submissions/frontend/widgets/controls_chart.py index 24947bb..11d29b4 100644 --- a/src/submissions/frontend/widgets/controls_chart.py +++ b/src/submissions/frontend/widgets/controls_chart.py @@ -1,3 +1,6 @@ +''' +Handles display of control charts +''' from PyQt6.QtWebEngineWidgets import QWebEngineView from PyQt6.QtWidgets import ( QWidget, QVBoxLayout, QComboBox, QHBoxLayout, @@ -54,43 +57,37 @@ class ControlsViewer(QWidget): """ self.controls_getter_function() - def chart_maker(self): - """ - Creates plotly charts for webview - """ - self.chart_maker_function() - def controls_getter_function(self): """ Get controls based on start/end dates """ report = Report() - # subtype defaults to disabled + # NOTE: subtype defaults to disabled try: self.sub_typer.disconnect() except TypeError: pass - # correct start date being more recent than end date and rerun + # NOTE: 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 + # NOTE: 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 + # NOTE: 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 + # NOTE: lookup subtypes sub_types = ControlType.query(name=self.con_type).get_subtypes(mode=self.mode) if sub_types != []: - # block signal that will rerun controls getter and update sub_typer + # NOTE: 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) @@ -100,7 +97,13 @@ class ControlsViewer(QWidget): self.sub_typer.setEnabled(False) self.chart_maker() self.report.add_result(report) - + + def chart_maker(self): + """ + Creates plotly charts for webview + """ + self.chart_maker_function() + def chart_maker_function(self): """ Create html chart for controls reporting @@ -119,7 +122,7 @@ class ControlsViewer(QWidget): 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 + # NOTE: query all controls using the type/start and end dates from the gui controls = Control.query(control_type=self.con_type, start_date=self.start_date, end_date=self.end_date) # NOTE: if no data found from query set fig to none for reporting in webview if controls is None: @@ -139,7 +142,7 @@ class ControlsViewer(QWidget): title = self.mode else: title = f"{self.mode} - {self.subtype}" - # send dataframe to chart maker + # NOTE: send dataframe to chart maker fig = create_charts(ctx=self.app.ctx, df=df, ytitle=title) # logger.debug(f"Updating figure...") # NOTE: construct html for webview @@ -157,7 +160,7 @@ class ControlsDatePicker(QWidget): def __init__(self) -> None: super().__init__() self.start_date = QDateEdit(calendarPopup=True) - # start date is two months prior to end date by default + # NOTE: 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) diff --git a/src/submissions/frontend/widgets/equipment_usage.py b/src/submissions/frontend/widgets/equipment_usage.py index 0b63e19..a6f3233 100644 --- a/src/submissions/frontend/widgets/equipment_usage.py +++ b/src/submissions/frontend/widgets/equipment_usage.py @@ -1,4 +1,6 @@ -import sys +''' +Creates forms that the user can enter equipment info into. +''' from pprint import pformat from PyQt6.QtCore import Qt from PyQt6.QtWidgets import (QDialog, QComboBox, QCheckBox, @@ -180,6 +182,9 @@ class RoleComboBox(QWidget): logger.error(f"Could create PydEquipment due to: {e}") def toggle_checked(self): + """ + If this equipment is disabled, the input fields will be disabled. + """ for widget in self.findChildren(QWidget): match widget: case QCheckBox(): diff --git a/src/submissions/frontend/widgets/gel_checker.py b/src/submissions/frontend/widgets/gel_checker.py index d005097..44c0344 100644 --- a/src/submissions/frontend/widgets/gel_checker.py +++ b/src/submissions/frontend/widgets/gel_checker.py @@ -23,32 +23,32 @@ class GelBox(QDialog): def __init__(self, parent, img_path:str|Path, submission:WastewaterArtic): super().__init__(parent) - # setting title + # NOTE: setting title self.setWindowTitle("PyQtGraph") self.img_path = img_path self.submission = submission - # setting geometry + # NOTE: setting geometry self.setGeometry(50, 50, 1200, 900) - # icon + # NOTE: icon icon = QIcon("skin.png") - # setting icon to the window + # NOTE: setting icon to the window self.setWindowIcon(icon) - # calling method + # NOTE: calling method self.UiComponents() - # showing all the widgets + # NOTE: showing all the widgets # method for components def UiComponents(self): """ Create widgets in ui """ - # setting configuration options + # NOTE: setting configuration options pg.setConfigOptions(antialias=True) - # creating image view object + # NOTE: creating image view object self.imv = pg.ImageView() - # Create image. - # For some reason, ImageView wants to flip the image, so we have to rotate and flip the array first. - # Using the Image.rotate function results in cropped image. + # NOTE: Create image. + # NOTE: For some reason, ImageView wants to flip the image, so we have to rotate and flip the array first. + # NOTE: Using the Image.rotate function results in cropped image, so using np. img = np.flip(np.rot90(np.array(Image.open(self.img_path)),1),0) self.imv.setImage(img) layout = QGridLayout() @@ -60,10 +60,10 @@ class GelBox(QDialog): self.gel_barcode = QLineEdit() self.gel_barcode.setText(self.submission.gel_barcode) layout.addWidget(self.gel_barcode, 0, 4) - # setting this layout to the widget - # plot window goes on right side, spanning 3 rows + # NOTE: setting this layout to the widget + # NOTE: plot window goes on right side, spanning 3 rows layout.addWidget(self.imv, 1, 1,20,20) - # setting this widget as central widget of the main window + # NOTE: setting this widget as central widget of the main window try: control_info = sorted(self.submission.gel_controls, key=lambda d: d['location']) except KeyError: @@ -123,16 +123,10 @@ class ControlsForm(QWidget): widge.setText("Neg") widge.setObjectName(f"{rows[iii]} : {columns[jjj]}") self.layout.addWidget(widge, iii+1, jjj+2, 1, 1) - # try: - # for iii, item in enumerate(control_info, start=1): - # self.layout.addWidget(QLabel(f"{item['sample_id']} - {item['location']}"), iii+4, 1) - # except TypeError: - # pass self.layout.addWidget(QLabel("Comments:"), 0,5,1,1) self.comment_field = QTextEdit(self) self.comment_field.setFixedHeight(50) self.layout.addWidget(self.comment_field, 1,5,4,1) - self.setLayout(self.layout) def parse_form(self) -> List[dict]: diff --git a/src/submissions/frontend/widgets/kit_creator.py b/src/submissions/frontend/widgets/kit_creator.py index dc38e0d..6bb65c3 100644 --- a/src/submissions/frontend/widgets/kit_creator.py +++ b/src/submissions/frontend/widgets/kit_creator.py @@ -30,27 +30,27 @@ class KitAdder(QWidget): scrollContent = QWidget(scroll) self.grid = QGridLayout() scrollContent.setLayout(self.grid) - # insert submit button at top + # NOTE: 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 + # NOTE: 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 + # NOTE: widget to get uses of kit used_for = QComboBox() used_for.setObjectName("used_for") - # Insert all existing sample types + # NOTE: Insert all existing sample types 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 + # NOTE: 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. + # NOTE: convert field name to human readable. field_name = column.name.replace("_", " ").title() self.grid.addWidget(QLabel(field_name),idx,0) match column.type: @@ -79,7 +79,7 @@ class KitAdder(QWidget): """ insert new reagent type row """ - # get bottommost row + # NOTE: get bottommost row maxrow = self.grid.rowCount() reg_form = ReagentRoleForm(parent=self) reg_form.setObjectName(f"ReagentForm_{maxrow}") @@ -90,14 +90,14 @@ class KitAdder(QWidget): send kit to database """ report = Report() - # get form info + # NOTE: get form info info, reagents = self.parse_form() 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_roles'] = reagents # logger.debug(pformat(info)) - # send to kit constructor + # NOTE: send to kit constructor kit = PydKit(name=info['kit_name']) for reagent in info['reagent_roles']: uses = { diff --git a/src/submissions/frontend/widgets/misc.py b/src/submissions/frontend/widgets/misc.py index 344a69b..96d00fa 100644 --- a/src/submissions/frontend/widgets/misc.py +++ b/src/submissions/frontend/widgets/misc.py @@ -27,15 +27,12 @@ class AddReagentForm(QDialog): super().__init__() if reagent_lot is None: reagent_lot = reagent_role - 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 + # NOTE: widget to get lot info self.name_input = QComboBox() self.name_input.setObjectName("name") self.name_input.setEditable(True) @@ -43,10 +40,10 @@ class AddReagentForm(QDialog): self.lot_input = QLineEdit() self.lot_input.setObjectName("lot") self.lot_input.setText(reagent_lot) - # widget to get expiry info + # NOTE: 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 + # NOTE: if expiry is not passed in from gui, use today if expiry is None: self.exp_input.setDate(QDate.currentDate()) else: @@ -54,17 +51,17 @@ class AddReagentForm(QDialog): self.exp_input.setDate(expiry) except TypeError: self.exp_input.setDate(QDate.currentDate()) - # widget to get reagent type info + # NOTE: widget to get reagent type info self.type_input = QComboBox() self.type_input.setObjectName('type') self.type_input.addItems([item.name for item in ReagentRole.query()]) # logger.debug(f"Trying to find index of {reagent_type}") - # convert input to user friendly string? + # NOTE: convert input to user friendly string? try: reagent_role = reagent_role.replace("_", " ").title() except AttributeError: reagent_role = None - # set parsed reagent type to top of list + # NOTE: set parsed reagent type to top of list index = self.type_input.findText(reagent_role, Qt.MatchFlag.MatchEndsWith) if index >= 0: self.type_input.setCurrentIndex(index) @@ -110,12 +107,12 @@ class ReportDatePicker(QDialog): def __init__(self) -> None: super().__init__() self.setWindowTitle("Select Report Date Range") - # make confirm/reject buttons + # NOTE: 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 + # NOTE: widgets to ask for dates self.start_date = QDateEdit(calendarPopup=True) self.start_date.setObjectName("start_date") self.start_date.setDate(QDate.currentDate()) @@ -139,48 +136,6 @@ class ReportDatePicker(QDialog): """ return dict(start_date=self.start_date.date().toPyDate(), end_date = self.end_date.date().toPyDate()) - -class FirstStrandSalvage(QDialog): - - def __init__(self, ctx:Settings, submitter_id:str, rsl_plate_num:str|None=None) -> None: - super().__init__() - if rsl_plate_num is 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) -> dict: - """ - Pulls first strand info from form. - - Returns: - dict: Output info - """ - 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 LogParser(QDialog): def __init__(self, parent): diff --git a/src/submissions/frontend/widgets/pop_ups.py b/src/submissions/frontend/widgets/pop_ups.py index 11abb20..2ce2b5d 100644 --- a/src/submissions/frontend/widgets/pop_ups.py +++ b/src/submissions/frontend/widgets/pop_ups.py @@ -22,13 +22,13 @@ class QuestionAsker(QDialog): def __init__(self, title:str, message:str) -> QDialog: super().__init__() self.setWindowTitle(title) - # set yes/no buttons + # NOTE: set yes/no buttons 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() - # Text for the yes/no question + # NOTE: Text for the yes/no question self.message = QLabel(message) self.layout.addWidget(self.message) self.layout.addWidget(self.buttonBox) @@ -41,7 +41,7 @@ class AlertPop(QMessageBox): """ def __init__(self, message:str, status:Literal['Information', 'Question', 'Warning', 'Critical'], owner:str|None=None) -> QMessageBox: super().__init__() - # select icon by string + # NOTE: select icon by string icon = getattr(QMessageBox.Icon, status) self.setIcon(icon) self.setInformativeText(message) @@ -61,13 +61,13 @@ class ObjectSelector(QDialog): items = [item.name for item in obj_type.query()] self.widget.addItems(items) self.widget.setEditable(False) - # set yes/no buttons + # NOTE: set yes/no buttons QBtn = QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel self.buttonBox = QDialogButtonBox(QBtn) self.buttonBox.accepted.connect(self.accept) self.buttonBox.rejected.connect(self.reject) self.layout = QVBoxLayout() - # Text for the yes/no question + # NOTE: Text for the yes/no question message = QLabel(message) self.layout.addWidget(message) self.layout.addWidget(self.widget) diff --git a/src/submissions/frontend/widgets/sample_search.py b/src/submissions/frontend/widgets/sample_search.py index 5667fae..669bcdb 100644 --- a/src/submissions/frontend/widgets/sample_search.py +++ b/src/submissions/frontend/widgets/sample_search.py @@ -1,3 +1,6 @@ +''' +Search box that performs fuzzy search for samples +''' from pprint import pformat from typing import Tuple from pandas import DataFrame @@ -33,6 +36,9 @@ class SearchBox(QDialog): self.update_widgets() def update_widgets(self): + """ + Changes form inputs based on sample type + """ deletes = [item for item in self.findChildren(FieldSearch)] # logger.debug(deletes) for item in deletes: @@ -45,13 +51,21 @@ class SearchBox(QDialog): widget = FieldSearch(parent=self, label=item['label'], field_name=item['field']) self.layout.addWidget(widget, start_row+iii, 0) - def parse_form(self): + def parse_form(self) -> dict: + """ + Converts form into dictionary. + + Returns: + dict: Fields dictionary + """ fields = [item.parse_form() for item in self.findChildren(FieldSearch)] return {item[0]:item[1] for item in fields if item[1] is not None} def update_data(self): + """ + Shows dataframe of relevant samples. + """ fields = self.parse_form() - # data = self.type.samples_to_df(sample_type=self.type, **fields) data = self.type.fuzzy_search(sample_type=self.type, **fields) data = self.type.samples_to_df(sample_list=data) # logger.debug(f"Data: {data}") @@ -72,6 +86,9 @@ class FieldSearch(QWidget): self.search_widget.returnPressed.connect(self.enter_pressed) def enter_pressed(self): + """ + Triggered when enter is pressed on this input field. + """ self.parent().update_data() def parse_form(self) -> Tuple: @@ -99,4 +116,5 @@ class SearchResults(QTableView): logger.error("Couldn't format id string.") proxy_model = QSortFilterProxyModel() proxy_model.setSourceModel(pandasModel(self.data)) - self.setModel(proxy_model) \ No newline at end of file + self.setModel(proxy_model) + \ No newline at end of file diff --git a/src/submissions/frontend/widgets/submission_details.py b/src/submissions/frontend/widgets/submission_details.py index 1bfc6e0..bb2b91b 100644 --- a/src/submissions/frontend/widgets/submission_details.py +++ b/src/submissions/frontend/widgets/submission_details.py @@ -1,6 +1,7 @@ -from PyQt6.QtGui import QPageSize -from PyQt6.QtPrintSupport import QPrinter -from PyQt6.QtWidgets import (QDialog, QPushButton, QVBoxLayout, QMessageBox, +''' +Webview to show submission and sample details. +''' +from PyQt6.QtWidgets import (QDialog, QPushButton, QVBoxLayout, QDialogButtonBox, QTextEdit) from PyQt6.QtWebEngineWidgets import QWebEngineView from PyQt6.QtWebChannel import QWebChannel @@ -9,14 +10,12 @@ from PyQt6.QtCore import Qt, pyqtSlot from backend.db.models import BasicSubmission, BasicSample from tools import is_power_user, html_to_pdf from .functions import select_save_file -from io import BytesIO from pathlib import Path -import logging, base64 +import logging from getpass import getuser from datetime import datetime from pprint import pformat -from html2image import Html2Image -from PIL import Image + from typing import List from backend.excel.writer import DocxWriter @@ -135,7 +134,7 @@ class SubmissionComment(QDialog): pass self.submission = submission self.setWindowTitle(f"{self.submission.rsl_plate_num} Submission Comment") - # create text field + # NOTE: create text field self.txt_editor = QTextEdit(self) self.txt_editor.setReadOnly(False) self.txt_editor.setText("Add Comment") diff --git a/src/submissions/frontend/widgets/submission_table.py b/src/submissions/frontend/widgets/submission_table.py index dcba392..002f2c3 100644 --- a/src/submissions/frontend/widgets/submission_table.py +++ b/src/submissions/frontend/widgets/submission_table.py @@ -261,32 +261,32 @@ class SubmissionsSheet(QTableView): html = make_report_html(df=summary_df, start_date=info['start_date'], end_date=info['end_date']) # NOTE: get save location of report fname = select_save_file(obj=self, default_name=f"Submissions_Report_{info['start_date']}-{info['end_date']}.docx", extension="docx") - html_to_pdf(html=html, output_file=fname) - 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: Worksheet = writer.sheets['Report'] - for idx, col in enumerate(summary_df, start=1): # 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: - # NOTE: Convert idx to letter - col_letter = chr(ord('@') + idx) - worksheet.column_dimensions[col_letter].width = max_len - except ValueError: - pass - blank_row = get_first_blank_df_row(summary_df) + 1 - # logger.debug(f"Blank row index = {blank_row}") - for col in range(3,6): - col_letter = row_map[col] - worksheet.cell(row=blank_row, column=col, value=f"=SUM({col_letter}2:{col_letter}{str(blank_row-1)})") - for cell in worksheet['D']: - if cell.row > 1: - cell.style = 'Currency' - writer.close() - # rp = ReportMaker(start_date=info['start_date'], end_date=info['end_date']) - # rp.write_report(filename=fname, obj=self) + # html_to_pdf(html=html, output_file=fname) + # 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: Worksheet = writer.sheets['Report'] + # for idx, col in enumerate(summary_df, start=1): # 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: + # # NOTE: Convert idx to letter + # col_letter = chr(ord('@') + idx) + # worksheet.column_dimensions[col_letter].width = max_len + # except ValueError: + # pass + # blank_row = get_first_blank_df_row(summary_df) + 1 + # # logger.debug(f"Blank row index = {blank_row}") + # for col in range(3,6): + # col_letter = row_map[col] + # worksheet.cell(row=blank_row, column=col, value=f"=SUM({col_letter}2:{col_letter}{str(blank_row-1)})") + # for cell in worksheet['D']: + # if cell.row > 1: + # cell.style = 'Currency' + # writer.close() + rp = ReportMaker(start_date=info['start_date'], end_date=info['end_date']) + rp.write_report(filename=fname, obj=self) self.report.add_result(report) diff --git a/src/submissions/frontend/widgets/submission_type_creator.py b/src/submissions/frontend/widgets/submission_type_creator.py index d77022c..5595703 100644 --- a/src/submissions/frontend/widgets/submission_type_creator.py +++ b/src/submissions/frontend/widgets/submission_type_creator.py @@ -114,4 +114,5 @@ class InfoWidget(QWidget): sheets = self.sheet.text().split(","), row = self.row.value(), column = self.column.value() - ) \ No newline at end of file + ) + \ No newline at end of file diff --git a/src/submissions/frontend/widgets/submission_widget.py b/src/submissions/frontend/widgets/submission_widget.py index d66433d..c85fdb7 100644 --- a/src/submissions/frontend/widgets/submission_widget.py +++ b/src/submissions/frontend/widgets/submission_widget.py @@ -1,5 +1,7 @@ +''' +Contains all submission related frontend functions +''' import sys - from PyQt6.QtWidgets import ( QWidget, QPushButton, QVBoxLayout, QComboBox, QDateEdit, QLineEdit, QLabel @@ -149,8 +151,6 @@ class SubmissionFormContainer(QWidget): class SubmissionFormWidget(QWidget): - - def __init__(self, parent: QWidget, submission: PydSubmission) -> None: super().__init__(parent) # self.report = Report() @@ -303,10 +303,10 @@ class SubmissionFormWidget(QWidget): # logger.debug(f"Base submission: {base_submission.to_dict()}") # NOTE: check output message for issues match result.code: - # code 0: everything is fine. + # NOTE: code 0: everything is fine. case 0: report.add_result(None) - # code 1: ask for overwrite + # NOTE: code 1: ask for overwrite case 1: dlg = QuestionAsker(title=f"Review {base_submission.rsl_plate_num}?", message=result.msg) if dlg.exec(): @@ -319,7 +319,7 @@ class SubmissionFormWidget(QWidget): self.app.report.add_result(report) self.app.result_reporter() return - # code 2: No RSL plate number given + # NOTE: code 2: No RSL plate number given case 2: report.add_result(result) self.app.report.add_result(report) @@ -351,9 +351,8 @@ class SubmissionFormWidget(QWidget): if isinstance(fname, bool) or fname is 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) - workbook_2_csv(worksheet=self.pyd.csv, filename=fname) + self.pyd.export_csv(fname) + # workbook_2_csv(worksheet=self.pyd.csv, filename=fname) except PermissionError: logger.warning(f"Could not get permissions to {fname}. Possibly the request was cancelled.") except AttributeError: diff --git a/src/submissions/tools.py b/src/submissions/tools.py index 05aeb03..6e50aaf 100644 --- a/src/submissions/tools.py +++ b/src/submissions/tools.py @@ -7,7 +7,6 @@ import json import numpy as np import logging, re, yaml, sys, os, stat, platform, getpass, inspect, csv import pandas as pd -from PyQt6.QtWidgets import QWidget from jinja2 import Environment, FileSystemLoader from logging import handlers from pathlib import Path @@ -65,6 +64,17 @@ def get_unique_values_in_df_column(df: pd.DataFrame, column_name: str) -> list: def check_key_or_attr(key: str, interest: dict | object, check_none: bool = False) -> bool: + """ + Checks if key exists in dict or object has attribute. + + Args: + key (str): key or attribute name + interest (dict | object): Dictionary or object to be checked. + check_none (bool, optional): Return false if value exists, but is None. Defaults to False. + + Returns: + bool: True if exists, else False + """ match interest: case dict(): if key in interest.keys(): @@ -105,10 +115,9 @@ def check_not_nan(cell_contents) -> bool: Returns: bool: True if cell has value, else, false. """ - # check for nan as a string first + # NOTE: check for nan as a string first exclude = ['unnamed:', 'blank', 'void'] try: - # if "Unnamed:" in cell_contents or "blank" in cell_contents.lower(): if cell_contents.lower() in exclude: cell_contents = np.nan cell_contents = cell_contents.lower() @@ -158,6 +167,15 @@ def convert_nans_to_nones(input_str) -> str | None: def is_missing(value: Any) -> Tuple[Any, bool]: + """ + Checks if a parsed value is missing. + + Args: + value (Any): Incoming value + + Returns: + Tuple[Any, bool]: Value, True if nan, else False + """ if check_not_nan(value): return value, False else: @@ -262,19 +280,19 @@ class Settings(BaseSettings, extra="allow"): else: database_path = values.data['database_path'] if database_path is None: - # check in user's .submissions directory for submissions.db + # NOTE: check in user's .submissions directory for submissions.db if Path.home().joinpath(".submissions", "submissions.db").exists(): database_path = Path.home().joinpath(".submissions", "submissions.db") - # finally, look in the local dir + # NOTE: finally, look in the local dir else: database_path = package_dir.joinpath("submissions.db") else: if database_path == ":memory:": pass - # check if user defined path is directory + # NOTE: check if user defined path is directory elif database_path.is_dir(): database_path = database_path.joinpath("submissions.db") - # check if user defined path is a file + # NOTE: check if user defined path is a file elif database_path.is_file(): database_path = database_path else: @@ -282,7 +300,6 @@ class Settings(BaseSettings, extra="allow"): logger.info(f"Using {database_path} for database file.") engine = create_engine(f"sqlite:///{database_path}") #, echo=True, future=True) session = Session(engine) - # metadata.session = session return session @field_validator('package', mode="before") @@ -403,7 +420,7 @@ class GroupWriteRotatingFileHandler(handlers.RotatingFileHandler): """ # Rotate the file first. handlers.RotatingFileHandler.doRollover(self) - # Add group write to the current permissions. + # NOTE: Add group write to the current permissions. currMode = os.stat(self.baseFilename).st_mode os.chmod(self.baseFilename, currMode | stat.S_IWGRP) @@ -629,6 +646,12 @@ class Report(BaseModel): return f"Report(result_count:{len(self.results)})" def add_result(self, result: Result | Report | None): + """ + Takes a result object or all results in another report and adds them to this one. + + Args: + result (Result | Report | None): Results to be added. + """ match result: case Result(): logger.info(f"Adding {result} to results.") @@ -644,15 +667,30 @@ class Report(BaseModel): case _: logger.error(f"Unknown variable type: {type(result)} for entry into ") - def is_empty(self): - return bool(self.results) +def rreplace(s:str, old:str, new:str) -> str: + """ + Removes rightmost occurence of a substring -def rreplace(s, old, new): + Args: + s (str): input string + old (str): original substring + new (str): new substring + + Returns: + str: updated string + """ return (s[::-1].replace(old[::-1], new[::-1], 1))[::-1] -def html_to_pdf(html, output_file: Path | str): +def html_to_pdf(html:str, output_file: Path | str): + """ + Attempts to print an html string as a PDF. (currently not working) + + Args: + html (str): Input html string. + output_file (Path | str): Output PDF file path. + """ if isinstance(output_file, str): output_file = Path(output_file) logger.debug(f"Printing PDF to {output_file}") @@ -688,6 +726,13 @@ def remove_key_from_list_of_dicts(input: list, key: str) -> list: def workbook_2_csv(worksheet: Worksheet, filename: Path): + """ + Export an excel worksheet (workbook is not correct) to csv file. + + Args: + worksheet (Worksheet): Incoming worksheet + filename (Path): Output csv filepath. + """ with open(filename, 'w', newline="") as f: c = csv.writer(f) for r in worksheet.rows: @@ -698,6 +743,12 @@ ctx = get_config(None) def is_power_user() -> bool: + """ + Checks if user is in list of power users + + Returns: + bool: True if yes, False if no. + """ try: check = getpass.getuser() in ctx.power_users except: