Mid-progress adding controls to pydantic creation.

This commit is contained in:
lwark
2024-11-21 08:46:41 -06:00
parent 506aac80c1
commit 7d1e6dc606
16 changed files with 224 additions and 187 deletions

View File

@@ -1,3 +1,7 @@
## 202411.04
- Add reagent from scrape now limits roles to those found in kit to prevent confusion.
## 202411.01 ## 202411.01
- Code clean up. - Code clean up.

View File

@@ -1,4 +1,4 @@
- [ ] Find a way to merge omni_search and sample_search - [x] Find a way to merge omni_search and sample_search
- [x] Allow parsing of custom fields to a json 'custom' field in _basicsubmissions - [x] Allow parsing of custom fields to a json 'custom' field in _basicsubmissions
- [x] Upgrade to generators when returning lists. - [x] Upgrade to generators when returning lists.
- [x] Revamp frontend.widgets.controls_chart to include visualizations? - [x] Revamp frontend.widgets.controls_chart to include visualizations?

View File

@@ -1,8 +1,10 @@
""" """
All database related operations. All database related operations.
""" """
from sqlalchemy import event import sqlalchemy.orm
from sqlalchemy import event, inspect
from sqlalchemy.engine import Engine from sqlalchemy.engine import Engine
from tools import ctx from tools import ctx
@@ -16,7 +18,7 @@ def set_sqlite_pragma(dbapi_connection, connection_record):
Args: Args:
dbapi_connection (_type_): _description_ dbapi_connection (_type_): _description_
connection_record (_type_): _description_ connection_record (_type_): _description_
""" """
cursor = dbapi_connection.cursor() cursor = dbapi_connection.cursor()
# print(ctx.database_schema) # print(ctx.database_schema)
if ctx.database_schema == "sqlite": if ctx.database_schema == "sqlite":
@@ -34,3 +36,39 @@ def set_sqlite_pragma(dbapi_connection, connection_record):
from .models import * from .models import *
def update_log(mapper, connection, target):
logger.debug("\n\nBefore update\n\n")
state = inspect(target)
logger.debug(state)
update = dict(user=getuser(), time=datetime.now(), object=str(state.object), changes=[])
logger.debug(update)
for attr in state.attrs:
hist = attr.load_history()
if not hist.has_changes():
continue
added = [str(item) for item in hist.added]
deleted = [str(item) for item in hist.deleted]
change = dict(field=attr.key, added=added, deleted=deleted)
logger.debug(f"Adding: {pformat(change)}")
try:
update['changes'].append(change)
except Exception as e:
logger.error(f"Something went horribly wrong adding attr: {attr.key}: {e}")
continue
logger.debug(f"Adding to audit logs: {pformat(update)}")
if update['changes']:
# Note: must use execute as the session will be busy at this point.
# https://medium.com/@singh.surbhicse/creating-audit-table-to-log-insert-update-and-delete-changes-in-flask-sqlalchemy-f2ca53f7b02f
table = AuditLog.__table__
logger.debug(f"Adding to {table}")
connection.execute(table.insert().values(**update))
# logger.debug("Here is where I would insert values, if I was able.")
else:
logger.info(f"No changes detected, not updating logs.")
# event.listen(LogMixin, 'after_update', update_log, propagate=True)
# event.listen(LogMixin, 'after_insert', update_log, propagate=True)

View File

@@ -5,7 +5,7 @@ from __future__ import annotations
import sys, logging import sys, logging
import pandas as pd import pandas as pd
from sqlalchemy import Column, INTEGER, String, JSON from sqlalchemy import Column, INTEGER, String, JSON, event, inspect
from sqlalchemy.orm import DeclarativeMeta, declarative_base, Query, Session from sqlalchemy.orm import DeclarativeMeta, declarative_base, Query, Session
from sqlalchemy.ext.declarative import declared_attr from sqlalchemy.ext.declarative import declared_attr
from sqlalchemy.exc import ArgumentError from sqlalchemy.exc import ArgumentError
@@ -22,6 +22,12 @@ Base: DeclarativeMeta = declarative_base()
logger = logging.getLogger(f"submissions.{__name__}") logger = logging.getLogger(f"submissions.{__name__}")
class LogMixin(Base):
__abstract__ = True
class BaseClass(Base): class BaseClass(Base):
""" """
Abstract class to pass ctx values to all SQLAlchemy objects. Abstract class to pass ctx values to all SQLAlchemy objects.
@@ -99,6 +105,15 @@ class BaseClass(Base):
singles = list(set(cls.singles + BaseClass.singles)) singles = list(set(cls.singles + BaseClass.singles))
return dict(singles=singles) return dict(singles=singles)
@classmethod
def find_regular_subclass(cls, name: str | None = None):
if not name:
return cls
if " " in name:
search = name.title().replace(" ", "")
logger.debug(f"Searching for subclass: {search}")
return next((item for item in cls.__subclasses__() if item.__name__ == search), cls)
@classmethod @classmethod
def fuzzy_search(cls, **kwargs): def fuzzy_search(cls, **kwargs):
query: Query = cls.__database_session__.query(cls) query: Query = cls.__database_session__.query(cls)
@@ -175,9 +190,11 @@ class BaseClass(Base):
""" """
# logger.debug(f"Saving object: {pformat(self.__dict__)}") # logger.debug(f"Saving object: {pformat(self.__dict__)}")
report = Report() report = Report()
state = inspect(self)
try: try:
self.__database_session__.add(self) self.__database_session__.add(self)
self.__database_session__.commit() self.__database_session__.commit()
return state
except Exception as e: except Exception as e:
logger.critical(f"Problem saving object: {e}") logger.critical(f"Problem saving object: {e}")
logger.error(f"Error message: {type(e)}") logger.error(f"Error message: {type(e)}")
@@ -186,6 +203,8 @@ class BaseClass(Base):
return report return report
class ConfigItem(BaseClass): class ConfigItem(BaseClass):
""" """
Key:JSON objects to store config settings in database. Key:JSON objects to store config settings in database.
@@ -222,6 +241,7 @@ from .controls import *
from .organizations import * from .organizations import *
from .kits import * from .kits import *
from .submissions import * from .submissions import *
from .audit import AuditLog
# NOTE: Add a creator to the submission for reagent association. Assigned here due to circular import constraints. # NOTE: Add a creator to the submission for reagent association. Assigned here due to circular import constraints.
# https://docs.sqlalchemy.org/en/20/orm/extensions/associationproxy.html#sqlalchemy.ext.associationproxy.association_proxy.params.creator # https://docs.sqlalchemy.org/en/20/orm/extensions/associationproxy.html#sqlalchemy.ext.associationproxy.association_proxy.params.creator

View File

@@ -16,6 +16,7 @@ from typing import List, Literal, Tuple, Generator
from dateutil.parser import parse from dateutil.parser import parse
from re import Pattern from re import Pattern
logger = logging.getLogger(f"submissions.{__name__}") logger = logging.getLogger(f"submissions.{__name__}")
@@ -429,6 +430,10 @@ class PCRControl(Control):
fig = PCRFigure(df=df, modes=[]) fig = PCRFigure(df=df, modes=[])
return report, fig return report, fig
def to_pydantic(self):
from backend.validators import PydPCRControl
return PydPCRControl(**self.to_sub_dict())
class IridaControl(Control): class IridaControl(Control):
@@ -878,3 +883,7 @@ class IridaControl(Control):
exclude = [re.sub(rerun_regex, "", sample) for sample in sample_names if rerun_regex.search(sample)] exclude = [re.sub(rerun_regex, "", sample) for sample in sample_names if rerun_regex.search(sample)]
df = df[df.name not in exclude] df = df[df.name not in exclude]
return df return df
def to_pydantic(self):
from backend.validators import PydIridaControl
return PydIridaControl(**self.__dict__)

View File

@@ -567,7 +567,7 @@ class Reagent(BaseClass):
return cls.execute_query(query=query, limit=limit) return cls.execute_query(query=query, limit=limit)
@check_authorization @check_authorization
def edit_from_search(self, **kwargs): def edit_from_search(self, obj, **kwargs):
from frontend.widgets.misc import AddReagentForm from frontend.widgets.misc import AddReagentForm
role = ReagentRole.query(kwargs['role']) role = ReagentRole.query(kwargs['role'])
if role: if role:
@@ -1279,7 +1279,10 @@ class SubmissionReagentAssociation(BaseClass):
Returns: Returns:
str: Representation of this SubmissionReagentAssociation str: Representation of this SubmissionReagentAssociation
""" """
return f"<{self.submission.rsl_plate_num} & {self.reagent.lot}>" try:
return f"<{self.submission.rsl_plate_num} & {self.reagent.lot}>"
except AttributeError:
return f"<Unknown Submission & {self.reagent.lot}"
def __init__(self, reagent=None, submission=None): def __init__(self, reagent=None, submission=None):
if isinstance(reagent, list): if isinstance(reagent, list):

View File

@@ -12,8 +12,8 @@ from zipfile import ZipFile
from tempfile import TemporaryDirectory, TemporaryFile from tempfile import TemporaryDirectory, TemporaryFile
from operator import itemgetter from operator import itemgetter
from pprint import pformat from pprint import pformat
from . import BaseClass, Reagent, SubmissionType, KitType, Organization, Contact from . import BaseClass, Reagent, SubmissionType, KitType, Organization, Contact, LogMixin
from sqlalchemy import Column, String, TIMESTAMP, INTEGER, ForeignKey, JSON, FLOAT, case from sqlalchemy import Column, String, TIMESTAMP, INTEGER, ForeignKey, JSON, FLOAT, case, event, inspect
from sqlalchemy.orm import relationship, validates, Query from sqlalchemy.orm import relationship, validates, Query
from sqlalchemy.orm.attributes import flag_modified from sqlalchemy.orm.attributes import flag_modified
from sqlalchemy.ext.associationproxy import association_proxy from sqlalchemy.ext.associationproxy import association_proxy
@@ -36,7 +36,7 @@ from PIL import Image
logger = logging.getLogger(f"submissions.{__name__}") logger = logging.getLogger(f"submissions.{__name__}")
class BasicSubmission(BaseClass): class BasicSubmission(BaseClass, LogMixin):
""" """
Concrete of basic submission which polymorphs into BacterialCulture and Wastewater Concrete of basic submission which polymorphs into BacterialCulture and Wastewater
""" """
@@ -544,7 +544,7 @@ class BasicSubmission(BaseClass):
field_value = len(self.samples) field_value = len(self.samples)
else: else:
field_value = value field_value = value
case "ctx" | "csv" | "filepath" | "equipment": case "ctx" | "csv" | "filepath" | "equipment" | "controls":
return return
case item if item in self.jsons(): case item if item in self.jsons():
match key: match key:
@@ -577,10 +577,13 @@ class BasicSubmission(BaseClass):
except AttributeError: except AttributeError:
field_value = value field_value = value
# NOTE: insert into field # NOTE: insert into field
try: current = self.__getattribute__(key)
self.__setattr__(key, field_value) if field_value and current != field_value:
except AttributeError as e: logger.debug(f"Updated value: {key}: {current} to {field_value}")
logger.error(f"Could not set {self} attribute {key} to {value} due to \n{e}") try:
self.__setattr__(key, field_value)
except AttributeError as e:
logger.error(f"Could not set {self} attribute {key} to {value} due to \n{e}")
def update_subsampassoc(self, sample: BasicSample, input_dict: dict): def update_subsampassoc(self, sample: BasicSample, input_dict: dict):
""" """
@@ -1339,7 +1342,7 @@ class BasicSubmission(BaseClass):
# Below are the custom submission types # Below are the custom submission types
class BacterialCulture(BasicSubmission): class BacterialCulture(BasicSubmission, LogMixin):
""" """
derivative submission type from BasicSubmission derivative submission type from BasicSubmission
""" """
@@ -1426,7 +1429,7 @@ class BacterialCulture(BasicSubmission):
return input_dict return input_dict
class Wastewater(BasicSubmission): class Wastewater(BasicSubmission, LogMixin):
""" """
derivative submission type from BasicSubmission derivative submission type from BasicSubmission
""" """
@@ -2189,6 +2192,8 @@ class BasicSample(BaseClass):
Base of basic sample which polymorphs into BCSample and WWSample Base of basic sample which polymorphs into BCSample and WWSample
""" """
searchables = ['submitter_id']
id = Column(INTEGER, primary_key=True) #: primary key id = Column(INTEGER, primary_key=True) #: primary key
submitter_id = Column(String(64), nullable=False, unique=True) #: identification from submitter submitter_id = Column(String(64), nullable=False, unique=True) #: identification from submitter
sample_type = Column(String(32)) #: mode_sub_type of sample sample_type = Column(String(32)) #: mode_sub_type of sample
@@ -2509,6 +2514,9 @@ class BasicSample(BaseClass):
if dlg.exec(): if dlg.exec():
pass pass
def edit_from_search(self, obj, **kwargs):
self.show_details(obj)
# Below are the custom sample types # Below are the custom sample types
@@ -2516,6 +2524,9 @@ class WastewaterSample(BasicSample):
""" """
Derivative wastewater sample Derivative wastewater sample
""" """
searchables = BasicSample.searchables + ['ww_processing_num', 'ww_full_sample_id', 'rsl_number']
id = Column(INTEGER, ForeignKey('_basicsample.id'), primary_key=True) id = Column(INTEGER, ForeignKey('_basicsample.id'), primary_key=True)
ww_processing_num = Column(String(64)) #: wastewater processing number ww_processing_num = Column(String(64)) #: wastewater processing number
ww_full_sample_id = Column(String(64)) #: full id given by entrics ww_full_sample_id = Column(String(64)) #: full id given by entrics

View File

@@ -203,4 +203,4 @@ class RSLNamer(object):
from .pydant import PydSubmission, PydKit, PydContact, PydOrganization, PydSample, PydReagent, PydReagentRole, \ from .pydant import PydSubmission, PydKit, PydContact, PydOrganization, PydSample, PydReagent, PydReagentRole, \
PydEquipment, PydEquipmentRole, PydTips PydEquipment, PydEquipmentRole, PydTips, PydPCRControl, PydIridaControl

View File

@@ -786,9 +786,10 @@ class PydSubmission(BaseModel, extra='allow'):
""" """
report = Report() report = Report()
dicto = self.improved_dict() dicto = self.improved_dict()
logger.warning(f"\n\nQuery or create: {self.submission_type['value']}, {self.rsl_plate_num['value']}")
instance, result = BasicSubmission.query_or_create(submission_type=self.submission_type['value'], instance, result = BasicSubmission.query_or_create(submission_type=self.submission_type['value'],
rsl_plate_num=self.rsl_plate_num['value']) rsl_plate_num=self.rsl_plate_num['value'])
logger.debug(f"Result of query or create: {type(result)}") logger.debug(f"Result of query or create: {instance}")
report.add_result(result) report.add_result(result)
self.handle_duplicate_samples() self.handle_duplicate_samples()
# logger.debug(f"Here's our list of duplicate removed samples: {self.samples}") # logger.debug(f"Here's our list of duplicate removed samples: {self.samples}")
@@ -830,7 +831,7 @@ class PydSubmission(BaseModel, extra='allow'):
equip, association = equip.toSQL(submission=instance) equip, association = equip.toSQL(submission=instance)
if association is not None: if association is not None:
instance.submission_equipment_associations.append(association) instance.submission_equipment_associations.append(association)
logger.debug(f"Equipment associations:\n\n{pformat(instance.submission_equipment_associations)}") logger.debug(f"Equipment associations: {instance.submission_equipment_associations}")
case "tips": case "tips":
for tips in self.tips: for tips in self.tips:
if tips is None: if tips is None:
@@ -871,16 +872,18 @@ class PydSubmission(BaseModel, extra='allow'):
case _: case _:
try: try:
instance.set_attribute(key=key, value=value) instance.set_attribute(key=key, value=value)
# instance.update({key:value})
except AttributeError as e: except AttributeError as e:
logger.error(f"Could not set attribute: {key} to {value} due to: \n\n {e}") logger.error(f"Could not set attribute: {key} to {value} due to: \n\n {e}")
continue continue
except KeyError: except KeyError:
continue continue
print(f"\n\n{instance}\n\n")
try: try:
# logger.debug(f"Calculating costs for procedure...") # logger.debug(f"Calculating costs for procedure...")
instance.calculate_base_cost() instance.calculate_base_cost()
except (TypeError, AttributeError) as e: except (TypeError, AttributeError) as e:
# logger.debug(f"Looks like that kit doesn't have cost breakdown yet due to: {e}, using full plate cost.") logger.debug(f"Looks like that kit doesn't have cost breakdown yet due to: {e}, using 0.")
try: try:
instance.run_cost = instance.extraction_kit.cost_per_run instance.run_cost = instance.extraction_kit.cost_per_run
except AttributeError: except AttributeError:
@@ -1104,3 +1107,29 @@ class PydEquipmentRole(BaseModel):
""" """
from frontend.widgets.equipment_usage import RoleComboBox from frontend.widgets.equipment_usage import RoleComboBox
return RoleComboBox(parent=parent, role=self, used=used) return RoleComboBox(parent=parent, role=self, used=used)
class PydPCRControl(BaseModel):
name: str
subtype: str
target: str
ct: float
reagent_lot: str
submitted_date: datetime #: Date submitted to Robotics
submission_id: int
controltype_name: str
class PydIridaControl(BaseModel, extra='ignore'):
name: str
contains: list | dict #: unstructured hashes in contains.tsv for each organism
matches: list | dict #: unstructured hashes in matches.tsv for each organism
kraken: list | dict #: unstructured output from kraken_report
subtype: str #: EN-NOS, MCS-NOS, etc
refseq_version: str #: version of refseq used in fastq parsing
kraken2_version: str
kraken2_db_version: str
sample_id: int
submitted_date: datetime #: Date submitted to Robotics
submission_id: int
controltype_name: str

View File

@@ -13,7 +13,7 @@ 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 from backend import SubmissionType, Reagent, BasicSample
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
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
@@ -23,7 +23,7 @@ import logging, webbrowser, sys, shutil
from .submission_table import SubmissionsSheet from .submission_table import SubmissionsSheet
from .submission_widget import SubmissionFormContainer from .submission_widget import SubmissionFormContainer
from .controls_chart import ControlsViewer from .controls_chart import ControlsViewer
from .sample_search import SampleSearchBox # from .sample_search import SampleSearchBox
from .summary import Summary from .summary import Summary
from .omni_search import SearchBox from .omni_search import SearchBox
@@ -138,6 +138,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.destroyed.connect(self.final_commit)
def showAbout(self): def showAbout(self):
""" """
@@ -186,7 +187,8 @@ class App(QMainWindow):
""" """
Create a search for samples. Create a search for samples.
""" """
dlg = SampleSearchBox(self) # dlg = SampleSearchBox(self)
dlg = SearchBox(self, object_type=BasicSample, extras=[])
dlg.exec() dlg.exec()
def backup_database(self): def backup_database(self):
@@ -251,6 +253,9 @@ 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 final_commit(self):
logger.debug("Running final commit")
self.ctx.database_session.commit()
class AddSubForm(QWidget): class AddSubForm(QWidget):

View File

@@ -28,7 +28,7 @@ class AddReagentForm(QDialog):
""" """
def __init__(self, reagent_lot: str | None = None, reagent_role: str | None = None, expiry: date | None = None, def __init__(self, reagent_lot: str | None = None, reagent_role: str | None = None, expiry: date | None = None,
reagent_name: str | None = None) -> None: reagent_name: str | None = None, kit: str | KitType | None = None) -> None:
super().__init__() super().__init__()
if reagent_name is None: if reagent_name is None:
reagent_name = reagent_role reagent_name = reagent_role
@@ -58,8 +58,16 @@ class AddReagentForm(QDialog):
self.exp_input.setDate(QDate(1970, 1, 1)) self.exp_input.setDate(QDate(1970, 1, 1))
# NOTE: widget to get reagent type info # NOTE: widget to get reagent type info
self.type_input = QComboBox() self.type_input = QComboBox()
self.type_input.setObjectName('type') self.type_input.setObjectName('role')
self.type_input.addItems([item.name for item in ReagentRole.query()]) if kit:
match kit:
case str():
kit = KitType.query(name=kit)
case _:
pass
self.type_input.addItems([item.name for item in ReagentRole.query() if kit in item.kit_types])
else:
self.type_input.addItems([item.name for item in ReagentRole.query()])
# logger.debug(f"Trying to find index of {reagent_type}") # logger.debug(f"Trying to find index of {reagent_type}")
# NOTE: convert input to user-friendly string? # NOTE: convert input to user-friendly string?
try: try:

View File

@@ -19,17 +19,24 @@ class SearchBox(QDialog):
def __init__(self, parent, object_type: Any, extras: List[str], **kwargs): def __init__(self, parent, object_type: Any, extras: List[str], **kwargs):
super().__init__(parent) super().__init__(parent)
self.object_type = object_type self.object_type = self.original_type = object_type
# options = ["Any"] + [cls.__name__ for cls in self.object_type.__subclasses__()] self.extras = extras
# self.sub_class = QComboBox(self) self.context = kwargs
# self.sub_class.setObjectName("sub_class") self.layout = QGridLayout(self)
# self.sub_class.currentTextChanged.connect(self.update_widgets)
# self.sub_class.addItems(options)
# self.sub_class.setEditable(False)
self.setMinimumSize(600, 600) self.setMinimumSize(600, 600)
# self.sub_class.setMinimumWidth(self.minimumWidth()) options = ["Any"] + [cls.__name__ for cls in self.object_type.__subclasses__()]
# self.layout.addWidget(self.sub_class, 0, 0) if len(options) > 1:
self.results = SearchResults(parent=self, object_type=self.object_type, extras=extras, **kwargs) self.sub_class = QComboBox(self)
self.sub_class.setObjectName("sub_class")
self.sub_class.addItems(options)
self.sub_class.currentTextChanged.connect(self.update_widgets)
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.results = SearchResults(parent=self, object_type=self.object_type, extras=self.extras, **kwargs)
logger.debug(f"results: {self.results}")
self.layout.addWidget(self.results, 5, 0) self.layout.addWidget(self.results, 5, 0)
self.setLayout(self.layout) self.setLayout(self.layout)
self.setWindowTitle(f"Search {self.object_type.__name__}") self.setWindowTitle(f"Search {self.object_type.__name__}")
@@ -40,10 +47,23 @@ class SearchBox(QDialog):
""" """
Changes form inputs based on sample type Changes form inputs based on sample type
""" """
deletes = [item for item in self.findChildren(FieldSearch)]
# logger.debug(deletes)
for item in deletes:
item.setParent(None)
if not self.sub_class:
self.update_data()
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())
logger.debug(f"{self.object_type} searchables: {self.object_type.searchables}")
for iii, searchable in enumerate(self.object_type.searchables): for iii, searchable in enumerate(self.object_type.searchables):
self.widget = FieldSearch(parent=self, label=searchable, field_name=searchable) widget = FieldSearch(parent=self, label=searchable, field_name=searchable)
self.layout.addWidget(self.widget, 1, 0) widget.setObjectName(searchable)
self.widget.search_widget.textChanged.connect(self.update_data) self.layout.addWidget(widget, 1+iii, 0)
widget.search_widget.textChanged.connect(self.update_data)
self.update_data() self.update_data()
def parse_form(self) -> dict: def parse_form(self) -> dict:
@@ -60,11 +80,11 @@ class SearchBox(QDialog):
""" """
Shows dataframe of relevant samples. Shows dataframe of relevant samples.
""" """
# logger.debug(f"Running update_data with sample type: {self.type}")
fields = self.parse_form() fields = self.parse_form()
# logger.debug(f"Got fields: {fields}") # logger.debug(f"Got fields: {fields}")
sample_list_creator = self.object_type.fuzzy_search(**fields) sample_list_creator = self.object_type.fuzzy_search(**fields)
data = self.object_type.results_to_df(objects=sample_list_creator) data = self.object_type.results_to_df(objects=sample_list_creator)
# Setting results moved to here from __init__ 202411118
self.results.setData(df=data) self.results.setData(df=data)
@@ -108,7 +128,6 @@ class SearchResults(QTableView):
sets data in model sets data in model
""" """
self.data = df self.data = df
print(self.data)
try: try:
self.columns_of_interest = [dict(name=item, column=self.data.columns.get_loc(item)) for item in self.extras] self.columns_of_interest = [dict(name=item, column=self.data.columns.get_loc(item)) for item in self.extras]
except KeyError: except KeyError:
@@ -125,14 +144,15 @@ class SearchResults(QTableView):
def parse_row(self, x): def parse_row(self, x):
context = {item['name']: x.sibling(x.row(), item['column']).data() for item in self.columns_of_interest} context = {item['name']: x.sibling(x.row(), item['column']).data() for item in self.columns_of_interest}
logger.debug(f"Context: {context}")
try: try:
object = self.object_type.query(**{self.object_type.search: context[self.object_type.search]}) # object = self.object_type.query(**{self.object_type.searchables: context[self.object_type.searchables]})
object = self.object_type.query(**context)
except KeyError: except KeyError:
object = None object = None
try: try:
object.edit_from_search(**context) object.edit_from_search(obj=self.parent, **context)
except AttributeError: except AttributeError as e:
pass logger.error(f"Error getting object function: {e}")
self.doubleClicked.disconnect() self.doubleClicked.disconnect()
self.parent.update_data() self.parent.update_data()

View File

@@ -1,128 +0,0 @@
'''
Search box that performs fuzzy search for samples
'''
from pprint import pformat
from typing import Tuple
from pandas import DataFrame
from PyQt6.QtCore import QSortFilterProxyModel
from PyQt6.QtWidgets import (
QLabel, QVBoxLayout, QDialog,
QComboBox, QTableView, QWidget, QLineEdit, QGridLayout
)
from backend.db.models import BasicSample
from .submission_table import pandasModel
import logging
logger = logging.getLogger(f"submissions.{__name__}")
class SampleSearchBox(QDialog):
def __init__(self, parent):
super().__init__(parent)
self.layout = QGridLayout(self)
self.sample_type = QComboBox(self)
self.sample_type.setObjectName("sample_type")
self.sample_type.currentTextChanged.connect(self.update_widgets)
options = ["Any"] + [cls.__mapper_args__['polymorphic_identity'] for cls in BasicSample.__subclasses__()]
self.sample_type.addItems(options)
self.sample_type.setEditable(False)
self.setMinimumSize(600, 600)
self.sample_type.setMinimumWidth(self.minimumWidth())
self.layout.addWidget(self.sample_type, 0, 0)
self.results = SearchResults()
self.layout.addWidget(self.results, 5, 0)
self.setLayout(self.layout)
self.update_widgets()
self.update_data()
def update_widgets(self):
"""
Changes form inputs based on sample type
"""
deletes = [item for item in self.findChildren(FieldSearch)]
# logger.debug(deletes)
for item in deletes:
item.setParent(None)
if self.sample_type.currentText() == "Any":
self.type = BasicSample
else:
self.type = BasicSample.find_polymorphic_subclass(self.sample_type.currentText())
# logger.debug(f"Sample type: {self.type}")
searchables = self.type.get_searchables()
start_row = 1
for iii, item in enumerate(searchables):
widget = FieldSearch(parent=self, label=item['label'], field_name=item['field'])
self.layout.addWidget(widget, start_row+iii, 0)
widget.search_widget.textChanged.connect(self.update_data)
self.update_data()
def parse_form(self) -> dict:
"""
Converts form into dictionary.
Returns:
dict: Fields dictionary
"""
fields = [item.parse_form() for item in self.findChildren(FieldSearch)]
return {item[0]:item[1] for item in fields if item[1] is not None}
def update_data(self):
"""
Shows dataframe of relevant samples.
"""
# logger.debug(f"Running update_data with sample type: {self.type}")
fields = self.parse_form()
# logger.debug(f"Got fields: {fields}")
sample_list_creator = self.type.fuzzy_search(**fields)
data = self.type.samples_to_df(sample_list=sample_list_creator)
# logger.debug(f"Data: {data}")
self.results.setData(df=data)
class FieldSearch(QWidget):
def __init__(self, parent, label, field_name):
super().__init__(parent)
self.layout = QVBoxLayout(self)
label_widget = QLabel(label)
self.layout.addWidget(label_widget)
self.search_widget = QLineEdit()
self.search_widget.setObjectName(field_name)
self.layout.addWidget(self.search_widget)
self.setLayout(self.layout)
self.search_widget.returnPressed.connect(self.enter_pressed)
def enter_pressed(self):
"""
Triggered when enter is pressed on this input field.
"""
self.parent().update_data()
def parse_form(self) -> Tuple:
field_value = self.search_widget.text()
if field_value == "":
field_value = None
return self.search_widget.objectName(), field_value
class SearchResults(QTableView):
def __init__(self):
super().__init__()
self.doubleClicked.connect(lambda x: BasicSample.query(submitter_id=x.sibling(x.row(), 0).data()).show_details(self))
def setData(self, df:DataFrame) -> None:
"""
sets data in model
"""
self.data = df
try:
self.data['id'] = self.data['id'].apply(str)
self.data['id'] = self.data['id'].str.zfill(3)
except (TypeError, KeyError):
logger.error("Couldn't format id string.")
proxy_model = QSortFilterProxyModel()
proxy_model.setSourceModel(pandasModel(self.data))
self.setModel(proxy_model)

View File

@@ -45,7 +45,8 @@ class SubmissionDetails(QDialog):
self.btn.clicked.connect(self.export) self.btn.clicked.connect(self.export)
self.back = QPushButton("Back") self.back = QPushButton("Back")
self.back.setFixedWidth(100) self.back.setFixedWidth(100)
self.back.clicked.connect(self.back_function) # self.back.clicked.connect(self.back_function)
self.back.clicked.connect(self.webview.back)
self.layout.addWidget(self.back, 0, 0, 1, 1) self.layout.addWidget(self.back, 0, 0, 1, 1)
self.layout.addWidget(self.btn, 0, 1, 1, 9) self.layout.addWidget(self.btn, 0, 1, 1, 9)
self.layout.addWidget(self.webview, 1, 0, 10, 10) self.layout.addWidget(self.webview, 1, 0, 10, 10)
@@ -63,8 +64,8 @@ class SubmissionDetails(QDialog):
self.reagent_details(reagent=sub) self.reagent_details(reagent=sub)
self.webview.page().setWebChannel(self.channel) self.webview.page().setWebChannel(self.channel)
def back_function(self): # def back_function(self):
self.webview.back() # self.webview.back()
def activate_export(self): def activate_export(self):
title = self.webview.title() title = self.webview.title()
@@ -75,7 +76,11 @@ class SubmissionDetails(QDialog):
# logger.debug(f"Updating export plate to: {self.export_plate}") # logger.debug(f"Updating export plate to: {self.export_plate}")
else: else:
self.btn.setEnabled(False) self.btn.setEnabled(False)
if title == self.webview.history().items()[0].title(): try:
check = self.webview.history().items()[0].title()
except IndexError as e:
check = title
if title == check:
# logger.debug("Disabling back button") # logger.debug("Disabling back button")
self.back.setEnabled(False) self.back.setEnabled(False)
else: else:
@@ -96,7 +101,7 @@ class SubmissionDetails(QDialog):
exclude = ['submissions', 'excluded', 'colour', 'tooltip'] exclude = ['submissions', 'excluded', 'colour', 'tooltip']
base_dict['excluded'] = exclude base_dict['excluded'] = exclude
template = sample.get_details_template() template = sample.get_details_template()
template_path = Path(self.template.environment.loader.__getattribute__("searchpath")[0]) template_path = Path(template.environment.loader.__getattribute__("searchpath")[0])
with open(template_path.joinpath("css", "styles.css"), "r") as f: with open(template_path.joinpath("css", "styles.css"), "r") as f:
css = f.read() css = f.read()
html = template.render(sample=base_dict, css=css) html = template.render(sample=base_dict, css=css)

View File

@@ -31,6 +31,7 @@ class MyQComboBox(QComboBox):
""" """
Custom combobox that disables wheel events until focussed on. Custom combobox that disables wheel events until focussed on.
""" """
def __init__(self, scrollWidget=None, *args, **kwargs): def __init__(self, scrollWidget=None, *args, **kwargs):
super(MyQComboBox, self).__init__(*args, **kwargs) super(MyQComboBox, self).__init__(*args, **kwargs)
self.scrollWidget = scrollWidget self.scrollWidget = scrollWidget
@@ -48,6 +49,7 @@ class MyQDateEdit(QDateEdit):
""" """
Custom date editor that disables wheel events until focussed on. Custom date editor that disables wheel events until focussed on.
""" """
def __init__(self, scrollWidget=None, *args, **kwargs): def __init__(self, scrollWidget=None, *args, **kwargs):
super(MyQDateEdit, self).__init__(*args, **kwargs) super(MyQDateEdit, self).__init__(*args, **kwargs)
self.scrollWidget = scrollWidget self.scrollWidget = scrollWidget
@@ -150,7 +152,7 @@ class SubmissionFormContainer(QWidget):
@report_result @report_result
def add_reagent(self, reagent_lot: str | None = None, reagent_role: str | None = None, expiry: date | None = None, def add_reagent(self, reagent_lot: str | None = None, reagent_role: str | None = None, expiry: date | None = None,
name: str | None = None) -> Tuple[PydReagent, Report]: name: str | None = None, kit: str | KitType | None = None) -> Tuple[PydReagent, Report]:
""" """
Action to create new reagent in DB. Action to create new reagent in DB.
@@ -167,7 +169,8 @@ class SubmissionFormContainer(QWidget):
if isinstance(reagent_lot, bool): if isinstance(reagent_lot, bool):
reagent_lot = "" reagent_lot = ""
# NOTE: create form # NOTE: create form
dlg = AddReagentForm(reagent_lot=reagent_lot, reagent_role=reagent_role, expiry=expiry, reagent_name=name) dlg = AddReagentForm(reagent_lot=reagent_lot, reagent_role=reagent_role, expiry=expiry, reagent_name=name,
kit=kit)
if dlg.exec(): if dlg.exec():
# NOTE: extract form info # NOTE: extract form info
info = dlg.parse_form() info = dlg.parse_form()
@@ -228,7 +231,7 @@ class SubmissionFormWidget(QWidget):
# self.scrape_reagents(self.pyd.extraction_kit) # self.scrape_reagents(self.pyd.extraction_kit)
self.scrape_reagents(self.extraction_kit) self.scrape_reagents(self.extraction_kit)
def create_widget(self, key: str, value: dict | PydReagent, submission_type: str | SubmissionType| None = None, def create_widget(self, key: str, value: dict | PydReagent, submission_type: str | SubmissionType | None = None,
extraction_kit: str | None = None, sub_obj: BasicSubmission | None = None, extraction_kit: str | None = None, sub_obj: BasicSubmission | None = None,
disable: bool = False) -> "self.InfoItem": disable: bool = False) -> "self.InfoItem":
""" """
@@ -506,7 +509,8 @@ class SubmissionFormWidget(QWidget):
return None, None return None, None
return self.input.objectName(), dict(value=value, missing=self.missing) return self.input.objectName(), dict(value=value, missing=self.missing)
def set_widget(self, parent: QWidget, key: str, value: dict, submission_type: str | SubmissionType | None = None, def set_widget(self, parent: QWidget, key: str, value: dict,
submission_type: str | SubmissionType | None = None,
sub_obj: BasicSubmission | None = None) -> QWidget: sub_obj: BasicSubmission | None = None) -> QWidget:
""" """
Creates form widget Creates form widget
@@ -682,9 +686,11 @@ class SubmissionFormWidget(QWidget):
message=f"Couldn't find reagent type {self.reagent.role}: {lot} in the database.\n\nWould you like to add it?") message=f"Couldn't find reagent type {self.reagent.role}: {lot} in the database.\n\nWould you like to add it?")
if dlg.exec(): if dlg.exec():
wanted_reagent = self.parent().parent().add_reagent(reagent_lot=lot, wanted_reagent = self.parent().parent().add_reagent(reagent_lot=lot,
reagent_role=self.reagent.role, reagent_role=self.reagent.role,
expiry=self.reagent.expiry, expiry=self.reagent.expiry,
name=self.reagent.name) name=self.reagent.name,
kit=self.extraction_kit
)
return wanted_reagent, report return wanted_reagent, report
else: else:
# NOTE: In this case we will have an empty reagent and the submission will fail kit integrity check # NOTE: In this case we will have an empty reagent and the submission will fail kit integrity check
@@ -772,4 +778,3 @@ class SubmissionFormWidget(QWidget):
self.setObjectName(f"lot_{reagent.role}") self.setObjectName(f"lot_{reagent.role}")
self.addItems(relevant_reagents) self.addItems(relevant_reagents)
self.setToolTip(f"Enter lot number for the reagent used for {reagent.role}") self.setToolTip(f"Enter lot number for the reagent used for {reagent.role}")

View File

@@ -13,6 +13,7 @@ from jinja2 import Environment, FileSystemLoader
from logging import handlers from logging import handlers
from pathlib import Path from pathlib import Path
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from sqlalchemy.orm.state import InstanceState
from sqlalchemy import create_engine, text, MetaData from sqlalchemy import create_engine, text, MetaData
from pydantic import field_validator, BaseModel, Field from pydantic import field_validator, BaseModel, Field
from pydantic_settings import BaseSettings, SettingsConfigDict from pydantic_settings import BaseSettings, SettingsConfigDict
@@ -952,6 +953,10 @@ def report_result(func):
match output: match output:
case Report(): case Report():
report = output report = output
# case InstanceState():
# for attr in output.attrs:
# print(f"{attr}: {attr.load_history()}")
# return
case tuple(): case tuple():
try: try:
report = [item for item in output if isinstance(item, Report)][0] report = [item for item in output if isinstance(item, Report)][0]
@@ -959,6 +964,7 @@ def report_result(func):
report = None report = None
case _: case _:
report = None report = None
return report
logger.debug(f"Got report: {report}") logger.debug(f"Got report: {report}")
try: try:
results = report.results results = report.results
@@ -982,3 +988,5 @@ def report_result(func):
# logger.debug(f"Returning true output: {true_output}") # logger.debug(f"Returning true output: {true_output}")
return true_output return true_output
return wrapper return wrapper