diff --git a/CHANGELOG.md b/CHANGELOG.md index d7f029c..0630981 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,7 @@ ## 202409.03 -- Improved error messaging when saving submissions. +- Upgraded sample search to (semi) realtime search. +- Improved error messaging. ## 202409.02 diff --git a/src/submissions/__init__.py b/src/submissions/__init__.py index f168563..3daf76b 100644 --- a/src/submissions/__init__.py +++ b/src/submissions/__init__.py @@ -19,7 +19,7 @@ def get_week_of_month() -> int: __project__ = "submissions" __version__ = f"{year}{str(month).zfill(2)}.{get_week_of_month()}b" __author__ = {"name":"Landon Wark", "email":"Landon.Wark@phac-aspc.gc.ca"} -__copyright__ = f"2022-{date.today().year}, Government of Canada" +__copyright__ = f"2022-{year}, Government of Canada" __github__ = "https://github.com/landowark/submissions" project_path = Path(__file__).parents[2].absolute() diff --git a/src/submissions/backend/db/models/__init__.py b/src/submissions/backend/db/models/__init__.py index caba244..8467609 100644 --- a/src/submissions/backend/db/models/__init__.py +++ b/src/submissions/backend/db/models/__init__.py @@ -167,20 +167,20 @@ class BaseClass(Base): except Exception as e: logger.critical(f"Problem saving object: {e}") logger.error(f"Error message: {type(e)}") - match e: - case sqlalcIntegrityError(): - origin = e.orig.__str__().lower() - logger.debug(f"Exception origin: {origin}") - if "unique constraint failed:" in origin: - field = origin.split(".")[1].replace("_", " ").upper() - logger.debug(field) - msg = f"{field} doesn't have a unique value.\nIt must be changed." - else: - msg = f"Got unknown integrity error: {e}" - case _: - msg = f"Got generic error: {e}" + # match e: + # case sqlalcIntegrityError(): + # origin = e.orig.__str__().lower() + # logger.error(f"Exception origin: {origin}") + # if "unique constraint failed:" in origin: + # field = " ".join(origin.split(".")[1:]).replace("_", " ").upper() + # # logger.debug(field) + # msg = f"{field} doesn't have a unique value.\nIt must be changed." + # else: + # msg = f"Got unknown integrity error: {e}" + # case _: + # msg = f"Got generic error: {e}" self.__database_session__.rollback() - report.add_result(Result(msg=msg, status="Critical")) + report.add_result(Result(msg=e, status="Critical")) return report diff --git a/src/submissions/backend/db/models/controls.py b/src/submissions/backend/db/models/controls.py index f0bbe5d..f965604 100644 --- a/src/submissions/backend/db/models/controls.py +++ b/src/submissions/backend/db/models/controls.py @@ -9,7 +9,7 @@ from operator import itemgetter from . import BaseClass from tools import setup_lookup from datetime import date, datetime -from typing import List +from typing import List, Literal from dateutil.parser import parse from re import Pattern @@ -53,7 +53,7 @@ class ControlType(BaseClass): pass return cls.execute_query(query=query, limit=limit) - def get_subtypes(self, mode: str) -> List[str]: + def get_subtypes(self, mode: Literal['kraken', 'matches', 'contains']) -> List[str]: """ Get subtypes associated with this controltype (currently used only for Kraken) @@ -161,7 +161,7 @@ class Control(BaseClass): } return output - def convert_by_mode(self, mode: str) -> list[dict]: + def convert_by_mode(self, mode: Literal['kraken', 'matches', 'contains']) -> list[dict]: """ split this instance into analysis types for controls graphs @@ -195,7 +195,7 @@ class Control(BaseClass): output.append(_dict) # logger.debug("Have to triage kraken data to keep program from getting overwhelmed") if "kraken" in mode: - output = sorted(output, key=lambda d: d[f"{mode}_count"], reverse=True)[:49] + output = sorted(output, key=lambda d: d[f"{mode}_count"], reverse=True)[:50] return output @classmethod diff --git a/src/submissions/backend/db/models/kits.py b/src/submissions/backend/db/models/kits.py index e168960..a2ba946 100644 --- a/src/submissions/backend/db/models/kits.py +++ b/src/submissions/backend/db/models/kits.py @@ -7,6 +7,7 @@ import datetime import json from pprint import pprint, pformat +import yaml from sqlalchemy import Column, String, TIMESTAMP, JSON, INTEGER, ForeignKey, Interval, Table, FLOAT, BLOB from sqlalchemy.orm import relationship, validates, Query from sqlalchemy.ext.associationproxy import association_proxy @@ -873,7 +874,12 @@ class SubmissionType(BaseClass): logging.critical(f"Given file could not be found.") return None with open(filepath, "r") as f: - import_dict = json.load(fp=f) + if filepath.suffix == ".json": + import_dict = json.load(fp=f) + elif filepath.suffix == ".yml": + import_dict = yaml.safe_load(stream=f) + else: + raise Exception(f"Filetype {filepath.suffix} not supported.") logger.debug(pformat(import_dict)) submission_type = cls.query(name=import_dict['name']) if submission_type: diff --git a/src/submissions/backend/db/models/submissions.py b/src/submissions/backend/db/models/submissions.py index 6744047..90a0ef7 100644 --- a/src/submissions/backend/db/models/submissions.py +++ b/src/submissions/backend/db/models/submissions.py @@ -2425,15 +2425,18 @@ class BasicSample(BaseClass): case _: model = cls.find_polymorphic_subclass(attrs=kwargs) # logger.debug(f"Length of kwargs: {len(kwargs)}") + # logger.debug(f"Fuzzy search received sample type: {sample_type}") query: Query = cls.__database_session__.query(model) + # logger.debug(f"Queried model. Now running searches in {kwargs}") for k, v in kwargs.items(): + # logger.debug(f"Running fuzzy search for attribute: {k} with value {v}") search = f"%{v}%" try: attr = getattr(model, k) query = query.filter(attr.like(search)) except (ArgumentError, AttributeError) as e: logger.error(f"Attribute {k} unavailable due to:\n\t{e}\nSkipping.") - return query.all() + return query.limit(50).all() def delete(self): raise AttributeError(f"Delete not implemented for {self.__class__}") diff --git a/src/submissions/frontend/widgets/sample_search.py b/src/submissions/frontend/widgets/sample_search.py index 669bcdb..b7bf73d 100644 --- a/src/submissions/frontend/widgets/sample_search.py +++ b/src/submissions/frontend/widgets/sample_search.py @@ -24,7 +24,7 @@ class SearchBox(QDialog): self.sample_type = QComboBox(self) self.sample_type.setObjectName("sample_type") self.sample_type.currentTextChanged.connect(self.update_widgets) - options = [cls.__mapper_args__['polymorphic_identity'] for cls in BasicSample.__subclasses__()] + options = ["Any"] + [cls.__mapper_args__['polymorphic_identity'] for cls in BasicSample.__subclasses__()] self.sample_type.addItems(options) self.sample_type.setEditable(False) self.setMinimumSize(600, 600) @@ -34,6 +34,7 @@ class SearchBox(QDialog): self.layout.addWidget(self.results, 5, 0) self.setLayout(self.layout) self.update_widgets() + self.update_data() def update_widgets(self): """ @@ -43,13 +44,17 @@ class SearchBox(QDialog): # logger.debug(deletes) for item in deletes: item.setParent(None) - self.type = BasicSample.find_polymorphic_subclass(self.sample_type.currentText()) + if self.sample_type.currentText() == "Any": + self.type = BasicSample + else: + self.type = BasicSample.find_polymorphic_subclass(self.sample_type.currentText()) # logger.debug(f"Sample type: {self.type}") searchables = self.type.get_searchables() start_row = 1 for iii, item in enumerate(searchables): 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) def parse_form(self) -> dict: """ @@ -64,10 +69,12 @@ class SearchBox(QDialog): def update_data(self): """ Shows dataframe of relevant samples. - """ + """ + # logger.debug(f"Running update_data with sample type: {self.type}") fields = self.parse_form() - data = self.type.fuzzy_search(sample_type=self.type, **fields) - data = self.type.samples_to_df(sample_list=data) + # logger.debug(f"Got fields: {fields}") + sample_list_creator = self.type.fuzzy_search(sample_type=self.type, **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/tools/__init__.py b/src/submissions/tools/__init__.py index 1eac10d..1f741c8 100644 --- a/src/submissions/tools/__init__.py +++ b/src/submissions/tools/__init__.py @@ -25,6 +25,7 @@ from __init__ import project_path from configparser import ConfigParser from tkinter import Tk # from tkinter import Tk for Python 3.x from tkinter.filedialog import askdirectory +from .error_messaging import parse_error_to_message logger = logging.getLogger(f"submissions.{__name__}") @@ -765,10 +766,10 @@ def setup_lookup(func): return wrapper -class Result(BaseModel): +class Result(BaseModel, arbitrary_types_allowed=True): owner: str = Field(default="", validate_default=True) code: int = Field(default=0) - msg: str + msg: str | Exception status: Literal["NoIcon", "Question", "Information", "Warning", "Critical"] = Field(default="NoIcon") @field_validator('status', mode='before') @@ -779,6 +780,13 @@ class Result(BaseModel): else: return value.title() + @field_validator('msg') + @classmethod + def set_message(cls, value): + if isinstance(value, Exception): + value = parse_error_to_message(value=value) + return value + def __repr__(self) -> str: return f"Result({self.owner})" @@ -822,7 +830,7 @@ class Report(BaseModel): def rreplace(s: str, old: str, new: str) -> str: """ - Removes rightmost occurence of a substring + Removes rightmost occurrence of a substring Args: s (str): input string @@ -873,20 +881,6 @@ def remove_key_from_list_of_dicts(input: list, key: str) -> list: return input -# 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: -# c.writerow([cell.value for cell in r]) - - ctx = get_config(None) @@ -909,7 +903,7 @@ def check_authorization(func): Decorator to check if user is authorized to access function Args: - func (_type_): Function to be used. + func (function): Function to be used. """ def wrapper(*args, **kwargs): @@ -918,15 +912,27 @@ def check_authorization(func): return func(*args, **kwargs) else: logger.error(f"User {getpass.getuser()} is not authorized for this function.") - return dict(code=1, message="This user does not have permission for this function.", status="warning") - + # return dict(code=1, message="This user does not have permission for this function.", status="warning") + report = Report() + report.add_result(Result(owner=func.__str__(), code=1, msg="This user does not have permission for this function.", status="warning")) + return report return wrapper def report_result(func): + """ + Decorator to display any reports returned from a function. + + Args: + func (function): Function being decorated + + Returns: + __type__: Output from decorated function + + """ def wrapper(*args, **kwargs): - logger.debug(f"Arguments: {args}") - logger.debug(f"Keyword arguments: {kwargs}") + # logger.debug(f"Arguments: {args}") + # logger.debug(f"Keyword arguments: {kwargs}") output = func(*args, **kwargs) match output: case Report(): @@ -955,3 +961,9 @@ def report_result(func): logger.debug(f"Returning: {output}") return output return wrapper + + +@report_result +@check_authorization +def test_function(): + print("Success!") diff --git a/src/submissions/tools/error_messaging.py b/src/submissions/tools/error_messaging.py new file mode 100644 index 0000000..c480511 --- /dev/null +++ b/src/submissions/tools/error_messaging.py @@ -0,0 +1,30 @@ +from sqlalchemy.exc import ArgumentError, IntegrityError as sqlalcIntegrityError +import logging + +logger = logging.getLogger(f"submissions.{__name__}") + + +def parse_error_to_message(value: Exception): + """ + Converts an except to a human-readable error message for display. + + Args: + value (Exception): Input exception + + Returns: + str: Output message for display + + """ + match value: + case sqlalcIntegrityError(): + origin = value.orig.__str__().lower() + logger.error(f"Exception origin: {origin}") + if "unique constraint failed:" in origin: + field = " ".join(origin.split(".")[1:]).replace("_", " ").upper() + # logger.debug(field) + value = f"{field} doesn't have a unique value.\nIt must be changed." + else: + value = f"Got unknown integrity error: {value}" + case _: + value = f"Got generic error: {value}" + return value diff --git a/submissions.spec b/submissions.spec index 64e0791..09fbb01 100644 --- a/submissions.spec +++ b/submissions.spec @@ -3,6 +3,7 @@ block_cipher = None #### custom for automation of documentation building #### + import sys, subprocess from pathlib import Path sys.path.append(Path(__name__).parent.joinpath('src').absolute().__str__()) @@ -17,6 +18,7 @@ print(bcolors.BOLD + "Running Sphinx subprocess to generate html docs..." + bcol docs_build = doc_path.joinpath("build") #docs_build.mkdir(exist_ok=True, parents=True) subprocess.run([build_path, doc_path.joinpath("source").__str__(), docs_build.__str__(), "-a"]) + ######################################################### options = [