diff --git a/TODO.md b/TODO.md index 0881731..6861ed5 100644 --- a/TODO.md +++ b/TODO.md @@ -1,4 +1,5 @@ -- [ ] Allow parsing of custom fields to a json 'custom' field in _basicsubmissions +- [ ] Find a way to merge omni_search and sample_search +- [x] Allow parsing of custom fields to a json 'custom' field in _basicsubmissions - [x] Upgrade to generators when returning lists. - [x] Revamp frontend.widgets.controls_chart to include visualizations? - [x] Convert Parsers to using openpyxl. diff --git a/src/submissions/backend/db/models/__init__.py b/src/submissions/backend/db/models/__init__.py index 004779b..e7cd746 100644 --- a/src/submissions/backend/db/models/__init__.py +++ b/src/submissions/backend/db/models/__init__.py @@ -3,6 +3,8 @@ Contains all models for sqlalchemy """ from __future__ import annotations import sys, logging + +import pandas as pd from sqlalchemy import Column, INTEGER, String, JSON from sqlalchemy.orm import DeclarativeMeta, declarative_base, Query, Session from sqlalchemy.ext.declarative import declared_attr @@ -97,6 +99,26 @@ class BaseClass(Base): singles = list(set(cls.singles + BaseClass.singles)) return dict(singles=singles) + @classmethod + def fuzzy_search(cls, **kwargs): + query: Query = cls.__database_session__.query(cls) + # logger.debug(f"Queried model. Now running searches in {kwargs}") + for k, v in kwargs.items(): + # logger.debug(f"Running fuzzy search for attribute: {k} with value {v}") + search = f"%{v}%" + try: + attr = getattr(cls, k) + # NOTE: the secret sauce is in attr.like + query = query.filter(attr.like(search)) + except (ArgumentError, AttributeError) as e: + logger.error(f"Attribute {k} unavailable due to:\n\t{e}\nSkipping.") + return query.limit(50).all() + + @classmethod + def results_to_df(cls, objects: list, **kwargs): + records = [object.to_sub_dict(**kwargs) for object in objects] + return pd.DataFrame.from_records(records) + @classmethod def query(cls, **kwargs) -> Any | List[Any]: """ diff --git a/src/submissions/backend/db/models/kits.py b/src/submissions/backend/db/models/kits.py index 1257cac..fda0a5d 100644 --- a/src/submissions/backend/db/models/kits.py +++ b/src/submissions/backend/db/models/kits.py @@ -7,8 +7,8 @@ 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 datetime import date -from tools import check_authorization, setup_lookup, Report, Result, check_regex_match, yaml_regex_creator +from datetime import date, datetime +from tools import check_authorization, setup_lookup, Report, Result, check_regex_match, yaml_regex_creator, timezone from typing import List, Literal, Generator, Any from pandas import ExcelFile from pathlib import Path @@ -355,7 +355,7 @@ class ReagentRole(BaseClass): match reagent: case str(): # logger.debug(f"Lookup ReagentType by reagent str {reagent}") - reagent = Reagent.query(lot_number=reagent) + reagent = Reagent.query(lot=reagent) case _: pass assert reagent.role @@ -405,6 +405,8 @@ class Reagent(BaseClass): Concrete reagent instance """ + searchables = ["lot"] + id = Column(INTEGER, primary_key=True) #: primary key role = relationship("ReagentRole", back_populates="instances", secondary=reagentroles_reagents) #: joined parent reagent type @@ -430,7 +432,7 @@ class Reagent(BaseClass): else: return f"" - def to_sub_dict(self, extraction_kit: KitType = None, full_data: bool = False) -> dict: + def to_sub_dict(self, extraction_kit: KitType = None, full_data: bool = False, **kwargs) -> dict: """ dictionary containing values necessary for gui @@ -441,6 +443,7 @@ class Reagent(BaseClass): Returns: dict: representation of the reagent's attributes """ + if extraction_kit is not None: # NOTE: Get the intersection of this reagent's ReagentType and all ReagentTypes in KitType try: @@ -449,7 +452,10 @@ class Reagent(BaseClass): except: reagent_role = self.role[0] else: - reagent_role = self.role[0] + try: + reagent_role = self.role[0] + except IndexError: + reagent_role = None try: rtype = reagent_role.name.replace("_", " ") except AttributeError: @@ -475,7 +481,8 @@ class Reagent(BaseClass): ) if full_data: output['submissions'] = [sub.rsl_plate_num for sub in self.submissions] - output['excluded'] = ['missing', 'submissions', 'excluded'] + output['excluded'] = ['missing', 'submissions', 'excluded', 'editable'] + output['editable'] = ['lot', 'expiry'] return output def update_last_used(self, kit: KitType) -> Report: @@ -508,8 +515,8 @@ class Reagent(BaseClass): @setup_lookup def query(cls, id: int | None = None, - reagent_role: str | ReagentRole | None = None, - lot_number: str | None = None, + role: str | ReagentRole | None = None, + lot: str | None = None, name: str | None = None, limit: int = 0 ) -> Reagent | List[Reagent]: @@ -533,13 +540,13 @@ class Reagent(BaseClass): limit = 1 case _: pass - match reagent_role: + match role: case str(): # logger.debug(f"Looking up reagents by reagent type str: {reagent_type}") - query = query.join(cls.role).filter(ReagentRole.name == reagent_role) + query = query.join(cls.role).filter(ReagentRole.name == role) case ReagentRole(): # logger.debug(f"Looking up reagents by reagent type ReagentType: {reagent_type}") - query = query.filter(cls.role.contains(reagent_role)) + query = query.filter(cls.role.contains(role)) case _: pass match name: @@ -549,16 +556,43 @@ class Reagent(BaseClass): query = query.filter(cls.name == name) case _: pass - match lot_number: + match lot: case str(): - # logger.debug(f"Looking up reagent by lot number str: {lot_number}") - query = query.filter(cls.lot == lot_number) + # logger.debug(f"Looking up reagent by lot number str: {lot}") + query = query.filter(cls.lot == lot) # NOTE: In this case limit number returned. limit = 1 case _: pass return cls.execute_query(query=query, limit=limit) + @check_authorization + def edit_from_search(self, **kwargs): + from frontend.widgets.misc import AddReagentForm + role = ReagentRole.query(kwargs['role']) + if role: + role_name = role.name + else: + role_name = None + dlg = AddReagentForm(reagent_lot=self.lot, reagent_role=role_name, expiry=self.expiry, reagent_name=self.name) + if dlg.exec(): + vars = dlg.parse_form() + for key, value in vars.items(): + match key: + case "expiry": + if not isinstance(value, date): + field_value = datetime.strptime(value, "%Y-%m-%d").date + field_value.replace(tzinfo=timezone) + else: + field_value = value + case "role": + continue + case _: + field_value = value + logger.debug(f"Setting reagent {key} to {field_value}") + self.__setattr__(key, field_value) + self.save() + class Discount(BaseClass): """ @@ -1278,7 +1312,7 @@ class SubmissionReagentAssociation(BaseClass): case Reagent() | str(): # logger.debug(f"Lookup SubmissionReagentAssociation by reagent Reagent {reagent}") if isinstance(reagent, str): - reagent = Reagent.query(lot_number=reagent) + reagent = Reagent.query(lot=reagent) query = query.filter(cls.reagent == reagent) case _: pass diff --git a/src/submissions/backend/db/models/submissions.py b/src/submissions/backend/db/models/submissions.py index 6eb740a..3dce4fe 100644 --- a/src/submissions/backend/db/models/submissions.py +++ b/src/submissions/backend/db/models/submissions.py @@ -844,7 +844,8 @@ class BasicSubmission(BaseClass): ws.cell(row=item['row'], column=item['column'], value=item['value']) return input_excel - def custom_sample_writer(self, sample:dict) -> dict: + @classmethod + def custom_sample_writer(self, sample: dict) -> dict: return sample @classmethod @@ -2091,7 +2092,7 @@ class WastewaterArtic(BasicSubmission): return input_excel @classmethod - def custom_sample_writer(self, sample:dict) -> dict: + def custom_sample_writer(self, sample: dict) -> dict: logger.debug("Wastewater Artic custom sample writer") if sample['source_plate_number'] in [0, "0"]: sample['source_plate_number'] = "control" diff --git a/src/submissions/backend/validators/pydant.py b/src/submissions/backend/validators/pydant.py index 00aa016..d394871 100644 --- a/src/submissions/backend/validators/pydant.py +++ b/src/submissions/backend/validators/pydant.py @@ -48,7 +48,7 @@ class PydReagent(BaseModel): def rescue_type_with_lookup(cls, value, values): if value is None and values.data['lot'] is not None: try: - return Reagent.query(lot_number=values.data['lot'].name) + return Reagent.query(lot=values.data['lot'].name) except AttributeError: return value return value @@ -127,7 +127,7 @@ class PydReagent(BaseModel): if self.model_extra is not None: self.__dict__.update(self.model_extra) # logger.debug(f"Reagent SQL constructor is looking up type: {self.type}, lot: {self.lot}") - reagent = Reagent.query(lot_number=self.lot, name=self.name) + reagent = Reagent.query(lot=self.lot, name=self.name) # logger.debug(f"Result: {reagent}") if reagent is None: reagent = Reagent() diff --git a/src/submissions/frontend/widgets/app.py b/src/submissions/frontend/widgets/app.py index adcbeda..3149818 100644 --- a/src/submissions/frontend/widgets/app.py +++ b/src/submissions/frontend/widgets/app.py @@ -13,7 +13,7 @@ from PyQt6.QtGui import QAction from pathlib import Path from markdown import markdown from __init__ import project_path -from backend import SubmissionType +from backend import SubmissionType, Reagent 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 datetime import date @@ -23,8 +23,9 @@ import logging, webbrowser, sys, shutil from .submission_table import SubmissionsSheet from .submission_widget import SubmissionFormContainer from .controls_chart import ControlsViewer -from .sample_search import SearchBox +from .sample_search import SampleSearchBox from .summary import Summary +from .omni_search import SearchBox logger = logging.getLogger(f'submissions.{__name__}') logger.info("Hello, I am a logger") @@ -185,7 +186,7 @@ class App(QMainWindow): """ Create a search for samples. """ - dlg = SearchBox(self) + dlg = SampleSearchBox(self) dlg.exec() def backup_database(self): @@ -226,7 +227,7 @@ class App(QMainWindow): @check_authorization def edit_reagent(self, *args, **kwargs): - dlg = EditReagent() + dlg = SearchBox(parent=self, object_type=Reagent, extras=['role']) dlg.exec() @check_authorization diff --git a/src/submissions/frontend/widgets/misc.py b/src/submissions/frontend/widgets/misc.py index d9f2285..785c738 100644 --- a/src/submissions/frontend/widgets/misc.py +++ b/src/submissions/frontend/widgets/misc.py @@ -30,8 +30,8 @@ class AddReagentForm(QDialog): def __init__(self, reagent_lot: str | None = None, reagent_role: str | None = None, expiry: date | None = None, reagent_name: str | None = None) -> None: super().__init__() - if reagent_lot is None: - reagent_lot = reagent_role + if reagent_name is None: + reagent_name = reagent_role self.setWindowTitle("Add Reagent") QBtn = QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel self.buttonBox = QDialogButtonBox(QBtn) diff --git a/src/submissions/frontend/widgets/omni_search.py b/src/submissions/frontend/widgets/omni_search.py index d939204..0fb4868 100644 --- a/src/submissions/frontend/widgets/omni_search.py +++ b/src/submissions/frontend/widgets/omni_search.py @@ -2,14 +2,13 @@ Search box that performs fuzzy search for samples ''' from pprint import pformat -from typing import Tuple +from typing import Tuple, Any, List from pandas import DataFrame from PyQt6.QtCore import QSortFilterProxyModel from PyQt6.QtWidgets import ( QLabel, QVBoxLayout, QDialog, - QComboBox, QTableView, QWidget, QLineEdit, QGridLayout + QTableView, QWidget, QLineEdit, QGridLayout, QComboBox ) -from backend.db.models import BasicSample from .submission_table import pandasModel import logging @@ -18,21 +17,22 @@ logger = logging.getLogger(f"submissions.{__name__}") class SearchBox(QDialog): - def __init__(self, parent): + def __init__(self, parent, object_type: Any, extras: List[str], **kwargs): 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.object_type = object_type + # options = ["Any"] + [cls.__name__ for cls in self.object_type.__subclasses__()] + # self.sub_class = QComboBox(self) + # self.sub_class.setObjectName("sub_class") + # self.sub_class.currentTextChanged.connect(self.update_widgets) + # self.sub_class.addItems(options) + # self.sub_class.setEditable(False) self.setMinimumSize(600, 600) - self.sample_type.setMinimumWidth(self.minimumWidth()) - self.layout.addWidget(self.sample_type, 0, 0) - self.results = SearchResults() + # self.sub_class.setMinimumWidth(self.minimumWidth()) + # self.layout.addWidget(self.sub_class, 0, 0) + self.results = SearchResults(parent=self, object_type=self.object_type, extras=extras, **kwargs) self.layout.addWidget(self.results, 5, 0) self.setLayout(self.layout) + self.setWindowTitle(f"Search {self.object_type.__name__}") self.update_widgets() self.update_data() @@ -40,21 +40,10 @@ class SearchBox(QDialog): """ 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) + for iii, searchable in enumerate(self.object_type.searchables): + self.widget = FieldSearch(parent=self, label=searchable, field_name=searchable) + self.layout.addWidget(self.widget, 1, 0) + self.widget.search_widget.textChanged.connect(self.update_data) self.update_data() def parse_form(self) -> dict: @@ -74,9 +63,8 @@ class SearchBox(QDialog): # 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}") + sample_list_creator = self.object_type.fuzzy_search(**fields) + data = self.object_type.results_to_df(objects=sample_list_creator) self.results.setData(df=data) @@ -108,21 +96,43 @@ class FieldSearch(QWidget): class SearchResults(QTableView): - def __init__(self): + def __init__(self, parent: SearchBox, object_type: Any, extras: List[str], **kwargs): super().__init__() - self.doubleClicked.connect( - lambda x: BasicSample.query(submitter_id=x.sibling(x.row(), 0).data()).show_details(self)) + self.context = kwargs + self.parent = parent + self.object_type = object_type + self.extras = extras + self.object_type.searchables def setData(self, df: DataFrame) -> None: """ sets data in model """ self.data = df + print(self.data) + try: + self.columns_of_interest = [dict(name=item, column=self.data.columns.get_loc(item)) for item in self.extras] + except KeyError: + self.columns_of_interest = [] try: self.data['id'] = self.data['id'].apply(str) self.data['id'] = self.data['id'].str.zfill(3) - except (TypeError, KeyError): - logger.error("Couldn't format id string.") + except (TypeError, KeyError) as e: + logger.error(f"Couldn't format id string: {e}") proxy_model = QSortFilterProxyModel() proxy_model.setSourceModel(pandasModel(self.data)) self.setModel(proxy_model) + self.doubleClicked.connect(self.parse_row) + + def parse_row(self, x): + context = {item['name']: x.sibling(x.row(), item['column']).data() for item in self.columns_of_interest} + try: + object = self.object_type.query(**{self.object_type.search: context[self.object_type.search]}) + except KeyError: + object = None + try: + object.edit_from_search(**context) + except AttributeError: + pass + self.doubleClicked.disconnect() + self.parent.update_data() + diff --git a/src/submissions/frontend/widgets/sample_search.py b/src/submissions/frontend/widgets/sample_search.py index b2c2b2c..6300b5b 100644 --- a/src/submissions/frontend/widgets/sample_search.py +++ b/src/submissions/frontend/widgets/sample_search.py @@ -16,7 +16,7 @@ import logging logger = logging.getLogger(f"submissions.{__name__}") -class SearchBox(QDialog): +class SampleSearchBox(QDialog): def __init__(self, parent): super().__init__(parent) diff --git a/src/submissions/frontend/widgets/submission_details.py b/src/submissions/frontend/widgets/submission_details.py index 53bc10a..a9b5f9a 100644 --- a/src/submissions/frontend/widgets/submission_details.py +++ b/src/submissions/frontend/widgets/submission_details.py @@ -106,10 +106,10 @@ class SubmissionDetails(QDialog): @pyqtSlot(str, str) def reagent_details(self, reagent: str | Reagent, kit: str | KitType): if isinstance(reagent, str): - reagent = Reagent.query(lot_number=reagent) + reagent = Reagent.query(lot=reagent) if isinstance(kit, str): - kit = KitType.query(name=kit) - base_dict = reagent.to_sub_dict(extraction_kit=kit, full_data=True) + self.kit = KitType.query(name=kit) + base_dict = reagent.to_sub_dict(extraction_kit=self.kit, full_data=True) env = jinja_template_loading() temp_name = "reagent_details.html" # logger.debug(f"Returning template: {temp_name}") @@ -121,10 +121,22 @@ class SubmissionDetails(QDialog): template_path = Path(self.template.environment.loader.__getattribute__("searchpath")[0]) with open(template_path.joinpath("css", "styles.css"), "r") as f: css = f.read() - html = template.render(reagent=base_dict, css=css) + html = template.render(reagent=base_dict, permission=is_power_user(), css=css) self.webview.setHtml(html) self.setWindowTitle(f"Reagent Details - {reagent.name} - {reagent.lot}") + @pyqtSlot(str, str, str) + def update_reagent(self, old_lot: str, new_lot: str, expiry: str): + expiry = datetime.strptime(expiry, "%Y-%m-%d") + reagent = Reagent.query(lot=old_lot) + if reagent: + reagent.lot = new_lot + reagent.expiry = expiry + reagent.save() + self.reagent_details(reagent=reagent, kit=self.kit) + else: + logger.error(f"Reagent with lot {old_lot} not found.") + @pyqtSlot(str) def submission_details(self, submission: str | BasicSubmission): """ @@ -150,7 +162,7 @@ class SubmissionDetails(QDialog): css = f.read() # logger.debug(f"Submission_details: {pformat(self.base_dict)}") # logger.debug(f"User is power user: {is_power_user()}") - self.html = self.template.render(sub=self.base_dict, signing_permission=is_power_user(), css=css) + self.html = self.template.render(sub=self.base_dict, permission=is_power_user(), css=css) self.webview.setHtml(self.html) @pyqtSlot(str) diff --git a/src/submissions/frontend/widgets/submission_widget.py b/src/submissions/frontend/widgets/submission_widget.py index 0fdc3f4..26c9981 100644 --- a/src/submissions/frontend/widgets/submission_widget.py +++ b/src/submissions/frontend/widgets/submission_widget.py @@ -675,7 +675,7 @@ class SubmissionFormWidget(QWidget): report = Report() lot = self.lot.currentText() # logger.debug(f"Using this lot for the reagent {self.reagent}: {lot}") - wanted_reagent = Reagent.query(lot_number=lot, reagent_role=self.reagent.role) + wanted_reagent = Reagent.query(lot=lot, role=self.reagent.role) # NOTE: if reagent doesn't exist in database, offer to add it (uses App.add_reagent) if wanted_reagent is None: dlg = QuestionAsker(title=f"Add {lot}?", @@ -745,7 +745,7 @@ class SubmissionFormWidget(QWidget): relevant_reagents.insert(0, str(reagent.lot)) else: try: - looked_up_reg = Reagent.query(lot_number=looked_up_rt.last_used) + looked_up_reg = Reagent.query(lot=looked_up_rt.last_used) except AttributeError: looked_up_reg = None if isinstance(looked_up_reg, list): diff --git a/src/submissions/templates/basicsubmission_details.html b/src/submissions/templates/basicsubmission_details.html index fa00580..5cd7c22 100644 --- a/src/submissions/templates/basicsubmission_details.html +++ b/src/submissions/templates/basicsubmission_details.html @@ -71,7 +71,7 @@ {% endif %} {% endblock %} {% block signing_button %} - {% if signing_permission %} + {% if permission %} {% endif %} {% endblock %} diff --git a/src/submissions/templates/reagent_details.html b/src/submissions/templates/reagent_details.html index 7602089..98de044 100644 --- a/src/submissions/templates/reagent_details.html +++ b/src/submissions/templates/reagent_details.html @@ -10,8 +10,11 @@

Reagent Details for {{ reagent['name'] }} - {{ reagent['lot'] }}

{{ super() }}

{% for key, value in reagent.items() if key not in reagent['excluded'] %} -     {{ key | replace("_", " ") | title }}: {{ value }}
+     {{ key | replace("_", " ") | title }}: {% if permission and key in reagent['editable']%}{% else %}{{ value }}{% endif %}
{% endfor %}

+ {% if permission %} + + {% endif %} {% if reagent['submissions'] %}

Submissions:

{% for submission in reagent['submissions'] %}

{{ submission }}: {{ reagent['role'] }}

@@ -22,6 +25,11 @@