Created omni-manager, omni-addit

This commit is contained in:
lwark
2025-01-03 12:37:03 -06:00
parent 482b641569
commit b55258f677
19 changed files with 502 additions and 77 deletions

View File

@@ -1,3 +1,7 @@
# 202412.06
- Switched startup/teardown scripts to importlib/getattr addition to ctx.
# 202412.05 # 202412.05
- Switched startup/teardown scripts to decorator registration. - Switched startup/teardown scripts to decorator registration.

View File

@@ -5,11 +5,9 @@ import logging, shutil, pyodbc
from datetime import date from datetime import date
from pathlib import Path from pathlib import Path
from tools import Settings from tools import Settings
# from .. import register_script
logger = logging.getLogger(f"submissions.{__name__}") logger = logging.getLogger(f"submissions.{__name__}")
# @register_script
def backup_database(ctx: Settings): def backup_database(ctx: Settings):
""" """
Copies the database into the backup directory the first time it is opened every month. Copies the database into the backup directory the first time it is opened every month.

22
src/scripts/goodbye.py Normal file
View File

@@ -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.
"""

22
src/scripts/hello.py Normal file
View File

@@ -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.
"""

View File

@@ -4,11 +4,9 @@ from datetime import datetime
from tools import Settings from tools import Settings
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
# from .. import register_script
logger = logging.getLogger(f"submissions.{__name__}") logger = logging.getLogger(f"submissions.{__name__}")
# @register_script
def import_irida(ctx: Settings): def import_irida(ctx: Settings):
""" """
Grabs Irida controls from secondary database. 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], subtype=row[6], refseq_version=row[7], kraken2_version=row[8], kraken2_db_version=row[9],
sample_id=row[10]) for row in cursor] sample_id=row[10]) for row in cursor]
for record in records: for record in records:
# instance = IridaControl.query(name=record['name'])
instance = new_session.query(IridaControl).filter(IridaControl.name == record['name']).first() instance = new_session.query(IridaControl).filter(IridaControl.name == record['name']).first()
if instance: if instance:
logger.warning(f"Irida Control {instance.name} already exists, skipping.") logger.warning(f"Irida Control {instance.name} already exists, skipping.")
@@ -49,19 +46,13 @@ def import_irida(ctx: Settings):
assert isinstance(record[thing], dict) assert isinstance(record[thing], dict)
else: else:
record[thing] = {} 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") record['submitted_date'] = datetime.strptime(record['submitted_date'], "%Y-%m-%d %H:%M:%S.%f")
assert isinstance(record['submitted_date'], datetime) assert isinstance(record['submitted_date'], datetime)
instance = IridaControl(controltype=ct, **record) instance = IridaControl(controltype=ct, **record)
# sample = BasicSample.query(submitter_id=instance.name)
sample = new_session.query(BasicSample).filter(BasicSample.submitter_id == instance.name).first() sample = new_session.query(BasicSample).filter(BasicSample.submitter_id == instance.name).first()
if sample: if sample:
instance.sample = sample instance.sample = sample
instance.submission = sample.submissions[0] instance.submission = sample.submissions[0]
# instance.save()
new_session.add(instance) new_session.add(instance)
new_session.commit() new_session.commit()
new_session.close() new_session.close()

View File

@@ -1,14 +1,13 @@
import sys, os import sys, os
from tools import ctx, setup_logger, check_if_app 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(): if check_if_app():
os.environ['QTWEBENGINE_DISABLE_SANDBOX'] = "1" os.environ['QTWEBENGINE_DISABLE_SANDBOX'] = "1"
# setup custom logger # NOTE: setup custom logger
logger = setup_logger(verbosity=3) logger = setup_logger(verbosity=3)
# from backend import scripts
from PyQt6.QtWidgets import QApplication from PyQt6.QtWidgets import QApplication
from frontend.widgets.app import App from frontend.widgets.app import App

View File

@@ -12,6 +12,7 @@ from typing import Any, List
from pathlib import Path from pathlib import Path
from tools import report_result from tools import report_result
# NOTE: Load testing environment # NOTE: Load testing environment
if 'pytest' in sys.modules: if 'pytest' in sys.modules:
sys.path.append(Path(__file__).parents[4].absolute().joinpath("tests").__str__()) sys.path.append(Path(__file__).parents[4].absolute().joinpath("tests").__str__())
@@ -167,7 +168,10 @@ class BaseClass(Base):
Returns: Returns:
Dataframe Dataframe
""" """
try:
records = [obj.to_sub_dict(**kwargs) for obj in objects] 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) return DataFrame.from_records(records)
@classmethod @classmethod
@@ -233,6 +237,15 @@ class BaseClass(Base):
report.add_result(Result(msg=e, status="Critical")) report.add_result(Result(msg=e, status="Critical"))
return report 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): class ConfigItem(BaseClass):
""" """

View File

@@ -124,6 +124,8 @@ class Contact(BaseClass):
Base of Contact Base of Contact
""" """
searchables =[]
id = Column(INTEGER, primary_key=True) #: primary key id = Column(INTEGER, primary_key=True) #: primary key
name = Column(String(64)) #: contact name name = Column(String(64)) #: contact name
email = Column(String(64)) #: contact email email = Column(String(64)) #: contact email

View File

@@ -7,6 +7,7 @@ from collections import OrderedDict
from copy import deepcopy from copy import deepcopy
from getpass import getuser from getpass import getuser
import logging, uuid, tempfile, re, base64, numpy as np, pandas as pd, types, sys import logging, uuid, tempfile, re, base64, numpy as np, pandas as pd, types, sys
from inspect import isclass
from zipfile import ZipFile, BadZipfile from zipfile import ZipFile, BadZipfile
from tempfile import TemporaryDirectory, TemporaryFile from tempfile import TemporaryDirectory, TemporaryFile
from operator import itemgetter from operator import itemgetter
@@ -175,7 +176,7 @@ class BasicSubmission(BaseClass, LogMixin):
# NOTE: Fields not placed in ui form # NOTE: Fields not placed in ui form
form_ignore=['reagents', 'ctx', 'id', 'cost', 'extraction_info', 'signed_by', 'comment', 'namer', form_ignore=['reagents', 'ctx', 'id', 'cost', 'extraction_info', 'signed_by', 'comment', 'namer',
'submission_object', "tips", 'contact_phone', 'custom', 'cost_centre', 'completed_date', '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 # NOTE: Fields not placed in ui form to be moved to pydantic
form_recover=recover form_recover=recover
)) ))
@@ -352,6 +353,9 @@ class BasicSubmission(BaseClass, LogMixin):
try: try:
contact = self.contact.name contact = self.contact.name
except AttributeError as e: except AttributeError as e:
try:
contact = f"Defaulted to: {self.submitting_lab.contacts[0].name}"
except (AttributeError, IndexError):
contact = "NA" contact = "NA"
try: try:
contact_phone = self.contact.phone contact_phone = self.contact.phone
@@ -627,8 +631,14 @@ class BasicSubmission(BaseClass, LogMixin):
continue continue
case "tips": case "tips":
field_value = [item.to_pydantic() for item in self.submission_tips_associations] 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) 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": case "plate_number":
key = 'rsl_plate_num' key = 'rsl_plate_num'
field_value = dict(value=self.rsl_plate_num, missing=missing) field_value = dict(value=self.rsl_plate_num, missing=missing)
@@ -640,10 +650,13 @@ class BasicSubmission(BaseClass, LogMixin):
case _: case _:
try: try:
key = key.lower().replace(" ", "_") key = key.lower().replace(" ", "_")
if isclass(value):
field_value = dict(value=self.__getattribute__(key).name, missing=missing)
else:
field_value = dict(value=self.__getattribute__(key), missing=missing) field_value = dict(value=self.__getattribute__(key), missing=missing)
except AttributeError: except AttributeError:
logger.error(f"{key} is not available in {self}") logger.error(f"{key} is not available in {self}")
continue field_value = dict(value="NA", missing=True)
new_dict[key] = field_value new_dict[key] = field_value
new_dict['filepath'] = Path(tempfile.TemporaryFile().name) new_dict['filepath'] = Path(tempfile.TemporaryFile().name)
dicto.update(new_dict) 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. # NOTE: Due to having to run through samples in for loop we need to convert to list.
output = [] output = []
for sample in samples: for sample in samples:
logger.debug(sample)
# NOTE: remove '-{target}' from controls # NOTE: remove '-{target}' from controls
sample['sample'] = re.sub('-N\\d*$', '', sample['sample']) sample['sample'] = re.sub('-N\\d*$', '', sample['sample'])
# NOTE: if sample is already in output skip # NOTE: if sample is already in output skip
@@ -1512,14 +1526,16 @@ class Wastewater(BasicSubmission):
logger.warning(f"Already have {sample['sample']}") logger.warning(f"Already have {sample['sample']}")
continue continue
# NOTE: Set ct values # 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 sample[f"ct_{sample['target'].lower()}"] = sample['ct'] if isinstance(sample['ct'], float) else 0.0
# NOTE: Set assessment # 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 # NOTE: Get sample having other target
other_targets = [s for s in samples if re.sub('-N\\d*$', '', s['sample']) == sample['sample']] other_targets = [s for s in samples if re.sub('-N\\d*$', '', s['sample']) == sample['sample']]
for s in other_targets: for s in other_targets:
sample[f"ct_{s['target'].lower()}"] = s['ct'] if isinstance(s['ct'], float) else 0.0 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: try:
del sample['ct'] del sample['ct']
except KeyError: except KeyError:
@@ -2915,7 +2931,8 @@ class WastewaterAssociation(SubmissionSampleAssociation):
sample['background_color'] = f"rgb({red}, {grn}, {blu})" sample['background_color'] = f"rgb({red}, {grn}, {blu})"
try: try:
sample[ sample[
'tooltip'] += f"<br>- ct N1: {'{:.2f}'.format(self.ct_n1)} ({self.n1_status})<br>- ct N2: {'{:.2f}'.format(self.ct_n2)} ({self.n2_status})" # 'tooltip'] += f"<br>- ct N1: {'{:.2f}'.format(self.ct_n1)} ({self.n1_status})<br>- ct N2: {'{:.2f}'.format(self.ct_n2)} ({self.n2_status})"
'tooltip'] += f"<br>- ct N1: {'{:.2f}'.format(self.ct_n1)}<br>- ct N2: {'{:.2f}'.format(self.ct_n2)}"
except (TypeError, AttributeError) as e: except (TypeError, AttributeError) as e:
logger.error(f"Couldn't set tooltip for {self.sample.rsl_number}. Looks like there isn't PCR data.") logger.error(f"Couldn't set tooltip for {self.sample.rsl_number}. Looks like there isn't PCR data.")
return sample return sample

View File

@@ -171,9 +171,9 @@ class InfoWriter(object):
try: try:
sheet.cell(row=loc['row'], column=loc['column'], value=v['value']) sheet.cell(row=loc['row'], column=loc['column'], value=v['value'])
except AttributeError as e: 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: 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) 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']) return self.sub_object.custom_info_writer(self.xl, info=final_info, custom_fields=self.info_map['custom'])

View File

@@ -645,6 +645,7 @@ class PydSubmission(BaseModel, extra='allow'):
@field_validator("contact") @field_validator("contact")
@classmethod @classmethod
def get_contact_from_org(cls, value, values): def get_contact_from_org(cls, value, values):
logger.debug(f"Value coming in: {value}")
match value: match value:
case dict(): case dict():
if isinstance(value['value'], tuple): if isinstance(value['value'], tuple):
@@ -653,14 +654,26 @@ class PydSubmission(BaseModel, extra='allow'):
value = dict(value=value[0], missing=False) value = dict(value=value[0], missing=False)
case _: case _:
value = dict(value=value, missing=False) value = dict(value=value, missing=False)
logger.debug(f"Value after match: {value}")
check = Contact.query(name=value['value']) check = Contact.query(name=value['value'])
if check is None: logger.debug(f"Check came back with {check}")
org = Organization.query(name=values.data['submitting_lab']['value']) 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 contact = org.contacts[0].name
else:
logger.warning(f"All attempts at defaulting Contact failed, returning: {value}")
return value
if isinstance(contact, tuple): if isinstance(contact, tuple):
contact = contact[0] 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: else:
logger.debug(f"Value after bypass check: {value}")
return value return value
def __init__(self, run_custom: bool = False, **data): def __init__(self, run_custom: bool = False, **data):
@@ -983,6 +996,17 @@ class PydContact(BaseModel):
phone: str | None phone: str | None
email: 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: def toSQL(self) -> Contact:
""" """
Converts this instance into a backend.db.models.organization.Contact instance Converts this instance into a backend.db.models.organization.Contact instance

View File

@@ -12,8 +12,8 @@ from PyQt6.QtGui import QAction
from pathlib import Path from pathlib import Path
from markdown import markdown from markdown import markdown
from __init__ import project_path from __init__ import project_path
from backend import SubmissionType, Reagent, BasicSample from backend import SubmissionType, Reagent, BasicSample, Organization
from tools import check_if_app, Settings, Report, jinja_template_loading, check_authorization, page_size 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 .functions import select_save_file, select_open_file
# from datetime import date # from datetime import date
from .pop_ups import HTMLPop, AlertPop from .pop_ups import HTMLPop, AlertPop
@@ -25,6 +25,7 @@ from .controls_chart import ControlsViewer
from .summary import Summary from .summary import Summary
from .turnaround import TurnaroundTime from .turnaround import TurnaroundTime
from .omni_search import SearchBox from .omni_search import SearchBox
from .omni_manager import ManagerWindow
logger = logging.getLogger(f'submissions.{__name__}') logger = logging.getLogger(f'submissions.{__name__}')
@@ -69,7 +70,7 @@ class App(QMainWindow):
fileMenu = menuBar.addMenu("&File") fileMenu = menuBar.addMenu("&File")
editMenu = menuBar.addMenu("&Edit") editMenu = menuBar.addMenu("&Edit")
# NOTE: Creating menus using a title # NOTE: Creating menus using a title
methodsMenu = menuBar.addMenu("&Methods") methodsMenu = menuBar.addMenu("&Search")
maintenanceMenu = menuBar.addMenu("&Monthly") maintenanceMenu = menuBar.addMenu("&Monthly")
helpMenu = menuBar.addMenu("&Help") helpMenu = menuBar.addMenu("&Help")
helpMenu.addAction(self.helpAction) helpMenu.addAction(self.helpAction)
@@ -82,6 +83,9 @@ class App(QMainWindow):
maintenanceMenu.addAction(self.joinExtractionAction) maintenanceMenu.addAction(self.joinExtractionAction)
maintenanceMenu.addAction(self.joinPCRAction) maintenanceMenu.addAction(self.joinPCRAction)
editMenu.addAction(self.editReagentAction) editMenu.addAction(self.editReagentAction)
editMenu.addAction(self.manageOrgsAction)
if not is_power_user():
editMenu.setEnabled(False)
def _createToolBar(self): def _createToolBar(self):
""" """
@@ -106,6 +110,7 @@ class App(QMainWindow):
self.yamlExportAction = QAction("Export Type Example", self) self.yamlExportAction = QAction("Export Type Example", self)
self.yamlImportAction = QAction("Import Type Template", self) self.yamlImportAction = QAction("Import Type Template", self)
self.editReagentAction = QAction("Edit Reagent", self) self.editReagentAction = QAction("Edit Reagent", self)
self.manageOrgsAction = QAction("Manage Clients", self)
def _connectActions(self): def _connectActions(self):
""" """
@@ -123,6 +128,7 @@ class App(QMainWindow):
self.yamlImportAction.triggered.connect(self.import_ST_yaml) self.yamlImportAction.triggered.connect(self.import_ST_yaml)
self.table_widget.pager.current_page.textChanged.connect(self.update_data) self.table_widget.pager.current_page.textChanged.connect(self.update_data)
self.editReagentAction.triggered.connect(self.edit_reagent) self.editReagentAction.triggered.connect(self.edit_reagent)
self.manageOrgsAction.triggered.connect(self.manage_orgs)
def showAbout(self): def showAbout(self):
""" """
@@ -207,6 +213,11 @@ class App(QMainWindow):
def update_data(self): def update_data(self):
self.table_widget.sub_wid.setData(page=self.table_widget.pager.page_anchor, page_size=page_size) 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): class AddSubForm(QWidget):

View File

@@ -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

View File

@@ -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)

View File

@@ -7,7 +7,7 @@ from pandas import DataFrame
from PyQt6.QtCore import QSortFilterProxyModel from PyQt6.QtCore import QSortFilterProxyModel
from PyQt6.QtWidgets import ( from PyQt6.QtWidgets import (
QLabel, QVBoxLayout, QDialog, QLabel, QVBoxLayout, QDialog,
QTableView, QWidget, QLineEdit, QGridLayout, QComboBox QTableView, QWidget, QLineEdit, QGridLayout, QComboBox, QDialogButtonBox
) )
from .submission_table import pandasModel from .submission_table import pandasModel
import logging import logging
@@ -20,7 +20,7 @@ class SearchBox(QDialog):
The full search widget. 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) super().__init__(parent)
self.object_type = self.original_type = object_type self.object_type = self.original_type = object_type
self.extras = extras self.extras = extras
@@ -44,6 +44,14 @@ class SearchBox(QDialog):
self.setWindowTitle(f"Search {self.object_type.__name__}") self.setWindowTitle(f"Search {self.object_type.__name__}")
self.update_widgets() self.update_widgets()
self.update_data() 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): def update_widgets(self):
""" """
@@ -87,6 +95,13 @@ class SearchBox(QDialog):
# NOTE: Setting results moved to here from __init__ 202411118 # NOTE: Setting results moved to here from __init__ 202411118
self.results.setData(df=data) 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): class FieldSearch(QWidget):
""" """

View File

@@ -7,9 +7,9 @@ import importlib
import time import time
from datetime import date, datetime, timedelta from datetime import date, datetime, timedelta
from json import JSONDecodeError 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 threading import Thread
from inspect import getmembers, isfunction, stack
from dateutil.easter import easter from dateutil.easter import easter
from jinja2 import Environment, FileSystemLoader from jinja2 import Environment, FileSystemLoader
from logging import handlers from logging import handlers
@@ -478,23 +478,25 @@ class Settings(BaseSettings, extra="allow"):
def set_scripts(self): 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() if check_if_app():
subs = [item.stem for item in p.glob("*.py") if "__" not in item.stem] p = Path(sys._MEIPASS).joinpath("files", "scripts")
for sub in subs: else:
mod = importlib.import_module(f"tools.scripts.{sub}") p = Path(__file__).parents[2].joinpath("scripts").absolute()
try: if p.__str__() not in sys.path:
func = mod.__getattribute__(sub) sys.path.append(p.__str__())
except AttributeError: modules = p.glob("[!__]*.py")
try: for module in modules:
func = mod.__getattribute__("script") mod = importlib.import_module(module.stem)
except AttributeError: for function in getmembers(mod, isfunction):
continue name = function[0]
if sub in self.startup_scripts.keys(): func = function[1]
self.startup_scripts[sub] = func # NOTE: assign function based on its name being in config: startup/teardown
if sub in self.teardown_scripts.keys(): if name in self.startup_scripts.keys():
self.teardown_scripts[sub] = func self.startup_scripts[name] = func
if name in self.teardown_scripts.keys():
self.teardown_scripts[name] = func
@timer @timer
def run_startup(self): def run_startup(self):
@@ -502,9 +504,12 @@ class Settings(BaseSettings, extra="allow"):
Runs startup scripts. Runs startup scripts.
""" """
for script in self.startup_scripts.values(): for script in self.startup_scripts.values():
try:
logger.info(f"Running startup script: {script.__name__}") logger.info(f"Running startup script: {script.__name__}")
thread = Thread(target=script, args=(ctx,)) thread = Thread(target=script, args=(ctx,))
thread.start() thread.start()
except AttributeError:
logger.error(f"Couldn't run startup script: {script}")
@timer @timer
def run_teardown(self): def run_teardown(self):
@@ -512,9 +517,12 @@ class Settings(BaseSettings, extra="allow"):
Runs teardown scripts. Runs teardown scripts.
""" """
for script in self.teardown_scripts.values(): for script in self.teardown_scripts.values():
try:
logger.info(f"Running teardown script: {script.__name__}") logger.info(f"Running teardown script: {script.__name__}")
thread = Thread(target=script, args=(ctx,)) thread = Thread(target=script, args=(ctx,))
thread.start() thread.start()
except AttributeError:
logger.error(f"Couldn't run teardown script: {script}")
@classmethod @classmethod
def get_alembic_db_path(cls, alembic_path, mode=Literal['path', 'schema', 'user', 'pass']) -> Path | str: 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): def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
self.owner = inspect.stack()[1].function self.owner = stack()[1].function
def report(self): def report(self):
from frontend.widgets.pop_ups import AlertPop from frontend.widgets.pop_ups import AlertPop

View File

@@ -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")

View File

@@ -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")

View File

@@ -36,6 +36,7 @@ a = Analysis(
("docs\\build", "files\\docs"), ("docs\\build", "files\\docs"),
("src\\submissions\\resources\\*", "files\\resources"), ("src\\submissions\\resources\\*", "files\\resources"),
("alembic.ini", "files"), ("alembic.ini", "files"),
("src\\scripts\\*.py", "files\\scripts")
], ],
hiddenimports=["pyodbc"], hiddenimports=["pyodbc"],
hookspath=[], hookspath=[],