diff --git a/CHANGELOG.md b/CHANGELOG.md index 0630981..32984f5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,6 @@ ## 202409.03 +- Better navigation and clarity in details panes. - Upgraded sample search to (semi) realtime search. - Improved error messaging. diff --git a/src/submissions/backend/db/models/kits.py b/src/submissions/backend/db/models/kits.py index a2ba946..e71b7d7 100644 --- a/src/submissions/backend/db/models/kits.py +++ b/src/submissions/backend/db/models/kits.py @@ -8,12 +8,13 @@ import json from pprint import pprint, pformat import yaml +from jinja2 import TemplateNotFound 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 import logging, re -from tools import check_authorization, setup_lookup, Report, Result +from tools import check_authorization, setup_lookup, Report, Result, jinja_template_loading from typing import List, Literal, Generator, Any from pandas import ExcelFile from pathlib import Path @@ -421,12 +422,13 @@ class Reagent(BaseClass): else: return f"" - def to_sub_dict(self, extraction_kit: KitType = None) -> dict: + def to_sub_dict(self, extraction_kit: KitType = None, full_data:bool=False) -> dict: """ dictionary containing values necessary for gui Args: extraction_kit (KitType, optional): KitType to use to get reagent type. Defaults to None. + full_data (bool, optional): Whether to include submissions in data for details. Defaults to False. Returns: dict: representation of the reagent's attributes @@ -456,13 +458,17 @@ class Reagent(BaseClass): place_holder = "NA" else: place_holder = place_holder.strftime("%Y-%m-%d") - return dict( + output = dict( name=self.name, role=rtype, lot=self.lot, expiry=place_holder, missing=False ) + if full_data: + output['submissions'] = [sub.rsl_plate_num for sub in self.submissions] + output['excluded'] = ['missing', 'submissions', 'excluded'] + return output def update_last_used(self, kit: KitType) -> Report: """ diff --git a/src/submissions/backend/db/models/submissions.py b/src/submissions/backend/db/models/submissions.py index 90a0ef7..f516f4a 100644 --- a/src/submissions/backend/db/models/submissions.py +++ b/src/submissions/backend/db/models/submissions.py @@ -168,7 +168,8 @@ class BasicSubmission(BaseClass): 'extraction_info', 'comment', 'barcode', 'platemap', 'export_map', 'equipment', 'tips'], # NOTE: Fields not placed in ui form - form_ignore=['reagents', 'ctx', 'id', 'cost', 'extraction_info', 'signed_by', 'comment', 'namer', 'submission_object', "tips"] + recover, + form_ignore=['reagents', 'ctx', 'id', 'cost', 'extraction_info', 'signed_by', 'comment', 'namer', + 'submission_object', "tips", 'contact_phone'] + recover, # NOTE: Fields not placed in ui form to be moved to pydantic form_recover=recover ) @@ -229,7 +230,8 @@ class BasicSubmission(BaseClass): return SubmissionType.query(cls.__mapper_args__['polymorphic_identity']) @classmethod - def construct_info_map(cls, submission_type:SubmissionType|None=None, mode: Literal["read", "write"]="read") -> dict: + def construct_info_map(cls, submission_type: SubmissionType | None = None, + mode: Literal["read", "write"] = "read") -> dict: """ Method to call submission type's construct info map. @@ -242,7 +244,7 @@ class BasicSubmission(BaseClass): return cls.get_submission_type(submission_type).construct_info_map(mode=mode) @classmethod - def construct_sample_map(cls, submission_type:SubmissionType|None=None) -> dict: + def construct_sample_map(cls, submission_type: SubmissionType | None = None) -> dict: """ Method to call submission type's construct_sample_map @@ -609,7 +611,7 @@ class BasicSubmission(BaseClass): new_dict = {} for key, value in dicto.items(): # logger.debug(f"Checking {key}") - missing = value is None or value in ['', 'None'] + missing = value in ['', 'None', None] match key: case "reagents": new_dict[key] = [PydReagent(**reagent) for reagent in value] @@ -628,7 +630,7 @@ class BasicSubmission(BaseClass): case "id": pass case _: - # logger.debug(f"Setting dict {key} to {value}") + logger.debug(f"Setting dict {key} to {value}") new_dict[key.lower().replace(" ", "_")] = dict(value=value, missing=missing) # logger.debug(f"{key} complete after {time()-start}") new_dict['filepath'] = Path(tempfile.TemporaryFile().name) @@ -648,7 +650,7 @@ class BasicSubmission(BaseClass): return super().save() @classmethod - def get_regex(cls, submission_type:SubmissionType|str|None=None): + def get_regex(cls, submission_type: SubmissionType | str | None = None): # logger.debug(f"Attempting to get regex for {cls.__mapper_args__['polymorphic_identity']}") logger.debug(f"Attempting to get regex for {submission_type}") try: @@ -707,7 +709,8 @@ class BasicSubmission(BaseClass): # item.__mapper_args__['polymorphic_identity'] == polymorphic_identity][0] model = cls.__mapper__.polymorphic_map[polymorphic_identity].class_ except Exception as e: - logger.error(f"Could not get polymorph {polymorphic_identity} of {cls} due to {e}, falling back to BasicSubmission") + logger.error( + f"Could not get polymorph {polymorphic_identity} of {cls} due to {e}, falling back to BasicSubmission") case _: pass if attrs is None or len(attrs) == 0: @@ -1199,6 +1202,8 @@ class BasicSubmission(BaseClass): dlg = SubmissionComment(parent=obj, submission=self) if dlg.exec(): comment = dlg.parse_form() + if comment in ["", None]: + return self.set_attribute(key='comment', value=comment) # logger.debug(self.comment) self.save(original=False) @@ -1368,49 +1373,6 @@ class BacterialCulture(BasicSubmission): return input_dict -# class ViralCulture(BasicSubmission): -# -# id = Column(INTEGER, ForeignKey('_basicsubmission.id'), primary_key=True) -# __mapper_args__ = dict(polymorphic_identity="Viral Culture", -# polymorphic_load="inline", -# inherit_condition=(id == BasicSubmission.id)) -# -# # @classmethod -# # def get_regex(cls) -> str: -# # """ -# # Retrieves string for regex construction. -# # -# # Returns: -# # str: string for regex construction -# # """ -# # return "(?PRSL(?:-|_)?VE(?:-|_)?20\d{2}-?\d{2}-?\d{2}(?:(_|-)?\d?([^_0123456789\sA-QS-Z]|$)?R?\d?)?)" -# -# @classmethod -# def custom_sample_autofill_row(cls, sample, worksheet: Worksheet) -> int: -# """ -# Extends parent -# """ -# # logger.debug(f"Checking {sample.well}") -# # logger.debug(f"here's the worksheet: {worksheet}") -# row = super().custom_sample_autofill_row(sample, worksheet) -# df = pd.DataFrame(list(worksheet.values)) -# # logger.debug(f"Here's the dataframe: {df}") -# idx = df[df[0] == sample.well] -# if idx.empty: -# new = f"{sample.well[0]}{sample.well[1:].zfill(2)}" -# # logger.debug(f"Checking: {new}") -# idx = df[df[0] == new] -# # logger.debug(f"Here is the row: {idx}") -# row = idx.index.to_list()[0] -# return row + 1 -# -# @classmethod -# def custom_info_parser(cls, input_dict: dict, xl: Workbook | None = None, custom_fields: dict = {}) -> dict: -# input_dict = super().custom_info_parser(input_dict=input_dict, xl=xl, custom_fields=custom_fields) -# logger.debug(f"\n\nInfo dictionary:\n\n{pformat(input_dict)}\n\n") -# return input_dict - - class Wastewater(BasicSubmission): """ derivative submission type from BasicSubmission @@ -2239,6 +2201,9 @@ class BasicSample(BaseClass): """ gui friendly dictionary, extends parent method. + Args: + full_data (bool): Whether to use full object or truncated. Defaults to False + Returns: dict: well location and name (sample id, organism) NOTE: keys must sync with WWSample to_sub_dict above """ diff --git a/src/submissions/frontend/widgets/misc.py b/src/submissions/frontend/widgets/misc.py index 7dbeae4..08ae7ee 100644 --- a/src/submissions/frontend/widgets/misc.py +++ b/src/submissions/frontend/widgets/misc.py @@ -137,6 +137,7 @@ class ReportDatePicker(QDialog): """ return dict(start_date=self.start_date.date().toPyDate(), end_date = self.end_date.date().toPyDate()) + class LogParser(QDialog): def __init__(self, parent): diff --git a/src/submissions/frontend/widgets/submission_details.py b/src/submissions/frontend/widgets/submission_details.py index ac1191f..481a83c 100644 --- a/src/submissions/frontend/widgets/submission_details.py +++ b/src/submissions/frontend/widgets/submission_details.py @@ -1,14 +1,16 @@ """ Webview to show submission and sample details. """ -from PyQt6.QtWidgets import (QDialog, QPushButton, QVBoxLayout, - QDialogButtonBox, QTextEdit) +from PyQt6.QtGui import QColor +from PyQt6.QtWidgets import (QDialog, QPushButton, QVBoxLayout, + QDialogButtonBox, QTextEdit, QGridLayout) from PyQt6.QtWebEngineWidgets import QWebEngineView from PyQt6.QtWebChannel import QWebChannel from PyQt6.QtCore import Qt, pyqtSlot +from jinja2 import TemplateNotFound -from backend.db.models import BasicSubmission, BasicSample -from tools import is_power_user, html_to_pdf +from backend.db.models import BasicSubmission, BasicSample, Reagent, KitType +from tools import is_power_user, html_to_pdf, jinja_template_loading from .functions import select_save_file from pathlib import Path import logging @@ -25,8 +27,9 @@ logger = logging.getLogger(f"submissions.{__name__}") class SubmissionDetails(QDialog): """ a window showing text details of submission - """ - def __init__(self, parent, sub:BasicSubmission|BasicSample) -> None: + """ + + def __init__(self, parent, sub: BasicSubmission | BasicSample | Reagent) -> None: super().__init__(parent) try: @@ -35,15 +38,20 @@ class SubmissionDetails(QDialog): self.app = None self.webview = QWebEngineView(parent=self) self.webview.setMinimumSize(900, 500) - self.webview.setMaximumSize(900, 500) - self.layout = QVBoxLayout() - self.setFixedSize(900, 500) + self.webview.setMaximumSize(900, 700) + self.webview.loadFinished.connect(self.activate_export) + self.layout = QGridLayout() + # self.setFixedSize(900, 500) # NOTE: button to export a pdf version - btn = QPushButton("Export DOCX") - btn.setFixedWidth(875) - btn.clicked.connect(self.export) - self.layout.addWidget(btn) - self.layout.addWidget(self.webview) + self.btn = QPushButton("Export DOCX") + self.btn.setFixedWidth(775) + self.btn.clicked.connect(self.export) + self.back = QPushButton("Back") + self.back.setFixedWidth(100) + self.back.clicked.connect(self.back_function) + self.layout.addWidget(self.back, 0, 0, 1, 1) + self.layout.addWidget(self.btn, 0, 1, 1, 9) + self.layout.addWidget(self.webview, 1, 0, 10, 10) self.setLayout(self.layout) # NOTE: setup channel self.channel = QWebChannel() @@ -54,10 +62,26 @@ class SubmissionDetails(QDialog): self.rsl_plate_num = sub.rsl_plate_num case BasicSample(): self.sample_details(sample=sub) + case Reagent(): + self.reagent_details(reagent=sub) self.webview.page().setWebChannel(self.channel) + def back_function(self): + self.webview.back() + + # @pyqtSlot(bool) + def activate_export(self): + title = self.webview.title() + self.setWindowTitle(title) + if "Submission" in title: + self.btn.setEnabled(True) + self.export_plate = title.split(" ")[-1] + logger.debug(f"Updating export plate to: {self.export_plate}") + else: + self.btn.setEnabled(False) + @pyqtSlot(str) - def sample_details(self, sample:str|BasicSample): + def sample_details(self, sample: str | BasicSample): """ Changes details view to summary of Sample @@ -68,21 +92,49 @@ class SubmissionDetails(QDialog): sample = BasicSample.query(submitter_id=sample) base_dict = sample.to_sub_dict(full_data=True) base_dict, template = sample.get_details_template(base_dict=base_dict) - html = template.render(sample=base_dict) + 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(sample=base_dict, css=css) self.webview.setHtml(html) self.setWindowTitle(f"Sample Details - {sample.submitter_id}") - + # self.btn.setEnabled(False) + + @pyqtSlot(str, str) + def reagent_details(self, reagent: str | Reagent, kit: str | KitType): + if isinstance(reagent, str): + reagent = Reagent.query(lot_number=reagent) + if isinstance(kit, str): + kit = KitType.query(name=kit) + base_dict = reagent.to_sub_dict(extraction_kit=kit, full_data=True) + env = jinja_template_loading() + temp_name = "reagent_details.html" + # logger.debug(f"Returning template: {temp_name}") + try: + template = env.get_template(temp_name) + except TemplateNotFound as e: + logger.error(f"Couldn't find template due to {e}") + return + 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) + self.webview.setHtml(html) + self.setWindowTitle(f"Reagent Details - {reagent.name} - {reagent.lot}") + # self.btn.setEnabled(False) + @pyqtSlot(str) - def submission_details(self, submission:str|BasicSubmission): + def submission_details(self, submission: str | BasicSubmission): """ Sets details view to summary of Submission. Args: submission (str | BasicSubmission): Submission of interest. - """ + """ # logger.debug(f"Details for: {submission}") if isinstance(submission, str): submission = BasicSubmission.query(rsl_plate_num=submission) + self.rsl_plate_num = submission.rsl_plate_num self.base_dict = submission.to_dict(full_data=True) # logger.debug(f"Submission details data:\n{pformat({k:v for k,v in self.base_dict.items() if k == 'reagents'})}") # NOTE: don't want id @@ -97,10 +149,10 @@ class SubmissionDetails(QDialog): # logger.debug(f"Submission_details: {pformat(self.base_dict)}") self.html = self.template.render(sub=self.base_dict, signing_permission=is_power_user(), css=css) self.webview.setHtml(self.html) - self.setWindowTitle(f"Submission Details - {submission.rsl_plate_num}") + # self.btn.setEnabled(True) @pyqtSlot(str) - def sign_off(self, submission:str|BasicSubmission): + def sign_off(self, submission: str | BasicSubmission): # logger.debug(f"Signing off on {submission} - ({getuser()})") if isinstance(submission, str): submission = BasicSubmission.query(rsl_plate_num=submission) @@ -112,19 +164,23 @@ class SubmissionDetails(QDialog): """ Renders submission to html, then creates and saves .pdf file to user selected file. """ - writer = DocxWriter(base_dict=self.base_dict) - fname = select_save_file(obj=self, default_name=self.base_dict['plate_number'], extension="docx") + export_plate = BasicSubmission.query(rsl_plate_num=self.export_plate) + base_dict = export_plate.to_dict(full_data=True) + writer = DocxWriter(base_dict=base_dict) + fname = select_save_file(obj=self, default_name=base_dict['plate_number'], extension="docx") writer.save(fname) try: html_to_pdf(html=self.html, output_file=fname) except PermissionError as e: logger.error(f"Error saving pdf: {e}") + class SubmissionComment(QDialog): """ a window for adding comment text to a submission - """ - def __init__(self, parent, submission:BasicSubmission) -> None: + """ + + def __init__(self, parent, submission: BasicSubmission) -> None: super().__init__(parent) try: @@ -137,7 +193,8 @@ class SubmissionComment(QDialog): # NOTE: create text field self.txt_editor = QTextEdit(self) self.txt_editor.setReadOnly(False) - self.txt_editor.setText("Add Comment") + self.txt_editor.setPlaceholderText("Write your comment here.") + self.txt_editor.setStyleSheet("background-color: rgb(255, 255, 255);") QBtn = QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel self.buttonBox = QDialogButtonBox(QBtn) self.buttonBox.accepted.connect(self.accept) @@ -147,14 +204,16 @@ class SubmissionComment(QDialog): self.layout.addWidget(self.txt_editor) self.layout.addWidget(self.buttonBox, alignment=Qt.AlignmentFlag.AlignBottom) self.setLayout(self.layout) - + def parse_form(self) -> List[dict]: """ Adds comment to submission object. - """ + """ commenter = getuser() comment = self.txt_editor.toPlainText() + if comment in ["", None]: + return None dt = datetime.strftime(datetime.now(), "%Y-%m-%d %H:%M:%S") - full_comment = {"name":commenter, "time": dt, "text": comment} + full_comment = {"name": commenter, "time": dt, "text": comment} # logger.debug(f"Full comment: {full_comment}") return full_comment diff --git a/src/submissions/frontend/widgets/submission_table.py b/src/submissions/frontend/widgets/submission_table.py index ed6a868..70ca3c2 100644 --- a/src/submissions/frontend/widgets/submission_table.py +++ b/src/submissions/frontend/widgets/submission_table.py @@ -257,7 +257,7 @@ class SubmissionsSheet(QTableView): dlg = ReportDatePicker() if dlg.exec(): info = dlg.parse_form() - fname = select_save_file(obj=self, default_name=f"Submissions_Report_{info['start_date']}-{info['end_date']}.docx", extension="docx") + fname = select_save_file(obj=self, default_name=f"Submissions_Report_{info['start_date']}-{info['end_date']}.xlsx", extension="xlsx") rp = ReportMaker(start_date=info['start_date'], end_date=info['end_date']) rp.write_report(filename=fname, obj=self) return report diff --git a/src/submissions/frontend/widgets/submission_widget.py b/src/submissions/frontend/widgets/submission_widget.py index f3a813d..51e9bb0 100644 --- a/src/submissions/frontend/widgets/submission_widget.py +++ b/src/submissions/frontend/widgets/submission_widget.py @@ -191,7 +191,9 @@ class SubmissionFormWidget(QWidget): # logger.debug(f"Attempting to extend ignore list with {self.pyd.submission_type['value']}") self.layout = QVBoxLayout() for k in list(self.pyd.model_fields.keys()) + list(self.pyd.model_extra.keys()): + logger.debug(f"Creating widget: {k}") if k in self.ignore: + logger.warning(f"{k} in form_ignore {self.ignore}, not creating widget") continue try: # logger.debug(f"Key: {k}, Disable: {disable}") @@ -201,9 +203,12 @@ class SubmissionFormWidget(QWidget): check = False try: value = self.pyd.__getattribute__(k) - except AttributeError: - logger.error(f"Couldn't get attribute from pyd: {k}") - value = dict(value=None, missing=True) + except AttributeError as e: + logger.error(f"Couldn't get attribute from pyd: {k} due to {e}") + try: + value = self.pyd.model_extra[k] + except KeyError: + value = dict(value=None, missing=True) add_widget = self.create_widget(key=k, value=value, submission_type=self.pyd.submission_type['value'], sub_obj=st, disable=check) if add_widget is not None: @@ -259,7 +264,7 @@ class SubmissionFormWidget(QWidget): Tuple[QMainWindow, dict]: Updated application and result """ extraction_kit = args[0] - caller = inspect.stack()[1].function.__repr__().replace("'", "") + # caller = inspect.stack()[1].function.__repr__().replace("'", "") # logger.debug(f"Self.reagents: {self.reagents}") # logger.debug(f"\n\n{pformat(caller)}\n\n") # logger.debug(f"SubmissionType: {self.submission_type}") diff --git a/src/submissions/templates/basicsample_details.html b/src/submissions/templates/basicsample_details.html index 4fb0a45..5fc8571 100644 --- a/src/submissions/templates/basicsample_details.html +++ b/src/submissions/templates/basicsample_details.html @@ -1,65 +1,37 @@ - +{% extends "details.html" %} + {% block head %} - - Sample Details for {{ sample['Submitter ID'] }} - + {{ super() }} + Sample Details for {{ sample['submitter_id'] }} {% endblock %} {% block body %} -

Sample Details for {{ sample['Submitter ID'] }}

+

Sample Details for {{ sample['submitter_id'] }}

+ {{ super() }}

{% for key, value in sample.items() if key not in sample['excluded'] %}     {{ key | replace("_", " ") | title }}: {{ value }}
{% endfor %}

{% if sample['submissions'] %}

Submissions:

{% for submission in sample['submissions'] %} -

{{ submission['plate_name'] }}: {{ submission['well'] }}

+

{{ submission['plate_name'] }}: {{ submission['well'] }}

{% endfor %} {% endif %} {% endblock %} diff --git a/src/submissions/templates/basicsubmission_details.html b/src/submissions/templates/basicsubmission_details.html index 8dbf00c..bc6e782 100644 --- a/src/submissions/templates/basicsubmission_details.html +++ b/src/submissions/templates/basicsubmission_details.html @@ -1,26 +1,25 @@ - +{% extends "details.html" %} + {% block head %} - {% if css %} - - {% endif %} - Submission Details for {{ sub['Plate Number'] }} - + {{ super() }} + Submission Details for {{ sub['plate_number'] }} {% endblock %} {% block body %}

Submission Details for {{ sub['plate_number'] }}

   {% if sub['barcode'] %}{% endif %} + {{ super() }}

{% for key, value in sub.items() if key not in sub['excluded'] %}     {{ key | replace("_", " ") | title | replace("Pcr", "PCR") }}: {% if key=='cost' %}{% if sub['cost'] %} {{ "${:,.2f}".format(value) }}{% endif %}{% else %}{{ value }}{% endif %}
{% endfor %}

+

Reagents:

{% for item in sub['reagents'] %} -     {{ item['role'] }}: {{ item['lot'] }} (EXP: {{ item['expiry'] }})
+     {{ item['role'] }}: {{ item['lot'] }} (EXP: {{ item['expiry'] }})
{% endfor %}

+ {% if sub['equipment'] %}

Equipment:

{% for item in sub['equipment'] %} @@ -36,7 +35,7 @@ {% if sub['samples'] %}

Samples:

{% for item in sub['samples'] %} -     {{ item['well'] }}: {% if item['organism'] %} {{ item['name'] }} - ({{ item['organism']|replace('\n\t', '
        ') }}){% else %} {{ item['name']|replace('\n\t', '
        ') }}{% endif %}
+     {{ item['well'] }}:{% if item['organism'] %} {{ item['name'] }} - ({{ item['organism']|replace('\n\t', '
        ') }}){% else %} {{ item['name']|replace('\n\t', '
        ') }}{% endif %}

{% endfor %}

{% endif %} @@ -76,17 +75,29 @@
diff --git a/src/submissions/templates/css/styles.css b/src/submissions/templates/css/styles.css index b19cf61..a761198 100644 --- a/src/submissions/templates/css/styles.css +++ b/src/submissions/templates/css/styles.css @@ -27,3 +27,15 @@ visibility: visible; font-size: large; } + +.data-link { + color: blue; + text-decoration: underline; + text-decoration-color: blue; +} + +.data-link:hover { + color: #ff33ff; + text-decoration-color: #ff33ff; + cursor: pointer; +} diff --git a/src/submissions/templates/details.html b/src/submissions/templates/details.html new file mode 100644 index 0000000..129b81b --- /dev/null +++ b/src/submissions/templates/details.html @@ -0,0 +1,28 @@ + + + + {% block head %} + + {% if css %} + + {% endif %} + + {% endblock %} + + + +{% block body %} + +{% endblock %} + + + \ No newline at end of file diff --git a/src/submissions/templates/plate_map.html b/src/submissions/templates/plate_map.html index 670c2c1..87e38c1 100644 --- a/src/submissions/templates/plate_map.html +++ b/src/submissions/templates/plate_map.html @@ -1,6 +1,6 @@