diff --git a/CHANGELOG.md b/CHANGELOG.md index 33f88ee..f8a947b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,7 @@ +# 202504.04 + +- Added html links for equipment/processes/tips. + # 202504.03 - Split Concentration controls on the chart so they are individually selectable. diff --git a/TODO.md b/TODO.md index 477c102..aeee29b 100644 --- a/TODO.md +++ b/TODO.md @@ -1,3 +1,6 @@ +- [ ] transfer details template rendering fully into sql objects +- [x] Add in connecting links for tips. +- [x] Add in connecting links for equipment/processes. - [ ] move functions from widgets.functions to... app? - [x] Apply below fix to all other date-based queries. - [x] Fix Control graph chart bug that excludes today's controls. @@ -12,7 +15,6 @@ - [x] Upgrade to generators when returning lists. - [x] Revamp frontend.widgets.controls_chart to include visualizations? - [x] Convert Parsers to using openpyxl. - - The hardest part of this is going to be the sample parsing. I'm onto using the cell formulas in the plate map to suss out the location in the lookup table, but it could get a little recursive up in here. - [ ] Create a default info return function. - [x] Parse comment from excel sheet. - [x] Make reporting better. diff --git a/src/submissions/backend/db/__init__.py b/src/submissions/backend/db/__init__.py index 27f068e..aff8dc6 100644 --- a/src/submissions/backend/db/__init__.py +++ b/src/submissions/backend/db/__init__.py @@ -55,9 +55,10 @@ def update_log(mapper, connection, target): continue added = [str(item) for item in hist.added] # NOTE: Attributes left out to save space - if attr.key in ['artic_technician', 'submission_sample_associations', 'submission_reagent_associations', - 'submission_equipment_associations', 'submission_tips_associations', 'contact_id', 'gel_info', - 'gel_controls', 'source_plates']: + # if attr.key in ['artic_technician', 'submission_sample_associations', 'submission_reagent_associations', + # 'submission_equipment_associations', 'submission_tips_associations', 'contact_id', 'gel_info', + # 'gel_controls', 'source_plates']: + if attr.key in LogMixin.tracking_exclusion: continue deleted = [str(item) for item in hist.deleted] change = dict(field=attr.key, added=added, deleted=deleted) diff --git a/src/submissions/backend/db/models/__init__.py b/src/submissions/backend/db/models/__init__.py index dfaa7ca..5dea287 100644 --- a/src/submissions/backend/db/models/__init__.py +++ b/src/submissions/backend/db/models/__init__.py @@ -9,7 +9,7 @@ from sqlalchemy import Column, INTEGER, String, JSON from sqlalchemy.orm import DeclarativeMeta, declarative_base, Query, Session, InstrumentedAttribute, ColumnProperty from sqlalchemy.ext.declarative import declared_attr from sqlalchemy.exc import ArgumentError -from typing import Any, List +from typing import Any, List, ClassVar from pathlib import Path from sqlalchemy.orm.relationships import _RelationshipDeclared from tools import report_result, list_sort_dict @@ -25,6 +25,12 @@ logger = logging.getLogger(f"submissions.{__name__}") class LogMixin(Base): + + tracking_exclusion: ClassVar = ['artic_technician', 'submission_sample_associations', + 'submission_reagent_associations', 'submission_equipment_associations', + 'submission_tips_associations', 'contact_id', 'gel_info', 'gel_controls', + 'source_plates'] + __abstract__ = True @property diff --git a/src/submissions/backend/db/models/kits.py b/src/submissions/backend/db/models/kits.py index 2017880..7e49f85 100644 --- a/src/submissions/backend/db/models/kits.py +++ b/src/submissions/backend/db/models/kits.py @@ -4,12 +4,15 @@ All kit and reagent related models from __future__ import annotations import json, zipfile, yaml, logging, re, sys from pprint import pformat + +from jinja2 import Template, 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 sqlalchemy.ext.hybrid import hybrid_property from datetime import date, datetime, timedelta -from tools import check_authorization, setup_lookup, Report, Result, check_regex_match, yaml_regex_creator, timezone +from tools import check_authorization, setup_lookup, Report, Result, check_regex_match, yaml_regex_creator, timezone, \ + jinja_template_loading from typing import List, Literal, Generator, Any, Tuple from pandas import ExcelFile from pathlib import Path @@ -2065,6 +2068,53 @@ class Equipment(BaseClass, LogMixin): output.append(equipment[choice]) return output + def to_sub_dict(self, full_data: bool = False, **kwargs) -> dict: + """ + dictionary containing values necessary for gui + + Args: + full_data (bool, optional): Whether to include submissions in data for details. Defaults to False. + + Returns: + dict: representation of the equipment's attributes + """ + if self.nickname: + nickname = self.nickname + else: + nickname = self.name + output = dict( + name=self.name, + nickname=nickname, + asset_number=self.asset_number + ) + if full_data: + subs = [] + output['submissions'] = [dict(plate=item.submission.rsl_plate_num, process=item.process.name) + if item.process else dict(plate=item.submission.rsl_plate_num, process="NA") + for item in self.equipment_submission_associations] + output['excluded'] = ['missing', 'submissions', 'excluded', 'editable'] + return output + + @classproperty + def details_template(cls) -> Template: + """ + Get the details jinja template for the correct class + + Args: + base_dict (dict): incoming dictionary of Submission fields + + Returns: + Tuple(dict, Template): (Updated dictionary, Template to be rendered) + """ + env = jinja_template_loading() + temp_name = f"{cls.__name__.lower()}_details.html" + try: + template = env.get_template(temp_name) + except TemplateNotFound as e: + logger.error(f"Couldn't find template {e}") + template = env.get_template("equipment_details.html") + return template + class EquipmentRole(BaseClass): """ @@ -2219,6 +2269,10 @@ class SubmissionEquipmentAssociation(BaseClass): self.equipment = equipment self.role = role + @property + def process(self): + return Process.query(id=self.process_id) + def to_sub_dict(self) -> dict: """ This SubmissionEquipmentAssociation as a dictionary @@ -2433,6 +2487,44 @@ class Process(BaseClass): tip_roles=tip_roles ) + def to_sub_dict(self, full_data: bool = False, **kwargs) -> dict: + """ + dictionary containing values necessary for gui + + Args: + full_data (bool, optional): Whether to include submissions in data for details. Defaults to False. + + Returns: + dict: representation of the equipment's attributes + """ + output = dict( + name=self.name, + ) + if full_data: + output['submissions'] = [dict(plate=sub.submission.rsl_plate_num, equipment=sub.equipment.name) for sub in self.submissions] + output['excluded'] = ['missing', 'submissions', 'excluded', 'editable'] + return output + + @classproperty + def details_template(cls) -> Template: + """ + Get the details jinja template for the correct class + + Args: + base_dict (dict): incoming dictionary of Submission fields + + Returns: + Tuple(dict, Template): (Updated dictionary, Template to be rendered) + """ + env = jinja_template_loading() + temp_name = f"{cls.__name__.lower()}_details.html" + try: + template = env.get_template(temp_name) + except TemplateNotFound as e: + logger.error(f"Couldn't find template {e}") + template = env.get_template("process_details.html") + return template + class TipRole(BaseClass): """ @@ -2576,6 +2668,45 @@ class Tips(BaseClass, LogMixin): name=self.name ) + def to_sub_dict(self, full_data: bool = False, **kwargs) -> dict: + """ + dictionary containing values necessary for gui + + Args: + full_data (bool, optional): Whether to include submissions in data for details. Defaults to False. + + Returns: + dict: representation of the equipment's attributes + """ + output = dict( + name=self.name, + lot=self.lot, + ) + if full_data: + output['submissions'] = [dict(plate=item.submission.rsl_plate_num, role=item.role_name) + for item in self.tips_submission_associations] + output['excluded'] = ['missing', 'submissions', 'excluded', 'editable'] + return output + + @classproperty + def details_template(cls) -> Template: + """ + Get the details jinja template for the correct class + + Args: + base_dict (dict): incoming dictionary of Submission fields + + Returns: + Tuple(dict, Template): (Updated dictionary, Template to be rendered) + """ + env = jinja_template_loading() + temp_name = f"{cls.__name__.lower()}_details.html" + try: + template = env.get_template(temp_name) + except TemplateNotFound as e: + logger.error(f"Couldn't find template {e}") + template = env.get_template("tips_details.html") + return template class SubmissionTypeTipRoleAssociation(BaseClass): """ diff --git a/src/submissions/frontend/visualizations/__init__.py b/src/submissions/frontend/visualizations/__init__.py index d96e665..9f66acd 100644 --- a/src/submissions/frontend/visualizations/__init__.py +++ b/src/submissions/frontend/visualizations/__init__.py @@ -133,8 +133,8 @@ class CustomFigure(Figure): else: html += "

No data was retrieved for the given parameters.

" html += '' - with open("test.html", "w", encoding="utf-8") as f: - f.write(html) + # with open("test.html", "w", encoding="utf-8") as f: + # f.write(html) return html diff --git a/src/submissions/frontend/widgets/submission_details.py b/src/submissions/frontend/widgets/submission_details.py index 2abb60a..47abc89 100644 --- a/src/submissions/frontend/widgets/submission_details.py +++ b/src/submissions/frontend/widgets/submission_details.py @@ -7,7 +7,7 @@ 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, Reagent, KitType +from backend.db.models import BasicSubmission, BasicSample, Reagent, KitType, Equipment, Process, Tips from tools import is_power_user, jinja_template_loading, timezone, get_application_from_parent from .functions import select_save_file, save_pdf from pathlib import Path @@ -84,6 +84,48 @@ class SubmissionDetails(QDialog): else: self.back.setEnabled(True) + @pyqtSlot(str) + def equipment_details(self, equipment: str | Equipment): + logger.debug(f"Equipment details") + if isinstance(equipment, str): + equipment = Equipment.query(name=equipment) + base_dict = equipment.to_sub_dict(full_data=True) + template = equipment.details_template + template_path = Path(template.environment.loader.__getattribute__("searchpath")[0]) + with open(template_path.joinpath("css", "styles.css"), "r") as f: + css = f.read() + html = template.render(equipment=base_dict, css=css) + self.webview.setHtml(html) + self.setWindowTitle(f"Equipment Details - {equipment.name}") + + @pyqtSlot(str) + def process_details(self, process: str | Process): + logger.debug(f"Equipment details") + if isinstance(process, str): + process = Process.query(name=process) + base_dict = process.to_sub_dict(full_data=True) + template = process.details_template + template_path = Path(template.environment.loader.__getattribute__("searchpath")[0]) + with open(template_path.joinpath("css", "styles.css"), "r") as f: + css = f.read() + html = template.render(process=base_dict, css=css) + self.webview.setHtml(html) + self.setWindowTitle(f"Process Details - {process.name}") + + @pyqtSlot(str) + def tips_details(self, tips: str | Tips): + logger.debug(f"Equipment details: {tips}") + if isinstance(tips, str): + tips = Tips.query(lot=tips) + base_dict = tips.to_sub_dict(full_data=True) + template = tips.details_template + template_path = Path(template.environment.loader.__getattribute__("searchpath")[0]) + with open(template_path.joinpath("css", "styles.css"), "r") as f: + css = f.read() + html = template.render(tips=base_dict, css=css) + self.webview.setHtml(html) + self.setWindowTitle(f"Process Details - {tips.name}") + @pyqtSlot(str) def sample_details(self, sample: str | BasicSample): """ @@ -103,8 +145,8 @@ class SubmissionDetails(QDialog): with open(template_path.joinpath("css", "styles.css"), "r") as f: css = f.read() html = template.render(sample=base_dict, css=css) - with open(f"{sample.submitter_id}.html", 'w') as f: - f.write(html) + # with open(f"{sample.submitter_id}.html", 'w') as f: + # f.write(html) self.webview.setHtml(html) self.setWindowTitle(f"Sample Details - {sample.submitter_id}") diff --git a/src/submissions/templates/basicsubmission_details.html b/src/submissions/templates/basicsubmission_details.html index b3e4266..2df1fe4 100644 --- a/src/submissions/templates/basicsubmission_details.html +++ b/src/submissions/templates/basicsubmission_details.html @@ -20,19 +20,19 @@ {% if sub['reagents'] %}

Reagents:

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

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

Equipment:

{% for item in sub['equipment'] %} -     {{ item['role'] }}: {{ item['name'] }} ({{ item['asset_number'] }}): {{ item['processes'][0]|replace('\n\t', '
        ') }}
+     {{ item['role'] }}: {{ item['name'] }} ({{ item['asset_number'] }}): {{ item['processes'][0]|replace('\n\t', '
        ') }}

{% endfor %}

{% endif %} {% if sub['tips'] %}

Tips:

{% for item in sub['tips'] %} -     {{ item['role'] }}: {{ item['name'] }} ({{ item['lot'] }})
+     {{ item['role'] }}: {{ item['name'] }} ({{ item['lot'] }})
{% endfor %}

{% endif %} {% if sub['samples'] %} @@ -99,6 +99,33 @@ }) } + var equipmentSelection = document.getElementsByClassName('equipment'); + + for(let i = 0; i < equipmentSelection.length; i++) { + equipmentSelection[i].addEventListener("click", function() { + console.log(equipmentSelection[i].id); + backend.equipment_details(equipmentSelection[i].id); + }) + } + + var processSelection = document.getElementsByClassName('process'); + + for(let i = 0; i < processSelection.length; i++) { + processSelection[i].addEventListener("click", function() { + console.log(processSelection[i].id); + backend.process_details(processSelection[i].id); + }) + } + + var tipsSelection = document.getElementsByClassName('tips'); + + for(let i = 0; i < tipsSelection.length; i++) { + tipsSelection[i].addEventListener("click", function() { + console.log(tipsSelection[i].id); + backend.tips_details(tipsSelection[i].id); + }) + } + document.getElementById("sign_btn").addEventListener("click", function(){ backend.sign_off("{{ sub['plate_number'] }}"); }); diff --git a/src/submissions/templates/equipment_details.html b/src/submissions/templates/equipment_details.html new file mode 100644 index 0000000..a2ab184 --- /dev/null +++ b/src/submissions/templates/equipment_details.html @@ -0,0 +1,50 @@ +{% extends "details.html" %} + + {% block head %} + {{ super() }} + Equipment Details for {{ equipment['name'] }} + {% endblock %} + + + {% block body %} +

Equipment Details for {{ equipment['name'] }}

+ {{ super() }} +

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

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

Submissions:

+ {% for submission in equipment['submissions'] %} +

{{ submission['plate'] }}: {{ submission['process'] }}

+ {% endfor %} + {% endif %} + {% endblock %} + + + \ No newline at end of file diff --git a/src/submissions/templates/process_details.html b/src/submissions/templates/process_details.html new file mode 100644 index 0000000..c76c587 --- /dev/null +++ b/src/submissions/templates/process_details.html @@ -0,0 +1,49 @@ +{% extends "details.html" %} + + {% block head %} + {{ super() }} + Process Details for {{ process['name'] }} + {% endblock %} + + + {% block body %} +

Process Details for {{ process['name'] }}

+ {{ super() }} +

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

+ + + + {% if process['submissions'] %}

Submissions:

+ {% for submission in process['submissions'] %} +

{{ submission['plate'] }}:{{ submission['equipment'] }}

+ {% endfor %} + {% endif %} + {% endblock %} + + + \ No newline at end of file diff --git a/src/submissions/templates/tips_details.html b/src/submissions/templates/tips_details.html new file mode 100644 index 0000000..b9a907f --- /dev/null +++ b/src/submissions/templates/tips_details.html @@ -0,0 +1,50 @@ +{% extends "details.html" %} + + {% block head %} + {{ super() }} + Tips Details for {{ tips['name'] }} + {% endblock %} + + + {% block body %} +

Tips Details for {{ tips['name'] }}

+ {{ super() }} +

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

+ + + + {% if tips['submissions'] %}

Submissions:

+ {% for submission in tips['submissions'] %} +

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

+ {% endfor %} + {% endif %} + {% endblock %} + + + \ No newline at end of file diff --git a/submissions.spec b/submissions.spec index d3326f5..e1a8694 100644 --- a/submissions.spec +++ b/submissions.spec @@ -16,7 +16,8 @@ api_path = project_path.joinpath(".venv", "Scripts", "sphinx-apidoc").absolute() subprocess.run([api_path, "-o", doc_path.joinpath("source").__str__(), project_path.joinpath("src", "submissions").__str__(), "-f"]) print(bcolors.BOLD + "Running Sphinx subprocess to generate html docs..." + bcolors.ENDC) docs_build = doc_path.joinpath("build") -#docs_build.mkdir(exist_ok=True, parents=True) +if not docs_build.exists(): + docs_build.mkdir(exist_ok=True, parents=True) subprocess.run([build_path, doc_path.joinpath("source").__str__(), docs_build.__str__(), "-a"]) #########################################################