From c5470b9062ba17f4e283bd21bbe6ca7854f3e350 Mon Sep 17 00:00:00 2001 From: lwark Date: Thu, 3 Oct 2024 15:09:41 -0500 Subject: [PATCH] Various bug fixes and streamlining. --- CHANGELOG.md | 5 + docs/source/conf.py | 2 +- .../backend/db/models/submissions.py | 117 +++++------------- src/submissions/backend/excel/reports.py | 18 ++- src/submissions/backend/excel/writer.py | 106 ++-------------- .../backend/validators/__init__.py | 62 +++++----- src/submissions/backend/validators/pydant.py | 57 ++++----- .../frontend/visualizations/control_charts.py | 18 +-- src/submissions/frontend/widgets/app.py | 15 +-- .../frontend/widgets/controls_chart.py | 87 +++++++------ .../frontend/widgets/equipment_usage.py | 30 +++-- .../frontend/widgets/gel_checker.py | 10 +- src/submissions/frontend/widgets/pop_ups.py | 33 +++-- .../frontend/widgets/sample_search.py | 4 +- .../frontend/widgets/submission_details.py | 4 +- .../frontend/widgets/submission_table.py | 6 - .../frontend/widgets/submission_widget.py | 6 +- .../bacterialculture_subdocument.docx | Bin 13016 -> 0 bytes .../templates/basicsubmission_document.docx | Bin 14385 -> 0 bytes .../templates/wastewater_subdocument.docx | Bin 12761 -> 0 bytes .../wastewaterartic_subdocument.docx | Bin 13665 -> 0 bytes src/submissions/tools/__init__.py | 22 ---- 22 files changed, 222 insertions(+), 380 deletions(-) delete mode 100644 src/submissions/templates/bacterialculture_subdocument.docx delete mode 100644 src/submissions/templates/basicsubmission_document.docx delete mode 100644 src/submissions/templates/wastewater_subdocument.docx delete mode 100644 src/submissions/templates/wastewaterartic_subdocument.docx diff --git a/CHANGELOG.md b/CHANGELOG.md index e093436..a0e7c6d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,8 @@ +## 202410.01 + +- Reverted details exports from docx back to pdf. +- Large scale speedups for control chart construction. + ## 202409.05 - Replaced some lists with generators to improve speed, added javascript to templates for click events. diff --git a/docs/source/conf.py b/docs/source/conf.py index dab0672..bbc9ccb 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -13,7 +13,7 @@ from submissions import __version__, __copyright__, __author__ project = 'RSL Submissions' copyright = __copyright__ -author = f"{__author__['sub_type']} - {__author__['email']}" +author = f"{__author__['name']} - {__author__['email']}" release = __version__ # -- General configuration --------------------------------------------------- diff --git a/src/submissions/backend/db/models/submissions.py b/src/submissions/backend/db/models/submissions.py index 0048192..cd4a8f2 100644 --- a/src/submissions/backend/db/models/submissions.py +++ b/src/submissions/backend/db/models/submissions.py @@ -27,7 +27,7 @@ from openpyxl.drawing.image import Image as OpenpyxlImage from tools import row_map, setup_lookup, jinja_template_loading, rreplace, row_keys, check_key_or_attr, Result, Report, \ report_result from datetime import datetime, date -from typing import List, Any, Tuple, Literal +from typing import List, Any, Tuple, Literal, Generator from dateutil.parser import parse from pathlib import Path from jinja2.exceptions import TemplateNotFound @@ -592,8 +592,8 @@ class BasicSubmission(BaseClass): case "ctx" | "csv" | "filepath" | "equipment": return case item if item in self.jsons(): - match value: - case dict(): + match key: + case "custom": existing = value case _: # logger.debug(f"Setting JSON attribute.") @@ -611,10 +611,7 @@ class BasicSubmission(BaseClass): existing += value else: if value is not None: - if key == "custom": - existing = value - else: - existing.append(value) + existing.append(value) self.__setattr__(key, existing) flag_modified(self, key) return @@ -889,19 +886,6 @@ class BasicSubmission(BaseClass): ws.cell(row=item['row'], column=item['column'], value=item['value']) return input_excel - @classmethod - def custom_docx_writer(cls, input_dict: dict, tpl_obj=None): - """ - Adds custom fields to docx template writer for exported details. - - Args: - input_dict (dict): Incoming default dictionary. - tpl_obj (_type_, optional): Template object. Defaults to None. - - Returns: - dict: Dictionary with information added. - """ - return input_dict @classmethod def enforce_name(cls, instr: str, data: dict | None = {}) -> str: @@ -962,7 +946,7 @@ class BasicSubmission(BaseClass): return re.sub(rf"{data['abbreviation']}(\d)", rf"{data['abbreviation']}-\1", outstr) @classmethod - def parse_pcr(cls, xl: Workbook, rsl_plate_num: str) -> list: + def parse_pcr(cls, xl: Workbook, rsl_plate_num: str) -> Generator[dict, None, None]: """ Perform parsing of pcr info. Since most of our PC outputs are the same format, this should work for most. @@ -977,7 +961,7 @@ class BasicSubmission(BaseClass): pcr_sample_map = cls.get_submission_type().sample_map['pcr_samples'] # logger.debug(f'sample map: {pcr_sample_map}') main_sheet = xl[pcr_sample_map['main_sheet']] - samples = [] + # samples = [] fields = {k: v for k, v in pcr_sample_map.items() if k not in ['main_sheet', 'start_row']} for row in main_sheet.iter_rows(min_row=pcr_sample_map['start_row']): idx = row[0].row @@ -985,8 +969,9 @@ class BasicSubmission(BaseClass): for k, v in fields.items(): sheet = xl[v['sheet']] sample[k] = sheet.cell(row=idx, column=v['column']).value - samples.append(sample) - return samples + yield sample + # samples.append(sample) + # return samples @classmethod def filename_template(cls) -> str: @@ -1533,17 +1518,17 @@ class Wastewater(BasicSubmission): return input_dict @classmethod - def parse_pcr(cls, xl: Workbook, rsl_plate_num: str) -> List[dict]: + def parse_pcr(cls, xl: Workbook, rsl_plate_num: str) -> Generator[dict, None, None]: """ Parse specific to wastewater samples. """ - samples = super().parse_pcr(xl=xl, rsl_plate_num=rsl_plate_num) + samples = [item for item in super().parse_pcr(xl=xl, rsl_plate_num=rsl_plate_num)] # logger.debug(f'Samples from parent pcr parser: {pformat(samples)}') output = [] for sample in samples: # NOTE: remove '-{target}' from controls sample['sample'] = re.sub('-N\\d$', '', sample['sample']) - # NOTE: if sample is already in output skip + # # NOTE: if sample is already in output skip if sample['sample'] in [item['sample'] for item in output]: logger.warning(f"Already have {sample['sample']}") continue @@ -1564,8 +1549,10 @@ class Wastewater(BasicSubmission): del sample['assessment'] except KeyError: pass + # yield sample output.append(sample) - return output + for sample in output: + yield sample @classmethod def enforce_name(cls, instr: str, data: dict | None = {}) -> str: @@ -1677,49 +1664,18 @@ class Wastewater(BasicSubmission): return report parser = PCRParser(filepath=fname) self.set_attribute("pcr_info", parser.pcr) + pcr_samples = [sample for sample in parser.samples] self.save(original=False) # logger.debug(f"Got {len(parser.samples)} samples to update!") # logger.debug(f"Parser samples: {parser.samples}") for sample in self.samples: # logger.debug(f"Running update on: {sample}") try: - sample_dict = next(item for item in parser.samples if item['sample'] == sample.rsl_number) + sample_dict = next(item for item in pcr_samples if item['sample'] == sample.rsl_number) except StopIteration: continue self.update_subsampassoc(sample=sample, input_dict=sample_dict) - @classmethod - def custom_docx_writer(cls, input_dict: dict, tpl_obj=None) -> dict: - """ - Adds custom fields to docx template writer for exported details. Extends parent. - - Args: - input_dict (dict): Incoming default dictionary. - tpl_obj (_type_, optional): Template object. Defaults to None. - - Returns: - dict: Dictionary with information added. - """ - from backend.excel.writer import DocxWriter - input_dict = super().custom_docx_writer(input_dict) - well_24 = [] - input_dict['samples'] = [item for item in input_dict['samples']] - samples_copy = deepcopy(input_dict['samples']) - for sample in sorted(samples_copy, key=itemgetter('column', 'row')): - try: - row = sample['source_row'] - except KeyError: - continue - try: - column = sample['source_column'] - except KeyError: - continue - copy = dict(submitter_id=sample['submitter_id'], row=row, column=column) - well_24.append(copy) - input_dict['origin_plate'] = [item for item in - DocxWriter.create_plate_map(sample_list=well_24, rows=4, columns=6)] - return input_dict - class WastewaterArtic(BasicSubmission): """ @@ -2038,11 +1994,17 @@ class WastewaterArtic(BasicSubmission): """ input_dict = super().custom_validation(pyd) # logger.debug(f"Incoming input_dict: {pformat(input_dict)}") + exclude_plates = [None, "", "none", "na"] + pyd.source_plates = [plate for plate in pyd.source_plates if plate['plate'].lower() not in exclude_plates] for sample in pyd.samples: # logger.debug(f"Sample: {sample}") if re.search(r"^NTC", sample.submitter_id): - sample.submitter_id = f"{sample.submitter_id}-WWG-{pyd.rsl_plate_num}" - # input_dict['csv'] = xl["hitpicks_csv_to_export"] + if isinstance(pyd.rsl_plate_num, dict): + placeholder = pyd.rsl_plate_num['value'] + else: + placeholder = pyd.rsl_plate_num + sample.submitter_id = f"{sample.submitter_id}-WWG-{placeholder}" + # logger.debug(f"sample id: {sample.submitter_id}") return input_dict @classmethod @@ -2075,6 +2037,7 @@ class WastewaterArtic(BasicSubmission): for iii, plate in enumerate(info['source_plates']['value']): # logger.debug(f"Plate: {plate}") row = start_row + iii + logger.debug(f"Writing {plate} to row {iii}") try: worksheet.cell(row=row, column=source_plates_section['plate_column'], value=plate['plate']) except TypeError: @@ -2209,30 +2172,6 @@ class WastewaterArtic(BasicSubmission): zipf.write(img_path, self.gel_image) self.save() - @classmethod - def custom_docx_writer(cls, input_dict: dict, tpl_obj=None) -> dict: - """ - Adds custom fields to docx template writer for exported details. - - Args: - input_dict (dict): Incoming default dictionary/ - tpl_obj (_type_, optional): Template object. Defaults to None. - - Returns: - dict: Dictionary with information added. - """ - input_dict = super().custom_docx_writer(input_dict) - # NOTE: if there's a gel image, extract it. - if check_key_or_attr(key='gel_image_path', interest=input_dict, check_none=True): - with ZipFile(cls.__directory_path__.joinpath("submission_imgs.zip")) as zipped: - img = zipped.read(input_dict['gel_image_path']) - with tempfile.TemporaryFile(mode="wb", suffix=".jpg", delete=False) as tmp: - tmp.write(img) - # logger.debug(f"Tempfile: {tmp.name}") - img = InlineImage(tpl_obj, image_descriptor=tmp.name, width=Inches(5.5)) #, width=5.5)#, height=400) - input_dict['gel_image'] = img - return input_dict - # Sample Classes @@ -2493,6 +2432,8 @@ class BasicSample(BaseClass): model = cls.find_polymorphic_subclass(polymorphic_identity=sample_type) case BasicSample(): model = sample_type + case None: + model = cls case _: model = cls.find_polymorphic_subclass(attrs=kwargs) # logger.debug(f"Length of kwargs: {len(kwargs)}") @@ -2514,7 +2455,7 @@ class BasicSample(BaseClass): raise AttributeError(f"Delete not implemented for {self.__class__}") @classmethod - def get_searchables(cls): + def get_searchables(cls) -> List[dict]: """ Delivers a list of fields that can be used in fuzzy search. diff --git a/src/submissions/backend/excel/reports.py b/src/submissions/backend/excel/reports.py index b5dd3d1..57cb991 100644 --- a/src/submissions/backend/excel/reports.py +++ b/src/submissions/backend/excel/reports.py @@ -1,13 +1,15 @@ ''' Contains functions for generating summary reports ''' +from PyQt6.QtCore import QMarginsF +from PyQt6.QtGui import QPageLayout, QPageSize from pandas import DataFrame, ExcelWriter import logging, re from pathlib import Path from datetime import date, timedelta from typing import List, Tuple, Any from backend.db.models import BasicSubmission -from tools import jinja_template_loading, html_to_pdf, get_first_blank_df_row, \ +from tools import jinja_template_loading, get_first_blank_df_row, \ row_map from PyQt6.QtWidgets import QWidget from openpyxl.worksheet.worksheet import Worksheet @@ -99,11 +101,15 @@ class ReportMaker(object): filename = Path(filename) filename = filename.absolute() # NOTE: html_to_pdf doesn't function without a PyQt6 app - if isinstance(obj, QWidget): - logger.info(f"We're in PyQt environment, writing PDF to: {filename}") - html_to_pdf(html=self.html, output_file=filename) - else: - logger.info("Not in PyQt. Skipping PDF writing.") + # if isinstance(obj, QWidget): + # logger.info(f"We're in PyQt environment, writing PDF to: {filename}") + # page_layout = QPageLayout() + # page_layout.setPageSize(QPageSize(QPageSize.PageSizeId.A4)) + # page_layout.setOrientation(QPageLayout.Orientation.Portrait) + # page_layout.setMargins(QMarginsF(25, 25, 25, 25)) + # self.webview.page().printToPdf(fname.with_suffix(".pdf").__str__(), page_layout) + # else: + # logger.info("Not in PyQt. Skipping PDF writing.") # logger.debug("Finished writing.") self.writer = ExcelWriter(filename.with_suffix(".xlsx"), engine='openpyxl') self.summary_df.to_excel(self.writer, sheet_name="Report") diff --git a/src/submissions/backend/excel/writer.py b/src/submissions/backend/excel/writer.py index 61f8104..e824c73 100644 --- a/src/submissions/backend/excel/writer.py +++ b/src/submissions/backend/excel/writer.py @@ -3,9 +3,6 @@ contains writer objects for pushing values to submission sheet templates. """ import logging from copy import copy -from operator import itemgetter -from pathlib import Path -# from pathlib import Path from pprint import pformat from typing import List, Generator from openpyxl import load_workbook, Workbook @@ -13,9 +10,6 @@ from backend.db.models import SubmissionType, KitType, BasicSubmission from backend.validators.pydant import PydSubmission from io import BytesIO from collections import OrderedDict -from tools import jinja_template_loading -from docxtpl import DocxTemplate -from docx import Document logger = logging.getLogger(f"submissions.{__name__}") @@ -147,7 +141,6 @@ class InfoWriter(object): Returns: dict: merged dictionary """ - # output = {} for k, v in info_dict.items(): if v is None: continue @@ -163,8 +156,6 @@ class InfoWriter(object): if len(dicto) > 0: # output[k] = dicto yield k, dicto - # logger.debug(f"Reconciled info: {pformat(output)}") - # return output def write_info(self) -> Workbook: """ @@ -217,7 +208,6 @@ class ReagentWriter(object): if isinstance(extraction_kit, str): kit_type = KitType.query(name=extraction_kit) reagent_map = {k: v for k, v in kit_type.construct_xl_map_for_use(submission_type)} - # self.reagents = {k: v for k, v in self.reconcile_map(reagent_list=reagent_list, reagent_map=reagent_map)} self.reagents = self.reconcile_map(reagent_list=reagent_list, reagent_map=reagent_map) def reconcile_map(self, reagent_list: List[dict], reagent_map: dict) -> Generator[dict, None, None]: @@ -231,7 +221,6 @@ class ReagentWriter(object): Returns: List[dict]: merged dictionary """ - # output = [] for reagent in reagent_list: try: mp_info = reagent_map[reagent['role']] @@ -246,9 +235,7 @@ class ReagentWriter(object): dicto = v placeholder[k] = dicto placeholder['sheet'] = mp_info['sheet'] - # output.append(placeholder) yield placeholder - # return output def write_reagents(self) -> Workbook: """ @@ -285,7 +272,6 @@ class SampleWriter(object): self.submission_type = submission_type self.xl = xl self.sample_map = submission_type.construct_sample_map()['lookup_table'] - # self.samples = self.reconcile_map(sample_list) # NOTE: exclude any samples without a submission rank. samples = [item for item in self.reconcile_map(sample_list) if item['submission_rank'] > 0] self.samples = sorted(samples, key=lambda k: k['submission_rank']) @@ -300,7 +286,6 @@ class SampleWriter(object): Returns: List[dict]: List of merged dictionaries """ - # output = [] multiples = ['row', 'column', 'assoc_id', 'submission_rank'] for sample in sample_list: # logger.debug(f"Writing sample: {sample}") @@ -311,7 +296,6 @@ class SampleWriter(object): continue new[k] = v yield new - # return sorted(output, key=lambda k: k['submission_rank']) def write_samples(self) -> Workbook: """ @@ -325,6 +309,11 @@ class SampleWriter(object): for sample in self.samples: row = self.sample_map['start_row'] + (sample['submission_rank'] - 1) for k, v in sample.items(): + if isinstance(v, dict): + try: + v = v['value'] + except KeyError: + logger.error(f"Cant convert {v} to single string.") try: column = columns[k] except KeyError: @@ -363,7 +352,6 @@ class EquipmentWriter(object): Returns: List[dict]: List of merged dictionaries """ - # output = [] if equipment_list is None: return for ii, equipment in enumerate(equipment_list, start=1): @@ -388,10 +376,7 @@ class EquipmentWriter(object): placeholder['sheet'] = mp_info['sheet'] except KeyError: placeholder['sheet'] = "Equipment" - # logger.debug(f"Final output of {equipment['role']} : {placeholder}") yield placeholder - # output.append(placeholder) - # return output def write_equipment(self) -> Workbook: """ @@ -452,19 +437,19 @@ class TipWriter(object): Returns: List[dict]: List of merged dictionaries """ - # output = [] if tips_list is None: return for ii, tips in enumerate(tips_list, start=1): - mp_info = tips_map[tips['role']] + # mp_info = tips_map[tips['role']] + mp_info = tips_map[tips.role] # logger.debug(f"{tips['role']} map: {mp_info}") - placeholder = copy(tips) + placeholder = {} if mp_info == {}: - for jj, (k, v) in enumerate(tips.items(), start=1): + for jj, (k, v) in enumerate(tips.__dict__.items(), start=1): dicto = dict(value=v, row=ii, column=jj) placeholder[k] = dicto else: - for jj, (k, v) in enumerate(tips.items(), start=1): + for jj, (k, v) in enumerate(tips.__dict__.items(), start=1): try: dicto = dict(value=v, row=mp_info[k]['row'], column=mp_info[k]['column']) except KeyError as e: @@ -477,8 +462,6 @@ class TipWriter(object): placeholder['sheet'] = "Tips" # logger.debug(f"Final output of {tips['role']} : {placeholder}") yield placeholder - # output.append(placeholder) - # return output def write_tips(self) -> Workbook: """ @@ -507,72 +490,3 @@ class TipWriter(object): logger.error(f"Couldn't write to {tips['sheet']}, row: {v['row']}, column: {v['column']}") logger.error(e) return self.xl - - -class DocxWriter(object): - """ - Object to render - """ - - def __init__(self, base_dict: dict): - """ - Args: - base_dict (dict): dictionary of info to be written to template. - """ - logger.debug(f"Incoming base dict: {pformat(base_dict)}") - 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" - path = Path(env.loader.__getattribute__("searchpath")[0]) - main_template = path.joinpath("basicsubmission_document.docx") - subdocument = path.joinpath(temp_name) - if subdocument.exists(): - main_template = self.create_merged_template(main_template, subdocument) - self.template = DocxTemplate(main_template) - base_dict['platemap'] = [item for item in self.create_plate_map(base_dict['samples'], rows=8, columns=12)] - # logger.debug(pformat(base_dict['platemap'])) - try: - base_dict['excluded'] += ["platemap"] - except KeyError: - base_dict['excluded'] = ["platemap"] - base_dict = self.sub_obj.custom_docx_writer(base_dict, tpl_obj=self.template) - # logger.debug(f"Base dict: {pformat(base_dict)}") - self.template.render({"sub": base_dict}) - - @classmethod - def create_plate_map(self, sample_list: List[dict], rows: int = 0, columns: int = 0) -> List[list]: - sample_list = sorted(sample_list, key=itemgetter('column', 'row')) - # NOTE if rows or samples is default, set to maximum value in sample list - if rows == 0: - rows = max([sample['row'] for sample in sample_list]) - if columns == 0: - columns = max([sample['column'] for sample in sample_list]) - for row in range(0, rows): - # NOTE: Create a list with length equal to columns length, padding with '' where necessary - contents = [next((item['submitter_id'] for item in sample_list if item['row'] == row + 1 and - item['column'] == column + 1), '') for column in range(0, columns)] - yield contents - - 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): - sub_doc = Document(file) - # Don't add a page break if you've reached the last file. - # if index < len(args) - 1: - # sub_doc.add_page_break() - for element in sub_doc.element.body: - merged_document.element.body.append(element) - merged_document.save(output) - return output - - def save(self, filename: Path | str): - if isinstance(filename, str): - filename = Path(filename) - self.template.save(filename) diff --git a/src/submissions/backend/validators/__init__.py b/src/submissions/backend/validators/__init__.py index 7108bed..44598e4 100644 --- a/src/submissions/backend/validators/__init__.py +++ b/src/submissions/backend/validators/__init__.py @@ -26,7 +26,7 @@ class RSLNamer(object): if self.submission_type is None: # logger.debug("Creating submission type because none exists") self.submission_type = self.retrieve_submission_type(filename=filename) - logger.debug(f"got submission type: {self.submission_type}") + logger.info(f"got submission type: {self.submission_type}") if self.submission_type is not None: # logger.debug("Retrieving BasicSubmission subclass") self.sub_object = BasicSubmission.find_polymorphic_subclass(polymorphic_identity=self.submission_type) @@ -48,36 +48,41 @@ class RSLNamer(object): Returns: str: parsed submission type """ + def st_from_path(filename:Path) -> str: + logger.debug(f"Using path method for {filename}.") + if filename.exists(): + wb = load_workbook(filename) + try: + # NOTE: Gets first category in the metadata. + submission_type = next(item.strip().title() for item in wb.properties.category.split(";")) + except (StopIteration, AttributeError): + sts = {item.name: item.get_template_file_sheets() for item in SubmissionType.query()} + try: + submission_type = next(k.title() for k,v in sts.items() if wb.sheetnames==v) + except StopIteration: + # NOTE: On failure recurse using filename as string for string method + submission_type = cls.retrieve_submission_type(filename=filename.stem.__str__()) + else: + submission_type = cls.retrieve_submission_type(filename=filename.stem.__str__()) + return submission_type + def st_from_str(filename:str) -> str: + regex = BasicSubmission.construct_regex() + logger.debug(f"Using string method for {filename}.") + logger.debug(f"Using regex: {regex}") + m = regex.search(filename) + print(m) + try: + submission_type = m.lastgroup + logger.debug(f"Got submission type: {submission_type}") + except AttributeError as e: + submission_type = None + logger.critical(f"No submission type found or submission type found!: {e}") + return submission_type match filename: case Path(): - logger.debug(f"Using path method for {filename}.") - if filename.exists(): - wb = load_workbook(filename) - try: - submission_type = [item.strip().title() for item in wb.properties.category.split(";")][0] - except AttributeError: - try: - sts = {item.name: item.get_template_file_sheets() for item in SubmissionType.query()} - for k, v in sts.items(): - # This gets the *first* submission type that matches the sheet names in the workbook - if wb.sheetnames == v: - submission_type = k.title() - break - except: - # On failure recurse using filename as string for string method - submission_type = cls.retrieve_submission_type(filename=filename.stem.__str__()) - else: - submission_type = cls.retrieve_submission_type(filename=filename.stem.__str__()) + submission_type = st_from_path(filename=filename) case str(): - regex = BasicSubmission.construct_regex() - logger.debug(f"Using string method for {filename}.") - logger.debug(f"Using regex: {regex}") - m = regex.search(filename) - try: - submission_type = m.lastgroup - logger.debug(f"Got submission type: {submission_type}") - except AttributeError as e: - logger.critical(f"No submission type found or submission type found!: {e}") + submission_type = st_from_str(filename=filename) case _: submission_type = None try: @@ -93,6 +98,7 @@ class RSLNamer(object): message="Please select submission type from list below.", obj_type=SubmissionType) if dlg.exec(): submission_type = dlg.parse_form() + print(submission_type) submission_type = submission_type.replace("_", " ") return submission_type diff --git a/src/submissions/backend/validators/pydant.py b/src/submissions/backend/validators/pydant.py index 5942616..b6acdac 100644 --- a/src/submissions/backend/validators/pydant.py +++ b/src/submissions/backend/validators/pydant.py @@ -9,7 +9,6 @@ from datetime import date, datetime, timedelta from dateutil.parser import parse from dateutil.parser import ParserError from typing import List, Tuple, Literal -from types import GeneratorType from . import RSLNamer from pathlib import Path from tools import check_not_nan, convert_nans_to_nones, Report, Result @@ -49,7 +48,6 @@ class PydReagent(BaseModel): def rescue_type_with_lookup(cls, value, values): if value is None and values.data['lot'] is not None: try: - # return lookup_reagents(ctx=values.data['ctx'], lot_number=values.data['lot']).name return Reagent.query(lot_number=values.data['lot'].name) except AttributeError: return value @@ -222,7 +220,8 @@ class PydSample(BaseModel, extra='allow'): fields = list(self.model_fields.keys()) + list(self.model_extra.keys()) return {k: getattr(self, k) for k in fields} - def toSQL(self, submission: BasicSubmission | str = None) -> Tuple[BasicSample, Result]: + def toSQL(self, submission: BasicSubmission | str = None) -> Tuple[ + BasicSample, List[SubmissionSampleAssociation], Result | None]: """ Converts this instance into a backend.db.models.submissions.Sample object @@ -238,6 +237,7 @@ class PydSample(BaseModel, extra='allow'): instance = BasicSample.query_or_create(sample_type=self.sample_type, submitter_id=self.submitter_id) for key, value in self.__dict__.items(): match key: + # NOTE: row, column go in the association case "row" | "column": continue case _: @@ -259,7 +259,6 @@ class PydSample(BaseModel, extra='allow'): **self.model_extra) # logger.debug(f"Using submission_sample_association: {association}") try: - # instance.sample_submission_associations.append(association) out_associations.append(association) except IntegrityError as e: logger.error(f"Could not attach submission sample association due to: {e}") @@ -316,10 +315,10 @@ class PydEquipment(BaseModel, extra='ignore'): def make_empty_list(cls, value): # logger.debug(f"Pydantic value: {value}") value = convert_nans_to_nones(value) - if value is None: - value = [''] - if len(value) == 0: + if not value: value = [''] + # if len(value) == 0: + # value = [''] try: value = [item.strip() for item in value] except AttributeError: @@ -337,7 +336,7 @@ class PydEquipment(BaseModel, extra='ignore'): Tuple[Equipment, SubmissionEquipmentAssociation]: SQL objects """ if isinstance(submission, str): - logger.info(f"Got string, querying {submission}") + # logger.debug(f"Got string, querying {submission}") submission = BasicSubmission.query(rsl_number=submission) equipment = Equipment.query(asset_number=self.asset_number) if equipment is None: @@ -347,7 +346,7 @@ class PydEquipment(BaseModel, extra='ignore'): # NOTE: Need to make sure the same association is not added to the submission try: assoc = SubmissionEquipmentAssociation.query(equipment_id=equipment.id, submission_id=submission.id, - role=self.role, limit=1) + role=self.role, limit=1) except TypeError as e: logger.error(f"Couldn't get association due to {e}, returning...") return equipment, None @@ -400,7 +399,7 @@ class PydSubmission(BaseModel, extra='allow'): equipment: List[PydEquipment] | None = [] cost_centre: dict | None = Field(default=dict(value=None, missing=True), validate_default=True) contact: dict | None = Field(default=dict(value=None, missing=True), validate_default=True) - tips: List[PydTips] | None =[] + tips: List[PydTips] | None = [] @field_validator("tips", mode="before") @classmethod @@ -409,7 +408,7 @@ class PydSubmission(BaseModel, extra='allow'): if isinstance(value, dict): value = value['value'] if isinstance(value, Generator): - logger.debug("We have a generator") + # logger.debug("We have a generator") return [PydTips(**tips) for tips in value] if not value: return [] @@ -466,7 +465,7 @@ class PydSubmission(BaseModel, extra='allow'): return dict(value=datetime.fromordinal(datetime(1900, 1, 1).toordinal() + value['value'] - 2).date(), missing=True) case str(): - string = re.sub(r"(_|-)\d$", "", value['value']) + string = re.sub(r"(_|-)\d(R\d)?$", "", value['value']) try: output = dict(value=parse(string).date(), missing=True) except ParserError as e: @@ -568,6 +567,7 @@ class PydSubmission(BaseModel, extra='allow'): else: raise ValueError(f"No extraction kit found.") if value is None: + # NOTE: Kit selection is done in the parser, so should not be necessary here. return dict(value=None, missing=True) return value @@ -575,7 +575,7 @@ class PydSubmission(BaseModel, extra='allow'): @classmethod def make_submission_type(cls, value, values): if not isinstance(value, dict): - value = {"value": value} + value = dict(value=value) if check_not_nan(value['value']): value = value['value'].title() return dict(value=value, missing=False) @@ -593,6 +593,8 @@ class PydSubmission(BaseModel, extra='allow'): @field_validator("submission_category") @classmethod def rescue_category(cls, value, values): + if isinstance(value['value'], str): + value['value'] = value['value'].title() if value['value'] not in ["Research", "Diagnostic", "Surveillance", "Validation"]: value['value'] = values.data['submission_type']['value'] return value @@ -600,18 +602,16 @@ class PydSubmission(BaseModel, extra='allow'): @field_validator("reagents", mode="before") @classmethod def expand_reagents(cls, value): - # print(f"\n{type(value)}\n") if isinstance(value, Generator): - logger.debug("We have a generator") + # logger.debug("We have a generator") return [PydReagent(**reagent) for reagent in value] return value @field_validator("samples", mode="before") @classmethod def expand_samples(cls, value): - # print(f"\n{type(value)}\n") if isinstance(value, Generator): - logger.debug("We have a generator") + # logger.debug("We have a generator") return [PydSample(**sample) for sample in value] return value @@ -619,11 +619,10 @@ class PydSubmission(BaseModel, extra='allow'): @classmethod def assign_ids(cls, value): starting_id = SubmissionSampleAssociation.autoincrement_id() - output = [] for iii, sample in enumerate(value, start=starting_id): + # NOTE: Why is this a list? Answer: to zip with the lists of rows and columns in case of multiple of the same sample. sample.assoc_id = [iii] - output.append(sample) - return output + return value @field_validator("cost_centre", mode="before") @classmethod @@ -672,7 +671,7 @@ class PydSubmission(BaseModel, extra='allow'): else: return value - def __init__(self, run_custom:bool=False, **data): + def __init__(self, run_custom: bool = False, **data): super().__init__(**data) # NOTE: this could also be done with default_factory logger.debug(data) @@ -682,7 +681,6 @@ class PydSubmission(BaseModel, extra='allow'): if run_custom: self.submission_object.custom_validation(pyd=self) - def set_attribute(self, key: str, value): """ Better handling of attribute setting. @@ -742,7 +740,7 @@ class PydSubmission(BaseModel, extra='allow'): output = {k: self.filter_field(k) for k in fields} return output - def filter_field(self, key:str): + def filter_field(self, key: str): item = getattr(self, key) # logger.debug(f"Attempting deconstruction of {key}: {item} with type {type(item)}") match item: @@ -796,8 +794,6 @@ class PydSubmission(BaseModel, extra='allow'): continue # logger.debug(f"Setting {key} to {value}") match key: - # case "custom": - # instance.custom = value case "reagents": if report.results[0].code == 1: instance.submission_reagent_associations = [] @@ -833,7 +829,6 @@ class PydSubmission(BaseModel, extra='allow'): except AttributeError: continue if association is not None and association not in instance.submission_tips_associations: - # association.save() instance.submission_tips_associations.append(association) case item if item in instance.jsons(): # logger.debug(f"{item} is a json.") @@ -877,16 +872,9 @@ class PydSubmission(BaseModel, extra='allow'): 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 - # logger.debug(f"We've got a total cost of {instance.run_cost}") - # try: - # logger.debug(f"Constructed instance: {instance}") - # except AttributeError as e: - # logger.debug(f"Something went wrong constructing instance {self.rsl_plate_num}: {e}") - # logger.debug(f"Constructed submissions message: {msg}") return instance, report - def to_form(self, parent: QWidget, disable:list|None=None): + def to_form(self, parent: QWidget, disable: list | None = None): """ Converts this instance into a frontend.widgets.submission_widget.SubmissionFormWidget @@ -1014,7 +1002,6 @@ class PydOrganization(BaseModel): value = [item.to_sql() for item in getattr(self, field)] case _: value = getattr(self, field) - # instance.set_attribute(name=field, value=value) instance.__setattr__(name=field, value=value) return instance diff --git a/src/submissions/frontend/visualizations/control_charts.py b/src/submissions/frontend/visualizations/control_charts.py index 4cbe7ff..dea7421 100644 --- a/src/submissions/frontend/visualizations/control_charts.py +++ b/src/submissions/frontend/visualizations/control_charts.py @@ -1,9 +1,13 @@ """ Functions for constructing controls graphs using plotly. """ +from copy import deepcopy +from pprint import pformat + import plotly import plotly.express as px import pandas as pd +from PyQt6.QtWidgets import QWidget from plotly.graph_objects import Figure import logging from tools import get_unique_values_in_df_column, divide_chunks @@ -14,7 +18,7 @@ logger = logging.getLogger(f"submissions.{__name__}") class CustomFigure(Figure): - def __init__(self, df: pd.DataFrame, modes: list, ytitle: str | None = None): + def __init__(self, df: pd.DataFrame, modes: list, ytitle: str | None = None, parent: QWidget | None = None): super().__init__() self.construct_chart(df=df, modes=modes) self.generic_figure_markers(modes=modes, ytitle=ytitle) @@ -140,7 +144,7 @@ class CustomFigure(Figure): {"yaxis.title.text": mode}, ]) - def save_figure(self, group_name: str = "plotly_output"): + def save_figure(self, group_name: str = "plotly_output", parent:QWidget|None=None): """ Writes plotly figure to html file. @@ -150,12 +154,10 @@ class CustomFigure(Figure): fig (Figure): input figure object group_name (str): controltype """ - output = select_save_file(None, default_name=group_name, extension="html") - with open(output, "w") as f: - try: - f.write(self.to_html()) - except AttributeError: - logger.error(f"The following figure was a string: {self}") + + output = select_save_file(obj=parent, default_name=group_name, extension="png") + self.write_image(output.absolute().__str__(), engine="kaleido") + def to_html(self) -> str: """ diff --git a/src/submissions/frontend/widgets/app.py b/src/submissions/frontend/widgets/app.py index 3823735..2125542 100644 --- a/src/submissions/frontend/widgets/app.py +++ b/src/submissions/frontend/widgets/app.py @@ -189,8 +189,8 @@ class App(QMainWindow): """ month = date.today().strftime("%Y-%m") current_month_bak = Path(self.ctx.backup_path).joinpath(f"submissions_backup-{month}").resolve() - logger.debug(f"Here is the db directory: {self.ctx.database_path}") - logger.debug(f"Here is the backup directory: {self.ctx.backup_path}") + logger.info(f"Here is the db directory: {self.ctx.database_path}") + logger.info(f"Here is the backup directory: {self.ctx.backup_path}") match self.ctx.database_schema: case "sqlite": db_path = self.ctx.database_path.joinpath(self.ctx.database_name).with_suffix(".db") @@ -206,15 +206,17 @@ class App(QMainWindow): current_month_bak = current_month_bak.with_suffix(".psql") def export_ST_yaml(self): + """ + Copies submission type yaml to file system for editing and remport + + Returns: + None + """ if check_if_app(): yaml_path = Path(sys._MEIPASS).joinpath("resources", "viral_culture.yml") else: yaml_path = project_path.joinpath("src", "submissions", "resources", "viral_culture.yml") - # with open(yaml_path, "r") as f: - # data = yaml.safe_load(f) fname = select_save_file(obj=self, default_name="Submission Type Template.yml", extension="yml") - # with open(fname, "w") as f: - # yaml.safe_dump(data=data, stream=f) shutil.copyfile(yaml_path, fname) @check_authorization @@ -230,7 +232,6 @@ class App(QMainWindow): print(pformat(st.to_export_dict())) choice = input("Save the above submission type? [y/N]: ") if choice.lower() == "y": - # st.save() pass else: logger.warning("Save of submission type cancelled.") diff --git a/src/submissions/frontend/widgets/controls_chart.py b/src/submissions/frontend/widgets/controls_chart.py index fab2f07..1a76e11 100644 --- a/src/submissions/frontend/widgets/controls_chart.py +++ b/src/submissions/frontend/widgets/controls_chart.py @@ -2,12 +2,13 @@ Handles display of control charts """ import re +import sys from datetime import timedelta from typing import Tuple from PyQt6.QtWebEngineWidgets import QWebEngineView from PyQt6.QtWidgets import ( QWidget, QVBoxLayout, QComboBox, QHBoxLayout, - QDateEdit, QLabel, QSizePolicy + QDateEdit, QLabel, QSizePolicy, QPushButton ) from PyQt6.QtCore import QSignalBlocker from backend.db import ControlType, Control @@ -15,11 +16,11 @@ from PyQt6.QtCore import QDate, QSize import logging from pandas import DataFrame from tools import Report, Result, get_unique_values_in_df_column, Settings, report_result -# from backend.excel.reports import convert_data_list_to_df from frontend.visualizations.control_charts import CustomFigure logger = logging.getLogger(f"submissions.{__name__}") + class ControlsViewer(QWidget): def __init__(self, parent: QWidget) -> None: @@ -29,7 +30,7 @@ class ControlsViewer(QWidget): self.report = Report() self.datepicker = ControlsDatePicker() self.webengineview = QWebEngineView() - # set tab2 layout + # NOTE: set tab2 layout self.layout = QVBoxLayout(self) self.control_typer = QComboBox() # NOTE: fetch types of controls @@ -54,18 +55,22 @@ class ControlsViewer(QWidget): self.mode_typer.currentIndexChanged.connect(self.controls_getter) self.datepicker.start_date.dateChanged.connect(self.controls_getter) self.datepicker.end_date.dateChanged.connect(self.controls_getter) + self.datepicker.save_button.pressed.connect(self.save_chart_function) + + def save_chart_function(self): + self.fig.save_figure(parent=self) def controls_getter(self): """ Lookup controls from database and send to chartmaker - """ + """ self.controls_getter_function() @report_result def controls_getter_function(self): """ Get controls based on start/end dates - """ + """ report = Report() # NOTE: subtype defaults to disabled try: @@ -96,7 +101,7 @@ class ControlsViewer(QWidget): sub_types = [] if sub_types != []: # NOTE: block signal that will rerun controls getter and update sub_typer - with QSignalBlocker(self.sub_typer) as blocker: + with QSignalBlocker(self.sub_typer) as blocker: self.sub_typer.addItems(sub_types) self.sub_typer.setEnabled(True) self.sub_typer.currentTextChanged.connect(self.chart_maker) @@ -109,8 +114,8 @@ class ControlsViewer(QWidget): def chart_maker(self): """ Creates plotly charts for webview - """ - self.chart_maker_function() + """ + self.chart_maker_function() @report_result def chart_maker_function(self): @@ -122,7 +127,7 @@ class ControlsViewer(QWidget): Returns: Tuple[QMainWindow, dict]: Collection of new main app window and result dict - """ + """ report = Report() # logger.debug(f"Control getter context: \n\tControl type: {self.con_type}\n\tMode: {self.mode}\n\tStart # Date: {self.start_date}\n\tEnd Date: {self.end_date}") NOTE: set the subtype for kraken @@ -136,6 +141,7 @@ class ControlsViewer(QWidget): # NOTE: if no data found from query set fig to none for reporting in webview if controls is None: fig = None + self.datepicker.save_button.setEnabled(False) else: # NOTE: change each control to list of dictionaries data = [control.convert_by_mode(mode=self.mode) for control in controls] @@ -153,8 +159,10 @@ class ControlsViewer(QWidget): title = f"{self.mode} - {self.subtype}" # NOTE: send dataframe to chart maker df, modes = self.prep_df(ctx=self.app.ctx, df=df) - fig = CustomFigure(df=df, ytitle=title, modes=modes) + fig = CustomFigure(df=df, ytitle=title, modes=modes, parent=self) + self.datepicker.save_button.setEnabled(True) # logger.debug(f"Updating figure...") + self.fig = fig # NOTE: construct html for webview html = fig.to_html() # logger.debug(f"The length of html code is: {len(html)}") @@ -179,6 +187,11 @@ class ControlsViewer(QWidget): df = DataFrame.from_records(input_df) safe = ['name', 'submitted_date', 'genus', 'target'] for column in df.columns: + if column not in safe: + if self.subtype is not None and column != self.subtype: + continue + else: + safe.append(column) if "percent" in column: # count_col = [item for item in df.columns if "count" in item][0] try: @@ -187,9 +200,9 @@ class ControlsViewer(QWidget): continue # NOTE: The actual percentage from kraken was off due to exclusion of NaN, recalculating. df[column] = 100 * df[count_col] / df.groupby('name')[count_col].transform('sum') - if column not in safe: - if self.subtype is not None and column != self.subtype: - del df[column] + logger.debug(df) + logger.debug(safe) + df = df[[c for c in df.columns if c in safe]] # NOTE: move date of sample submitted on same date as previous ahead one. df = self.displace_date(df=df) # NOTE: ad hoc method to make data labels more accurate. @@ -229,12 +242,13 @@ class ControlsViewer(QWidget): # NOTE: get submitted dates for each control dict_list = [dict(name=item, date=df[df.name == item].iloc[0]['submitted_date']) for item in sorted(df['name'].unique())] - previous_dates = [] - for _, item in enumerate(dict_list): + previous_dates = set() + # for _, item in enumerate(dict_list): + for item in dict_list: df, previous_dates = self.check_date(df=df, item=item, previous_dates=previous_dates) return df - def check_date(self, df: DataFrame, item: dict, previous_dates: list) -> Tuple[DataFrame, list]: + def check_date(self, df: DataFrame, item: dict, previous_dates: set) -> Tuple[DataFrame, list]: """ Checks if an items date is already present in df and adjusts df accordingly @@ -250,7 +264,7 @@ class ControlsViewer(QWidget): check = item['date'] in previous_dates except IndexError: check = False - previous_dates.append(item['date']) + previous_dates.add(item['date']) if check: # logger.debug(f"We found one! Increment date!\n\t{item['date']} to {item['date'] + timedelta(days=1)}") # NOTE: get df locations where name == item name @@ -273,7 +287,7 @@ class ControlsViewer(QWidget): df, previous_dates = self.check_date(df, item, previous_dates) return df, previous_dates - def prep_df(self, ctx: Settings, df: DataFrame) -> DataFrame: + def prep_df(self, ctx: Settings, df: DataFrame) -> Tuple[DataFrame, list]: """ Constructs figures based on parsed pandas dataframe. @@ -285,27 +299,17 @@ class ControlsViewer(QWidget): Returns: Figure: Plotly figure """ - # from backend.excel import drop_reruns_from_df - # converts starred genera to normal and splits off list of starred - genera = [] + # NOTE: converts starred genera to normal and splits off list of starred if df.empty: return None - for item in df['genus'].to_list(): - try: - if item[-1] == "*": - genera.append(item[-1]) - else: - genera.append("") - except IndexError: - genera.append("") df['genus'] = df['genus'].replace({'\*': ''}, regex=True).replace({"NaN": "Unknown"}) - df['genera'] = genera + df['genera'] = [item[-1] if item and item[-1] == "*" else "" for item in df['genus'].to_list()] # NOTE: remove original runs, using reruns if applicable df = self.drop_reruns_from_df(ctx=ctx, df=df) # NOTE: sort by and exclude from sorts = ['submitted_date', "target", "genus"] exclude = ['name', 'genera'] - modes = [item for item in df.columns if item not in sorts and item not in exclude] # and "_hashes" not in item] + modes = [item for item in df.columns if item not in sorts and item not in exclude] # NOTE: Set descending for any columns that have "{mode}" in the header. ascending = [False if item == "target" else True for item in sorts] df = df.sort_values(by=sorts, ascending=ascending) @@ -327,23 +331,26 @@ class ControlsViewer(QWidget): if 'rerun_regex' in ctx: sample_names = get_unique_values_in_df_column(df, column_name="name") rerun_regex = re.compile(fr"{ctx.rerun_regex}") - for sample in sample_names: - if rerun_regex.search(sample): - first_run = re.sub(rerun_regex, "", sample) - df = df.drop(df[df.name == first_run].index) + exclude = [re.sub(rerun_regex, "", sample) for sample in sample_names if rerun_regex.search(sample)] + df = df[df.name not in exclude] + # for sample in sample_names: + # if rerun_regex.search(sample): + # first_run = re.sub(rerun_regex, "", sample) + # df = df.drop(df[df.name == first_run].index) return df class ControlsDatePicker(QWidget): """ custom widget to pick start and end dates for controls graphs - """ + """ + def __init__(self) -> None: super().__init__() self.start_date = QDateEdit(calendarPopup=True) # NOTE: start date is two months prior to end date by default - twomonthsago = QDate.currentDate().addDays(-60) - self.start_date.setDate(twomonthsago) + sixmonthsago = QDate.currentDate().addDays(-180) + self.start_date.setDate(sixmonthsago) self.end_date = QDateEdit(calendarPopup=True) self.end_date.setDate(QDate.currentDate()) self.layout = QHBoxLayout() @@ -353,6 +360,8 @@ class ControlsDatePicker(QWidget): self.layout.addWidget(self.end_date) self.setLayout(self.layout) self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) + self.save_button = QPushButton("Save Chart", parent=self) + self.layout.addWidget(self.save_button) def sizeHint(self) -> QSize: - return QSize(80,20) + return QSize(80, 20) diff --git a/src/submissions/frontend/widgets/equipment_usage.py b/src/submissions/frontend/widgets/equipment_usage.py index 4340cc4..540fc3e 100644 --- a/src/submissions/frontend/widgets/equipment_usage.py +++ b/src/submissions/frontend/widgets/equipment_usage.py @@ -8,7 +8,7 @@ from PyQt6.QtWidgets import (QDialog, QComboBox, QCheckBox, from backend.db.models import Equipment, BasicSubmission, Process from backend.validators.pydant import PydEquipment, PydEquipmentRole, PydTips import logging -from typing import List +from typing import List, Generator logger = logging.getLogger(f"submissions.{__name__}") @@ -45,26 +45,26 @@ class EquipmentUsage(QDialog): widg.update_processes() self.layout.addWidget(self.buttonBox) - def parse_form(self) -> List[PydEquipment]: + def parse_form(self) -> Generator[PydEquipment, None, None]: """ Pull info from all RoleComboBox widgets Returns: List[PydEquipment]: All equipment pulled from widgets """ - output = [] for widget in self.findChildren(QWidget): match widget: case RoleComboBox(): if widget.check.isChecked(): - output.append(widget.parse_form()) + item = widget.parse_form() + if item: + yield item + else: + continue + else: + continue case _: - pass - # logger.debug(f"parsed output of Equsage form: {pformat(output)}") - try: - return [item.strip() for item in output if item is not None] - except AttributeError: - return [item for item in output if item is not None] + continue class LabelRow(QWidget): @@ -93,14 +93,10 @@ class RoleComboBox(QWidget): def __init__(self, parent, role: PydEquipmentRole, used: list) -> None: super().__init__(parent) - # self.layout = QHBoxLayout() self.layout = QGridLayout() self.role = role self.check = QCheckBox() - # if role.name in used: self.check.setChecked(False) - # else: - # self.check.setChecked(True) self.check.stateChanged.connect(self.toggle_checked) self.box = QComboBox() self.box.setMaximumWidth(200) @@ -129,7 +125,6 @@ class RoleComboBox(QWidget): """ equip = self.box.currentText() # logger.debug(f"Updating equipment: {equip}") - # equip2 = [item for item in self.role.equipment if item.name == equip][0] equip2 = next((item for item in self.role.equipment if item.name == equip), self.role.equipment[0]) # logger.debug(f"Using: {equip2}") self.process.clear() @@ -158,7 +153,10 @@ class RoleComboBox(QWidget): widget.setMinimumWidth(200) widget.setMaximumWidth(200) self.layout.addWidget(widget, 0, 4) - widget.setEnabled(self.check.isChecked()) + try: + widget.setEnabled(self.check.isChecked()) + except NameError: + pass def parse_form(self) -> PydEquipment | None: """ diff --git a/src/submissions/frontend/widgets/gel_checker.py b/src/submissions/frontend/widgets/gel_checker.py index 07ef50f..bf31278 100644 --- a/src/submissions/frontend/widgets/gel_checker.py +++ b/src/submissions/frontend/widgets/gel_checker.py @@ -74,7 +74,7 @@ class GelBox(QDialog): self.buttonBox = QDialogButtonBox(QBtn) self.buttonBox.accepted.connect(self.accept) self.buttonBox.rejected.connect(self.reject) - layout.addWidget(self.buttonBox, 23, 1, 1, 1) #, alignment=Qt.AlignmentFlag.AlignTop) + layout.addWidget(self.buttonBox, 23, 1, 1, 1) self.setLayout(layout) @@ -135,7 +135,7 @@ class ControlsForm(QWidget): self.layout.addWidget(self.comment_field, 1, 5, 4, 1) self.setLayout(self.layout) - def parse_form(self) -> List[dict]: + def parse_form(self) -> Tuple[List[dict], str]: """ Pulls the controls statuses from the form. @@ -145,11 +145,7 @@ class ControlsForm(QWidget): output = [] for le in self.findChildren(QComboBox): label = [item.strip() for item in le.objectName().split(" : ")] - try: - # dicto = [item for item in output if item['name'] == label[0]][0] - dicto = next(item for item in output if item['name'] == label[0]) - except StopIteration: - dicto = dict(name=label[0], values=[]) + dicto = next((item for item in output if item['name'] == label[0]), dict(name=label[0], values=[])) dicto['values'].append(dict(name=label[1], value=le.currentText())) if label[0] not in [item['name'] for item in output]: output.append(dicto) diff --git a/src/submissions/frontend/widgets/pop_ups.py b/src/submissions/frontend/widgets/pop_ups.py index 6bbfaa9..d7fb4cb 100644 --- a/src/submissions/frontend/widgets/pop_ups.py +++ b/src/submissions/frontend/widgets/pop_ups.py @@ -2,11 +2,10 @@ Contains dialogs for notification and prompting. ''' from PyQt6.QtWidgets import ( - QLabel, QVBoxLayout, QDialog, + QLabel, QVBoxLayout, QDialog, QDialogButtonBox, QMessageBox, QComboBox ) from PyQt6.QtWebEngineWidgets import QWebEngineView -from PyQt6.QtCore import Qt from tools import jinja_template_loading import logging from backend.db import models @@ -20,8 +19,9 @@ env = jinja_template_loading() class QuestionAsker(QDialog): """ dialog to ask yes/no questions - """ - def __init__(self, title:str, message:str): + """ + + def __init__(self, title: str, message: str): super().__init__() self.setWindowTitle(title) # NOTE: set yes/no buttons @@ -40,8 +40,10 @@ class QuestionAsker(QDialog): class AlertPop(QMessageBox): """ Dialog to show an alert. - """ - def __init__(self, message:str, status:Literal['Information', 'Question', 'Warning', 'Critical'], owner:str|None=None): + """ + + def __init__(self, message: str, status: Literal['Information', 'Question', 'Warning', 'Critical'], + owner: str | None = None): super().__init__() # NOTE: select icon by string icon = getattr(QMessageBox.Icon, status) @@ -49,9 +51,10 @@ class AlertPop(QMessageBox): self.setInformativeText(message) self.setWindowTitle(f"{owner} - {status.title()}") + class HTMLPop(QDialog): - def __init__(self, html:str, owner:str|None=None, title:str="python"): + def __init__(self, html: str, owner: str | None = None, title: str = "python"): super().__init__() self.webview = QWebEngineView(parent=self) @@ -66,14 +69,18 @@ class HTMLPop(QDialog): class ObjectSelector(QDialog): """ dialog to input BaseClass type manually - """ - def __init__(self, title:str, message:str, obj_type:str|type[models.BaseClass]): + """ + + def __init__(self, title: str, message: str, obj_type: str | type[models.BaseClass], values: list | None = None): super().__init__() self.setWindowTitle(title) self.widget = QComboBox() - if isinstance(obj_type, str): - obj_type: models.BaseClass = getattr(models, obj_type) - items = [item.name for item in obj_type.query()] + if values: + items = values + else: + if isinstance(obj_type, str): + obj_type: models.BaseClass = getattr(models, obj_type) + items = [item.name for item in obj_type.query()] self.widget.addItems(items) self.widget.setEditable(False) # NOTE: set yes/no buttons @@ -95,5 +102,5 @@ class ObjectSelector(QDialog): Returns: str: KitType as str - """ + """ return self.widget.currentText() diff --git a/src/submissions/frontend/widgets/sample_search.py b/src/submissions/frontend/widgets/sample_search.py index b7bf73d..1363c87 100644 --- a/src/submissions/frontend/widgets/sample_search.py +++ b/src/submissions/frontend/widgets/sample_search.py @@ -55,6 +55,7 @@ class SearchBox(QDialog): widget = FieldSearch(parent=self, label=item['label'], field_name=item['field']) self.layout.addWidget(widget, start_row+iii, 0) widget.search_widget.textChanged.connect(self.update_data) + self.update_data() def parse_form(self) -> dict: """ @@ -73,7 +74,8 @@ class SearchBox(QDialog): # logger.debug(f"Running update_data with sample type: {self.type}") fields = self.parse_form() # logger.debug(f"Got fields: {fields}") - sample_list_creator = self.type.fuzzy_search(sample_type=self.type, **fields) + # sample_list_creator = self.type.fuzzy_search(sample_type=self.type, **fields) + sample_list_creator = self.type.fuzzy_search(**fields) data = self.type.samples_to_df(sample_list=sample_list_creator) # logger.debug(f"Data: {data}") self.results.setData(df=data) diff --git a/src/submissions/frontend/widgets/submission_details.py b/src/submissions/frontend/widgets/submission_details.py index ab15294..456b5ed 100644 --- a/src/submissions/frontend/widgets/submission_details.py +++ b/src/submissions/frontend/widgets/submission_details.py @@ -9,7 +9,6 @@ from PyQt6.QtWebEngineWidgets import QWebEngineView from PyQt6.QtWebChannel import QWebChannel from PyQt6.QtCore import Qt, pyqtSlot, QMarginsF from jinja2 import TemplateNotFound - from backend.db.models import BasicSubmission, BasicSample, Reagent, KitType from tools import is_power_user, html_to_pdf, jinja_template_loading from .functions import select_save_file @@ -18,9 +17,8 @@ import logging from getpass import getuser from datetime import datetime from pprint import pformat - from typing import List -from backend.excel.writer import DocxWriter + logger = logging.getLogger(f"submissions.{__name__}") diff --git a/src/submissions/frontend/widgets/submission_table.py b/src/submissions/frontend/widgets/submission_table.py index 70ca3c2..9dfb22b 100644 --- a/src/submissions/frontend/widgets/submission_table.py +++ b/src/submissions/frontend/widgets/submission_table.py @@ -11,8 +11,6 @@ from backend.excel import ReportMaker from tools import Report, Result, report_result from .functions import select_save_file, select_open_file from .misc import ReportDatePicker -import pandas as pd -from openpyxl.worksheet.worksheet import Worksheet logger = logging.getLogger(f"submissions.{__name__}") @@ -222,10 +220,6 @@ class SubmissionsSheet(QTableView): # NOTE: if imported submission doesn't exist move on to next run if sub is None: continue - # try: - # logger.debug(f"Found submission: {sub.rsl_plate_num}") - # except AttributeError: - # continue sub.set_attribute('pcr_info', new_run) # NOTE: check if pcr_info already exists sub.save() diff --git a/src/submissions/frontend/widgets/submission_widget.py b/src/submissions/frontend/widgets/submission_widget.py index deeeeec..93e5d41 100644 --- a/src/submissions/frontend/widgets/submission_widget.py +++ b/src/submissions/frontend/widgets/submission_widget.py @@ -9,7 +9,7 @@ from PyQt6.QtWidgets import ( ) from PyQt6.QtCore import pyqtSignal, Qt from . import select_open_file, select_save_file -import logging, difflib, inspect +import logging, difflib from pathlib import Path from tools import Report, Result, check_not_nan, main_form_style, report_result, check_regex_match from backend.excel.parser import SheetParser @@ -163,7 +163,7 @@ class SubmissionFormContainer(QWidget): # NOTE: create form dlg = AddReagentForm(reagent_lot=reagent_lot, reagent_role=reagent_role, expiry=expiry, reagent_name=name) if dlg.exec(): - # extract form info + # NOTE: extract form info info = dlg.parse_form() # logger.debug(f"Reagent info: {info}") # NOTE: create reagent object @@ -180,7 +180,6 @@ class SubmissionFormWidget(QWidget): def __init__(self, parent: QWidget, submission: PydSubmission, disable: list | None = None) -> None: super().__init__(parent) - # self.report = Report() # logger.debug(f"Disable: {disable}") if disable is None: disable = [] @@ -268,7 +267,6 @@ class SubmissionFormWidget(QWidget): Tuple[QMainWindow, dict]: Updated application and result """ extraction_kit = args[0] - # caller = inspect.stack()[1].function.__repr__().replace("'", "") report = Report() # logger.debug(f"Extraction kit: {extraction_kit}") # NOTE: Remove previous reagent widgets diff --git a/src/submissions/templates/bacterialculture_subdocument.docx b/src/submissions/templates/bacterialculture_subdocument.docx deleted file mode 100644 index 03e7ca2752385da3ce482beeec68200d9f2e2357..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 13016 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 diff --git a/src/submissions/templates/basicsubmission_document.docx b/src/submissions/templates/basicsubmission_document.docx deleted file mode 100644 index 5e9bd491b0c2ec21bfaeb55cca42217e9e84e84b..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 14385 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 KNQE)~y83_Z9Fc+m diff --git a/src/submissions/tools/__init__.py b/src/submissions/tools/__init__.py index 39a28ae..40cc2f6 100644 --- a/src/submissions/tools/__init__.py +++ b/src/submissions/tools/__init__.py @@ -874,28 +874,6 @@ def rreplace(s: str, old: str, new: str) -> str: return (s[::-1].replace(old[::-1], new[::-1], 1))[::-1] -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}") - document = QWebEngineView() - document.setHtml(html) - # document.show() - printer = QPrinter(QPrinter.PrinterMode.HighResolution) - printer.setOutputFormat(QPrinter.OutputFormat.PdfFormat) - printer.setOutputFileName(output_file.absolute().__str__()) - printer.setPageSize(QPageSize(QPageSize.PageSizeId.A4)) - document.print(printer) - # document.close() - - def remove_key_from_list_of_dicts(input: list, key: str) -> list: """ Removes a key from all dictionaries in a list of dictionaries