Files
Submissions-App/src/submissions/frontend/widgets/omni_manager.py
2025-03-26 14:19:13 -05:00

595 lines
25 KiB
Python

"""
Provides a screen for managing all attributes of a database object.
"""
import json, logging
from pprint import pformat
from typing import Any, List, Literal
from PyQt6.QtCore import QSortFilterProxyModel, Qt
from PyQt6.QtGui import QAction, QCursor
from PyQt6.QtWidgets import (
QLabel, QDialog,
QTableView, QWidget, QLineEdit, QGridLayout, QComboBox, QPushButton, QDialogButtonBox, QDateEdit, QMenu,
QDoubleSpinBox, QSpinBox, QCheckBox, QTextEdit, QVBoxLayout, QHBoxLayout
)
from sqlalchemy import String, TIMESTAMP, FLOAT, INTEGER, JSON, BLOB
from sqlalchemy.orm import InstrumentedAttribute
from sqlalchemy.orm.properties import ColumnProperty
from sqlalchemy.orm.relationships import _RelationshipDeclared
from pandas import DataFrame
from backend import db
from tools import check_object_in_manager
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],
manager: Any | None = None,
add_edit: Literal['add', 'edit'] = 'edit',
**kwargs):
super().__init__(parent)
self.class_object = self.original_type = object_type
self.add_edit = add_edit
# NOTE: Should I pass in an instance?
self.instance = None
if manager is None:
try:
self.manager = self.parent().instance
except AttributeError:
self.manager = None
else:
self.manager = manager
# logger.debug(f"Managers: {managers}")
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.class_object.__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
if self.add_edit == "edit":
self.options = QComboBox(self)
self.options.setObjectName("options")
self.update_options()
else:
self.update_data(initial=True)
self.setLayout(self.layout)
self.setWindowTitle(f"Manage {self.class_object.__name__} - Manager: {self.manager}")
def update_options(self) -> None:
"""
Changes form inputs based on sample type
"""
# logger.debug(f"Instance: {self.instance}")
if self.sub_class:
self.class_object = getattr(db, self.sub_class.currentText())
# logger.debug(f"From update options, managers: {self.managers}")
try:
query_kwargs = {self.parent().instance.query_alias: self.parent().instance}
except AttributeError as e:
# logger.debug(f"Couldn't set query kwargs due to: {e}")
query_kwargs = {}
# logger.debug(f"Query kwargs: {query_kwargs}")
options = [item.name for item in self.class_object.query(**query_kwargs)]
# logger.debug(f"self.class_object: {self.class_object}")
if self.instance:
try:
inserter = options.pop(options.index(self.instance.name))
except ValueError:
inserter = self.instance.name
options.insert(0, inserter)
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.add_button.clicked.connect(self.add_new)
self.options.currentTextChanged.connect(self.update_data)
# logger.debug(f"Instance: {self.instance}")
self.update_data()
def update_data(self, initial: bool = False) -> 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)
# logger.debug(f"Current options text lower: {self.options.currentText().lower()}")
if self.add_edit == "edit" and initial:
# logger.debug(f"Querying with {self.options.currentText()}")
self.instance = self.class_object.query(name=self.options.currentText(), limit=1)
# logger.debug(f"Instance: {self.instance}")
if not self.instance:
self.instance = self.class_object()
# logger.debug(f"self.instance: {self.instance}")
fields = self.instance.omnigui_instance_dict
for key, field in fields.items():
try:
value = getattr(self.instance, key)
except AttributeError:
value = None
match field['class_attr'].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,
value=value)
# NOTE: RelationshipDeclareds will be given a list of existing related objects.
case _RelationshipDeclared():
if key != "submissions":
# NOTE: field.comparator.class_object.class_ gives the relationship class
widget = EditRelationship(self, key=key, class_object=field['class_attr'].comparator.entity.class_,
value=value)
else:
continue
case _:
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) -> Any:
"""
Returns the instance associated with this window.
Returns:
Any: The instance with updated fields.
"""
results = [item.parse_form() for item in self.findChildren(EditProperty)]
for result in results:
# logger.debug(f"Incoming result: {result}")
setattr(self.instance, result['field'], result['value'])
# logger.debug(f"Set result: {getattr(self.instance, result['field'])}")
results = [item.parse_form() for item in self.findChildren(EditRelationship)]
for result in results:
logger.debug(f"Incoming result: {result}")
if not getattr(self.instance, result['field']):
setattr(self.instance, result['field'], result['value'])
logger.debug(f"Set result: {getattr(self.instance, result['field'])}")
logger.debug(f"Instance coming from parsed form: {self.instance.__dict__}")
return self.instance
def add_new(self):
new_instance = self.class_object()
self.instance = new_instance
self.update_options()
def add_to_json(self, caller_child=None):
try:
name = caller_child.objectName()
except AttributeError:
name = "No Caller"
jsonedit = JsonEditScreen(parent=self, key=name)
if jsonedit.exec():
data = jsonedit.parse_form()
logger.debug(f"Data: {pformat(data)}")
current_value = getattr(self.instance, name)
if isinstance(jsonedit.json_field, dict):
value = data
elif isinstance(jsonedit.json_field, list):
if isinstance(data, list):
value = current_value + data
else:
value = current_value + [data]
setattr(self.instance, name, value)
def toggle_textedit(self, caller_child=None):
already_exists = self.findChildren(LargeTextEdit)
if not already_exists:
try:
name = caller_child.objectName()
except AttributeError:
name = "No Caller"
logger.debug(f"Name: {name}, instance: {self.instance}")
textedit = LargeTextEdit(parent=self, key=name)
self.layout.addWidget(textedit, 1, self.layout.columnCount(), self.layout.rowCount() - 1, 1)
data = getattr(self.instance, name)
logger.debug(f"Data: {data}")
data = json.dumps(data, indent=4)
textedit.widget.setText(data)
else:
for item in already_exists:
item.setParent(None)
item.destroy()
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)
self.setObjectName(key)
logger.debug(f"Column type for {key}: {column_type['class_attr'].property.expression.type}")
match column_type['class_attr'].property.expression.type:
case String():
self.widget = QLineEdit(self)
self.widget.setText(value)
case INTEGER():
if isinstance(column_type['instance_attr'], bool):
self.widget = QCheckBox()
self.widget.setChecked(value)
else:
if value is None:
value = 0
self.widget = QSpinBox()
self.widget.setMaximum(999)
self.widget.setValue(value)
case FLOAT():
if not value:
value = 0.0
self.widget = QDoubleSpinBox()
self.widget.setMaximum(999.99)
self.widget.setValue(value)
case TIMESTAMP():
self.widget = QDateEdit(self)
self.widget.setDate(value)
case JSON():
self.widget = JsonEditButton(parent=self, key=key)
self.widget.viewButton.clicked.connect(lambda: self.parent().toggle_textedit(self.widget))
self.widget.addButton.clicked.connect(lambda: self.parent().add_to_json(self.widget))
case BLOB():
self.widget = QLabel("BLOB Under construction")
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 QSpinBox() | QDoubleSpinBox():
value = self.widget.value()
case QCheckBox():
value = self.widget.isChecked()
case _:
value = None
return dict(field=self.objectName(), value=value)
class EditRelationship(QWidget):
def __init__(self, parent, key: str, class_object: Any, value):
super().__init__(parent)
self.class_object = class_object #: The class of interest
self.setParent(parent)
# logger.debug(f"Edit relationship class_object: {self.class_object}")
self.label = QLabel(key.title().replace("_", " "))
self.setObjectName(key) #: key is the name of the relationship this represents
self.relationship = getattr(self.parent().instance.__class__,
key) #: relationship object for type differentiation
# logger.debug(f"self.relationship: {self.relationship}")
# logger.debug(f"Relationship uses list: {self.relationship.property.uselist}")
# NOTE: value is a database object in this case.
# logger.debug(f"Data for edit relationship: {self.data}")
self.widget = 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.existing_button.setEnabled(self.class_object.level == 1)
if not isinstance(value, list):
if value is not None:
value = [value]
else:
value = []
self.data = value
checked_manager, is_primary = check_object_in_manager(self.parent().manager, self.objectName())
if checked_manager:
logger.debug(f"Checked manager for {self.objectName()}: {checked_manager}")
logger.debug(f"Omni will inherit: {self.class_object.omni_inheritable} from {self.parent().class_object}")
if checked_manager is not None and not self.data and self.objectName() in self.parent().class_object.omni_inheritable:
logger.debug(f"Setting {checked_manager} in self.data")
self.data = [checked_manager]
if not self.relationship.property.uselist:
self.add_button.setEnabled(False)
self.existing_button.setEnabled(False)
if is_primary:
self.widget.setEnabled(False)
self.layout = QGridLayout()
self.layout.addWidget(self.label, 0, 0, 1, 5)
self.layout.addWidget(self.widget, 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 update_buttons(self):
if not self.relationship.property.uselist and len(self.data) >= 1:
logger.debug(f"Property {self.relationship} doesn't use list and data is of length: {len(self.data)}")
self.add_button.setEnabled(False)
self.existing_button.setEnabled(False)
else:
self.add_button.setEnabled(True)
self.existing_button.setEnabled(True)
def parse_row(self, x):
context = {item: x.sibling(x.row(), self.df.columns.get_loc(item)).data() for item in self.df.columns}
try:
object = self.class_object.query(**context)
except KeyError:
object = None
self.widget.doubleClicked.disconnect()
self.add_edit(instance=object)
def add_new(self, instance: Any = None):
# NOTE: if an existing instance is not being edited, create a new instance
if not instance:
instance = self.class_object()
manager = self.parent().manager
# logger.debug(f"Managers going into add new: {managers}")
dlg = ManagerWindow(self.parent(), object_type=instance.__class__, extras=[], manager=manager, add_edit="add")
if dlg.exec():
new_instance = dlg.parse_form()
# NOTE: My custom __setattr__ should take care of any list problems.
self.parent().instance.__setattr__(self.objectName(), new_instance)
self.parent().update_data()
def add_existing(self):
dlg = SearchBox(self, object_type=self.class_object, returnable=True, extras=[])
if dlg.exec():
rows = dlg.return_selected_rows()
for row in rows:
# logger.debug(f"Querying with {row}")
instance = self.class_object.query(**row)
# NOTE: My custom __setattr__ should take care of any list problems.
self.parent().instance.__setattr__(self.objectName(), instance)
self.parent().update_data()
def set_data(self) -> None:
"""
sets data in model
"""
logger.debug(f"Self.data: {self.data}")
try:
records = [{k: v['instance_attr'] for k, v in item.omnigui_instance_dict.items()} for item in self.data]
except AttributeError:
records = []
# logger.debug(f"Records: {records}")
self.df = DataFrame.from_records(records)
try:
self.columns_of_interest = [dict(name=item, column=self.df.columns.get_loc(item)) for item in self.extras]
except (KeyError, AttributeError):
self.columns_of_interest = []
try:
self.df['id'] = self.df['id'].apply(str)
self.df['id'] = self.df['id'].str.zfill(4)
except KeyError as e:
logger.error(f"Could not alter id to string due to KeyError: {e}")
proxy_model = QSortFilterProxyModel()
proxy_model.setSourceModel(pandasModel(self.df))
self.widget.setModel(proxy_model)
self.widget.resizeColumnsToContents()
self.widget.resizeRowsToContents()
self.widget.setSortingEnabled(True)
self.widget.doubleClicked.connect(self.parse_row)
self.update_buttons()
def contextMenuEvent(self, event):
"""
Creates actions for right click menu events.
Args:
event (_type_): the item of interest
"""
if not self.widget.isEnabled():
logger.warning(f"{self.objectName()} is disabled.")
return
id = self.widget.selectionModel().currentIndex()
# NOTE: the overly complicated {column_name: row_value} dictionary construction
row_data = {self.df.columns[column]: self.widget.model().index(id.row(), column).data() for column in
range(self.widget.model().columnCount())}
logger.debug(f"Row data: {row_data}")
logger.debug(f"Attempting to grab {self.objectName()} from {self.parent().instance}")
object = getattr(self.parent().instance, self.objectName())
if isinstance(object, list):
object = next((item for item in object if item.check_all_attributes(attributes=row_data)), None)
logger.debug(f"Object of interest: {object}")
# logger.debug(object)
self.menu = QMenu(self)
try:
remove_action = QAction(f"Remove {object.name}", self)
except AttributeError:
remove_action = QAction(f"Remove object", self)
remove_action.triggered.connect(lambda: self.remove_item(object=object))
self.menu.addAction(remove_action)
try:
edit_action = QAction(f"Edit {object.name}", self)
except AttributeError:
edit_action = QAction(f"Edit object", self)
edit_action.triggered.connect(lambda: self.add_new(instance=object))
self.menu.addAction(edit_action)
self.menu.popup(QCursor.pos())
def remove_item(self, object):
logger.debug(f"Attempting to remove {object} from {self.parent().instance.__dict__}")
editor = getattr(self.parent().instance, self.objectName().lower())
logger.debug(f"Editor: {editor}")
if object == self.parent().manager:
logger.error(f"Can't remove manager object.")
return
try:
self.data.remove(object)
except (AttributeError, ValueError) as e:
logger.error(f"Could remove object from self.data due to: {e}")
self.data = []
try:
logger.debug(f"Using remove technique")
editor.remove(object)
except AttributeError as e:
logger.error(f"Remove failed using set to None for {self.objectName().lower()}.")
setattr(self.parent().instance, self.objectName().lower(), None)
except ValueError as e:
logger.error(f"Remove failed for {self.objectName().lower()} due to {e}.")
self.parent().instance.save()
self.set_data()
def parse_form(self):
return dict(field=self.objectName(), value=self.data)
class JsonEditButton(QWidget):
def __init__(self, parent, key: str):
super().__init__(parent)
self.setParent(parent)
self.setObjectName(key)
self.addButton = QPushButton("Add Entry", parent=self)
self.viewButton = QPushButton("View >>>", parent=self)
self.layout = QGridLayout()
self.layout.addWidget(self.addButton, 0, 0)
self.layout.addWidget(self.viewButton, 0, 1)
self.setLayout(self.layout)
class JsonEditScreen(QDialog):
def __init__(self, parent, key: str):
super().__init__(parent)
self.class_obj = parent.class_object
self.layout = QGridLayout()
self.setWindowTitle(key)
self.json_field = self.class_obj.json_edit_fields
match self.json_field:
case dict():
for key, value in self.json_field.items():
logger.debug(f"Key: {key}, Value: {value}")
row = self.layout.rowCount()
self.layout.addWidget(QLabel(key), row, 0)
match value:
case "int":
self.widget = QSpinBox()
case "str":
self.widget = QLineEdit()
case dict():
self.widget = DictionaryJsonSubEdit(parent=self, key=key, dic=value)
case _:
continue
self.widget.setObjectName(key)
self.layout.addWidget(self.widget, row, 1)
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.setLayout(self.layout)
def parse_form(self):
widgets = [item for item in self.findChildren(QWidget) if item.objectName() in self.json_field.keys()]
logger.debug(f"Widgets: {widgets}")
logger.debug(type(self.json_field))
if isinstance(self.json_field, dict):
output = {}
elif isinstance(self.json_field, list):
output = []
else:
raise ValueError(f"Inappropriate data type: {type(self.json_field)}")
for widget in widgets:
logger.debug(f"JsonEditScreen Widget: {widget}")
key = widget.objectName()
match widget:
case QSpinBox():
value = widget.value()
case QLineEdit():
value = widget.text()
case DictionaryJsonSubEdit():
value = widget.parse_form()
case _:
continue
if isinstance(self.json_field, dict):
output[key] = value
elif isinstance(self.json_field, list):
if isinstance(value, list):
output += value
else:
output.append(value)
else:
raise ValueError(f"Inappropriate data type: {type(self.json_field)}")
return output
class DictionaryJsonSubEdit(QWidget):
def __init__(self, parent, key, dic: dict):
super().__init__(parent)
self.layout = QHBoxLayout()
self.setObjectName(key)
self.data = dic
for key, value in self.data.items():
self.layout.addWidget(QLabel(key))
match value:
case "int":
self.widget = QSpinBox()
case "str":
self.widget = QLineEdit()
case dict():
self.widget = DictionaryJsonSubEdit(parent, key=key, dic=value)
self.widget.setObjectName(key)
self.layout.addWidget(self.widget)
self.setLayout(self.layout)
def parse_form(self):
widgets = [item for item in self.findChildren(QWidget) if item.objectName() in self.data.keys()]
logger.debug(f"Widgets: {widgets}")
output = {}
for widget in widgets:
logger.debug(f"DictionaryJsonSubEdit Widget: {widget}")
key = widget.objectName()
match widget:
case QSpinBox():
value = widget.value()
case QLineEdit():
value = widget.text()
case DictionaryJsonSubEdit():
value = widget.parse_form()
case _:
continue
output[key] = value
return output
class LargeTextEdit(QWidget):
def __init__(self, parent, key: str):
super().__init__(parent)
self.setParent(parent)
self.setObjectName(key)
self.widget = QTextEdit()
self.layout = QVBoxLayout()
self.layout.addWidget(self.widget)
self.setLayout(self.layout)