diff --git a/src/submissions/backend/db/models/kits.py b/src/submissions/backend/db/models/kits.py index a276ce3..5394bbc 100644 --- a/src/submissions/backend/db/models/kits.py +++ b/src/submissions/backend/db/models/kits.py @@ -180,9 +180,9 @@ class KitType(BaseClass): pass case _: raise ValueError(f"Wrong variable type: {type(submission_type)} used!") - logger.debug(f"Submission type: {submission_type}, Kit: {self}") + # logger.debug(f"Submission type: {submission_type}, Kit: {self}") assocs = [item for item in self.kit_reagentrole_associations if item.submission_type == submission_type] - logger.debug(f"Associations: {assocs}") + # logger.debug(f"Associations: {assocs}") # NOTE: rescue with submission type's default kit. if not assocs: logger.error( @@ -211,7 +211,7 @@ class KitType(BaseClass): # except TypeError: # continue output = {assoc.reagent_role.name: assoc.uses for assoc in assocs} - logger.debug(f"Output: {output}") + # logger.debug(f"Output: {output}") return output, new_kit @classmethod @@ -1718,7 +1718,7 @@ class SubmissionEquipmentAssociation(BaseClass): @classmethod @setup_lookup - def query(cls, equipment_id: int, submission_id: int, role: str | None = None, limit: int = 0, **kwargs) \ + def query(cls, equipment_id: int|None=None, submission_id: int|None=None, role: str | None = None, limit: int = 0, **kwargs) \ -> Any | List[Any]: query: Query = cls.__database_session__.query(cls) query = query.filter(cls.equipment_id == equipment_id) @@ -2013,7 +2013,8 @@ class SubmissionTipsAssociation(BaseClass): @classmethod def query_or_create(cls, tips, submission, role: str, **kwargs): - instance = cls.query(tip_id=tips.id, role=role, submission_id=submission.id, limit=1, **kwargs) + kwargs['limit'] = 1 + instance = cls.query(tip_id=tips.id, role=role, submission_id=submission.id, **kwargs) if instance is None: instance = SubmissionTipsAssociation(submission=submission, tips=tips, role_name=role) return instance diff --git a/src/submissions/backend/db/models/organizations.py b/src/submissions/backend/db/models/organizations.py index c081341..1ac2907 100644 --- a/src/submissions/backend/db/models/organizations.py +++ b/src/submissions/backend/db/models/organizations.py @@ -185,3 +185,6 @@ class Contact(BaseClass): pass return cls.execute_query(query=query, limit=limit) + def to_pydantic(self) -> "PydContact": + from backend.validators import PydContact + return PydContact(name=self.name, email=self.email, phone=self.phone) diff --git a/src/submissions/backend/excel/parser.py b/src/submissions/backend/excel/parser.py index 6844de6..a5168d7 100644 --- a/src/submissions/backend/excel/parser.py +++ b/src/submissions/backend/excel/parser.py @@ -279,7 +279,7 @@ class ReagentParser(object): # submission_type = submission_type['value'] # if isinstance(submission_type, str): # submission_type = SubmissionType.query(name=submission_type) - logger.debug("Running kit map") + # logger.debug("Running kit map") associations, self.kit_object = self.kit_object.construct_xl_map_for_use(submission_type=self.submission_type_obj) reagent_map = {k: v for k, v in associations.items() if k != 'info'} try: diff --git a/src/submissions/backend/excel/writer.py b/src/submissions/backend/excel/writer.py index 7e8b902..7eea064 100644 --- a/src/submissions/backend/excel/writer.py +++ b/src/submissions/backend/excel/writer.py @@ -158,12 +158,15 @@ class InfoWriter(object): match k: case "custom": continue - # case "comment": - - # NOTE: merge all comments to fit in single cell. - if k == "comment" and isinstance(v['value'], list): - json_join = [item['text'] for item in v['value'] if 'text' in item.keys()] - v['value'] = "\n".join(json_join) + case "comment": + # NOTE: merge all comments to fit in single cell. + if isinstance(v['value'], list): + json_join = [item['text'] for item in v['value'] if 'text' in item.keys()] + v['value'] = "\n".join(json_join) + case thing if thing in self.sub_object.timestamps: + v['value'] = v['value'].date() + case _: + pass final_info[k] = v try: locations = v['locations'] @@ -252,6 +255,11 @@ class ReagentWriter(object): for v in reagent.values(): if not isinstance(v, dict): continue + match v['value']: + case datetime(): + v['value'] = v['value'].date() + case _: + pass sheet.cell(row=v['row'], column=v['column'], value=v['value']) return self.xl diff --git a/src/submissions/backend/validators/pydant.py b/src/submissions/backend/validators/pydant.py index 13cfeb6..b138ae7 100644 --- a/src/submissions/backend/validators/pydant.py +++ b/src/submissions/backend/validators/pydant.py @@ -641,7 +641,7 @@ class PydSubmission(BaseModel, extra='allow'): @field_validator("contact") @classmethod def get_contact_from_org(cls, value, values): - logger.debug(f"Value coming in: {value}") + # logger.debug(f"Value coming in: {value}") match value: case dict(): if isinstance(value['value'], tuple): @@ -650,12 +650,12 @@ class PydSubmission(BaseModel, extra='allow'): value = dict(value=value[0], missing=False) case _: value = dict(value=value, missing=False) - logger.debug(f"Value after match: {value}") + # logger.debug(f"Value after match: {value}") check = Contact.query(name=value['value']) - logger.debug(f"Check came back with {check}") + # logger.debug(f"Check came back with {check}") if not isinstance(check, Contact): org = values.data['submitting_lab']['value'] - logger.debug(f"Checking organization: {org}") + # logger.debug(f"Checking organization: {org}") if isinstance(org, str): org = Organization.query(name=values.data['submitting_lab']['value'], limit=1) if isinstance(org, Organization): @@ -666,10 +666,10 @@ class PydSubmission(BaseModel, extra='allow'): if isinstance(contact, tuple): contact = contact[0] value = dict(value=f"Defaulted to: {contact}", missing=False) - logger.debug(f"Value after query: {value}") + # logger.debug(f"Value after query: {value}") return value else: - logger.debug(f"Value after bypass check: {value}") + # logger.debug(f"Value after bypass check: {value}") return value def __init__(self, run_custom: bool = False, **data): @@ -879,6 +879,7 @@ class PydSubmission(BaseModel, extra='allow'): Converts this instance into a frontend.widgets.submission_widget.SubmissionFormWidget Args: + disable (list, optional): a list of widgets to be disabled in the form. Defaults to None. parent (QWidget): parent widget of the constructed object Returns: @@ -911,7 +912,7 @@ class PydSubmission(BaseModel, extra='allow'): # @report_result def check_kit_integrity(self, extraction_kit: str | dict | None = None, exempt: List[PydReagent] = []) -> Tuple[ - List[PydReagent], Report]: + List[PydReagent], Report, List[PydReagent]]: """ Ensures all reagents expected in kit are listed in Submission @@ -933,13 +934,11 @@ class PydSubmission(BaseModel, extra='allow'): ext_kit.get_reagents(required=True, submission_type=self.submission_type['value'])] # NOTE: Exclude any reagenttype found in this pyd not expected in kit. expected_check = [item.role for item in ext_kit_rtypes] - logger.debug(self.reagents) output_reagents = [rt for rt in self.reagents if rt.role in expected_check] missing_check = [item.role for item in output_reagents] missing_reagents = [rt for rt in ext_kit_rtypes if rt.role not in missing_check and rt.role not in exempt] # logger.debug(f"Missing reagents: {missing_reagents}") missing_reagents += [rt for rt in output_reagents if rt.missing] - logger.debug(pformat(missing_reagents)) output_reagents += [rt for rt in missing_reagents if rt not in output_reagents] # NOTE: if lists are equal return no problem if len(missing_reagents) == 0: @@ -956,13 +955,13 @@ class PydSubmission(BaseModel, extra='allow'): expired = [] for reagent in self.reagents: if reagent not in exempt: - role_expiry = ReagentRole.query(name=reagent.role).eol_ext + role_eol = ReagentRole.query(name=reagent.role).eol_ext try: dt = datetime.combine(reagent.expiry, datetime.max.time()) except TypeError: continue - if datetime.now() > dt + role_expiry: - expired.append(f"{reagent.role}, {reagent.lot}: {reagent.expiry} + {role_expiry.days}") + if datetime.now() > dt + role_eol: + expired.append(f"{reagent.role}, {reagent.lot}: {reagent.expiry.date()} + {role_eol.days}") if expired: output = '\n'.join(expired) result = Result(status="Warning", @@ -996,11 +995,12 @@ class PydContact(BaseModel): area_regex = re.compile(r"^\(?(\d{3})\)?(-| )?") if len(value) > 8: match = area_regex.match(value) - logger.debug(f"Match: {match.group(1)}") + # logger.debug(f"Match: {match.group(1)}") value = area_regex.sub(f"({match.group(1).strip()}) ", value) - logger.debug(f"Output phone: {value}") + # logger.debug(f"Output phone: {value}") return value + @report_result def to_sql(self) -> Tuple[Contact, Report]: """ Converts this instance into a backend.db.models.organization. Contact instance. @@ -1036,6 +1036,18 @@ class PydOrganization(BaseModel): cost_centre: str contacts: List[PydContact] | None + @field_validator("contacts", mode="before") + @classmethod + def string_to_list(cls, value): + if isinstance(value, str): + value = Contact.query(name=value) + try: + value = [value.to_pydantic()] + except AttributeError: + return None + return value + + def to_sql(self) -> Organization: """ Converts this instance into a backend.db.models.organization.Organization instance. @@ -1047,10 +1059,14 @@ class PydOrganization(BaseModel): for field in self.model_fields: match field: case "contacts": - value = [item.to_sql() for item in getattr(self, field)] + value = getattr(self, field) + if value: + value = [item.to_sql() for item in value if item] case _: value = getattr(self, field) - instance.__setattr__(name=field, value=value) + logger.debug(f"Setting {field} to {value}") + if value: + setattr(instance, field, value) return instance @@ -1105,7 +1121,8 @@ class PydKit(BaseModel): instance = KitType.query(name=self.name) if instance is None: instance = KitType(name=self.name) - [item.to_sql(instance) for item in self.reagent_roles] + for role in self.reagent_roles: + role.to_sql(instance) return instance, report @@ -1162,7 +1179,8 @@ class PydIridaControl(BaseModel, extra='ignore'): contains: list | dict #: unstructured hashes in contains.tsv for each organism matches: list | dict #: unstructured hashes in matches.tsv for each organism kraken: list | dict #: unstructured output from kraken_report - subtype: str #: EN-NOS, MCS-NOS, etc + # subtype: str #: EN-NOS, MCS-NOS, etc + subtype: Literal["ATCC49226", "ATCC49619", "EN-NOS", "EN-SSTI", "MCS-NOS", "MCS-SSTI", "SN-NOS", "SN-SSTI"] refseq_version: str #: version of refseq used in fastq parsing kraken2_version: str kraken2_db_version: str @@ -1171,6 +1189,13 @@ class PydIridaControl(BaseModel, extra='ignore'): submission_id: int controltype_name: str + @field_validator("refseq_version", "kraken2_version", "kraken2_db_version", mode='before') + @classmethod + def enforce_string(cls, value): + if not value: + value = "" + return value + def to_sql(self): instance = IridaControl.query(name=self.name) if not instance: diff --git a/src/submissions/frontend/visualizations/__init__.py b/src/submissions/frontend/visualizations/__init__.py index 614828b..62fe408 100644 --- a/src/submissions/frontend/visualizations/__init__.py +++ b/src/submissions/frontend/visualizations/__init__.py @@ -7,7 +7,6 @@ from PyQt6.QtWidgets import QWidget import plotly, logging from plotly.graph_objects import Figure import pandas as pd -from frontend.widgets.functions import select_save_file from tools import divide_chunks logger = logging.getLogger(f"submissions.{__name__}") @@ -65,7 +64,8 @@ class CustomFigure(Figure): ) assert isinstance(self, CustomFigure) - def make_plotly_buttons(self, months: int = 6) -> Generator[dict, None, None]: + @classmethod + def make_plotly_buttons(cls, months: int = 6) -> Generator[dict, None, None]: """ Creates html buttons to zoom in on date areas @@ -115,7 +115,8 @@ class CustomFigure(Figure): {"yaxis.title.text": mode}, ]) - def to_html(self) -> str: + @property + def html(self) -> str: """ Creates final html code from plotly diff --git a/src/submissions/frontend/visualizations/irida_charts.py b/src/submissions/frontend/visualizations/irida_charts.py index 28592ef..78eae18 100644 --- a/src/submissions/frontend/visualizations/irida_charts.py +++ b/src/submissions/frontend/visualizations/irida_charts.py @@ -19,10 +19,10 @@ class IridaFigure(CustomFigure): super().__init__(df=df, modes=modes, settings=settings) self.df = df - try: - months = int(settings['months']) - except KeyError: - months = 6 + # try: + # months = int(settings['months']) + # except KeyError: + # months = 6 self.construct_chart(df=df, modes=modes, start_date=settings['start_date'], end_date=settings['end_date']) diff --git a/src/submissions/frontend/visualizations/pcr_charts.py b/src/submissions/frontend/visualizations/pcr_charts.py index ed29060..dec8a25 100644 --- a/src/submissions/frontend/visualizations/pcr_charts.py +++ b/src/submissions/frontend/visualizations/pcr_charts.py @@ -17,10 +17,10 @@ class PCRFigure(CustomFigure): months: int = 6): super().__init__(df=df, modes=modes, settings=settings) self.df = df - try: - months = int(settings['months']) - except KeyError: - months = 6 + # try: + # months = int(settings['months']) + # except KeyError: + # months = 6 self.construct_chart(df=df) def construct_chart(self, df: pd.DataFrame): diff --git a/src/submissions/frontend/visualizations/turnaround_chart.py b/src/submissions/frontend/visualizations/turnaround_chart.py index a3173b0..42501e9 100644 --- a/src/submissions/frontend/visualizations/turnaround_chart.py +++ b/src/submissions/frontend/visualizations/turnaround_chart.py @@ -19,10 +19,10 @@ class TurnaroundChart(CustomFigure): months: int = 6): super().__init__(df=df, modes=modes, settings=settings) self.df = df - try: - months = int(settings['months']) - except KeyError: - months = 6 + # try: + # months = int(settings['months']) + # except KeyError: + # months = 6 self.construct_chart() if threshold: self.add_hline(y=threshold) diff --git a/src/submissions/frontend/widgets/app.py b/src/submissions/frontend/widgets/app.py index 96aceac..9bd1913 100644 --- a/src/submissions/frontend/widgets/app.py +++ b/src/submissions/frontend/widgets/app.py @@ -1,6 +1,7 @@ """ Constructs main application. """ +import getpass from pprint import pformat from PyQt6.QtCore import qInstallMessageHandler from PyQt6.QtWidgets import ( @@ -13,9 +14,10 @@ from pathlib import Path from markdown import markdown from __init__ import project_path from backend import SubmissionType, Reagent, BasicSample, Organization, KitType -from tools import check_if_app, Settings, Report, jinja_template_loading, check_authorization, page_size, is_power_user +from tools import ( + check_if_app, Settings, Report, jinja_template_loading, check_authorization, page_size, is_power_user, under_development +) from .functions import select_save_file, select_open_file -# from datetime import date from .pop_ups import HTMLPop, AlertPop from .misc import Pagifier import logging, webbrowser, sys, shutil @@ -84,7 +86,8 @@ class App(QMainWindow): maintenanceMenu.addAction(self.joinPCRAction) editMenu.addAction(self.editReagentAction) editMenu.addAction(self.manageOrgsAction) - # editMenu.addAction(self.manageKitsAction) + if getpass.getuser() == "lwark": + editMenu.addAction(self.manageKitsAction) if not is_power_user(): editMenu.setEnabled(False) @@ -119,7 +122,7 @@ class App(QMainWindow): connect menu and tool bar item to functions """ self.importAction.triggered.connect(self.table_widget.formwidget.importSubmission) - self.addReagentAction.triggered.connect(self.table_widget.formwidget.new_add_reagent) + self.addReagentAction.triggered.connect(self.table_widget.formwidget.add_reagent) self.joinExtractionAction.triggered.connect(self.table_widget.sub_wid.link_extractions) self.joinPCRAction.triggered.connect(self.table_widget.sub_wid.link_pcr) self.helpAction.triggered.connect(self.showAbout) @@ -177,6 +180,11 @@ class App(QMainWindow): dlg = SearchBox(self, object_type=BasicSample, extras=[]) dlg.exec() + @check_authorization + def edit_reagent(self, *args, **kwargs): + dlg = SearchBox(parent=self, object_type=Reagent, extras=[dict(name='Role', field="role")]) + dlg.exec() + def export_ST_yaml(self): """ Copies submission type yaml to file system for editing and remport @@ -191,13 +199,18 @@ class App(QMainWindow): fname = select_save_file(obj=self, default_name="Submission Type Template.yml", extension="yml") shutil.copyfile(yaml_path, fname) - @check_authorization - def edit_reagent(self, *args, **kwargs): - dlg = SearchBox(parent=self, object_type=Reagent, extras=[dict(name='Role', field="role")]) - dlg.exec() - @check_authorization def import_ST_yaml(self, *args, **kwargs): + """ + Imports a yml form into a submission type. + + Args: + *args (): + **kwargs (): + + Returns: + + """ fname = select_open_file(obj=self, file_extension="yml") if not fname: logger.info(f"Import cancelled.") @@ -220,9 +233,11 @@ class App(QMainWindow): dlg = ManagerWindow(parent=self, object_type=Organization, extras=[]) if dlg.exec(): new_org = dlg.parse_form() + new_org.save() # logger.debug(new_org.__dict__) - def manage_kits(self): + @under_development + def manage_kits(self, *args, **kwargs): dlg = ManagerWindow(parent=self, object_type=KitType, extras=[]) if dlg.exec(): print(dlg.parse_form()) diff --git a/src/submissions/frontend/widgets/controls_chart.py b/src/submissions/frontend/widgets/controls_chart.py index 14d6b5b..fff89b5 100644 --- a/src/submissions/frontend/widgets/controls_chart.py +++ b/src/submissions/frontend/widgets/controls_chart.py @@ -99,18 +99,24 @@ class ControlsViewer(InfoPane): self.mode_sub_type = self.mode_sub_typer.currentText() months = self.diff_month(self.start_date, self.end_date) # NOTE: query all controls using the type/start and end dates from the gui - chart_settings = dict(sub_type=self.con_sub_type, start_date=self.start_date, end_date=self.end_date, - mode=self.mode, - sub_mode=self.mode_sub_type, parent=self, months=months) + chart_settings = dict( + sub_type=self.con_sub_type, + start_date=self.start_date, + end_date=self.end_date, + mode=self.mode, + sub_mode=self.mode_sub_type, + parent=self, + months=months + ) self.fig = self.archetype.instance_class.make_chart(chart_settings=chart_settings, parent=self, ctx=self.app.ctx) self.report_obj = ChartReportMaker(df=self.fig.df, sheet_name=self.archetype.name) if issubclass(self.fig.__class__, CustomFigure): self.save_button.setEnabled(True) # NOTE: construct html for webview - try: - html = self.fig.to_html() - except AttributeError: - html = "" - self.webview.setHtml(html) + # try: + # html = self.fig.html + # except AttributeError: + # html = "" + self.webview.setHtml(self.fig.html) self.webview.update() return report diff --git a/src/submissions/frontend/widgets/equipment_usage.py b/src/submissions/frontend/widgets/equipment_usage.py index 3b059d1..e527918 100644 --- a/src/submissions/frontend/widgets/equipment_usage.py +++ b/src/submissions/frontend/widgets/equipment_usage.py @@ -3,8 +3,9 @@ Creates forms that the user can enter equipment info into. ''' from pprint import pformat from PyQt6.QtCore import Qt, QSignalBlocker -from PyQt6.QtWidgets import (QDialog, QComboBox, QCheckBox, - QLabel, QWidget, QVBoxLayout, QDialogButtonBox, QGridLayout) +from PyQt6.QtWidgets import ( + QDialog, QComboBox, QCheckBox, QLabel, QWidget, QVBoxLayout, QDialogButtonBox, QGridLayout +) from backend.db.models import Equipment, BasicSubmission, Process from backend.validators.pydant import PydEquipment, PydEquipmentRole, PydTips import logging @@ -36,8 +37,8 @@ class EquipmentUsage(QDialog): self.buttonBox.rejected.connect(self.reject) label = self.LabelRow(parent=self) self.layout.addWidget(label) - for eq in self.opt_equipment: - widg = eq.to_form(parent=self, used=self.used_equipment) + for equipment in self.opt_equipment: + widg = equipment.to_form(parent=self, used=self.used_equipment) self.layout.addWidget(widg) widg.update_processes() self.layout.addWidget(self.buttonBox) @@ -64,6 +65,7 @@ class EquipmentUsage(QDialog): continue class LabelRow(QWidget): + """Provides column headers""" def __init__(self, parent) -> None: super().__init__(parent) diff --git a/src/submissions/frontend/widgets/functions.py b/src/submissions/frontend/widgets/functions.py index ef2dafe..bf33e39 100644 --- a/src/submissions/frontend/widgets/functions.py +++ b/src/submissions/frontend/widgets/functions.py @@ -1,8 +1,11 @@ -''' +""" functions used by all windows in the application's frontend -''' +""" from pathlib import Path import logging +from PyQt6.QtCore import QMarginsF +from PyQt6.QtGui import QPageLayout, QPageSize +from PyQt6.QtWebEngineWidgets import QWebEngineView from PyQt6.QtWidgets import QMainWindow, QFileDialog logger = logging.getLogger(f"submissions.{__name__}") @@ -60,3 +63,21 @@ def select_save_file(obj: QMainWindow, default_name: str, extension: str) -> Pat fname = Path(QFileDialog.getSaveFileName(obj, "Save File", home_dir, filter=f"{extension}(*.{extension})")[0]) obj.last_dir = fname.parent return fname + + +def save_pdf(obj: QWebEngineView, filename: Path): + """ + Handles printing to PDF + + Args: + obj (): Parent object + filename (): Where to save pdf. + + Returns: + None + """ + page_layout = QPageLayout() + page_layout.setPageSize(QPageSize(QPageSize.PageSizeId.A4)) + page_layout.setOrientation(QPageLayout.Orientation.Portrait) + page_layout.setMargins(QMarginsF(25, 25, 25, 25)) + obj.page().printToPdf(filename.absolute().__str__(), page_layout) diff --git a/src/submissions/frontend/widgets/gel_checker.py b/src/submissions/frontend/widgets/gel_checker.py index 5810aca..1e9a529 100644 --- a/src/submissions/frontend/widgets/gel_checker.py +++ b/src/submissions/frontend/widgets/gel_checker.py @@ -2,15 +2,13 @@ Gel box for artic quality control """ from operator import itemgetter -from PyQt6.QtWidgets import (QWidget, QDialog, QGridLayout, - QLabel, QLineEdit, QDialogButtonBox, - QTextEdit, QComboBox - ) +from PyQt6.QtWidgets import ( + QWidget, QDialog, QGridLayout, QLabel, QLineEdit, QDialogButtonBox, QTextEdit, QComboBox +) import pyqtgraph as pg from PyQt6.QtGui import QIcon from PIL import Image -import numpy as np -import logging +import logging, numpy as np from pprint import pformat from typing import Tuple, List from pathlib import Path @@ -103,7 +101,8 @@ class ControlsForm(QWidget): except TypeError: tt_text = None for iii, item in enumerate( - ["Negative Control Key", "Description", "Results - 65 C", "Results - 63 C", "Results - Spike"]): + ["Negative Control Key", "Description", "Results - 65 C", "Results - 63 C", "Results - Spike"] + ): label = QLabel(item) self.layout.addWidget(label, 0, iii, 1, 1) if iii > 1: diff --git a/src/submissions/frontend/widgets/info_tab.py b/src/submissions/frontend/widgets/info_tab.py index 37f6f82..608cd01 100644 --- a/src/submissions/frontend/widgets/info_tab.py +++ b/src/submissions/frontend/widgets/info_tab.py @@ -6,8 +6,8 @@ from PyQt6.QtCore import QSignalBlocker from PyQt6.QtWebEngineWidgets import QWebEngineView from PyQt6.QtWidgets import QWidget, QGridLayout from tools import Report, report_result, Result -from .misc import StartEndDatePicker, save_pdf -from .functions import select_save_file +from .misc import StartEndDatePicker +from .functions import select_save_file, save_pdf import logging logger = logging.getLogger(f"submissions.{__name__}") @@ -38,8 +38,7 @@ class InfoPane(QWidget): lastmonth = self.datepicker.end_date.date().addDays(-31) msg = f"Start date after end date is not allowed! Setting to {lastmonth.toString()}." logger.warning(msg) - # NOTE: block signal that will rerun controls getter and set start date - # Without triggering this function again + # NOTE: block signal that will rerun controls getter and set start date without triggering this function again with QSignalBlocker(self.datepicker.start_date) as blocker: self.datepicker.start_date.setDate(lastmonth) self.update_data() diff --git a/src/submissions/frontend/widgets/misc.py b/src/submissions/frontend/widgets/misc.py index 51249f1..6c270f2 100644 --- a/src/submissions/frontend/widgets/misc.py +++ b/src/submissions/frontend/widgets/misc.py @@ -3,12 +3,10 @@ Contains miscellaneous widgets for frontend functions """ import math from datetime import date -from PyQt6.QtGui import QPageLayout, QPageSize, QStandardItem, QIcon -from PyQt6.QtWebEngineWidgets import QWebEngineView +from PyQt6.QtGui import QStandardItem, QIcon from PyQt6.QtWidgets import ( - QLabel, QVBoxLayout, - QLineEdit, QComboBox, QDialog, - QDialogButtonBox, QDateEdit, QPushButton, QWidget, QHBoxLayout, QSizePolicy + QLabel, QLineEdit, QComboBox, QDateEdit, QPushButton, QWidget, + QHBoxLayout, QSizePolicy ) from PyQt6.QtCore import Qt, QDate, QSize, QMarginsF from tools import jinja_template_loading @@ -20,96 +18,98 @@ logger = logging.getLogger(f"submissions.{__name__}") env = jinja_template_loading() -class AddReagentForm(QDialog): - """ - dialog to add gather info about new reagent - """ - - def __init__(self, reagent_lot: str | None = None, reagent_role: str | None = None, expiry: date | None = None, - reagent_name: str | None = None, kit: str | KitType | None = None) -> None: - super().__init__() - if reagent_name is None: - reagent_name = reagent_role - self.setWindowTitle("Add Reagent") - QBtn = QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel - self.buttonBox = QDialogButtonBox(QBtn) - self.buttonBox.accepted.connect(self.accept) - self.buttonBox.rejected.connect(self.reject) - # NOTE: widget to get lot info - self.name_input = QComboBox() - self.name_input.setObjectName("name") - self.name_input.setEditable(True) - self.name_input.setCurrentText(reagent_name) - self.lot_input = QLineEdit() - self.lot_input.setObjectName("lot") - self.lot_input.setText(reagent_lot) - # NOTE: widget to get expiry info - self.exp_input = QDateEdit(calendarPopup=True) - self.exp_input.setObjectName('expiry') - # NOTE: if expiry is not passed in from gui, use today - if expiry is None: - self.exp_input.setDate(QDate(1970, 1, 1)) - else: - try: - self.exp_input.setDate(expiry) - except TypeError: - self.exp_input.setDate(QDate(1970, 1, 1)) - # NOTE: widget to get reagent type info - self.type_input = QComboBox() - self.type_input.setObjectName('role') - if kit: - match kit: - case str(): - kit = KitType.query(name=kit) - case _: - pass - self.type_input.addItems([item.name for item in ReagentRole.query() if kit in item.kit_types]) - else: - self.type_input.addItems([item.name for item in ReagentRole.query()]) - # NOTE: convert input to user-friendly string? - try: - reagent_role = reagent_role.replace("_", " ").title() - except AttributeError: - reagent_role = None - # NOTE: set parsed reagent type to top of list - index = self.type_input.findText(reagent_role, Qt.MatchFlag.MatchEndsWith) - if index >= 0: - self.type_input.setCurrentIndex(index) - self.layout = QVBoxLayout() - self.layout.addWidget(QLabel("Name:")) - self.layout.addWidget(self.name_input) - self.layout.addWidget(QLabel("Lot:")) - self.layout.addWidget(self.lot_input) - self.layout.addWidget( - QLabel("Expiry:\n(use exact date on reagent.\nEOL will be calculated from kit automatically)")) - self.layout.addWidget(self.exp_input) - self.layout.addWidget(QLabel("Type:")) - self.layout.addWidget(self.type_input) - self.layout.addWidget(self.buttonBox) - self.setLayout(self.layout) - self.type_input.currentTextChanged.connect(self.update_names) - - def parse_form(self) -> dict: - """ - Converts information in form to dict. - - Returns: - dict: Output info - """ - return dict(name=self.name_input.currentText().strip(), - lot=self.lot_input.text().strip(), - expiry=self.exp_input.date().toPyDate(), - role=self.type_input.currentText().strip()) - - def update_names(self): - """ - Updates reagent names form field with examples from reagent type - """ - self.name_input.clear() - lookup = Reagent.query(role=self.type_input.currentText()) - self.name_input.addItems(list(set([item.name for item in lookup]))) - - +# class AddReagentForm(QDialog): +# """ +# dialog to add gather info about new reagent (Defunct) +# """ +# +# def __init__(self, reagent_lot: str | None = None, reagent_role: str | None = None, expiry: date | None = None, +# reagent_name: str | None = None, kit: str | KitType | None = None) -> None: +# super().__init__() +# if reagent_name is None: +# reagent_name = reagent_role +# self.setWindowTitle("Add Reagent") +# QBtn = QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel +# self.buttonBox = QDialogButtonBox(QBtn) +# self.buttonBox.accepted.connect(self.accept) +# self.buttonBox.rejected.connect(self.reject) +# # NOTE: widget to get lot info +# self.name_input = QComboBox() +# self.name_input.setObjectName("name") +# self.name_input.setEditable(True) +# self.name_input.setCurrentText(reagent_name) +# self.lot_input = QLineEdit() +# self.lot_input.setObjectName("lot") +# self.lot_input.setText(reagent_lot) +# # NOTE: widget to get expiry info +# self.expiry_input = QDateEdit(calendarPopup=True) +# self.expiry_input.setObjectName('expiry') +# # NOTE: if expiry is not passed in from gui, use today +# if expiry is None: +# logger.warning(f"Did not receive expiry, setting to 1970, 1, 1") +# self.expiry_input.setDate(QDate(1970, 1, 1)) +# else: +# try: +# self.expiry_input.setDate(expiry) +# except TypeError: +# self.expiry_input.setDate(QDate(1970, 1, 1)) +# # NOTE: widget to get reagent type info +# self.role_input = QComboBox() +# self.role_input.setObjectName('role') +# if kit: +# match kit: +# case str(): +# kit = KitType.query(name=kit) +# case _: +# pass +# self.role_input.addItems([item.name for item in ReagentRole.query() if kit in item.kit_types]) +# else: +# self.role_input.addItems([item.name for item in ReagentRole.query()]) +# # NOTE: convert input to user-friendly string? +# try: +# reagent_role = reagent_role.replace("_", " ").title() +# except AttributeError: +# reagent_role = None +# # NOTE: set parsed reagent type to top of list +# index = self.role_input.findText(reagent_role, Qt.MatchFlag.MatchEndsWith) +# if index >= 0: +# self.role_input.setCurrentIndex(index) +# self.layout = QVBoxLayout() +# self.layout.addWidget(QLabel("Name:")) +# self.layout.addWidget(self.name_input) +# self.layout.addWidget(QLabel("Lot:")) +# self.layout.addWidget(self.lot_input) +# self.layout.addWidget( +# QLabel("Expiry:\n(use exact date on reagent.\nEOL will be calculated from kit automatically)") +# ) +# self.layout.addWidget(self.expiry_input) +# self.layout.addWidget(QLabel("Type:")) +# self.layout.addWidget(self.role_input) +# self.layout.addWidget(self.buttonBox) +# self.setLayout(self.layout) +# self.role_input.currentTextChanged.connect(self.update_names) +# +# def parse_form(self) -> dict: +# """ +# Converts information in form to dict. +# +# Returns: +# dict: Output info +# """ +# return dict(name=self.name_input.currentText().strip(), +# lot=self.lot_input.text().strip(), +# expiry=self.expiry_input.date().toPyDate(), +# role=self.role_input.currentText().strip()) +# +# def update_names(self): +# """ +# Updates reagent names form field with examples from reagent type +# """ +# self.name_input.clear() +# lookup = Reagent.query(role=self.role_input.currentText()) +# self.name_input.addItems(list(set([item.name for item in lookup]))) +# +# class StartEndDatePicker(QWidget): """ custom widget to pick start and end dates for controls graphs @@ -135,12 +135,12 @@ class StartEndDatePicker(QWidget): return QSize(80, 20) -def save_pdf(obj: QWebEngineView, filename: Path): - page_layout = QPageLayout() - page_layout.setPageSize(QPageSize(QPageSize.PageSizeId.A4)) - page_layout.setOrientation(QPageLayout.Orientation.Portrait) - page_layout.setMargins(QMarginsF(25, 25, 25, 25)) - obj.page().printToPdf(filename.absolute().__str__(), page_layout) +# def save_pdf(obj: QWebEngineView, filename: Path): +# page_layout = QPageLayout() +# page_layout.setPageSize(QPageSize(QPageSize.PageSizeId.A4)) +# page_layout.setOrientation(QPageLayout.Orientation.Portrait) +# page_layout.setMargins(QMarginsF(25, 25, 25, 25)) +# obj.page().printToPdf(filename.absolute().__str__(), page_layout) # NOTE: subclass diff --git a/src/submissions/frontend/widgets/omni_add_edit.py b/src/submissions/frontend/widgets/omni_add_edit.py index 1da01d2..b27d80a 100644 --- a/src/submissions/frontend/widgets/omni_add_edit.py +++ b/src/submissions/frontend/widgets/omni_add_edit.py @@ -1,17 +1,18 @@ +""" +A widget to handle adding/updating any database object. +""" from datetime import date from pprint import pformat -from typing import Any, List, Tuple +from typing import Any, Tuple from pydantic import BaseModel from PyQt6.QtWidgets import ( - QLabel, QDialog, QTableView, QWidget, QLineEdit, QGridLayout, QComboBox, QPushButton, QDialogButtonBox, QDateEdit + QLabel, QDialog, QWidget, QLineEdit, QGridLayout, QComboBox, QDialogButtonBox, QDateEdit, QSpinBox, QDoubleSpinBox ) -from sqlalchemy import String, TIMESTAMP +from sqlalchemy import String, TIMESTAMP, INTEGER, FLOAT from sqlalchemy.orm import InstrumentedAttribute, ColumnProperty import logging - from sqlalchemy.orm.relationships import _RelationshipDeclared - -from tools import Report, Result, report_result +from tools import Report, report_result logger = logging.getLogger(f"submissions.{__name__}") @@ -23,7 +24,6 @@ class AddEdit(QDialog): self.instance = instance self.object_type = instance.__class__ self.layout = QGridLayout(self) - # logger.debug(f"Manager: {manager}") QBtn = QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel self.buttonBox = QDialogButtonBox(QBtn) self.buttonBox.accepted.connect(self.accept) @@ -36,7 +36,6 @@ class AddEdit(QDialog): fields = {'name': fields.pop('name'), **fields} except KeyError: pass - # logger.debug(pformat(fields, indent=4)) height_counter = 0 for key, field in fields.items(): try: @@ -45,9 +44,6 @@ class AddEdit(QDialog): value = None try: logger.debug(f"{key} property: {type(field['class_attr'].property)}") - # widget = EditProperty(self, key=key, column_type=field.property.expression.type, - # value=getattr(self.instance, key)) - # logger.debug(f"Column type: {field}, Value: {value}") widget = EditProperty(self, key=key, column_type=field, value=value) except AttributeError as e: logger.error(f"Problem setting widget {key}: {e}") @@ -64,15 +60,11 @@ class AddEdit(QDialog): def parse_form(self) -> Tuple[BaseModel, Report]: report = Report() parsed = {result[0].strip(":"): result[1] for result in [item.parse_form() for item in self.findChildren(EditProperty)] if result[0]} - logger.debug(parsed) + # logger.debug(parsed) model = self.object_type.pydantic_model # NOTE: Hand-off to pydantic model for validation. # NOTE: Also, why am I not just using the toSQL method here. I could write one for contacts. model = model(**parsed) - # output, result = model.to_sql() - # report.add_result(result) - # if len(report.results) < 1: - # report.add_result(Result(msg="Added new regeant.", icon="Information", owner=__name__)) return model, report @@ -84,7 +76,7 @@ class EditProperty(QWidget): self.label = QLabel(key.title().replace("_", " ")) self.layout = QGridLayout() self.layout.addWidget(self.label, 0, 0, 1, 1) - self.setObjectName(f"{key}:") + self.setObjectName(key) match column_type['class_attr'].property: case ColumnProperty(): self.column_property_set(column_type, value=value) @@ -97,15 +89,15 @@ class EditProperty(QWidget): self.setLayout(self.layout) def relationship_property_set(self, relationship_property, value=None): - # print(relationship_property) self.property_class = relationship_property['class_attr'].property.entity.class_ self.is_list = relationship_property['class_attr'].property.uselist - choices = [item.name for item in self.property_class.query()] + choices = [""] + [item.name for item in self.property_class.query()] try: - instance_value = getattr(self.parent().instance, self.name) + instance_value = getattr(self.parent().instance, self.objectName()) except AttributeError: - logger.error(f"Unable to get instance {self.parent().instance} attribute: {self.name}") + logger.error(f"Unable to get instance {self.parent().instance} attribute: {self.objectName()}") instance_value = None + # NOTE: get the value for the current instance and move it to the front. if isinstance(instance_value, list): instance_value = next((item.name for item in instance_value), None) if instance_value: @@ -120,6 +112,16 @@ class EditProperty(QWidget): value = "" self.widget = QLineEdit(self) self.widget.setText(value) + case INTEGER(): + if not value: + value = 1 + self.widget = QSpinBox() + self.widget.setValue(value) + case FLOAT(): + if not value: + value = 1.0 + self.widget = QDoubleSpinBox() + self.widget.setValue(value) case TIMESTAMP(): self.widget = QDateEdit(self, calendarPopup=True) if not value: @@ -129,12 +131,13 @@ class EditProperty(QWidget): logger.error(f"{column_property} not a supported property.") self.widget = None try: - tooltip_text = self.parent().object_type.add_edit_tooltips[self.name] + tooltip_text = self.parent().object_type.add_edit_tooltips[self.objectName()] self.widget.setToolTip(tooltip_text) except KeyError: pass def parse_form(self): + # NOTE: Make sure there's a widget. try: check = self.widget except AttributeError: @@ -146,10 +149,12 @@ class EditProperty(QWidget): value = self.widget.date().toPyDate() case QComboBox(): value = self.widget.currentText() + case QSpinBox() | QDoubleSpinBox(): + value = self.widget.value() # if self.is_list: # value = [self.property_class.query(name=prelim)] # else: # value = self.property_class.query(name=prelim) case _: value = None - return self.name, value + return self.objectName(), value diff --git a/src/submissions/frontend/widgets/omni_manager.py b/src/submissions/frontend/widgets/omni_manager.py index b4a4a27..3f5d9b5 100644 --- a/src/submissions/frontend/widgets/omni_manager.py +++ b/src/submissions/frontend/widgets/omni_manager.py @@ -1,4 +1,6 @@ -from operator import itemgetter +""" +Provides a screen for managing all attributes of a database object. +""" from typing import Any, List from PyQt6.QtCore import QSortFilterProxyModel, Qt from PyQt6.QtGui import QAction, QCursor @@ -49,21 +51,22 @@ class ManagerWindow(QDialog): self.layout.addWidget(self.sub_class, 0, 0) else: self.sub_class = None - # self.layout.addWidget(self.buttonBox, self.layout.rowCount(), 0) self.options = QComboBox(self) self.options.setObjectName("options") self.update_options() self.setLayout(self.layout) self.setWindowTitle(f"Manage {self.object_type.__name__}") - def update_options(self): + def update_options(self) -> None: """ Changes form inputs based on sample type """ - if self.sub_class: self.object_type = getattr(db, self.sub_class.currentText()) options = [item.name for item in self.object_type.query()] + logger.debug(f"self.instance: {self.instance}") + if self.instance: + options.insert(0, options.pop(options.index(self.instance.name))) self.options.clear() self.options.addItems(options) self.options.setEditable(False) @@ -75,23 +78,34 @@ class ManagerWindow(QDialog): self.add_button.clicked.connect(self.add_new) self.update_data() - def update_data(self): + def update_data(self) -> None: + """ + Performs updating of widgets on first run and after options change. + + Returns: + None + """ + # NOTE: Remove all old widgets. deletes = [item for item in self.findChildren(EditProperty)] + \ [item for item in self.findChildren(EditRelationship)] + \ [item for item in self.findChildren(QDialogButtonBox)] for item in deletes: item.setParent(None) + # NOTE: Find the instance this manager will update self.instance = self.object_type.query(name=self.options.currentText()) fields = {k: v for k, v in self.object_type.__dict__.items() if isinstance(v, InstrumentedAttribute) and k != "id"} for key, field in fields.items(): - # logger.debug(f"Key: {key}, Value: {field}") match field.property: + # NOTE: ColumnProperties will be directly edited. case ColumnProperty(): + # NOTE: field.property.expression.type gives db column type eg. STRING or TIMESTAMP widget = EditProperty(self, key=key, column_type=field.property.expression.type, value=getattr(self.instance, key)) + # NOTE: RelationshipDeclareds will be given a list of existing related objects. case _RelationshipDeclared(): if key != "submissions": + # NOTE: field.comparator.entity.class_ gives the relationship class widget = EditRelationship(self, key=key, entity=field.comparator.entity.class_, value=getattr(self.instance, key)) else: @@ -100,11 +114,18 @@ class ManagerWindow(QDialog): continue if widget: self.layout.addWidget(widget, self.layout.rowCount(), 0, 1, 2) + # NOTE: Add OK|Cancel to bottom of dialog. self.layout.addWidget(self.buttonBox, self.layout.rowCount(), 0, 1, 2) - def parse_form(self): + def parse_form(self) -> Any: + """ + Returns the instance associated with this window. + + Returns: + Any: The instance with updated fields. + """ + # TODO: Need Relationship property here too? results = [item.parse_form() for item in self.findChildren(EditProperty)] - # logger.debug(results) for result in results: # logger.debug(result) self.instance.__setattr__(result[0], result[1]) @@ -113,9 +134,10 @@ class ManagerWindow(QDialog): def add_new(self): dlg = AddEdit(parent=self, instance=self.object_type(), manager=self.object_type.__name__.lower()) if dlg.exec(): - new_instance = dlg.parse_form() - # logger.debug(new_instance.__dict__) + new_pyd = dlg.parse_form() + new_instance = new_pyd.to_sql() new_instance.save() + self.instance = new_instance self.update_options() @@ -222,10 +244,13 @@ class EditRelationship(QWidget): self.data['id'] = self.data['id'].apply(str) self.data['id'] = self.data['id'].str.zfill(4) except KeyError as e: - logger.error(f"Could not alter id to string due to {e}") + logger.error(f"Could not alter id to string due to KeyError: {e}") proxy_model = QSortFilterProxyModel() proxy_model.setSourceModel(pandasModel(self.data)) self.table.setModel(proxy_model) + self.table.resizeColumnsToContents() + self.table.resizeRowsToContents() + self.table.setSortingEnabled(True) self.table.doubleClicked.connect(self.parse_row) def contextMenuEvent(self, event): diff --git a/src/submissions/frontend/widgets/omni_search.py b/src/submissions/frontend/widgets/omni_search.py index ebd0081..978c37f 100644 --- a/src/submissions/frontend/widgets/omni_search.py +++ b/src/submissions/frontend/widgets/omni_search.py @@ -2,9 +2,9 @@ Search box that performs fuzzy search for various object types """ from pprint import pformat -from typing import Tuple, Any, List +from typing import Tuple, Any, List, Generator from pandas import DataFrame -from PyQt6.QtCore import QSortFilterProxyModel +from PyQt6.QtCore import QSortFilterProxyModel, QModelIndex from PyQt6.QtWidgets import ( QLabel, QVBoxLayout, QDialog, QTableView, QWidget, QLineEdit, QGridLayout, QComboBox, QDialogButtonBox @@ -74,7 +74,6 @@ class SearchBox(QDialog): search_fields = [] for iii, searchable in enumerate(search_fields): widget = FieldSearch(parent=self, label=searchable['label'], field_name=searchable['field']) - # widget = FieldSearch(parent=self, label=k, field_name=v) widget.setObjectName(searchable['field']) self.layout.addWidget(widget, 1 + iii, 0) widget.search_widget.textChanged.connect(self.update_data) @@ -100,11 +99,17 @@ class SearchBox(QDialog): # NOTE: Setting results moved to here from __init__ 202411118 self.results.setData(df=data) - def return_selected_rows(self): - rows = sorted(set(index.row() for index in - self.results.selectedIndexes())) + def return_selected_rows(self) -> Generator[dict, None, None]: + """ + Yields data from selected rows + + Returns: + dict: Dictionary of column name: data + """ + rows = sorted(set(index.row() for index in self.results.selectedIndexes())) for index in rows: - output = {column:self.results.model().data(self.results.model().index(index, ii)) for ii, column in enumerate(self.results.data.columns)} + output = {column: self.results.model().data(self.results.model().index(index, ii)) for ii, column in + enumerate(self.results.data.columns)} yield output @@ -130,7 +135,13 @@ class FieldSearch(QWidget): """ self.parent().update_data() - def parse_form(self) -> Tuple: + def parse_form(self) -> Tuple[str, str]: + """ + Gets object name and widget value. + + Returns: + Tuple(str, str): Key, value to be used in constructing a dictionary. + """ field_value = self.search_widget.text() if field_value == "": field_value = None @@ -147,6 +158,7 @@ class SearchResults(QTableView): self.context = kwargs self.parent = parent self.object_type = object_type + try: self.extras = extras + self.object_type.searchables except AttributeError: @@ -160,7 +172,8 @@ class SearchResults(QTableView): self.data = df try: - self.columns_of_interest = [dict(name=item['field'], column=self.data.columns.get_loc(item['field'])) for item in self.extras] + self.columns_of_interest = [dict(name=item['field'], column=self.data.columns.get_loc(item['field'])) for + item in self.extras] except KeyError: self.columns_of_interest = [] try: @@ -171,9 +184,21 @@ class SearchResults(QTableView): proxy_model = QSortFilterProxyModel() proxy_model.setSourceModel(pandasModel(self.data)) self.setModel(proxy_model) + self.resizeColumnsToContents() + self.resizeRowsToContents() + self.setSortingEnabled(True) self.doubleClicked.connect(self.parse_row) - def parse_row(self, x): + def parse_row(self, x: QModelIndex) -> None: + """ + Runs the self.object_type edit from search method for row X. + + Args: + x (QModelIndex): Row to be parsed. + + Returns: + None + """ context = {item['name']: x.sibling(x.row(), item['column']).data() for item in self.columns_of_interest} try: object = self.object_type.query(**context) diff --git a/src/submissions/frontend/widgets/submission_details.py b/src/submissions/frontend/widgets/submission_details.py index 82e272e..d5291c3 100644 --- a/src/submissions/frontend/widgets/submission_details.py +++ b/src/submissions/frontend/widgets/submission_details.py @@ -8,9 +8,8 @@ from PyQt6.QtWebChannel import QWebChannel from PyQt6.QtCore import Qt, pyqtSlot from jinja2 import TemplateNotFound from backend.db.models import BasicSubmission, BasicSample, Reagent, KitType -from tools import is_power_user, jinja_template_loading, timezone -from .functions import select_save_file -from .misc import save_pdf +from tools import is_power_user, jinja_template_loading, timezone, get_application_from_parent +from .functions import select_save_file, save_pdf from pathlib import Path import logging from getpass import getuser @@ -30,13 +29,11 @@ class SubmissionDetails(QDialog): def __init__(self, parent, sub: BasicSubmission | BasicSample | Reagent) -> None: super().__init__(parent) - try: - self.app = parent.parent().parent().parent().parent().parent().parent() - except AttributeError: - self.app = None + self.app = get_application_from_parent(parent) self.webview = QWebEngineView(parent=self) self.webview.setMinimumSize(900, 500) self.webview.setMaximumWidth(900) + # NOTE: Decide if exporting should be allowed. self.webview.loadFinished.connect(self.activate_export) self.layout = QGridLayout() # NOTE: button to export a pdf version @@ -61,9 +58,16 @@ class SubmissionDetails(QDialog): self.sample_details(sample=sub) case Reagent(): self.reagent_details(reagent=sub) + # NOTE: Used to maintain javascript functions. self.webview.page().setWebChannel(self.channel) - def activate_export(self): + def activate_export(self) -> None: + """ + Determines if export pdf should be active. + + Returns: + None + """ title = self.webview.title() self.setWindowTitle(title) if "Submission" in title: @@ -103,6 +107,13 @@ class SubmissionDetails(QDialog): @pyqtSlot(str, str) def reagent_details(self, reagent: str | Reagent, kit: str | KitType): + """ + Changes details view to summary of Reagent + + Args: + kit (str | KitType): Name of kit. + reagent (str | Reagent): Lot number of the reagent + """ if isinstance(reagent, str): reagent = Reagent.query(lot=reagent) if isinstance(kit, str): @@ -124,6 +135,17 @@ class SubmissionDetails(QDialog): @pyqtSlot(str, str, str) def update_reagent(self, old_lot: str, new_lot: str, expiry: str): + """ + Designed to allow editing reagent in details view (depreciated) + + Args: + old_lot (): + new_lot (): + expiry (): + + Returns: + + """ expiry = datetime.strptime(expiry, "%Y-%m-%d") reagent = Reagent.query(lot=old_lot) if reagent: @@ -157,7 +179,16 @@ class SubmissionDetails(QDialog): self.webview.setHtml(self.html) @pyqtSlot(str) - def sign_off(self, submission: str | BasicSubmission): + def sign_off(self, submission: str | BasicSubmission) -> None: + """ + Allows power user to signify a submission is complete. + + Args: + submission (str | BasicSubmission): Submission to be completed + + Returns: + None + """ logger.info(f"Signing off on {submission} - ({getuser()})") if isinstance(submission, str): submission = BasicSubmission.query(rsl_plate_num=submission) @@ -183,10 +214,7 @@ class SubmissionComment(QDialog): def __init__(self, parent, submission: BasicSubmission) -> None: super().__init__(parent) - try: - self.app = parent.parent().parent().parent().parent().parent().parent - except AttributeError: - pass + self.app = get_application_from_parent(parent) self.submission = submission self.setWindowTitle(f"{self.submission.rsl_plate_num} Submission Comment") # NOTE: create text field diff --git a/src/submissions/frontend/widgets/submission_table.py b/src/submissions/frontend/widgets/submission_table.py index f98cfb8..9d7e463 100644 --- a/src/submissions/frontend/widgets/submission_table.py +++ b/src/submissions/frontend/widgets/submission_table.py @@ -65,12 +65,6 @@ class SubmissionsSheet(QTableView): """ def __init__(self, parent) -> None: - """ - initialize - - Args: - ctx (dict): settings passed from gui - """ super().__init__(parent) self.app = self.parent() self.report = Report() @@ -107,7 +101,9 @@ class SubmissionsSheet(QTableView): Args: event (_type_): the item of interest """ + # NOTE: Get current row index id = self.selectionModel().currentIndex() + # NOTE: Convert to data in id column (i.e. column 0) id = id.sibling(id.row(), 0).data() submission = BasicSubmission.query(id=id) self.menu = QMenu(self) diff --git a/src/submissions/frontend/widgets/submission_widget.py b/src/submissions/frontend/widgets/submission_widget.py index e3778a6..13a6a08 100644 --- a/src/submissions/frontend/widgets/submission_widget.py +++ b/src/submissions/frontend/widgets/submission_widget.py @@ -9,16 +9,15 @@ from PyQt6.QtCore import pyqtSignal, Qt, QSignalBlocker from .functions import select_open_file, select_save_file import logging from pathlib import Path -from tools import Report, Result, check_not_nan, main_form_style, report_result +from tools import Report, Result, check_not_nan, main_form_style, report_result, get_application_from_parent from backend.excel.parser import SheetParser from backend.validators import PydSubmission, PydReagent from backend.db import ( - KitType, Organization, SubmissionType, Reagent, + Organization, SubmissionType, Reagent, ReagentRole, KitTypeReagentRoleAssociation, BasicSubmission ) from pprint import pformat from .pop_ups import QuestionAsker, AlertPop -from .misc import AddReagentForm from .omni_add_edit import AddEdit from typing import List, Tuple from datetime import date @@ -67,7 +66,6 @@ class SubmissionFormContainer(QWidget): def __init__(self, parent: QWidget) -> None: super().__init__(parent) self.app = self.parent().parent() - self.report = Report() self.setStyleSheet('background-color: light grey;') self.setAcceptDrops(True) # NOTE: if import_drag is emitted, importSubmission will fire @@ -97,12 +95,12 @@ class SubmissionFormContainer(QWidget): """ self.app.raise_() self.app.activateWindow() - self.report = Report() + report = Report() self.import_submission_function(fname) - return self.report + return report @report_result - def import_submission_function(self, fname: Path | None = None): + def import_submission_function(self, fname: Path | None = None) -> Report: """ Import a new submission to the app window @@ -110,10 +108,11 @@ class SubmissionFormContainer(QWidget): obj (QMainWindow): original app window Returns: - Tuple[QMainWindow, dict|None]: Collection of new main app window and result dict + Report: Object to give results of import. """ logger.info(f"\n\nStarting Import...\n\n") report = Report() + # NOTE: Clear any previous forms. try: self.form.setParent(None) except AttributeError: @@ -141,7 +140,16 @@ class SubmissionFormContainer(QWidget): return report @report_result - def new_add_reagent(self, instance: Reagent | None = None): + def add_reagent(self, instance: Reagent | None = None): + """ + Action to create new reagent in DB. + + Args: + instance (Reagent | None): Blank reagent instance to be edited and then added. + + Returns: + models.Reagent: the constructed reagent object to add to submission + """ report = Report() if not instance: instance = Reagent() @@ -149,48 +157,12 @@ class SubmissionFormContainer(QWidget): if dlg.exec(): reagent = dlg.parse_form() reagent.missing = False - # logger.debug(f"Reagent: {reagent}, result: {result}") - # report.add_result(result) # NOTE: send reagent to db sqlobj = reagent.to_sql() sqlobj.save() - logger.debug(f"Reagent added!") report.add_result(Result(owner=__name__, code=0, msg="New reagent created.", status="Information")) - # report.add_result(result) return reagent, report - @report_result - def add_reagent(self, reagent_lot: str | None = None, reagent_role: str | None = None, expiry: date | None = None, - name: str | None = None, kit: str | KitType | None = None) -> Tuple[PydReagent, Report]: - """ - Action to create new reagent in DB. - - Args: - reagent_lot (str | None, optional): Parsed reagent from import form. Defaults to None. - reagent_role (str | None, optional): Parsed reagent type from import form. Defaults to None. - expiry (date | None, optional): Parsed reagent expiry data. Defaults to None. - name (str | None, optional): Parsed reagent name. Defaults to None. - - Returns: - models.Reagent: the constructed reagent object to add to submission - """ - report = Report() - if isinstance(reagent_lot, bool): - reagent_lot = "" - # NOTE: create form - dlg = AddReagentForm(reagent_lot=reagent_lot, reagent_role=reagent_role, expiry=expiry, reagent_name=name, - kit=kit) - if dlg.exec(): - # NOTE: extract form info - info = dlg.parse_form() - # NOTE: create reagent object - reagent = PydReagent(ctx=self.app.ctx, **info, missing=False) - # NOTE: send reagent to db - sqlobj = reagent.to_sql() - sqlobj.save() - # report.add_result(result) - return reagent - class SubmissionFormWidget(QWidget): update_reagent_fields = ['extraction_kit'] @@ -199,12 +171,12 @@ class SubmissionFormWidget(QWidget): super().__init__(parent) if disable is None: disable = [] - self.app = parent.app + self.app = get_application_from_parent(parent) self.pyd = submission self.missing_info = [] self.submission_type = SubmissionType.query(name=self.pyd.submission_type['value']) - st = self.submission_type.submission_class - defaults = st.get_default_info("form_recover", "form_ignore", submission_type=self.pyd.submission_type['value']) + basic_submission_class = self.submission_type.submission_class + defaults = basic_submission_class.get_default_info("form_recover", "form_ignore", submission_type=self.pyd.submission_type['value']) self.recover = defaults['form_recover'] self.ignore = defaults['form_ignore'] self.layout = QVBoxLayout() @@ -225,7 +197,7 @@ class SubmissionFormWidget(QWidget): except KeyError: value = dict(value=None, missing=True) add_widget = self.create_widget(key=k, value=value, submission_type=self.submission_type, - sub_obj=st, disable=check) + sub_obj=basic_submission_class, disable=check) if add_widget is not None: self.layout.addWidget(add_widget) if k in self.__class__.update_reagent_fields: @@ -302,10 +274,9 @@ class SubmissionFormWidget(QWidget): if isinstance(reagent, self.ReagentFormWidget) or isinstance(reagent, QPushButton): reagent.setParent(None) reagents, integrity_report, missing_reagents = self.pyd.check_kit_integrity(extraction_kit=self.extraction_kit) - logger.debug(f"Reagents: {reagents}") + # logger.debug(f"Reagents: {reagents}") expiry_report = self.pyd.check_reagent_expiries(exempt=missing_reagents) for reagent in reagents: - add_widget = self.ReagentFormWidget(parent=self, reagent=reagent, extraction_kit=self.extraction_kit) self.layout.addWidget(add_widget) report.add_result(integrity_report) @@ -340,9 +311,12 @@ class SubmissionFormWidget(QWidget): Returns: List[QWidget]: Widgets matching filter """ - query = self.findChildren(QWidget) - if object_name is not None: - query = [widget for widget in query if widget.objectName() == object_name] + if object_name: + query = self.findChildren(QWidget, name=object_name) + else: + query = self.findChildren(QWidget) + # if object_name is not None: + # query = [widget for widget in query if widget.objectName() == object_name] return query @report_result @@ -581,13 +555,13 @@ class SubmissionFormWidget(QWidget): parent.extraction_kit = add_widget.currentText() case 'submission_category': add_widget = MyQComboBox(scrollWidget=parent) - cats = ['Diagnostic', "Surveillance", "Research"] - cats += [item.name for item in SubmissionType.query()] + categories = ['Diagnostic', "Surveillance", "Research"] + categories += [item.name for item in SubmissionType.query()] try: - cats.insert(0, cats.pop(cats.index(value))) + categories.insert(0, categories.pop(categories.index(value))) except ValueError: - cats.insert(0, cats.pop(cats.index(submission_type))) - add_widget.addItems(cats) + categories.insert(0, categories.pop(categories.index(submission_type))) + add_widget.addItems(categories) add_widget.setToolTip("Enter submission category or select from list.") case _: if key in sub_obj.timestamps: @@ -655,7 +629,8 @@ class SubmissionFormWidget(QWidget): def __init__(self, parent: QWidget, reagent: PydReagent, extraction_kit: str): super().__init__(parent) - self.app = self.parent().parent().parent().parent().parent().parent().parent().parent() + self.parent = parent + self.app = get_application_from_parent(parent) self.reagent = reagent self.extraction_kit = extraction_kit layout = QGridLayout() @@ -684,10 +659,11 @@ class SubmissionFormWidget(QWidget): def disable(self): self.lot.setEnabled(self.check.isChecked()) self.label.setEnabled(self.check.isChecked()) - if not any([item.lot.isEnabled() for item in self.parent().findChildren(self.__class__)]): - self.parent().disabler.checkbox.setChecked(False) - else: - self.parent().disabler.checkbox.setChecked(True) + with QSignalBlocker(self.parent.disabler.checkbox) as blocker: + if any([item.lot.isEnabled() for item in self.parent.findChildren(self.__class__)]): + self.parent.disabler.checkbox.setChecked(True) + else: + self.parent.disabler.checkbox.setChecked(False) @report_result def parse_form(self) -> Tuple[PydReagent | None, Report]: @@ -703,31 +679,23 @@ class SubmissionFormWidget(QWidget): lot = self.lot.currentText() wanted_reagent, new = Reagent.query_or_create(lot=lot, role=self.reagent.role, expiry=self.reagent.expiry) # NOTE: if reagent doesn't exist in database, offer to add it (uses App.add_reagent) - logger.debug(f"Wanted reagent: {wanted_reagent}, New: {new}") - # if wanted_reagent is None: if new: dlg = QuestionAsker(title=f"Add {lot}?", message=f"Couldn't find reagent type {self.reagent.role}: {lot} in the database.\n\nWould you like to add it?") if dlg.exec(): wanted_reagent = self.parent().parent().new_add_reagent(instance=wanted_reagent) - logger.debug(f"Reagent added!") - report.add_result(Result(owner=__name__, code=0, msg="New reagent created.", status="Information")) return wanted_reagent, report else: # NOTE: In this case we will have an empty reagent and the submission will fail kit integrity check - return None, report else: - # NOTE: Since this now gets passed in directly from the parser -> pyd -> form and the parser gets the name - # from the db, it should no longer be necessary to query the db with reagent/kit, but with rt name directly. + # NOTE: Since this now gets passed in directly from the parser -> pyd -> form and the parser gets the name from the db, it should no longer be necessary to query the db with reagent/kit, but with rt name directly. rt = ReagentRole.query(name=self.reagent.role) - logger.debug(f"Reagent role: {rt}") if rt is None: rt = ReagentRole.query(kit_type=self.extraction_kit, reagent=wanted_reagent) final = PydReagent(name=wanted_reagent.name, lot=wanted_reagent.lot, role=rt.name, expiry=wanted_reagent.expiry.date(), missing=False) - logger.debug(f"Final Reagent: {final}") return final, report def updated(self): @@ -781,10 +749,11 @@ class SubmissionFormWidget(QWidget): looked_up_reg = None if looked_up_reg: try: - relevant_reagents.remove(str(looked_up_reg.lot)) + # relevant_reagents.remove(str(looked_up_reg.lot)) + relevant_reagents.insert(0, relevant_reagents.pop(relevant_reagents.index(looked_up_reg.lot))) except ValueError as e: logger.error(f"Error reordering relevant reagents: {e}") - relevant_reagents.insert(0, str(looked_up_reg.lot)) + # relevant_reagents.insert(0, str(looked_up_reg.lot)) else: if len(relevant_reagents) > 1: idx = relevant_reagents.index(str(reagent.lot)) diff --git a/src/submissions/frontend/widgets/summary.py b/src/submissions/frontend/widgets/summary.py index 27e25a1..4e44f67 100644 --- a/src/submissions/frontend/widgets/summary.py +++ b/src/submissions/frontend/widgets/summary.py @@ -32,7 +32,13 @@ class Summary(InfoPane): self.update_data() - def update_data(self): + def update_data(self) -> None: + """ + Sets data in the info pane + + Returns: + None + """ super().update_data() orgs = [self.org_select.itemText(i) for i in range(self.org_select.count()) if self.org_select.itemChecked(i)] self.report_obj = ReportMaker(start_date=self.start_date, end_date=self.end_date, organizations=orgs) diff --git a/src/submissions/frontend/widgets/turnaround.py b/src/submissions/frontend/widgets/turnaround.py index 19c6a99..fd4b1fe 100644 --- a/src/submissions/frontend/widgets/turnaround.py +++ b/src/submissions/frontend/widgets/turnaround.py @@ -31,7 +31,13 @@ class TurnaroundTime(InfoPane): self.submission_typer.currentTextChanged.connect(self.update_data) self.update_data() - def update_data(self): + def update_data(self) -> None: + """ + Sets data in the info pane + + Returns: + None + """ super().update_data() months = self.diff_month(self.start_date, self.end_date) chart_settings = dict(start_date=self.start_date, end_date=self.end_date) @@ -47,4 +53,4 @@ class TurnaroundTime(InfoPane): else: threshold = None self.fig = TurnaroundChart(df=self.report_obj.df, settings=chart_settings, modes=[], threshold=threshold, months=months) - self.webview.setHtml(self.fig.to_html()) + self.webview.setHtml(self.fig.html) diff --git a/src/submissions/templates/reagent_details.html b/src/submissions/templates/reagent_details.html index 98de044..95ddaa4 100644 --- a/src/submissions/templates/reagent_details.html +++ b/src/submissions/templates/reagent_details.html @@ -10,11 +10,12 @@

Reagent Details for {{ reagent['name'] }} - {{ reagent['lot'] }}

{{ super() }}

{% for key, value in reagent.items() if key not in reagent['excluded'] %} -     {{ key | replace("_", " ") | title }}: {% if permission and key in reagent['editable']%}{% else %}{{ value }}{% endif %}
+ +     {{ key | replace("_", " ") | title }}: {{ value }}
{% endfor %}

- {% if permission %} - - {% endif %} + + + {% if reagent['submissions'] %}

Submissions:

{% for submission in reagent['submissions'] %}

{{ submission }}: {{ reagent['role'] }}

diff --git a/src/submissions/tools/__init__.py b/src/submissions/tools/__init__.py index d80f472..a8b0903 100644 --- a/src/submissions/tools/__init__.py +++ b/src/submissions/tools/__init__.py @@ -10,6 +10,8 @@ from json import JSONDecodeError import logging, re, yaml, sys, os, stat, platform, getpass, json, numpy as np, pandas as pd from threading import Thread from inspect import getmembers, isfunction, stack +from types import GeneratorType + from dateutil.easter import easter from jinja2 import Environment, FileSystemLoader from logging import handlers @@ -18,7 +20,7 @@ from sqlalchemy.orm import Session from sqlalchemy import create_engine, text, MetaData from pydantic import field_validator, BaseModel, Field from pydantic_settings import BaseSettings, SettingsConfigDict -from typing import Any, Tuple, Literal, List +from typing import Any, Tuple, Literal, List, Generator from __init__ import project_path from configparser import ConfigParser from tkinter import Tk # NOTE: This is for choosing database path before app is created. @@ -38,7 +40,7 @@ if platform.system() == "Windows": logger.info(f"Got platform Windows, config_dir: {os_config_dir}") else: os_config_dir = ".config" - logger.info(f"Got platform other, config_dir: {os_config_dir}") + logger.info(f"Got platform {platform.system()}, config_dir: {os_config_dir}") main_aux_dir = Path.home().joinpath(f"{os_config_dir}/submissions") @@ -58,7 +60,7 @@ main_form_style = ''' page_size = 250 -def divide_chunks(input_list: list, chunk_count: int): +def divide_chunks(input_list: list, chunk_count: int) -> Generator[Any, Any, None]: """ Divides a list into {chunk_count} equal parts @@ -179,7 +181,7 @@ def check_not_nan(cell_contents) -> bool: return False -def convert_nans_to_nones(input_str) -> str | None: +def convert_nans_to_nones(input_str:str) -> str | None: """ Get rid of various "nan", "NAN", "NaN", etc/ @@ -289,12 +291,10 @@ class Settings(BaseSettings, extra="allow"): @classmethod def set_schema(cls, value): if value is None: - # print("No value for dir path") if check_if_app(): alembic_path = Path(sys._MEIPASS).joinpath("files", "alembic.ini") else: alembic_path = project_path.joinpath("alembic.ini") - # print(f"Getting alembic path: {alembic_path}") value = cls.get_alembic_db_path(alembic_path=alembic_path, mode='schema') if value is None: value = "sqlite" @@ -321,14 +321,11 @@ class Settings(BaseSettings, extra="allow"): if value is None: match values.data['database_schema']: case "sqlite": - # print("No value for dir path") if check_if_app(): alembic_path = Path(sys._MEIPASS).joinpath("files", "alembic.ini") else: alembic_path = project_path.joinpath("alembic.ini") - # print(f"Getting alembic path: {alembic_path}") value = cls.get_alembic_db_path(alembic_path=alembic_path, mode='path').parent - # print(f"Using {value}") case _: Tk().withdraw() # we don't want a full GUI, so keep the root window from appearing value = Path(askdirectory( @@ -340,9 +337,7 @@ class Settings(BaseSettings, extra="allow"): except AttributeError: check = False if not check: - # print(f"No directory found, using Documents/submissions") value.mkdir(exist_ok=True) - # print(f"Final return of directory_path: {value}") return value @field_validator('database_path', mode="before") @@ -360,7 +355,6 @@ class Settings(BaseSettings, extra="allow"): alembic_path = Path(sys._MEIPASS).joinpath("files", "alembic.ini") else: alembic_path = project_path.joinpath("alembic.ini") - # print(f"Getting alembic path: {alembic_path}") value = cls.get_alembic_db_path(alembic_path=alembic_path, mode='path').parent return value @@ -372,7 +366,6 @@ class Settings(BaseSettings, extra="allow"): alembic_path = Path(sys._MEIPASS).joinpath("files", "alembic.ini") else: alembic_path = project_path.joinpath("alembic.ini") - # print(f"Getting alembic path: {alembic_path}") value = cls.get_alembic_db_path(alembic_path=alembic_path, mode='path').stem return value @@ -384,9 +377,7 @@ class Settings(BaseSettings, extra="allow"): alembic_path = Path(sys._MEIPASS).joinpath("files", "alembic.ini") else: alembic_path = project_path.joinpath("alembic.ini") - # print(f"Getting alembic path: {alembic_path}") value = cls.get_alembic_db_path(alembic_path=alembic_path, mode='user') - # print(f"Got {value} for user") return value @field_validator("database_password", mode='before') @@ -397,9 +388,7 @@ class Settings(BaseSettings, extra="allow"): alembic_path = Path(sys._MEIPASS).joinpath("files", "alembic.ini") else: alembic_path = project_path.joinpath("alembic.ini") - # print(f"Getting alembic path: {alembic_path}") value = cls.get_alembic_db_path(alembic_path=alembic_path, mode='pass') - # print(f"Got {value} for pass") return value @field_validator('database_session', mode="before") @@ -421,7 +410,6 @@ class Settings(BaseSettings, extra="allow"): "{{ values['database_schema'] }}://{{ value }}/{{ db_name }}?driver=ODBC+Driver+18+for+SQL+Server&TrustServerCertificate=yes&Trusted_Connection=yes" ) case _: - # print(pprint.pprint(values.data)) tmp = jinja_template_loading().from_string( "{% if values['database_user'] %}{{ values['database_user'] }}{% if values['database_password'] %}:{{ values['database_password'] }}{% endif %}{% endif %}@{{ values['database_path'] }}") value = tmp.render(values=values.data) @@ -444,7 +432,6 @@ class Settings(BaseSettings, extra="allow"): super().__init__(*args, **kwargs) self.set_from_db() self.set_scripts() - # pprint(f"User settings:\n{self.__dict__}") def set_from_db(self): if 'pytest' in sys.modules: @@ -453,11 +440,8 @@ class Settings(BaseSettings, extra="allow"): teardown_scripts=dict(goodbye=None) ) else: - # print(f"Hello from database settings getter.") - # print(self.__dict__) session = self.database_session metadata = MetaData() - # print(self.database_session.get_bind()) try: metadata.reflect(bind=session.get_bind()) except AttributeError as e: @@ -467,7 +451,6 @@ class Settings(BaseSettings, extra="allow"): print(f"Couldn't find _configitems in {metadata.tables.keys()}.") return config_items = session.execute(text("SELECT * FROM _configitem")).all() - # print(f"Config: {pprint.pprint(config_items)}") output = {} for item in config_items: try: @@ -488,6 +471,7 @@ class Settings(BaseSettings, extra="allow"): p = Path(__file__).parents[2].joinpath("scripts").absolute() if p.__str__() not in sys.path: sys.path.append(p.__str__()) + # NOTE: Get all .py files that don't have __ in them. modules = p.glob("[!__]*.py") for module in modules: mod = importlib.import_module(module.stem) @@ -495,6 +479,7 @@ class Settings(BaseSettings, extra="allow"): name = function[0] func = function[1] # NOTE: assign function based on its name being in config: startup/teardown + # NOTE: scripts must be registered using {name: Null} in the database if name in self.startup_scripts.keys(): self.startup_scripts[name] = func if name in self.teardown_scripts.keys(): @@ -543,14 +528,12 @@ class Settings(BaseSettings, extra="allow"): try: return url[:url.index("@")].split(":")[0] except (IndexError, ValueError) as e: - # print(f"Error on user: {e}") return None case "pass": url = re.sub(r"^.*//", "", url) try: return url[:url.index("@")].split(":")[1] except (IndexError, ValueError) as e: - # print(f"Error on user: {e}") return None def save(self, settings_path: Path): @@ -592,7 +575,6 @@ def get_config(settings_path: Path | str | None = None) -> Settings: def join(loader, node): seq = loader.construct_sequence(node) return ''.join([str(i) for i in seq]) - # NOTE: register the tag handler yaml.add_constructor('!join', join) # NOTE: make directories @@ -624,7 +606,6 @@ def get_config(settings_path: Path | str | None = None) -> Settings: # NOTE: copy settings to config directory settings = Settings(**default_settings) settings.save(settings_path=CONFIGDIR.joinpath("config.yml")) - # print(f"Default settings: {pprint.pprint(settings.__dict__)}") return settings else: # NOTE: check if user defined path is directory @@ -829,10 +810,23 @@ def setup_lookup(func): elif v is not None: sanitized_kwargs[k] = v return func(*args, **sanitized_kwargs) - return wrapper +def get_application_from_parent(widget): + try: + return widget.app + except AttributeError: + logger.info("Using recursion to get application object.") + from frontend.widgets.app import App + while not isinstance(widget, App): + try: + widget = widget.parent() + except AttributeError: + return widget + return widget + + class Result(BaseModel, arbitrary_types_allowed=True): owner: str = Field(default="", validate_default=True) code: int = Field(default=0) @@ -937,20 +931,20 @@ def rreplace(s: str, old: str, new: str) -> str: return (s[::-1].replace(old[::-1], new[::-1], 1))[::-1] -def remove_key_from_list_of_dicts(input: list, key: str) -> list: +def remove_key_from_list_of_dicts(input_list: list, key: str) -> list: """ Removes a key from all dictionaries in a list of dictionaries Args: - input (list): Input list of dicts + input_list (list): Input list of dicts key (str): Name of key to remove. Returns: list: List of updated dictionaries """ - for item in input: + for item in input_list: del item[key] - return input + return input_list def yaml_regex_creator(loader, node): @@ -963,6 +957,7 @@ def yaml_regex_creator(loader, node): def super_splitter(ins_str: str, substring: str, idx: int) -> str: """ + Splits string on substring at index Args: ins_str (str): input string @@ -978,6 +973,20 @@ def super_splitter(ins_str: str, substring: str, idx: int) -> str: return ins_str +def is_developer() -> bool: + """ + Checks if user is in list of super users + + Returns: + bool: True if yes, False if no. + """ + try: + check = getpass.getuser() in ctx.super_users + except: + check = False + return check + + def is_power_user() -> bool: """ Checks if user is in list of power users @@ -1000,21 +1009,49 @@ def check_authorization(func): func (function): Function to be used. """ + @wraps(func) + @report_result def wrapper(*args, **kwargs): logger.info(f"Checking authorization") - if is_power_user(): + error_msg = f"User {getpass.getuser()} is not authorized for this function." + auth_func = is_power_user + if auth_func(): return func(*args, **kwargs) else: - logger.error(f"User {getpass.getuser()} is not authorized for this function.") + logger.error(error_msg) report = Report() report.add_result( - Result(owner=func.__str__(), code=1, msg="This user does not have permission for this function.", - status="warning")) + Result(owner=func.__str__(), code=1, msg=error_msg, status="warning")) return report return wrapper +def under_development(func): + """ + Decorator to check if user is authorized to access function + + Args: + func (function): Function to be used. + """ + + @wraps(func) + @report_result + def wrapper(*args, **kwargs): + logger.warning(f"This feature is under development") + if is_developer(): + return func(*args, **kwargs) + else: + error_msg = f"User {getpass.getuser()} is not authorized for this function." + logger.error(error_msg) + report = Report() + report.add_result( + Result(owner=func.__str__(), code=1, msg=error_msg, + status="warning")) + return report + return wrapper + + def report_result(func): """ Decorator to display any reports returned from a function. @@ -1036,14 +1073,9 @@ def report_result(func): case Report(): report = output case tuple(): - # try: report = next((item for item in output if isinstance(item, Report)), None) - # except IndexError: - # report = None case _: report = Report() - # return report - # logger.info(f"Got report: {report}") try: results = report.results except AttributeError: @@ -1058,13 +1090,11 @@ def report_result(func): logger.error(result.msg) if output: true_output = tuple(item for item in output if not isinstance(item, Report)) - # logger.debug(f"True output: {true_output}") if len(true_output) == 1: true_output = true_output[0] else: true_output = None return true_output - return wrapper @@ -1084,20 +1114,19 @@ def create_holidays_for_year(year: int | None = None) -> List[date]: offset = -d.weekday() # weekday == 0 means Monday output = d + timedelta(offset) return output.date() - if not year: year = date.today().year - # Includes New Year's day for next year. + # NOTE: Includes New Year's day for next year. holidays = [date(year, 1, 1), date(year, 7, 1), date(year, 9, 30), date(year, 11, 11), date(year, 12, 25), date(year, 12, 26), date(year + 1, 1, 1)] - # Labour Day + # NOTE: Labour Day holidays.append(find_nth_monday(year, 9)) - # Thanksgiving + # NOTE: Thanksgiving holidays.append(find_nth_monday(year, 10, occurence=2)) - # Victoria Day + # NOTE: Victoria Day holidays.append(find_nth_monday(year, 5, day=25)) - # Easter, etc + # NOTE: Easter, etc holidays.append(easter(year) - timedelta(days=2)) holidays.append(easter(year) + timedelta(days=1)) return sorted(holidays) @@ -1107,8 +1136,7 @@ class classproperty(property): def __get__(self, owner_self, owner_cls): return self.fget(owner_cls) - +# NOTE: Monkey patching... hooray! builtins.classproperty = classproperty - ctx = get_config(None)