From 12ca3157e598f8eb204e6bbef717b3b10c60d937 Mon Sep 17 00:00:00 2001 From: lwark Date: Wed, 3 Jul 2024 09:50:44 -0500 Subject: [PATCH] Mid documentation and code clean-up. --- CHANGELOG.md | 1 + src/submissions/backend/db/models/__init__.py | 7 +- src/submissions/backend/db/models/controls.py | 12 +- src/submissions/backend/db/models/kits.py | 18 +- .../backend/db/models/organizations.py | 1 - .../backend/db/models/submissions.py | 154 +++++++++++------- src/submissions/backend/validators/pydant.py | 5 +- .../frontend/widgets/equipment_usage.py | 13 ++ .../frontend/widgets/sample_search.py | 4 +- .../frontend/widgets/submission_details.py | 24 +-- .../wastewaterartic_subdocument.docx | Bin 13586 -> 13665 bytes 11 files changed, 140 insertions(+), 99 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4866c11..6b064ee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,6 @@ ## 202406.04 +- Exported submission details will now be in docx format. - Adding in tips to Equipment usage. - New WastewaterArticAssociation will track previously missed sample info. diff --git a/src/submissions/backend/db/models/__init__.py b/src/submissions/backend/db/models/__init__.py index fb5c396..b0627b9 100644 --- a/src/submissions/backend/db/models/__init__.py +++ b/src/submissions/backend/db/models/__init__.py @@ -112,7 +112,7 @@ class BaseClass(Base): @classmethod def execute_query(cls, query: Query = None, model=None, limit: int = 0, **kwargs) -> Any | List[Any]: """ - Execute sqlalchemy query. + Execute sqlalchemy query with relevant defaults. Args: model (Any, optional): model to be queried. Defaults to None @@ -137,6 +137,7 @@ class BaseClass(Base): except (ArgumentError, AttributeError) as e: logger.error(f"Attribute {k} unavailable due to:\n\t{e}\nSkipping.") if k in singles: + logger.warning(f"{k} is in singles. Returning only one value.") limit = 1 with query.session.no_autoflush: match limit: @@ -165,8 +166,8 @@ class ConfigItem(BaseClass): Key:JSON objects to store config settings in database. """ id = Column(INTEGER, primary_key=True) - key = Column(String(32)) - value = Column(JSON) + key = Column(String(32)) #: Name of the configuration item. + value = Column(JSON) #: Value associated with the config item. def __repr__(self): return f"ConfigItem({self.key} : {self.value})" diff --git a/src/submissions/backend/db/models/controls.py b/src/submissions/backend/db/models/controls.py index 423dd29..85fc040 100644 --- a/src/submissions/backend/db/models/controls.py +++ b/src/submissions/backend/db/models/controls.py @@ -55,7 +55,7 @@ class ControlType(BaseClass): def get_subtypes(self, mode: str) -> List[str]: """ - Get subtypes associated with this controltype + Get subtypes associated with this controltype (currently used only for Kraken) Args: mode (str): analysis mode name @@ -140,15 +140,11 @@ class Control(BaseClass): kraken = {} # logger.debug("calculating kraken count total to use in percentage") kraken_cnt_total = sum([kraken[item]['kraken_count'] for item in kraken]) - new_kraken = [] - for item in kraken: - # logger.debug("calculating kraken percent (overwrites what's already been scraped)") - kraken_percent = kraken[item]['kraken_count'] / kraken_cnt_total - new_kraken.append({'name': item, 'kraken_count': kraken[item]['kraken_count'], - 'kraken_percent': "{0:.0%}".format(kraken_percent)}) + # logger.debug("Creating new kraken.") + new_kraken = [dict(name=item, kraken_count=kraken[item]['kraken_count'], kraken_percent="{0:.0%}".format(kraken[item]['kraken_count'] / kraken_cnt_total)) for item in kraken] new_kraken = sorted(new_kraken, key=itemgetter('kraken_count'), reverse=True) # logger.debug("setting targets") - if self.controltype.targets == []: + if not self.controltype.targets: targets = ["None"] else: targets = self.controltype.targets diff --git a/src/submissions/backend/db/models/kits.py b/src/submissions/backend/db/models/kits.py index e8ac061..813fb9a 100644 --- a/src/submissions/backend/db/models/kits.py +++ b/src/submissions/backend/db/models/kits.py @@ -70,6 +70,7 @@ kittypes_processes = Table( extend_existing=True ) +# logger.debug("Table for TipRole/Tips relations") tiproles_tips = Table( "_tiproles_tips", Base.metadata, @@ -78,6 +79,7 @@ tiproles_tips = Table( extend_existing=True ) +# logger.debug("Table for Process/TipRole relations") process_tiprole = Table( "_process_tiprole", Base.metadata, @@ -86,6 +88,7 @@ process_tiprole = Table( extend_existing=True ) +# logger.debug("Table for Equipment/Tips relations") equipment_tips = Table( "_equipment_tips", Base.metadata, @@ -410,6 +413,7 @@ class Reagent(BaseClass): except (TypeError, AttributeError) as e: place_holder = date.today() logger.error(f"We got a type error setting {self.lot} expiry: {e}. setting to today for testing") + # NOTE: The notation for not having an expiry is 1970.1.1 if self.expiry.year == 1970: place_holder = "NA" else: @@ -700,7 +704,13 @@ class SubmissionType(BaseClass): output[item.equipment_role.name] = emap return output - def construct_tips_map(self): + def construct_tips_map(self) -> dict: + """ + Constructs map of tips to excel cells. + + Returns: + dict: Tip locations in the excel sheet. + """ output = {} for item in self.submissiontype_tiprole_associations: tmap = item.uses @@ -1142,6 +1152,7 @@ class Equipment(BaseClass): processes = [process for process in processes if extraction_kit in process.kit_types] case _: pass + # NOTE: Convert to strings processes = [process.name for process in processes] assert all([isinstance(process, str) for process in processes]) if len(processes) == 0: @@ -1193,7 +1204,8 @@ class Equipment(BaseClass): return cls.execute_query(query=query, limit=limit) def to_pydantic(self, submission_type: SubmissionType, - extraction_kit: str | KitType | None = None) -> "PydEquipment": + extraction_kit: str | KitType | None = None, + role: str = None) -> "PydEquipment": """ Creates PydEquipment of this Equipment @@ -1206,7 +1218,7 @@ class Equipment(BaseClass): """ from backend.validators.pydant import PydEquipment return PydEquipment( - processes=self.get_processes(submission_type=submission_type, extraction_kit=extraction_kit), role=None, + processes=self.get_processes(submission_type=submission_type, extraction_kit=extraction_kit), role=role, **self.to_dict(processes=False)) @classmethod diff --git a/src/submissions/backend/db/models/organizations.py b/src/submissions/backend/db/models/organizations.py index ec352bd..0daaf90 100644 --- a/src/submissions/backend/db/models/organizations.py +++ b/src/submissions/backend/db/models/organizations.py @@ -17,7 +17,6 @@ orgs_contacts = Table( Base.metadata, Column("org_id", INTEGER, ForeignKey("_organization.id")), Column("contact_id", INTEGER, ForeignKey("_contact.id")), - # __table_args__ = {'extend_existing': True} extend_existing=True ) diff --git a/src/submissions/backend/db/models/submissions.py b/src/submissions/backend/db/models/submissions.py index 717de4d..97d8937 100644 --- a/src/submissions/backend/db/models/submissions.py +++ b/src/submissions/backend/db/models/submissions.py @@ -32,7 +32,7 @@ from pathlib import Path from jinja2.exceptions import TemplateNotFound from jinja2 import Template from docxtpl import InlineImage -from io import BytesIO +from docx.shared import Inches logger = logging.getLogger(f"submissions.{__name__}") @@ -229,6 +229,15 @@ class BasicSubmission(BaseClass): @classmethod def finalize_details(cls, input_dict: dict) -> dict: + """ + Make final adjustments to the details dictionary before display. + + Args: + input_dict (dict): Incoming dictionary. + + Returns: + dict: Final details dictionary. + """ del input_dict['id'] return input_dict @@ -610,6 +619,12 @@ class BasicSubmission(BaseClass): @classmethod def get_regex(cls) -> str: + """ + Dummy for inheritence. + + Returns: + str: Regex for submission type. + """ return cls.construct_regex() # Polymorphic functions @@ -733,7 +748,16 @@ class BasicSubmission(BaseClass): @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 @@ -785,6 +809,7 @@ class BasicSubmission(BaseClass): repeat = "" outstr = re.sub(r"(-\dR)\d?", rf"\1 {repeat}", outstr).replace(" ", "") abb = cls.get_default_info('abbreviation') + outstr = re.sub(rf"RSL{abb}", rf"RSL-{abb}", outstr) return re.sub(rf"{abb}(\d)", rf"{abb}-\1", outstr) @classmethod @@ -924,7 +949,7 @@ class BasicSubmission(BaseClass): if submission_type is not None: model = cls.find_polymorphic_subclass(polymorphic_identity=submission_type) elif len(kwargs) > 0: - # find the subclass containing the relevant attributes + # NOTE: find the subclass containing the relevant attributes # logger.debug(f"Attributes for search: {kwargs}") model = cls.find_polymorphic_subclass(attrs=kwargs) else: @@ -1241,7 +1266,7 @@ class BacterialCulture(BasicSubmission): plate_map (dict | None, optional): _description_. Defaults to None. Returns: - dict: _description_ + dict: Updated dictionary. """ from . import ControlType input_dict = super().finalize_parse(input_dict, xl, info_map) @@ -1495,7 +1520,17 @@ class Wastewater(BasicSubmission): # self.report.add_result(Result(msg=f"We added PCR info to {sub.rsl_plate_num}.", status='Information')) @classmethod - def custom_docx_writer(cls, input_dict:dict, tpl_obj=None): + 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 = [] @@ -1611,6 +1646,7 @@ class WastewaterArtic(BasicSubmission): instr = re.sub(r"^(\d{6})", f"RSL-AR-\\1", instr) # logger.debug(f"name coming out of Artic namer: {instr}") outstr = super().enforce_name(instr=instr, data=data) + outstr = outstr.replace("RSLAR", "RSL-AR") return outstr @classmethod @@ -1788,7 +1824,6 @@ class WastewaterArtic(BasicSubmission): input_excel = super().custom_info_writer(input_excel, info, backup) # logger.debug(f"Info:\n{pformat(info)}") # NOTE: check for source plate information - # check = 'source_plates' in info.keys() and info['source_plates'] is not None if check_key_or_attr(key='source_plates', interest=info, check_none=True): worksheet = input_excel['First Strand List'] start_row = 8 @@ -1805,15 +1840,11 @@ class WastewaterArtic(BasicSubmission): except TypeError: pass # NOTE: check for gel information - # check = 'gel_info' in info.keys() and info['gel_info']['value'] is not None if check_key_or_attr(key='gel_info', interest=info, check_none=True): # logger.debug(f"Gel info check passed.") - # if info['gel_info'] is not None: - # logger.debug(f"Gel info not none.") # NOTE: print json field gel results to Egel results worksheet = input_excel['Egel results'] # TODO: Move all this into a seperate function? - # start_row = 21 start_column = 15 for row, ki in enumerate(info['gel_info']['value'], start=1): @@ -1831,13 +1862,11 @@ class WastewaterArtic(BasicSubmission): worksheet.cell(row=row, column=column, value=kj['value']) except AttributeError: logger.error(f"Failed {kj['name']} with value {kj['value']} to row {row}, column {column}") - # check = 'gel_image' in info.keys() and info['gel_image']['value'] is not None - if check_key_or_attr(key='gel_image', interest=info, check_none=True): - # if info['gel_image'] is not None: + if check_key_or_attr(key='gel_image_path', interest=info, check_none=True): worksheet = input_excel['Egel results'] # logger.debug(f"We got an image: {info['gel_image']}") with ZipFile(cls.__directory_path__.joinpath("submission_imgs.zip")) as zipped: - z = zipped.extract(info['gel_image']['value'], Path(TemporaryDirectory().name)) + z = zipped.extract(info['gel_image_path']['value'], Path(TemporaryDirectory().name)) img = OpenpyxlImage(z) img.height = 400 # insert image height in pixels as float or int (e.g. 305.5) img.width = 600 @@ -1860,36 +1889,16 @@ class WastewaterArtic(BasicSubmission): base_dict['excluded'] += ['gel_info', 'gel_image', 'headers', "dna_core_submission_number", "source_plates", "gel_controls, gel_image_path"] base_dict['DNA Core ID'] = base_dict['dna_core_submission_number'] - # check = 'gel_info' in base_dict.keys() and base_dict['gel_info'] is not None if check_key_or_attr(key='gel_info', interest=base_dict, check_none=True): headers = [item['name'] for item in base_dict['gel_info'][0]['values']] base_dict['headers'] = [''] * (4 - len(headers)) base_dict['headers'] += headers # logger.debug(f"Gel info: {pformat(base_dict['headers'])}") - # check = 'gel_image' in base_dict.keys() and base_dict['gel_image'] is not None if check_key_or_attr(key='gel_image_path', interest=base_dict, check_none=True): with ZipFile(cls.__directory_path__.joinpath("submission_imgs.zip")) as zipped: base_dict['gel_image'] = base64.b64encode(zipped.read(base_dict['gel_image_path'])).decode('utf-8') return base_dict, template - # def adjust_to_dict_samples(self, backup: bool = False) -> List[dict]: - # """ - # Updates sample dictionaries with custom values - # - # Args: - # backup (bool, optional): Whether to perform backup. Defaults to False. - # - # Returns: - # List[dict]: Updated dictionaries - # """ - # # logger.debug(f"Hello from {self.__class__.__name__} dictionary sample adjuster.") - # output = [] - # - # for assoc in self.submission_sample_associations: - # dicto = assoc.to_sub_dict() - # output.append(dicto) - # return output - def custom_context_events(self) -> dict: """ Creates dictionary of str:function to be passed to context menu. Extends parent @@ -1902,6 +1911,13 @@ class WastewaterArtic(BasicSubmission): return events def set_attribute(self, key: str, value): + """ + Performs custom attribute setting based on values. Extends parent + + Args: + key (str): name of attribute + value (_type_): value of attribute + """ super().set_attribute(key=key, value=value) if key == 'gel_info': if len(self.gel_info) > 3: @@ -1938,7 +1954,17 @@ class WastewaterArtic(BasicSubmission): self.save() @classmethod - def custom_docx_writer(cls, input_dict:dict, tpl_obj=None): + 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) 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: @@ -1946,12 +1972,11 @@ class WastewaterArtic(BasicSubmission): 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=5.5)#, height=400) + 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 class BasicSample(BaseClass): @@ -2009,6 +2034,12 @@ class BasicSample(BaseClass): @classmethod def timestamps(cls) -> List[str]: + """ + Constructs a list of all attributes stored as SQL Timestamps + + Returns: + List[str]: Attribute list + """ output = [item.name for item in cls.__table__.columns if isinstance(item.type, TIMESTAMP)] if issubclass(cls, BasicSample) and not cls.__name__ == "BasicSample": output += BasicSample.timestamps() @@ -2082,10 +2113,6 @@ class BasicSample(BaseClass): logger.info(f"Recruiting model: {model}") return model - @classmethod - def sql_enforcer(cls, pyd_sample: "PydSample"): - return pyd_sample - @classmethod def parse_sample(cls, input_dict: dict) -> dict: f""" @@ -2194,6 +2221,15 @@ class BasicSample(BaseClass): # limit: int = 0, **kwargs ) -> List[BasicSample]: + """ + Allows for fuzzy search of samples. (Experimental) + + Args: + sample_type (str | BasicSample | None, optional): Type of sample. Defaults to None. + + Returns: + List[BasicSample]: List of samples that match kwarg search parameters. + """ match sample_type: case str(): model = cls.find_polymorphic_subclass(polymorphic_identity=sample_type) @@ -2220,26 +2256,20 @@ class BasicSample(BaseClass): return [dict(label="Submitter ID", field="submitter_id")] @classmethod - def samples_to_df(cls, sample_type: str | None | BasicSample = None, **kwargs): - # def samples_to_df(cls, sample_type:str|None|BasicSample=None, searchables:dict={}): - logger.debug(f"Checking {sample_type} with type {type(sample_type)}") - match sample_type: - case str(): - model = BasicSample.find_polymorphic_subclass(polymorphic_identity=sample_type) - case _: - try: - check = issubclass(sample_type, BasicSample) - except TypeError: - check = False - if check: - model = sample_type - else: - model = cls - q_out = model.fuzzy_search(sample_type=sample_type, **kwargs) - if not isinstance(q_out, list): - q_out = [q_out] + def samples_to_df(cls, sample_list:List[BasicSample], **kwargs) -> pd.DataFrame: + """ + Runs a fuzzy search and converts into a dataframe. + + Args: + sample_list (List[BasicSample]): List of samples to be parsed. Defaults to None. + + Returns: + pd.DataFrame: Dataframe all samples + """ + if not isinstance(sample_list, list): + sample_list = [sample_list] try: - samples = [sample.to_sub_dict() for sample in q_out] + samples = [sample.to_sub_dict() for sample in sample_list] except TypeError as e: logger.error(f"Couldn't find any samples with data: {kwargs}\nDue to {e}") return None @@ -2287,6 +2317,12 @@ class WastewaterSample(BasicSample): @classmethod def get_default_info(cls, *args): + """ + Returns default info for a model. Extends BaseClass method. + + Returns: + dict | list | str: Output of key:value dict or single (list, str) desired variable + """ dicto = super().get_default_info(*args) match dicto: case dict(): diff --git a/src/submissions/backend/validators/pydant.py b/src/submissions/backend/validators/pydant.py index bfa809e..8ea5504 100644 --- a/src/submissions/backend/validators/pydant.py +++ b/src/submissions/backend/validators/pydant.py @@ -740,7 +740,10 @@ class PydSubmission(BaseModel, extra='allow'): if tips is None: continue logger.debug(f"Converting tips: {tips} to sql.") - association = tips.to_sql(submission=instance) + try: + association = tips.to_sql(submission=instance) + except AttributeError: + continue if association is not None and association not in instance.submission_tips_associations: # association.save() instance.submission_tips_associations.append(association) diff --git a/src/submissions/frontend/widgets/equipment_usage.py b/src/submissions/frontend/widgets/equipment_usage.py index f6dc766..0b63e19 100644 --- a/src/submissions/frontend/widgets/equipment_usage.py +++ b/src/submissions/frontend/widgets/equipment_usage.py @@ -99,6 +99,7 @@ class RoleComboBox(QWidget): self.check.setChecked(False) else: self.check.setChecked(True) + self.check.stateChanged.connect(self.toggle_checked) self.box = QComboBox() self.box.setMaximumWidth(200) self.box.setMinimumWidth(200) @@ -118,6 +119,7 @@ class RoleComboBox(QWidget): self.layout.addWidget(self.box, 0, 2) self.layout.addWidget(self.process, 0, 3) self.setLayout(self.layout) + self.toggle_checked() def update_processes(self): """ @@ -176,3 +178,14 @@ class RoleComboBox(QWidget): ) except Exception as e: logger.error(f"Could create PydEquipment due to: {e}") + + def toggle_checked(self): + for widget in self.findChildren(QWidget): + match widget: + case QCheckBox(): + continue + case _: + if self.check.isChecked(): + widget.setEnabled(True) + else: + widget.setEnabled(False) \ No newline at end of file diff --git a/src/submissions/frontend/widgets/sample_search.py b/src/submissions/frontend/widgets/sample_search.py index 7005aad..5667fae 100644 --- a/src/submissions/frontend/widgets/sample_search.py +++ b/src/submissions/frontend/widgets/sample_search.py @@ -51,7 +51,9 @@ class SearchBox(QDialog): def update_data(self): fields = self.parse_form() - data = self.type.samples_to_df(sample_type=self.type, **fields) + # 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}") self.results.setData(df=data) diff --git a/src/submissions/frontend/widgets/submission_details.py b/src/submissions/frontend/widgets/submission_details.py index e395dfd..1bfc6e0 100644 --- a/src/submissions/frontend/widgets/submission_details.py +++ b/src/submissions/frontend/widgets/submission_details.py @@ -20,8 +20,6 @@ from PIL import Image from typing import List from backend.excel.writer import DocxWriter - - logger = logging.getLogger(f"submissions.{__name__}") @@ -97,7 +95,7 @@ class SubmissionDetails(QDialog): template_path = Path(self.template.environment.loader.__getattribute__("searchpath")[0]) with open(template_path.joinpath("css", "styles.css"), "r") as f: css = f.read() - logger.debug(f"Submission_details: {pformat(self.base_dict)}") + # logger.debug(f"Submission_details: {pformat(self.base_dict)}") self.html = self.template.render(sub=self.base_dict, signing_permission=is_power_user(), css=css) self.webview.setHtml(self.html) self.setWindowTitle(f"Submission Details - {submission.rsl_plate_num}") @@ -118,30 +116,10 @@ class SubmissionDetails(QDialog): writer = DocxWriter(base_dict=self.base_dict) fname = select_save_file(obj=self, default_name=self.base_dict['plate_number'], extension="docx") writer.save(fname) - # image_io = BytesIO() - # temp_dir = Path(TemporaryDirectory().name) - # hti = Html2Image(output_path=temp_dir, size=(2400, 1500)) - # temp_file = Path(TemporaryFile(dir=temp_dir, suffix=".png").name) - # screenshot = hti.screenshot(self.base_dict['platemap'], save_as=temp_file.name) - # export_map = Image.open(screenshot[0]) - # export_map = export_map.convert('RGB') - # try: - # export_map.save(image_io, 'JPEG') - # except AttributeError: - # logger.error(f"No plate map found") - # self.base_dict['export_map'] = base64.b64encode(image_io.getvalue()).decode('utf-8') - # del self.base_dict['platemap'] - # self.html2 = self.template.render(sub=self.base_dict) try: html_to_pdf(html=self.html, output_file=fname) except PermissionError as e: logger.error(f"Error saving pdf: {e}") - # msg = QMessageBox() - # msg.setText("Permission Error") - # msg.setInformativeText(f"Looks like {fname.__str__()} is open.\nPlease close it and try again.") - # msg.setWindowTitle("Permission Error") - # msg.exec() - class SubmissionComment(QDialog): """ diff --git a/src/submissions/templates/wastewaterartic_subdocument.docx b/src/submissions/templates/wastewaterartic_subdocument.docx index 4c98cab7e2c18720fda129b7a2c0038fa449b6a8..f4aa2a9eab8bf7e1d1f470965bd9fcd3069adb90 100644 GIT binary patch delta 3956 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 delta 3838 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