From b55258f677818aa7e92331a04c8b7249550f2e78 Mon Sep 17 00:00:00 2001 From: lwark Date: Fri, 3 Jan 2025 12:37:03 -0600 Subject: [PATCH] Created omni-manager, omni-addit --- CHANGELOG.md | 4 + .../tools => }/scripts/backup_database.py | 2 - src/scripts/goodbye.py | 22 ++ src/scripts/hello.py | 22 ++ .../tools => }/scripts/import_irida.py | 9 - src/submissions/__main__.py | 5 +- src/submissions/backend/db/models/__init__.py | 15 +- .../backend/db/models/organizations.py | 2 + .../backend/db/models/submissions.py | 33 ++- src/submissions/backend/excel/writer.py | 4 +- src/submissions/backend/validators/pydant.py | 32 ++- src/submissions/frontend/widgets/app.py | 17 +- .../frontend/widgets/omni_add_edit.py | 88 +++++++ .../frontend/widgets/omni_manager.py | 227 ++++++++++++++++++ .../frontend/widgets/omni_search.py | 21 +- src/submissions/tools/__init__.py | 58 +++-- src/submissions/tools/scripts/goodbye.py | 9 - src/submissions/tools/scripts/hello.py | 8 - submissions.spec | 1 + 19 files changed, 502 insertions(+), 77 deletions(-) rename src/{submissions/tools => }/scripts/backup_database.py (97%) create mode 100644 src/scripts/goodbye.py create mode 100644 src/scripts/hello.py rename src/{submissions/tools => }/scripts/import_irida.py (86%) create mode 100644 src/submissions/frontend/widgets/omni_add_edit.py create mode 100644 src/submissions/frontend/widgets/omni_manager.py delete mode 100644 src/submissions/tools/scripts/goodbye.py delete mode 100644 src/submissions/tools/scripts/hello.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 98eafad..530b6d2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,7 @@ +# 202412.06 + +- Switched startup/teardown scripts to importlib/getattr addition to ctx. + # 202412.05 - Switched startup/teardown scripts to decorator registration. diff --git a/src/submissions/tools/scripts/backup_database.py b/src/scripts/backup_database.py similarity index 97% rename from src/submissions/tools/scripts/backup_database.py rename to src/scripts/backup_database.py index daa3cd5..7ba17d5 100644 --- a/src/submissions/tools/scripts/backup_database.py +++ b/src/scripts/backup_database.py @@ -5,11 +5,9 @@ import logging, shutil, pyodbc from datetime import date from pathlib import Path from tools import Settings -# from .. import register_script logger = logging.getLogger(f"submissions.{__name__}") -# @register_script def backup_database(ctx: Settings): """ Copies the database into the backup directory the first time it is opened every month. diff --git a/src/scripts/goodbye.py b/src/scripts/goodbye.py new file mode 100644 index 0000000..dbc80c3 --- /dev/null +++ b/src/scripts/goodbye.py @@ -0,0 +1,22 @@ +""" +Test script for teardown_scripts +""" + + +def goodbye(ctx): + """ + Args: + ctx (Settings): All scripts must take ctx as an argument to maintain interoperability. + + Returns: + None: Scripts are currently unable to return results to the program. + """ + print("\n\nGoodbye. Thank you for using Robotics Submission Tracker.\n\n") + + +""" +For scripts to be run, they must be added to the _configitem.startup_scripts or _configitem.teardown_scripts +rows as a key: value (name: null) entry in the JSON. +ex: {"goodbye": null, "backup_database": null} +The program will overwrite null with the actual function upon startup. +""" diff --git a/src/scripts/hello.py b/src/scripts/hello.py new file mode 100644 index 0000000..efb7cb7 --- /dev/null +++ b/src/scripts/hello.py @@ -0,0 +1,22 @@ +""" +Test script for startup_scripts +""" + + +def hello(ctx) -> None: + """ + Args: + ctx (Settings): All scripts must take ctx as an argument to maintain interoperability. + + Returns: + None: Scripts are currently unable to return results to the program. + """ + print("\n\nHello! Welcome to Robotics Submission Tracker.\n\n") + + +""" +For scripts to be run, they must be added to the _configitem.startup_scripts or _configitem.teardown_scripts +rows as a key: value (name: null) entry in the JSON. +ex: {"hello": null, "import_irida": null} +The program will overwrite null with the actual function upon startup. +""" diff --git a/src/submissions/tools/scripts/import_irida.py b/src/scripts/import_irida.py similarity index 86% rename from src/submissions/tools/scripts/import_irida.py rename to src/scripts/import_irida.py index f3e8349..3ea84dd 100644 --- a/src/submissions/tools/scripts/import_irida.py +++ b/src/scripts/import_irida.py @@ -4,11 +4,9 @@ from datetime import datetime from tools import Settings from sqlalchemy.orm import Session -# from .. import register_script logger = logging.getLogger(f"submissions.{__name__}") -# @register_script def import_irida(ctx: Settings): """ Grabs Irida controls from secondary database. @@ -38,7 +36,6 @@ def import_irida(ctx: Settings): subtype=row[6], refseq_version=row[7], kraken2_version=row[8], kraken2_db_version=row[9], sample_id=row[10]) for row in cursor] for record in records: - # instance = IridaControl.query(name=record['name']) instance = new_session.query(IridaControl).filter(IridaControl.name == record['name']).first() if instance: logger.warning(f"Irida Control {instance.name} already exists, skipping.") @@ -49,19 +46,13 @@ def import_irida(ctx: Settings): assert isinstance(record[thing], dict) else: record[thing] = {} - # record['matches'] = json.loads(record['matches']) - # assert isinstance(record['matches'], dict) - # record['kraken'] = json.loads(record['kraken']) - # assert isinstance(record['kraken'], dict) record['submitted_date'] = datetime.strptime(record['submitted_date'], "%Y-%m-%d %H:%M:%S.%f") assert isinstance(record['submitted_date'], datetime) instance = IridaControl(controltype=ct, **record) - # sample = BasicSample.query(submitter_id=instance.name) sample = new_session.query(BasicSample).filter(BasicSample.submitter_id == instance.name).first() if sample: instance.sample = sample instance.submission = sample.submissions[0] - # instance.save() new_session.add(instance) new_session.commit() new_session.close() diff --git a/src/submissions/__main__.py b/src/submissions/__main__.py index 9f7fd7c..d8e2a82 100644 --- a/src/submissions/__main__.py +++ b/src/submissions/__main__.py @@ -1,14 +1,13 @@ import sys, os from tools import ctx, setup_logger, check_if_app -# environment variable must be set to enable qtwebengine in network path +# NOTE: environment variable must be set to enable qtwebengine in network path if check_if_app(): os.environ['QTWEBENGINE_DISABLE_SANDBOX'] = "1" -# setup custom logger +# NOTE: setup custom logger logger = setup_logger(verbosity=3) -# from backend import scripts from PyQt6.QtWidgets import QApplication from frontend.widgets.app import App diff --git a/src/submissions/backend/db/models/__init__.py b/src/submissions/backend/db/models/__init__.py index e1a284c..fd3b0be 100644 --- a/src/submissions/backend/db/models/__init__.py +++ b/src/submissions/backend/db/models/__init__.py @@ -12,6 +12,7 @@ from typing import Any, List from pathlib import Path from tools import report_result + # NOTE: Load testing environment if 'pytest' in sys.modules: sys.path.append(Path(__file__).parents[4].absolute().joinpath("tests").__str__()) @@ -167,7 +168,10 @@ class BaseClass(Base): Returns: Dataframe """ - records = [obj.to_sub_dict(**kwargs) for obj in objects] + try: + records = [obj.to_sub_dict(**kwargs) for obj in objects] + except AttributeError: + records = [obj.to_dict() for obj in objects] return DataFrame.from_records(records) @classmethod @@ -233,6 +237,15 @@ class BaseClass(Base): report.add_result(Result(msg=e, status="Critical")) return report + def to_dict(self): + return {k: v for k, v in self.__dict__.items() if k not in ["_sa_instance_state", "id"]} + + @classmethod + def get_pydantic_model(cls): + from backend.validators import pydant + model = getattr(pydant, f"Pyd{cls.__name__}") + return model + class ConfigItem(BaseClass): """ diff --git a/src/submissions/backend/db/models/organizations.py b/src/submissions/backend/db/models/organizations.py index 4f3f191..5424c11 100644 --- a/src/submissions/backend/db/models/organizations.py +++ b/src/submissions/backend/db/models/organizations.py @@ -124,6 +124,8 @@ class Contact(BaseClass): Base of Contact """ + searchables =[] + id = Column(INTEGER, primary_key=True) #: primary key name = Column(String(64)) #: contact name email = Column(String(64)) #: contact email diff --git a/src/submissions/backend/db/models/submissions.py b/src/submissions/backend/db/models/submissions.py index 2538797..50e8001 100644 --- a/src/submissions/backend/db/models/submissions.py +++ b/src/submissions/backend/db/models/submissions.py @@ -7,6 +7,7 @@ from collections import OrderedDict from copy import deepcopy from getpass import getuser import logging, uuid, tempfile, re, base64, numpy as np, pandas as pd, types, sys +from inspect import isclass from zipfile import ZipFile, BadZipfile from tempfile import TemporaryDirectory, TemporaryFile from operator import itemgetter @@ -175,7 +176,7 @@ class BasicSubmission(BaseClass, LogMixin): # NOTE: Fields not placed in ui form form_ignore=['reagents', 'ctx', 'id', 'cost', 'extraction_info', 'signed_by', 'comment', 'namer', 'submission_object', "tips", 'contact_phone', 'custom', 'cost_centre', 'completed_date', - 'controls'] + recover, + 'controls', "origin_plate"] + recover, # NOTE: Fields not placed in ui form to be moved to pydantic form_recover=recover )) @@ -352,7 +353,10 @@ class BasicSubmission(BaseClass, LogMixin): try: contact = self.contact.name except AttributeError as e: - contact = "NA" + try: + contact = f"Defaulted to: {self.submitting_lab.contacts[0].name}" + except (AttributeError, IndexError): + contact = "NA" try: contact_phone = self.contact.phone except AttributeError: @@ -627,8 +631,14 @@ class BasicSubmission(BaseClass, LogMixin): continue case "tips": field_value = [item.to_pydantic() for item in self.submission_tips_associations] - case "submission_type" | "contact": + case "submission_type": field_value = dict(value=self.__getattribute__(key).name, missing=missing) + # case "contact": + # try: + # field_value = dict(value=self.__getattribute__(key).name, missing=missing) + # except AttributeError: + # contact = self.submitting_lab.contacts[0] + # field_value = dict(value=contact.name, missing=True) case "plate_number": key = 'rsl_plate_num' field_value = dict(value=self.rsl_plate_num, missing=missing) @@ -640,10 +650,13 @@ class BasicSubmission(BaseClass, LogMixin): case _: try: key = key.lower().replace(" ", "_") - field_value = dict(value=self.__getattribute__(key), missing=missing) + if isclass(value): + field_value = dict(value=self.__getattribute__(key).name, missing=missing) + else: + field_value = dict(value=self.__getattribute__(key), missing=missing) except AttributeError: logger.error(f"{key} is not available in {self}") - continue + field_value = dict(value="NA", missing=True) new_dict[key] = field_value new_dict['filepath'] = Path(tempfile.TemporaryFile().name) dicto.update(new_dict) @@ -1505,6 +1518,7 @@ class Wastewater(BasicSubmission): # NOTE: Due to having to run through samples in for loop we need to convert to list. output = [] for sample in samples: + logger.debug(sample) # NOTE: remove '-{target}' from controls sample['sample'] = re.sub('-N\\d*$', '', sample['sample']) # NOTE: if sample is already in output skip @@ -1512,14 +1526,16 @@ class Wastewater(BasicSubmission): logger.warning(f"Already have {sample['sample']}") continue # NOTE: Set ct values + logger.debug(f"Sample ct: {sample['ct']}") sample[f"ct_{sample['target'].lower()}"] = sample['ct'] if isinstance(sample['ct'], float) else 0.0 # NOTE: Set assessment - sample[f"{sample['target'].lower()}_status"] = sample['assessment'] + logger.debug(f"Sample assessemnt: {sample['assessment']}") + # sample[f"{sample['target'].lower()}_status"] = sample['assessment'] # NOTE: Get sample having other target other_targets = [s for s in samples if re.sub('-N\\d*$', '', s['sample']) == sample['sample']] for s in other_targets: sample[f"ct_{s['target'].lower()}"] = s['ct'] if isinstance(s['ct'], float) else 0.0 - sample[f"{s['target'].lower()}_status"] = s['assessment'] + # sample[f"{s['target'].lower()}_status"] = s['assessment'] try: del sample['ct'] except KeyError: @@ -2915,7 +2931,8 @@ class WastewaterAssociation(SubmissionSampleAssociation): sample['background_color'] = f"rgb({red}, {grn}, {blu})" try: sample[ - 'tooltip'] += f"
- ct N1: {'{:.2f}'.format(self.ct_n1)} ({self.n1_status})
- ct N2: {'{:.2f}'.format(self.ct_n2)} ({self.n2_status})" + # 'tooltip'] += f"
- ct N1: {'{:.2f}'.format(self.ct_n1)} ({self.n1_status})
- ct N2: {'{:.2f}'.format(self.ct_n2)} ({self.n2_status})" + 'tooltip'] += f"
- ct N1: {'{:.2f}'.format(self.ct_n1)}
- ct N2: {'{:.2f}'.format(self.ct_n2)}" except (TypeError, AttributeError) as e: logger.error(f"Couldn't set tooltip for {self.sample.rsl_number}. Looks like there isn't PCR data.") return sample diff --git a/src/submissions/backend/excel/writer.py b/src/submissions/backend/excel/writer.py index 17ac7e6..cac6fb8 100644 --- a/src/submissions/backend/excel/writer.py +++ b/src/submissions/backend/excel/writer.py @@ -171,9 +171,9 @@ class InfoWriter(object): try: sheet.cell(row=loc['row'], column=loc['column'], value=v['value']) except AttributeError as e: - logger.error(f"Can't write {k} to that cell due to {e}") + logger.error(f"Can't write {k} to that cell due to AttributeError: {e}") except ValueError as e: - logger.error(f"Can't write {v} to that cell due to {e}") + logger.error(f"Can't write {v} to that cell due to ValueError: {e}") sheet.cell(row=loc['row'], column=loc['column'], value=v['value'].name) return self.sub_object.custom_info_writer(self.xl, info=final_info, custom_fields=self.info_map['custom']) diff --git a/src/submissions/backend/validators/pydant.py b/src/submissions/backend/validators/pydant.py index 6dc6383..c0701b3 100644 --- a/src/submissions/backend/validators/pydant.py +++ b/src/submissions/backend/validators/pydant.py @@ -645,6 +645,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}") match value: case dict(): if isinstance(value['value'], tuple): @@ -653,14 +654,26 @@ 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}") check = Contact.query(name=value['value']) - if check is None: - org = Organization.query(name=values.data['submitting_lab']['value']) - contact = org.contacts[0].name + 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}") + if isinstance(org, str): + org = Organization.query(name=values.data['submitting_lab']['value'], limit=1) + if isinstance(org, Organization): + contact = org.contacts[0].name + else: + logger.warning(f"All attempts at defaulting Contact failed, returning: {value}") + return value if isinstance(contact, tuple): contact = contact[0] - return dict(value=contact, missing=True) + value = dict(value=f"Defaulted to: {contact}", missing=True) + logger.debug(f"Value after query: {value}") + return else: + logger.debug(f"Value after bypass check: {value}") return value def __init__(self, run_custom: bool = False, **data): @@ -983,6 +996,17 @@ class PydContact(BaseModel): phone: str | None email: str | None + @field_validator("phone") + @classmethod + def enforce_phone_number(cls, value): + area_regex = re.compile(r"^\(?(\d{3})\)?(-| )?") + if len(value) > 8: + match = area_regex.match(value) + logger.debug(f"Match: {match.group(1)}") + value = area_regex.sub(f"({match.group(1).strip()}) ", value) + logger.debug(f"Output phone: {value}") + return value + def toSQL(self) -> Contact: """ Converts this instance into a backend.db.models.organization.Contact instance diff --git a/src/submissions/frontend/widgets/app.py b/src/submissions/frontend/widgets/app.py index 21bdee8..5373c24 100644 --- a/src/submissions/frontend/widgets/app.py +++ b/src/submissions/frontend/widgets/app.py @@ -12,8 +12,8 @@ from PyQt6.QtGui import QAction from pathlib import Path from markdown import markdown from __init__ import project_path -from backend import SubmissionType, Reagent, BasicSample -from tools import check_if_app, Settings, Report, jinja_template_loading, check_authorization, page_size +from backend import SubmissionType, Reagent, BasicSample, Organization +from tools import check_if_app, Settings, Report, jinja_template_loading, check_authorization, page_size, is_power_user from .functions import select_save_file, select_open_file # from datetime import date from .pop_ups import HTMLPop, AlertPop @@ -25,6 +25,7 @@ from .controls_chart import ControlsViewer from .summary import Summary from .turnaround import TurnaroundTime from .omni_search import SearchBox +from .omni_manager import ManagerWindow logger = logging.getLogger(f'submissions.{__name__}') @@ -69,7 +70,7 @@ class App(QMainWindow): fileMenu = menuBar.addMenu("&File") editMenu = menuBar.addMenu("&Edit") # NOTE: Creating menus using a title - methodsMenu = menuBar.addMenu("&Methods") + methodsMenu = menuBar.addMenu("&Search") maintenanceMenu = menuBar.addMenu("&Monthly") helpMenu = menuBar.addMenu("&Help") helpMenu.addAction(self.helpAction) @@ -82,6 +83,9 @@ class App(QMainWindow): maintenanceMenu.addAction(self.joinExtractionAction) maintenanceMenu.addAction(self.joinPCRAction) editMenu.addAction(self.editReagentAction) + editMenu.addAction(self.manageOrgsAction) + if not is_power_user(): + editMenu.setEnabled(False) def _createToolBar(self): """ @@ -106,6 +110,7 @@ class App(QMainWindow): self.yamlExportAction = QAction("Export Type Example", self) self.yamlImportAction = QAction("Import Type Template", self) self.editReagentAction = QAction("Edit Reagent", self) + self.manageOrgsAction = QAction("Manage Clients", self) def _connectActions(self): """ @@ -123,6 +128,7 @@ class App(QMainWindow): self.yamlImportAction.triggered.connect(self.import_ST_yaml) self.table_widget.pager.current_page.textChanged.connect(self.update_data) self.editReagentAction.triggered.connect(self.edit_reagent) + self.manageOrgsAction.triggered.connect(self.manage_orgs) def showAbout(self): """ @@ -207,6 +213,11 @@ class App(QMainWindow): def update_data(self): self.table_widget.sub_wid.setData(page=self.table_widget.pager.page_anchor, page_size=page_size) + def manage_orgs(self): + dlg = ManagerWindow(parent=self, object_type=Organization, extras=[]) + if dlg.exec(): + new_org = dlg.parse_form() + logger.debug(new_org.__dict__) class AddSubForm(QWidget): diff --git a/src/submissions/frontend/widgets/omni_add_edit.py b/src/submissions/frontend/widgets/omni_add_edit.py new file mode 100644 index 0000000..cb7eb05 --- /dev/null +++ b/src/submissions/frontend/widgets/omni_add_edit.py @@ -0,0 +1,88 @@ +from typing import Any + +from PyQt6.QtWidgets import ( + QLabel, QDialog, QTableView, QWidget, QLineEdit, QGridLayout, QComboBox, QPushButton, QDialogButtonBox, QDateEdit +) +from sqlalchemy import String, TIMESTAMP +from sqlalchemy.orm import InstrumentedAttribute +import logging + +logger = logging.getLogger(f"submissions.{__name__}") + + +class AddEdit(QDialog): + + def __init__(self, parent, instance: Any): + super().__init__(parent) + self.instance = instance + self.object_type = instance.__class__ + self.layout = QGridLayout(self) + + QBtn = QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel + self.buttonBox = QDialogButtonBox(QBtn) + self.buttonBox.accepted.connect(self.accept) + self.buttonBox.rejected.connect(self.reject) + 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(): + try: + widget = EditProperty(self, key=key, column_type=field.property.expression.type, + value=getattr(self.instance, key)) + except AttributeError: + continue + self.layout.addWidget(widget, self.layout.rowCount(), 0) + self.layout.addWidget(self.buttonBox) + self.setWindowTitle(f"Add/Edit {self.object_type.__name__}") + self.setMinimumSize(600, 50 * len(fields)) + self.setLayout(self.layout) + + def parse_form(self): + results = {result[0]:result[1] for result in [item.parse_form() for item in self.findChildren(EditProperty)]} + # logger.debug(results) + model = self.object_type.get_pydantic_model() + model = model(**results) + try: + extras = list(model.model_extra.keys()) + except AttributeError: + extras = [] + fields = list(model.model_fields.keys()) + extras + for field in fields: + # logger.debug(result) + self.instance.__setattr__(field, model.__getattribute__(field)) + return self.instance + + +class EditProperty(QWidget): + + def __init__(self, parent: AddEdit, key: str, column_type: Any, value): + super().__init__(parent) + self.label = QLabel(key.title().replace("_", " ")) + self.layout = QGridLayout() + self.layout.addWidget(self.label, 0, 0, 1, 1) + self.setObjectName(key) + match column_type: + case String(): + self.widget = QLineEdit(self) + self.widget.setText(value) + case TIMESTAMP(): + self.widget = QDateEdit(self) + self.widget.setDate(value) + case _: + logger.error(f"{column_type} not a supported type.") + self.widget = None + self.layout.addWidget(self.widget, 0, 1, 1, 3) + self.setLayout(self.layout) + + def parse_form(self): + match self.widget: + case QLineEdit(): + value = self.widget.text() + case QDateEdit(): + value = self.widget.date() + case _: + value = None + return self.objectName(), value + + + + diff --git a/src/submissions/frontend/widgets/omni_manager.py b/src/submissions/frontend/widgets/omni_manager.py new file mode 100644 index 0000000..5e79099 --- /dev/null +++ b/src/submissions/frontend/widgets/omni_manager.py @@ -0,0 +1,227 @@ +from typing import Any, List +from PyQt6.QtCore import QSortFilterProxyModel, Qt +from PyQt6.QtWidgets import ( + QLabel, QDialog, + QTableView, QWidget, QLineEdit, QGridLayout, QComboBox, QPushButton, QDialogButtonBox, QDateEdit +) +from sqlalchemy import String, TIMESTAMP +from sqlalchemy.orm import InstrumentedAttribute +from sqlalchemy.orm.collections import InstrumentedList +from sqlalchemy.orm.properties import ColumnProperty +from sqlalchemy.orm.relationships import _RelationshipDeclared +from pandas import DataFrame +from backend import db +import logging +from .omni_add_edit import AddEdit +from .omni_search import SearchBox + +from frontend.widgets.submission_table import pandasModel + +logger = logging.getLogger(f"submissions.{__name__}") + + +class ManagerWindow(QDialog): + """ + Initially this is a window to manage Organization Contacts, but hope to abstract it more later. + """ + + def __init__(self, parent, object_type: Any, extras: List[str], **kwargs): + super().__init__(parent) + self.object_type = self.original_type = object_type + self.instance = None + self.extras = extras + self.context = kwargs + self.layout = QGridLayout(self) + QBtn = QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel + self.buttonBox = QDialogButtonBox(QBtn) + self.buttonBox.accepted.connect(self.accept) + self.buttonBox.rejected.connect(self.reject) + self.setMinimumSize(600, 600) + sub_classes = ["Any"] + [cls.__name__ for cls in self.object_type.__subclasses__()] + if len(sub_classes) > 1: + self.sub_class = QComboBox(self) + self.sub_class.setObjectName("sub_class") + self.sub_class.addItems(sub_classes) + self.sub_class.currentTextChanged.connect(self.update_options) + self.sub_class.setEditable(False) + self.sub_class.setMinimumWidth(self.minimumWidth()) + 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): + """ + 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()] + self.options.clear() + self.options.addItems(options) + self.options.setEditable(False) + self.options.setMinimumWidth(self.minimumWidth()) + self.layout.addWidget(self.options, 1, 0, 1, 1) + self.add_button = QPushButton("Add New") + self.layout.addWidget(self.add_button, 1, 1, 1, 1) + self.options.currentTextChanged.connect(self.update_data) + self.add_button.clicked.connect(self.add_new) + self.update_data() + + def update_data(self): + 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) + 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: + case ColumnProperty(): + widget = EditProperty(self, key=key, column_type=field.property.expression.type, + value=getattr(self.instance, key)) + case _RelationshipDeclared(): + if key != "submissions": + widget = EditRelationship(self, key=key, entity=field.comparator.entity.class_, + value=getattr(self.instance, key)) + else: + continue + case _: + continue + if widget: + self.layout.addWidget(widget, self.layout.rowCount(), 0, 1, 2) + self.layout.addWidget(self.buttonBox, self.layout.rowCount(), 0, 1, 2) + + def parse_form(self): + 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]) + return self.instance + + def add_new(self): + dlg = AddEdit(parent=self, instance=self.object_type()) + if dlg.exec(): + new_instance = dlg.parse_form() + # logger.debug(new_instance.__dict__) + new_instance.save() + self.update_options() + + +class EditProperty(QWidget): + + def __init__(self, parent: ManagerWindow, key: str, column_type: Any, value): + super().__init__(parent) + self.label = QLabel(key.title().replace("_", " ")) + self.layout = QGridLayout() + self.layout.addWidget(self.label, 0, 0, 1, 1) + match column_type: + case String(): + self.widget = QLineEdit(self) + self.widget.setText(value) + case TIMESTAMP(): + self.widget = QDateEdit(self) + self.widget.setDate(value) + case _: + self.widget = None + self.layout.addWidget(self.widget, 0, 1, 1, 3) + self.setLayout(self.layout) + + def parse_form(self): + match self.widget: + case QLineEdit(): + value = self.widget.text() + case QDateEdit(): + value = self.widget.date() + case _: + value = None + return self.objectName(), value + + +class EditRelationship(QWidget): + + def __init__(self, parent, key: str, entity: Any, value): + super().__init__(parent) + self.entity = entity + self.data = value + self.label = QLabel(key.title().replace("_", " ")) + self.setObjectName(key) + self.table = QTableView() + self.add_button = QPushButton("Add New") + self.add_button.clicked.connect(self.add_new) + self.existing_button = QPushButton("Add Existing") + self.existing_button.clicked.connect(self.add_existing) + self.layout = QGridLayout() + self.layout.addWidget(self.label, 0, 0, 1, 5) + self.layout.addWidget(self.table, 1, 0, 1, 8) + self.layout.addWidget(self.add_button, 0, 6, 1, 1, alignment=Qt.AlignmentFlag.AlignRight) + self.layout.addWidget(self.existing_button, 0, 7, 1, 1, alignment=Qt.AlignmentFlag.AlignRight) + self.setLayout(self.layout) + self.set_data() + + def parse_row(self, x): + context = {item: x.sibling(x.row(), self.data.columns.get_loc(item)).data() for item in self.data.columns} + try: + object = self.entity.query(**context) + except KeyError: + object = None + # logger.debug(object) + self.table.doubleClicked.disconnect() + self.add_edit(instance=object) + + def add_new(self, instance: Any = None): + if not instance: + instance = self.entity() + dlg = AddEdit(self, instance=instance) + if dlg.exec(): + new_instance = dlg.parse_form() + # logger.debug(new_instance.__dict__) + addition = getattr(self.parent().instance, self.objectName()) + if isinstance(addition, InstrumentedList): + addition.append(new_instance) + self.parent().instance.save() + self.parent().update_data() + + def add_existing(self): + dlg = SearchBox(self, object_type=self.entity, returnable=True, extras=[]) + if dlg.exec(): + rows = dlg.return_selected_rows() + # print(f"Rows selected: {[row for row in rows]}") + for row in rows: + instance = self.entity.query(**row) + # logger.debug(instance) + addition = getattr(self.parent().instance, self.objectName()) + if isinstance(addition, InstrumentedList): + addition.append(instance) + self.parent().instance.save() + self.parent().update_data() + + def set_data(self) -> None: + """ + sets data in model + """ + # logger.debug(self.data) + self.data = DataFrame.from_records([item.to_dict() for item in self.data]) + try: + self.columns_of_interest = [dict(name=item, column=self.data.columns.get_loc(item)) for item in self.extras] + except (KeyError, AttributeError): + self.columns_of_interest = [] + # try: + # self.data['id'] = self.data['id'].apply(str) + # self.data['id'] = self.data['id'].str.zfill(3) + # except (TypeError, KeyError) as e: + # logger.error(f"Couldn't format id string: {e}") + proxy_model = QSortFilterProxyModel() + proxy_model.setSourceModel(pandasModel(self.data)) + self.table.setModel(proxy_model) + self.table.doubleClicked.connect(self.parse_row) diff --git a/src/submissions/frontend/widgets/omni_search.py b/src/submissions/frontend/widgets/omni_search.py index eec6771..b74d57f 100644 --- a/src/submissions/frontend/widgets/omni_search.py +++ b/src/submissions/frontend/widgets/omni_search.py @@ -7,7 +7,7 @@ from pandas import DataFrame from PyQt6.QtCore import QSortFilterProxyModel from PyQt6.QtWidgets import ( QLabel, QVBoxLayout, QDialog, - QTableView, QWidget, QLineEdit, QGridLayout, QComboBox + QTableView, QWidget, QLineEdit, QGridLayout, QComboBox, QDialogButtonBox ) from .submission_table import pandasModel import logging @@ -20,7 +20,7 @@ class SearchBox(QDialog): The full search widget. """ - def __init__(self, parent, object_type: Any, extras: List[str], **kwargs): + def __init__(self, parent, object_type: Any, extras: List[str], returnable: bool = False, **kwargs): super().__init__(parent) self.object_type = self.original_type = object_type self.extras = extras @@ -44,6 +44,14 @@ class SearchBox(QDialog): self.setWindowTitle(f"Search {self.object_type.__name__}") self.update_widgets() self.update_data() + if returnable: + QBtn = QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel + self.buttonBox = QDialogButtonBox(QBtn) + self.buttonBox.accepted.connect(self.accept) + self.buttonBox.rejected.connect(self.reject) + self.layout.addWidget(self.buttonBox, self.layout.rowCount(), 0, 1, 2) + self.results.doubleClicked.disconnect() + self.results.doubleClicked.connect(self.accept) def update_widgets(self): """ @@ -63,7 +71,7 @@ class SearchBox(QDialog): for iii, searchable in enumerate(self.object_type.searchables): widget = FieldSearch(parent=self, label=searchable, field_name=searchable) widget.setObjectName(searchable) - self.layout.addWidget(widget, 1+iii, 0) + self.layout.addWidget(widget, 1 + iii, 0) widget.search_widget.textChanged.connect(self.update_data) self.update_data() @@ -87,6 +95,13 @@ 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())) + 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)} + yield output + class FieldSearch(QWidget): """ diff --git a/src/submissions/tools/__init__.py b/src/submissions/tools/__init__.py index 4bff1e6..9684887 100644 --- a/src/submissions/tools/__init__.py +++ b/src/submissions/tools/__init__.py @@ -7,9 +7,9 @@ import importlib import time from datetime import date, datetime, timedelta from json import JSONDecodeError -import logging, re, yaml, sys, os, stat, platform, getpass, inspect, json, numpy as np, pandas as pd +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 dateutil.easter import easter from jinja2 import Environment, FileSystemLoader from logging import handlers @@ -478,23 +478,25 @@ class Settings(BaseSettings, extra="allow"): def set_scripts(self): """ - Imports all functions from "scripts" folder which will run their @registers, adding them to ctx scripts + Imports all functions from "scripts" folder, adding them to ctx scripts """ - p = Path(__file__).parent.joinpath("scripts").absolute() - subs = [item.stem for item in p.glob("*.py") if "__" not in item.stem] - for sub in subs: - mod = importlib.import_module(f"tools.scripts.{sub}") - try: - func = mod.__getattribute__(sub) - except AttributeError: - try: - func = mod.__getattribute__("script") - except AttributeError: - continue - if sub in self.startup_scripts.keys(): - self.startup_scripts[sub] = func - if sub in self.teardown_scripts.keys(): - self.teardown_scripts[sub] = func + if check_if_app(): + p = Path(sys._MEIPASS).joinpath("files", "scripts") + else: + p = Path(__file__).parents[2].joinpath("scripts").absolute() + if p.__str__() not in sys.path: + sys.path.append(p.__str__()) + modules = p.glob("[!__]*.py") + for module in modules: + mod = importlib.import_module(module.stem) + for function in getmembers(mod, isfunction): + name = function[0] + func = function[1] + # NOTE: assign function based on its name being in config: startup/teardown + if name in self.startup_scripts.keys(): + self.startup_scripts[name] = func + if name in self.teardown_scripts.keys(): + self.teardown_scripts[name] = func @timer def run_startup(self): @@ -502,9 +504,12 @@ class Settings(BaseSettings, extra="allow"): Runs startup scripts. """ for script in self.startup_scripts.values(): - logger.info(f"Running startup script: {script.__name__}") - thread = Thread(target=script, args=(ctx,)) - thread.start() + try: + logger.info(f"Running startup script: {script.__name__}") + thread = Thread(target=script, args=(ctx,)) + thread.start() + except AttributeError: + logger.error(f"Couldn't run startup script: {script}") @timer def run_teardown(self): @@ -512,9 +517,12 @@ class Settings(BaseSettings, extra="allow"): Runs teardown scripts. """ for script in self.teardown_scripts.values(): - logger.info(f"Running teardown script: {script.__name__}") - thread = Thread(target=script, args=(ctx,)) - thread.start() + try: + logger.info(f"Running teardown script: {script.__name__}") + thread = Thread(target=script, args=(ctx,)) + thread.start() + except AttributeError: + logger.error(f"Couldn't run teardown script: {script}") @classmethod def get_alembic_db_path(cls, alembic_path, mode=Literal['path', 'schema', 'user', 'pass']) -> Path | str: @@ -874,7 +882,7 @@ class Result(BaseModel, arbitrary_types_allowed=True): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - self.owner = inspect.stack()[1].function + self.owner = stack()[1].function def report(self): from frontend.widgets.pop_ups import AlertPop diff --git a/src/submissions/tools/scripts/goodbye.py b/src/submissions/tools/scripts/goodbye.py deleted file mode 100644 index c76d69e..0000000 --- a/src/submissions/tools/scripts/goodbye.py +++ /dev/null @@ -1,9 +0,0 @@ -""" -Test script for teardown_scripts -""" - -# from .. import register_script - -# @register_script -def goodbye(ctx): - print("\n\nGoodbye. Thank you for using Robotics Submission Tracker.\n\n") diff --git a/src/submissions/tools/scripts/hello.py b/src/submissions/tools/scripts/hello.py deleted file mode 100644 index 5cbec2d..0000000 --- a/src/submissions/tools/scripts/hello.py +++ /dev/null @@ -1,8 +0,0 @@ -""" -Test script for startup_scripts -""" -# from .. import register_script - -# @register_script -def hello(ctx): - print("\n\nHello! Welcome to Robotics Submission Tracker.\n\n") diff --git a/submissions.spec b/submissions.spec index 7f95b62..d3326f5 100644 --- a/submissions.spec +++ b/submissions.spec @@ -36,6 +36,7 @@ a = Analysis( ("docs\\build", "files\\docs"), ("src\\submissions\\resources\\*", "files\\resources"), ("alembic.ini", "files"), + ("src\\scripts\\*.py", "files\\scripts") ], hiddenimports=["pyodbc"], hookspath=[],