From 73752cde8769721653c75983835e54574cc66c69 Mon Sep 17 00:00:00 2001 From: lwark Date: Mon, 27 Jan 2025 08:55:34 -0600 Subject: [PATCH] Prior to updating queries to use query alias. --- src/submissions/backend/db/models/__init__.py | 55 +++++++- src/submissions/backend/db/models/kits.py | 82 ++++++++++-- .../backend/db/models/organizations.py | 8 +- .../backend/db/models/submissions.py | 13 +- src/submissions/backend/validators/pydant.py | 30 +++++ .../frontend/widgets/omni_add_edit.py | 88 +++++++++---- .../frontend/widgets/omni_manager.py | 118 ++++++++++++++---- .../frontend/widgets/omni_search.py | 27 ++-- .../frontend/widgets/submission_widget.py | 2 +- src/submissions/tools/__init__.py | 15 ++- 10 files changed, 351 insertions(+), 87 deletions(-) diff --git a/src/submissions/backend/db/models/__init__.py b/src/submissions/backend/db/models/__init__.py index 0d47641..356878b 100644 --- a/src/submissions/backend/db/models/__init__.py +++ b/src/submissions/backend/db/models/__init__.py @@ -6,13 +6,12 @@ import sys, logging from pandas import DataFrame from pydantic import BaseModel from sqlalchemy import Column, INTEGER, String, JSON -from sqlalchemy.orm import DeclarativeMeta, declarative_base, Query, Session +from sqlalchemy.orm import DeclarativeMeta, declarative_base, Query, Session, InstrumentedAttribute from sqlalchemy.ext.declarative import declared_attr from sqlalchemy.exc import ArgumentError from typing import Any, List from pathlib import Path -from tools import report_result - +from tools import report_result, list_sort_dict # NOTE: Load testing environment if 'pytest' in sys.modules: @@ -49,6 +48,30 @@ class BaseClass(Base): __table_args__ = {'extend_existing': True} #: Will only add new columns singles = ['id'] + omni_removes = ['submissions'] + omni_sort = ["name"] + + @classproperty + def skip_on_edit(cls): + if "association" in cls.__name__.lower() or cls.__name__.lower() == "discount": + return True + else: + return False + + @classproperty + def aliases(cls): + return [cls.__name__.lower()] + + @classproperty + def level(cls): + if "association" in cls.__name__.lower() or cls.__name__.lower() == "discount": + return 2 + else: + return 1 + + @classproperty + def query_alias(cls): + return cls.__name__.lower() @classmethod @declared_attr @@ -175,7 +198,7 @@ class BaseClass(Base): try: records = [obj.to_sub_dict(**kwargs) for obj in objects] except AttributeError: - records = [obj.omnigui_dict for obj in objects] + records = [{k:v['instance_attr'] for k, v in obj.to_omnigui_dict(**kwargs).items()} for obj in objects] return DataFrame.from_records(records) @classmethod @@ -249,11 +272,25 @@ class BaseClass(Base): Returns: dict: Dictionary of object minus _sa_instance_state with id at the front. """ - dicto = {k: v for k, v in self.__dict__.items() if k not in ["_sa_instance_state"]} + # dicto = {k: v for k, v in self.__dict__.items() if k not in ["_sa_instance_state"]} + dicto = {key: dict(class_attr=getattr(self.__class__, key), instance_attr=getattr(self, key)) + for key in dir(self.__class__) if + isinstance(getattr(self.__class__, key), InstrumentedAttribute) and key not in self.omni_removes + } + for k, v in dicto.items(): + try: + v['instance_attr'] = v['instance_attr'].name + except AttributeError: + continue + try: + dicto = list_sort_dict(input_dict=dicto, sort_list=self.__class__.omni_sort) + except TypeError as e: + logger.error(f"Could not sort {self.__class__.__name__} by list due to :{e}") try: dicto = {'id': dicto.pop('id'), **dicto} except KeyError: pass + # logger.debug(f"{self.__class__.__name__} omnigui dict:\n\n{pformat(dicto)}") return dicto @classproperty @@ -268,6 +305,7 @@ class BaseClass(Base): try: model = getattr(pydant, f"Pyd{cls.__name__}") except AttributeError: + logger.warning(f"Couldn't get {cls.__name__} pydantic model.") return None return model @@ -281,6 +319,13 @@ class BaseClass(Base): """ return dict() + @classmethod + def relevant_relationships(cls, relationship_instance): + query_kwargs = {relationship_instance.query_alias:relationship_instance} + return cls.query(**query_kwargs) + + + class ConfigItem(BaseClass): """ diff --git a/src/submissions/backend/db/models/kits.py b/src/submissions/backend/db/models/kits.py index 5394bbc..d9ddb5f 100644 --- a/src/submissions/backend/db/models/kits.py +++ b/src/submissions/backend/db/models/kits.py @@ -7,6 +7,7 @@ from pprint import pformat from sqlalchemy import Column, String, TIMESTAMP, JSON, INTEGER, ForeignKey, Interval, Table, FLOAT, BLOB from sqlalchemy.orm import relationship, validates, Query from sqlalchemy.ext.associationproxy import association_proxy +from sqlalchemy.ext.hybrid import hybrid_property from datetime import date, datetime, timedelta from tools import check_authorization, setup_lookup, Report, Result, check_regex_match, yaml_regex_creator, timezone from typing import List, Literal, Generator, Any, Tuple @@ -95,6 +96,8 @@ class KitType(BaseClass): Base of kits used in submission processing """ + query_alias = "kit_type" + id = Column(INTEGER, primary_key=True) #: primary key name = Column(String(64), unique=True) #: name of kit submissions = relationship("BasicSubmission", back_populates="extraction_kit") #: submissions this kit was used for @@ -129,6 +132,10 @@ class KitType(BaseClass): """ return f"" + @classproperty + def aliases(cls): + return super().aliases + [cls.query_alias, "kit_types"] + def get_reagents(self, required: bool = False, submission_type: str | SubmissionType | None = None @@ -157,7 +164,7 @@ class KitType(BaseClass): else: return (item.reagent_role for item in relevant_associations) - def construct_xl_map_for_use(self, submission_type: str | SubmissionType) -> Tuple[dict|None, KitType]: + def construct_xl_map_for_use(self, submission_type: str | SubmissionType) -> Tuple[dict | None, KitType]: """ Creates map of locations in Excel workbook for a SubmissionType @@ -402,12 +409,14 @@ class ReagentRole(BaseClass): name: str | None = None, kit_type: KitType | str | None = None, reagent: Reagent | str | None = None, + id: int | None = None, limit: int = 0, ) -> ReagentRole | List[ReagentRole]: """ Lookup reagent types in the database. Args: + id (id | None, optional): Id of the object. Defaults to None. name (str | None, optional): Reagent type name. Defaults to None. kit_type (KitType | str | None, optional): Kit the type of interest belongs to. Defaults to None. reagent (Reagent | str | None, optional): Concrete instance of the type of interest. Defaults to None. @@ -445,6 +454,12 @@ class ReagentRole(BaseClass): limit = 1 case _: pass + match id: + case int(): + query = query.filter(cls.id == id) + limit = 1 + case _: + pass return cls.execute_query(query=query, limit=limit) def to_pydantic(self) -> "PydReagent": @@ -476,7 +491,7 @@ class Reagent(BaseClass, LogMixin): Concrete reagent instance """ - searchables = [dict(label="Lot", field="lot")] + id = Column(INTEGER, primary_key=True) #: primary key role = relationship("ReagentRole", back_populates="instances", @@ -504,6 +519,10 @@ class Reagent(BaseClass, LogMixin): name = f"" return name + @classproperty + def searchables(cls): + return [dict(label="Lot", field="lot")] + def to_sub_dict(self, extraction_kit: KitType = None, full_data: bool = False, **kwargs) -> dict: """ dictionary containing values necessary for gui @@ -581,7 +600,7 @@ class Reagent(BaseClass, LogMixin): from backend.validators.pydant import PydReagent new = False disallowed = ['expiry'] - sanitized_kwargs = {k:v for k,v in kwargs.items() if k not in disallowed} + sanitized_kwargs = {k: v for k, v in kwargs.items() if k not in disallowed} instance = cls.query(**sanitized_kwargs) if not instance or isinstance(instance, list): if "role" not in kwargs: @@ -700,6 +719,8 @@ class Discount(BaseClass): Relationship table for client labs for certain kits. """ + skip_on_edit = True + id = Column(INTEGER, primary_key=True) #: primary key kit = relationship("KitType") #: joined parent reagent type kit_id = Column(INTEGER, ForeignKey("_kittype.id", ondelete='SET NULL', name="fk_kit_type_id")) #: id of joined kit @@ -812,6 +833,14 @@ class SubmissionType(BaseClass): """ return f"" + @classproperty + def aliases(cls): + return super().aliases + ["submission_types", "submission_type"] + + @classproperty + def omni_removes(cls): + return super().omni_removes + ["template_file", "defaults", "instances"] + @classproperty def basic_template(cls) -> bytes: """ @@ -1063,6 +1092,10 @@ class SubmissionTypeKitTypeAssociation(BaseClass): Abstract of relationship between kits and their submission type. """ + omni_removes = BaseClass.omni_removes + ["submission_types_id", "kits_id"] + omni_sort = ["submission_type", "kit_type"] + level = 2 + submission_types_id = Column(INTEGER, ForeignKey("_submissiontype.id"), primary_key=True) #: id of joined submission type kits_id = Column(INTEGER, ForeignKey("_kittype.id"), primary_key=True) #: id of joined kit @@ -1093,12 +1126,17 @@ class SubmissionTypeKitTypeAssociation(BaseClass): """ return f"" + @property + def name(self): + return f"{self.submission_type.name} -> {self.kit_type.name}" + @classmethod @setup_lookup def query(cls, submission_type: SubmissionType | str | int | None = None, kit_type: KitType | str | int | None = None, - limit: int = 0 + limit: int = 0, + **kwargs ) -> SubmissionTypeKitTypeAssociation | List[SubmissionTypeKitTypeAssociation]: """ Lookup SubmissionTypeKitTypeAssociations of interest. @@ -1126,7 +1164,9 @@ class SubmissionTypeKitTypeAssociation(BaseClass): query = query.join(KitType).filter(KitType.name == kit_type) case int(): query = query.join(KitType).filter(KitType.id == kit_type) - limit = query.count() + if kit_type is not None and submission_type is not None: + limit = 1 + # limit = query.count() return cls.execute_query(query=query, limit=limit) def to_export_dict(self): @@ -1148,6 +1188,9 @@ class KitTypeReagentRoleAssociation(BaseClass): DOC: https://docs.sqlalchemy.org/en/14/orm/extensions/associationproxy.html """ + omni_removes = BaseClass.omni_removes + ["submission_type_id", "kits_id", "reagent_roles_id", "last_used"] + omni_sort = ["submission_type", "kit_type", "reagent_role", "required", "uses"] + reagent_roles_id = Column(INTEGER, ForeignKey("_reagentrole.id"), primary_key=True) #: id of associated reagent type kits_id = Column(INTEGER, ForeignKey("_kittype.id"), primary_key=True) #: id of associated reagent type @@ -1176,6 +1219,10 @@ class KitTypeReagentRoleAssociation(BaseClass): def __repr__(self) -> str: return f"" + @property + def name(self): + return f"{self.kit_type.name} -> {self.reagent_role.name}" + @validates('required') def validate_required(self, key, value): """ @@ -1219,7 +1266,8 @@ class KitTypeReagentRoleAssociation(BaseClass): def query(cls, kit_type: KitType | str | None = None, reagent_role: ReagentRole | str | None = None, - limit: int = 0 + limit: int = 0, + **kwargs ) -> KitTypeReagentRoleAssociation | List[KitTypeReagentRoleAssociation]: """ Lookup junction of ReagentType and KitType @@ -1280,6 +1328,12 @@ class KitTypeReagentRoleAssociation(BaseClass): for rel_reagent in relevant_reagents: yield rel_reagent + @property + def omnigui_dict(self) -> dict: + dicto = super().omnigui_dict + dicto['required']['instance_attr'] = bool(dicto['required']['instance_attr']) + return dicto + class SubmissionReagentAssociation(BaseClass): """ @@ -1420,7 +1474,7 @@ class Equipment(BaseClass, LogMixin): def get_processes(self, submission_type: str | SubmissionType | None = None, extraction_kit: str | KitType | None = None, - equipment_role: str | EquipmentRole | None=None) -> List[str]: + equipment_role: str | EquipmentRole | None = None) -> List[str]: """ Get all processes associated with this Equipment for a given SubmissionType @@ -1498,7 +1552,8 @@ class Equipment(BaseClass, LogMixin): PydEquipment: pydantic equipment object """ from backend.validators.pydant import PydEquipment - processes = self.get_processes(submission_type=submission_type, extraction_kit=extraction_kit, equipment_role=role) + processes = self.get_processes(submission_type=submission_type, extraction_kit=extraction_kit, + equipment_role=role) return PydEquipment(processes=processes, role=role, **self.to_dict(processes=False)) @@ -1718,7 +1773,8 @@ class SubmissionEquipmentAssociation(BaseClass): @classmethod @setup_lookup - def query(cls, equipment_id: int|None=None, submission_id: int|None=None, role: str | None = None, limit: int = 0, **kwargs) \ + def query(cls, equipment_id: int | None = None, submission_id: int | None = None, role: str | None = None, + limit: int = 0, **kwargs) \ -> Any | List[Any]: query: Query = cls.__database_session__.query(cls) query = query.filter(cls.equipment_id == equipment_id) @@ -1819,9 +1875,10 @@ class Process(BaseClass): name: str | None = None, id: int | None = None, submission_type: str | SubmissionType | None = None, - extraction_kit : str | KitType | None = None, + extraction_kit: str | KitType | None = None, equipment_role: str | KitType | None = None, - limit: int = 0) -> Process | List[Process]: + limit: int = 0, + **kwargs) -> Process | List[Process]: """ Lookup Processes @@ -1876,6 +1933,8 @@ class Process(BaseClass): def save(self): super().save() + # @classmethod + class TipRole(BaseClass): """ @@ -2019,7 +2078,6 @@ class SubmissionTipsAssociation(BaseClass): instance = SubmissionTipsAssociation(submission=submission, tips=tips, role_name=role) return instance - def to_pydantic(self): from backend.validators import PydTips return PydTips(name=self.tips.name, lot=self.tips.lot, role=self.role_name) diff --git a/src/submissions/backend/db/models/organizations.py b/src/submissions/backend/db/models/organizations.py index 1ac2907..ffc563d 100644 --- a/src/submissions/backend/db/models/organizations.py +++ b/src/submissions/backend/db/models/organizations.py @@ -28,6 +28,8 @@ class Organization(BaseClass): Base of organization """ + + id = Column(INTEGER, primary_key=True) #: primary key name = Column(String(64)) #: organization name submissions = relationship("BasicSubmission", @@ -124,8 +126,6 @@ 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 @@ -137,6 +137,10 @@ class Contact(BaseClass): def __repr__(self) -> str: return f"" + @classproperty + def searchables(cls): + return [] + @classmethod @setup_lookup def query(cls, diff --git a/src/submissions/backend/db/models/submissions.py b/src/submissions/backend/db/models/submissions.py index fe2353e..ad69e3c 100644 --- a/src/submissions/backend/db/models/submissions.py +++ b/src/submissions/backend/db/models/submissions.py @@ -1960,6 +1960,7 @@ class WastewaterArtic(BasicSubmission): input_dict['rsl_number'] = cls.en_adapter(input_str=input_dict['submitter_id']) # NOTE: Check for extraction negative control (Robotics) if re.search(rf"^{year}-(RSL)", input_dict['submitter_id']): + logger.debug(f"Found {year}-(RSL), so we are going to run PBS adapter:") input_dict['rsl_number'] = cls.pbs_adapter(input_str=input_dict['submitter_id']) return input_dict @@ -2019,7 +2020,9 @@ class WastewaterArtic(BasicSubmission): """ # NOTE: Remove letters. processed = input_str.replace("RSL", "") + # NOTE: Remove brackets at end processed = re.sub(r"\(.*\)$", "", processed).strip() + # NOTE: Remove any non-R letters at end. processed = re.sub(r"[A-QS-Z]+\d*", "", processed) # NOTE: Remove trailing '-' if any processed = processed.strip("-") @@ -2037,6 +2040,8 @@ class WastewaterArtic(BasicSubmission): if repeat_num is None and "R" in plate_num: repeat_num = "1" plate_num = re.sub(r"R", rf"R{repeat_num}", plate_num) + # NOTE: Remove any redundant -digits + processed = re.sub(r"-\d$", "", processed) day = re.search(r"\d{2}$", processed).group() processed = rreplace(processed, day, "") month = re.search(r"\d{2}$", processed).group() @@ -2237,8 +2242,6 @@ class BasicSample(BaseClass, LogMixin): Base of basic sample which polymorphs into BCSample and WWSample """ - searchables = [dict(label="Submitter ID", field="submitter_id")] - id = Column(INTEGER, primary_key=True) #: primary key submitter_id = Column(String(64), nullable=False, unique=True) #: identification from submitter sample_type = Column(String(32)) #: mode_sub_type of sample @@ -2287,6 +2290,10 @@ class BasicSample(BaseClass, LogMixin): except AttributeError: return f" List[str]: """ @@ -2657,7 +2664,7 @@ class WastewaterSample(BasicSample): Returns: List[str]: List of fields. """ - searchables = super().searchables + searchables = deepcopy(super().searchables) for item in ["ww_processing_num", "ww_full_sample_id", "rsl_number"]: label = item.strip("ww_").replace("_", " ").replace("rsl", "RSL").title() searchables.append(dict(label=label, field=item)) diff --git a/src/submissions/backend/validators/pydant.py b/src/submissions/backend/validators/pydant.py index b138ae7..84ef286 100644 --- a/src/submissions/backend/validators/pydant.py +++ b/src/submissions/backend/validators/pydant.py @@ -1205,3 +1205,33 @@ class PydIridaControl(BaseModel, extra='ignore'): if instance.__getattribute__(key) != field_value: instance.__setattr__(key, field_value) return instance + + +class PydProcess(BaseModel, extra="allow"): + name: str + submission_types: List[str] + equipment: List[str] + equipment_roles: List[str] + kit_types: List[str] + tip_roles: List[str] + + @field_validator("submission_types", "equipment", "equipment_roles", "kit_types", "tip_roles", mode="before") + @classmethod + def enforce_list(cls, value): + if not isinstance(value, list): + return [value] + return value + + def to_sql(self): + instance = Process.query(name=self.name) + if not instance: + instance = Process() + dicto = instance.omnigui_dict + for key in self.model_fields: + # field_value = self.__getattribute__(key) + # if instance.__getattribute__(key) != field_value: + # instance.__setattr__(key, field_value) + test = dicto[key] + print(f"Attribute: {test['class_attr'].property}") + + return instance diff --git a/src/submissions/frontend/widgets/omni_add_edit.py b/src/submissions/frontend/widgets/omni_add_edit.py index b27d80a..96eff45 100644 --- a/src/submissions/frontend/widgets/omni_add_edit.py +++ b/src/submissions/frontend/widgets/omni_add_edit.py @@ -6,31 +6,40 @@ from pprint import pformat from typing import Any, Tuple from pydantic import BaseModel from PyQt6.QtWidgets import ( - QLabel, QDialog, QWidget, QLineEdit, QGridLayout, QComboBox, QDialogButtonBox, QDateEdit, QSpinBox, QDoubleSpinBox + QLabel, QDialog, QWidget, QLineEdit, QGridLayout, QComboBox, QDialogButtonBox, QDateEdit, QSpinBox, QDoubleSpinBox, + QCheckBox ) -from sqlalchemy import String, TIMESTAMP, INTEGER, FLOAT +from sqlalchemy import String, TIMESTAMP, INTEGER, FLOAT, JSON, BLOB from sqlalchemy.orm import InstrumentedAttribute, ColumnProperty import logging from sqlalchemy.orm.relationships import _RelationshipDeclared from tools import Report, report_result +from backend import db logger = logging.getLogger(f"submissions.{__name__}") class AddEdit(QDialog): - def __init__(self, parent, instance: Any | None = None, manager: str = ""): + def __init__(self, parent, instance: Any | None = None, managers: set = set()): super().__init__(parent) + logger.debug(f"Managers: {managers}") self.instance = instance self.object_type = instance.__class__ + self.managers = managers + if instance.level < 2: + try: + self.managers.add(self.parent().instance) + except AttributeError: + pass + logger.debug(f"Managers: {managers}") 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 = {key: dict(class_attr=getattr(self.object_type, key), instance_attr=getattr(self.instance, key)) - for key in dir(self.object_type) if isinstance(getattr(self.object_type, key), InstrumentedAttribute) - and "id" not in key and key != manager} + logger.debug(f"Fields: {pformat(self.instance.omnigui_dict)}") + fields = {k: v for k, v in self.instance.omnigui_dict.items() if "id" not in k} # NOTE: Move 'name' to the front try: fields = {'name': fields.pop('name'), **fields} @@ -52,15 +61,17 @@ class AddEdit(QDialog): self.layout.addWidget(widget, self.layout.rowCount(), 0) height_counter += 1 self.layout.addWidget(self.buttonBox) - self.setWindowTitle(f"Add/Edit {self.object_type.__name__}") + self.setWindowTitle(f"Add/Edit {self.object_type.__name__} - Manager: {self.managers}") self.setMinimumSize(600, 50 * height_counter) self.setLayout(self.layout) @report_result def parse_form(self) -> Tuple[BaseModel, Report]: + from backend.validators import pydant report = Report() - parsed = {result[0].strip(":"): result[1] for result in [item.parse_form() for item in self.findChildren(EditProperty)] if result[0]} - # logger.debug(parsed) + parsed = {result[0].strip(":"): result[1] for result in + [item.parse_form() for item in self.findChildren(EditProperty)] if result[0]} + logger.debug(f"Parsed form: {parsed}") model = self.object_type.pydantic_model # NOTE: Hand-off to pydantic model for validation. # NOTE: Also, why am I not just using the toSQL method here. I could write one for contacts. @@ -75,23 +86,42 @@ class EditProperty(QWidget): self.name = key self.label = QLabel(key.title().replace("_", " ")) self.layout = QGridLayout() - self.layout.addWidget(self.label, 0, 0, 1, 1) self.setObjectName(key) + try: + self.property_class = column_type['class_attr'].property.entity.class_ + except AttributeError: + self.property_class = None + try: + self.is_list = column_type['class_attr'].property.uselist + except AttributeError: + self.is_list = False match column_type['class_attr'].property: case ColumnProperty(): self.column_property_set(column_type, value=value) case _RelationshipDeclared(): - self.relationship_property_set(column_type, value=value) + if not self.property_class.skip_on_edit: + self.relationship_property_set(column_type, value=value) + else: + return case _: logger.error(f"{column_type} not a supported type.") return + # if not self.is_list: + self.layout.addWidget(self.label, 0, 0, 1, 1) self.layout.addWidget(self.widget, 0, 1, 1, 3) self.setLayout(self.layout) - def relationship_property_set(self, relationship_property, value=None): - self.property_class = relationship_property['class_attr'].property.entity.class_ - self.is_list = relationship_property['class_attr'].property.uselist - choices = [""] + [item.name for item in self.property_class.query()] + def relationship_property_set(self, relationship, value=None): + self.widget = QComboBox() + logger.debug(self.parent().managers) + for manager in self.parent().managers: + if self.name in manager.aliases: + logger.debug(f"Name: {self.name} is in aliases: {manager.aliases}") + choices = [manager.name] + self.widget.setEnabled(False) + break + else: + choices = [""] + [item.name for item in self.property_class.query()] try: instance_value = getattr(self.parent().instance, self.objectName()) except AttributeError: @@ -102,21 +132,25 @@ class EditProperty(QWidget): instance_value = next((item.name for item in instance_value), None) if instance_value: choices.insert(0, choices.pop(choices.index(instance_value))) - self.widget = QComboBox() self.widget.addItems(choices) def column_property_set(self, column_property, value=None): + logger.debug(f"Column Property: {column_property['class_attr'].expression} {column_property}, Value: {value}") match column_property['class_attr'].expression.type: case String(): - if not value: + if value is None: value = "" self.widget = QLineEdit(self) self.widget.setText(value) case INTEGER(): - if not value: - value = 1 - self.widget = QSpinBox() - self.widget.setValue(value) + if isinstance(column_property['instance_attr'], bool): + self.widget = QCheckBox() + self.widget.setChecked(value) + else: + if value is None: + value = 1 + self.widget = QSpinBox() + self.widget.setValue(value) case FLOAT(): if not value: value = 1.0 @@ -127,6 +161,10 @@ class EditProperty(QWidget): if not value: value = date.today() self.widget.setDate(value) + case JSON(): + self.widget = QLabel("JSON Under construction") + case BLOB(): + self.widget = QLabel("BLOB Under construction") case _: logger.error(f"{column_property} not a supported property.") self.widget = None @@ -151,10 +189,10 @@ class EditProperty(QWidget): value = self.widget.currentText() case QSpinBox() | QDoubleSpinBox(): value = self.widget.value() - # if self.is_list: - # value = [self.property_class.query(name=prelim)] - # else: - # value = self.property_class.query(name=prelim) + case QCheckBox(): + value = self.widget.isChecked() 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 index 3f5d9b5..fa51212 100644 --- a/src/submissions/frontend/widgets/omni_manager.py +++ b/src/submissions/frontend/widgets/omni_manager.py @@ -6,9 +6,10 @@ 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 + QTableView, QWidget, QLineEdit, QGridLayout, QComboBox, QPushButton, QDialogButtonBox, QDateEdit, QMenu, + QDoubleSpinBox, QSpinBox, QCheckBox ) -from sqlalchemy import String, TIMESTAMP +from sqlalchemy import String, TIMESTAMP, FLOAT, INTEGER, JSON, BLOB from sqlalchemy.orm import InstrumentedAttribute from sqlalchemy.orm.collections import InstrumentedList from sqlalchemy.orm.properties import ColumnProperty @@ -28,10 +29,16 @@ 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): + def __init__(self, parent, object_type: Any, extras: List[str], managers: set = set(), **kwargs): super().__init__(parent) self.object_type = self.original_type = object_type self.instance = None + self.managers = managers + try: + self.managers.add(self.parent().instance) + except AttributeError: + pass + logger.debug(f"Managers: {managers}") self.extras = extras self.context = kwargs self.layout = QGridLayout(self) @@ -55,7 +62,7 @@ class ManagerWindow(QDialog): self.options.setObjectName("options") self.update_options() self.setLayout(self.layout) - self.setWindowTitle(f"Manage {self.object_type.__name__}") + self.setWindowTitle(f"Manage {self.object_type.__name__} - Managers: {self.managers}") def update_options(self) -> None: """ @@ -63,8 +70,15 @@ class ManagerWindow(QDialog): """ if self.sub_class: self.object_type = getattr(db, self.sub_class.currentText()) - options = [item.name for item in self.object_type.query()] - logger.debug(f"self.instance: {self.instance}") + 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.object_type.query(**query_kwargs)] + logger.debug(f"self.object_type: {self.object_type}") if self.instance: options.insert(0, options.pop(options.index(self.instance.name))) self.options.clear() @@ -92,21 +106,24 @@ class ManagerWindow(QDialog): for item in deletes: item.setParent(None) # NOTE: Find the instance this manager will update - self.instance = self.object_type.query(name=self.options.currentText()) - fields = {k: v for k, v in self.object_type.__dict__.items() if - isinstance(v, InstrumentedAttribute) and k != "id"} + logger.debug(f"Querying with {self.options.currentText()}") + self.instance = self.object_type.query(name=self.options.currentText(), limit=1) + logger.debug(f"Instance: {self.instance}") + fields = {k: v for k, v in self.instance.omnigui_dict.items() if + isinstance(v['class_attr'], InstrumentedAttribute) and k != "id"} + # logger.debug(f"Instance fields: {fields}") for key, field in fields.items(): - match field.property: + 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.property.expression.type, + widget = EditProperty(self, key=key, column_type=field, value=getattr(self.instance, key)) # NOTE: RelationshipDeclareds will be given a list of existing related objects. case _RelationshipDeclared(): if key != "submissions": # NOTE: field.comparator.entity.class_ gives the relationship class - widget = EditRelationship(self, key=key, entity=field.comparator.entity.class_, + widget = EditRelationship(self, key=key, entity=field['class_attr'].comparator.entity.class_, value=getattr(self.instance, key)) else: continue @@ -132,7 +149,7 @@ class ManagerWindow(QDialog): return self.instance def add_new(self): - dlg = AddEdit(parent=self, instance=self.object_type(), manager=self.object_type.__name__.lower()) + dlg = AddEdit(parent=self, instance=self.object_type(), managers=self.managers) if dlg.exec(): new_pyd = dlg.parse_form() new_instance = new_pyd.to_sql() @@ -148,13 +165,33 @@ class EditProperty(QWidget): self.label = QLabel(key.title().replace("_", " ")) self.layout = QGridLayout() self.layout.addWidget(self.label, 0, 0, 1, 1) - match column_type: + logger.debug(f"Column type: {column_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 = 1 + self.widget = QSpinBox() + self.widget.setValue(value) + case FLOAT(): + if not value: + value = 1.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 = QLabel("JSON Under construction") + case BLOB(): + self.widget = QLabel("BLOB Under construction") case _: self.widget = None self.layout.addWidget(self.widget, 0, 1, 1, 3) @@ -175,7 +212,7 @@ class EditRelationship(QWidget): def __init__(self, parent, key: str, entity: Any, value): super().__init__(parent) - self.entity = entity + self.entity = entity #: The class of interest self.data = value self.label = QLabel(key.title().replace("_", " ")) self.setObjectName(key) @@ -184,10 +221,11 @@ class EditRelationship(QWidget): 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.entity.level == 1) 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.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() @@ -205,17 +243,30 @@ class EditRelationship(QWidget): 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.entity() - dlg = AddEdit(self, instance=instance, manager=self.parent().object_type.__name__.lower()) + # if self.parent().object_type.level == 2: + managers = self.parent().managers + # else: + # managers = self.parent().managers + [self.parent().instance] + match instance.level: + case 1: + dlg = AddEdit(self.parent(), instance=instance, managers=managers) + case 2: + dlg = ManagerWindow(self.parent(), object_type=instance.__class__, extras=[], managers=managers) + case _: + return if dlg.exec(): new_instance = dlg.parse_form() - new_instance, result = new_instance.to_sql() + new_instance = new_instance.to_sql() logger.debug(f"New instance: {new_instance}") addition = getattr(self.parent().instance, self.objectName()) - if isinstance(addition, InstrumentedList): - addition.append(new_instance) - self.parent().instance.save() + logger.debug(f"Addition: {addition}") + # NOTE: Saving currently disabled + # if isinstance(addition, InstrumentedList): + # addition.append(new_instance) + # self.parent().instance.save() self.parent().update_data() def add_existing(self): @@ -223,11 +274,15 @@ class EditRelationship(QWidget): if dlg.exec(): rows = dlg.return_selected_rows() for row in rows: + logger.debug(f"Querying with {row}") instance = self.entity.query(**row) + logger.debug(f"Queried instance: {instance}") addition = getattr(self.parent().instance, self.objectName()) - if isinstance(addition, InstrumentedList): - addition.append(instance) - self.parent().instance.save() + logger.debug(f"Addition: {addition}") + # NOTE: Saving currently disabled + # if isinstance(addition, InstrumentedList): + # addition.append(instance) + # self.parent().instance.save() self.parent().update_data() def set_data(self) -> None: @@ -235,7 +290,11 @@ class EditRelationship(QWidget): sets data in model """ # logger.debug(self.data) - self.data = DataFrame.from_records([item.omnigui_dict for item in self.data]) + if not isinstance(self.data, list): + self.data = [self.data] + records = [{k: v['instance_attr'] for k, v in item.omnigui_dict.items()} for item in self.data] + # logger.debug(f"Records: {records}") + self.data = DataFrame.from_records(records) try: self.columns_of_interest = [dict(name=item, column=self.data.columns.get_loc(item)) for item in self.extras] except (KeyError, AttributeError): @@ -261,8 +320,13 @@ class EditRelationship(QWidget): event (_type_): the item of interest """ id = self.table.selectionModel().currentIndex() - id = int(id.sibling(id.row(), 0).data()) - object = self.entity.query(id=id) + # NOTE: the overly complicated {column_name: row_value} dictionary construction + row_data = {self.data.columns[column]: self.table.model().index(id.row(), column).data() for column in + range(self.table.model().columnCount())} + object = self.entity.query(**row_data) + if isinstance(object, list): + object = object[0] + logger.debug(object) self.menu = QMenu(self) action = QAction(f"Remove {object.name}", self) action.triggered.connect(lambda: self.remove_item(object=object)) diff --git a/src/submissions/frontend/widgets/omni_search.py b/src/submissions/frontend/widgets/omni_search.py index 978c37f..f9a5de9 100644 --- a/src/submissions/frontend/widgets/omni_search.py +++ b/src/submissions/frontend/widgets/omni_search.py @@ -1,6 +1,7 @@ """ Search box that performs fuzzy search for various object types """ +from copy import deepcopy from pprint import pformat from typing import Tuple, Any, List, Generator from pandas import DataFrame @@ -22,7 +23,8 @@ class SearchBox(QDialog): def __init__(self, parent, object_type: Any, extras: List[dict], returnable: bool = False, **kwargs): super().__init__(parent) - self.object_type = self.original_type = object_type + self.object_type = object_type + self.original_type = object_type self.extras = extras self.context = kwargs self.layout = QGridLayout(self) @@ -43,7 +45,7 @@ class SearchBox(QDialog): self.setLayout(self.layout) self.setWindowTitle(f"Search {self.object_type.__name__}") self.update_widgets() - self.update_data() + # self.update_data() if returnable: QBtn = QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel self.buttonBox = QDialogButtonBox(QBtn) @@ -57,21 +59,30 @@ class SearchBox(QDialog): """ Changes form inputs based on sample type """ + search_fields = [] + logger.debug(f"Search fields: {search_fields}") deletes = [item for item in self.findChildren(FieldSearch)] for item in deletes: item.setParent(None) # NOTE: Handle any subclasses if not self.sub_class: + logger.warning(f"No subclass selected.") self.update_data() + return else: if self.sub_class.currentText() == "Any": self.object_type = self.original_type else: self.object_type = self.original_type.find_regular_subclass(self.sub_class.currentText()) - try: - search_fields = self.object_type.searchables - except AttributeError: - search_fields = [] + # logger.debug(f"Object type: {self.object_type} - {self.object_type.searchables}") + # logger.debug(f"Original type: {self.original_type} - {self.original_type.searchables}") + for item in self.object_type.searchables: + if item['field'] in [item['field'] for item in search_fields]: + logger.debug(f"Already have {item['field']}") + continue + else: + search_fields.append(item) + logger.debug(f"Search fields: {search_fields}") for iii, searchable in enumerate(search_fields): widget = FieldSearch(parent=self, label=searchable['label'], field_name=searchable['field']) widget.setObjectName(searchable['field']) @@ -120,6 +131,7 @@ class FieldSearch(QWidget): def __init__(self, parent, label, field_name): super().__init__(parent) + self.setParent(parent) self.layout = QVBoxLayout(self) label_widget = QLabel(label) self.layout.addWidget(label_widget) @@ -158,9 +170,8 @@ class SearchResults(QTableView): self.context = kwargs self.parent = parent self.object_type = object_type - try: - self.extras = extras + self.object_type.searchables + self.extras = extras + [item for item in deepcopy(self.object_type.searchables)] except AttributeError: self.extras = extras logger.debug(f"Extras: {self.extras}") diff --git a/src/submissions/frontend/widgets/submission_widget.py b/src/submissions/frontend/widgets/submission_widget.py index 13a6a08..4e96756 100644 --- a/src/submissions/frontend/widgets/submission_widget.py +++ b/src/submissions/frontend/widgets/submission_widget.py @@ -684,7 +684,7 @@ class SubmissionFormWidget(QWidget): message=f"Couldn't find reagent type {self.reagent.role}: {lot} in the database.\n\nWould you like to add it?") if dlg.exec(): - wanted_reagent = self.parent().parent().new_add_reagent(instance=wanted_reagent) + wanted_reagent = self.parent.parent().add_reagent(instance=wanted_reagent) return wanted_reagent, report else: # NOTE: In this case we will have an empty reagent and the submission will fail kit integrity check diff --git a/src/submissions/tools/__init__.py b/src/submissions/tools/__init__.py index 01ca799..420b1a5 100644 --- a/src/submissions/tools/__init__.py +++ b/src/submissions/tools/__init__.py @@ -931,6 +931,17 @@ def rreplace(s: str, old: str, new: str) -> str: return (s[::-1].replace(old[::-1], new[::-1], 1))[::-1] +def list_sort_dict(input_dict: dict, sort_list: list) -> dict: + # sort_list.reverse() + sort_list = reversed(sort_list) + for item in sort_list: + try: + input_dict = {item: input_dict.pop(item), **input_dict} + except KeyError: + continue + return input_dict + + def remove_key_from_list_of_dicts(input_list: list, key: str) -> list: """ Removes a key from all dictionaries in a list of dictionaries @@ -1067,7 +1078,6 @@ def report_result(func): def wrapper(*args, **kwargs): logger.info(f"Report result being called by {func.__name__}") output = func(*args, **kwargs) - print(f"Function output: {output}") match output: case Report(): report = output @@ -1091,14 +1101,11 @@ def report_result(func): logger.error(f"Problem reporting due to {e}") logger.error(result.msg) if output: - print(f"Output going into checking: {output}") if is_list_etc(output): - print(f"Output of type {type(output)} is iterable") true_output = tuple(item for item in output if not isinstance(item, Report)) if len(true_output) == 1: true_output = true_output[0] else: - print(f"Output is of type {type(output)}") if isinstance(output, Report): true_output = None else: