Prior to merging omni_search.py and sample_search.py

This commit is contained in:
lwark
2024-11-18 09:53:03 -06:00
parent 3d6a42b36f
commit 506aac80c1
13 changed files with 162 additions and 73 deletions

View File

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

View File

@@ -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]:
"""

View File

@@ -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"<Reagent({self.role.name}-{self.lot})>"
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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -71,7 +71,7 @@
{% endif %}
{% endblock %}
{% block signing_button %}
{% if signing_permission %}
{% if permission %}
<button type="button" id="sign_btn">Sign Off</button>
{% endif %}
{% endblock %}

View File

@@ -10,8 +10,11 @@
<h2><u>Reagent Details for {{ reagent['name'] }} - {{ reagent['lot'] }}</u></h2>
{{ super() }}
<p>{% for key, value in reagent.items() if key not in reagent['excluded'] %}
&nbsp;&nbsp;&nbsp;&nbsp;<b>{{ key | replace("_", " ") | title }}: </b>{{ value }}<br>
&nbsp;&nbsp;&nbsp;&nbsp;<b>{{ key | replace("_", " ") | title }}: </b>{% if permission and key in reagent['editable']%}<input type={% if key=='expiry' %}"date"{% else %}"text"{% endif %} id="{{ key }}" name="{{ key }}" value="{{ value }}">{% else %}{{ value }}{% endif %}<br>
{% endfor %}</p>
{% if permission %}
<button type="button" id="save_btn">Save</button>
{% endif %}
{% if reagent['submissions'] %}<h2>Submissions:</h2>
{% for submission in reagent['submissions'] %}
<p><b><a class="data-link" id="{{ submission }}">{{ submission }}:</a></b> {{ reagent['role'] }}</p>
@@ -22,6 +25,11 @@
<script>
{% block script %}
{{ super() }}
document.getElementById("save_btn").addEventListener("click", function(){
var new_lot = document.getElementById('lot').value
var new_exp = document.getElementById('expiry').value
backend.update_reagent("{{ reagent['lot'] }}", new_lot, new_exp);
});
{% for submission in reagent['submissions'] %}
document.getElementById("{{ submission }}").addEventListener("click", function(){
backend.submission_details("{{ submission }}");