Post code-cleanup

This commit is contained in:
lwark
2025-01-21 15:18:37 -06:00
parent bf711369c6
commit bc4af61f5f
26 changed files with 546 additions and 377 deletions

View File

@@ -180,9 +180,9 @@ class KitType(BaseClass):
pass
case _:
raise ValueError(f"Wrong variable type: {type(submission_type)} used!")
logger.debug(f"Submission type: {submission_type}, Kit: {self}")
# logger.debug(f"Submission type: {submission_type}, Kit: {self}")
assocs = [item for item in self.kit_reagentrole_associations if item.submission_type == submission_type]
logger.debug(f"Associations: {assocs}")
# logger.debug(f"Associations: {assocs}")
# NOTE: rescue with submission type's default kit.
if not assocs:
logger.error(
@@ -211,7 +211,7 @@ class KitType(BaseClass):
# except TypeError:
# continue
output = {assoc.reagent_role.name: assoc.uses for assoc in assocs}
logger.debug(f"Output: {output}")
# logger.debug(f"Output: {output}")
return output, new_kit
@classmethod
@@ -1718,7 +1718,7 @@ class SubmissionEquipmentAssociation(BaseClass):
@classmethod
@setup_lookup
def query(cls, equipment_id: int, submission_id: int, role: str | None = None, limit: int = 0, **kwargs) \
def query(cls, equipment_id: int|None=None, submission_id: int|None=None, role: str | None = None, limit: int = 0, **kwargs) \
-> Any | List[Any]:
query: Query = cls.__database_session__.query(cls)
query = query.filter(cls.equipment_id == equipment_id)
@@ -2013,7 +2013,8 @@ class SubmissionTipsAssociation(BaseClass):
@classmethod
def query_or_create(cls, tips, submission, role: str, **kwargs):
instance = cls.query(tip_id=tips.id, role=role, submission_id=submission.id, limit=1, **kwargs)
kwargs['limit'] = 1
instance = cls.query(tip_id=tips.id, role=role, submission_id=submission.id, **kwargs)
if instance is None:
instance = SubmissionTipsAssociation(submission=submission, tips=tips, role_name=role)
return instance

View File

@@ -185,3 +185,6 @@ class Contact(BaseClass):
pass
return cls.execute_query(query=query, limit=limit)
def to_pydantic(self) -> "PydContact":
from backend.validators import PydContact
return PydContact(name=self.name, email=self.email, phone=self.phone)

View File

@@ -279,7 +279,7 @@ class ReagentParser(object):
# submission_type = submission_type['value']
# if isinstance(submission_type, str):
# submission_type = SubmissionType.query(name=submission_type)
logger.debug("Running kit map")
# logger.debug("Running kit map")
associations, self.kit_object = self.kit_object.construct_xl_map_for_use(submission_type=self.submission_type_obj)
reagent_map = {k: v for k, v in associations.items() if k != 'info'}
try:

View File

@@ -158,12 +158,15 @@ class InfoWriter(object):
match k:
case "custom":
continue
# case "comment":
# NOTE: merge all comments to fit in single cell.
if k == "comment" and isinstance(v['value'], list):
json_join = [item['text'] for item in v['value'] if 'text' in item.keys()]
v['value'] = "\n".join(json_join)
case "comment":
# NOTE: merge all comments to fit in single cell.
if isinstance(v['value'], list):
json_join = [item['text'] for item in v['value'] if 'text' in item.keys()]
v['value'] = "\n".join(json_join)
case thing if thing in self.sub_object.timestamps:
v['value'] = v['value'].date()
case _:
pass
final_info[k] = v
try:
locations = v['locations']
@@ -252,6 +255,11 @@ class ReagentWriter(object):
for v in reagent.values():
if not isinstance(v, dict):
continue
match v['value']:
case datetime():
v['value'] = v['value'].date()
case _:
pass
sheet.cell(row=v['row'], column=v['column'], value=v['value'])
return self.xl

View File

@@ -641,7 +641,7 @@ class PydSubmission(BaseModel, extra='allow'):
@field_validator("contact")
@classmethod
def get_contact_from_org(cls, value, values):
logger.debug(f"Value coming in: {value}")
# logger.debug(f"Value coming in: {value}")
match value:
case dict():
if isinstance(value['value'], tuple):
@@ -650,12 +650,12 @@ class PydSubmission(BaseModel, extra='allow'):
value = dict(value=value[0], missing=False)
case _:
value = dict(value=value, missing=False)
logger.debug(f"Value after match: {value}")
# logger.debug(f"Value after match: {value}")
check = Contact.query(name=value['value'])
logger.debug(f"Check came back with {check}")
# logger.debug(f"Check came back with {check}")
if not isinstance(check, Contact):
org = values.data['submitting_lab']['value']
logger.debug(f"Checking organization: {org}")
# logger.debug(f"Checking organization: {org}")
if isinstance(org, str):
org = Organization.query(name=values.data['submitting_lab']['value'], limit=1)
if isinstance(org, Organization):
@@ -666,10 +666,10 @@ class PydSubmission(BaseModel, extra='allow'):
if isinstance(contact, tuple):
contact = contact[0]
value = dict(value=f"Defaulted to: {contact}", missing=False)
logger.debug(f"Value after query: {value}")
# logger.debug(f"Value after query: {value}")
return value
else:
logger.debug(f"Value after bypass check: {value}")
# logger.debug(f"Value after bypass check: {value}")
return value
def __init__(self, run_custom: bool = False, **data):
@@ -879,6 +879,7 @@ class PydSubmission(BaseModel, extra='allow'):
Converts this instance into a frontend.widgets.submission_widget.SubmissionFormWidget
Args:
disable (list, optional): a list of widgets to be disabled in the form. Defaults to None.
parent (QWidget): parent widget of the constructed object
Returns:
@@ -911,7 +912,7 @@ class PydSubmission(BaseModel, extra='allow'):
# @report_result
def check_kit_integrity(self, extraction_kit: str | dict | None = None, exempt: List[PydReagent] = []) -> Tuple[
List[PydReagent], Report]:
List[PydReagent], Report, List[PydReagent]]:
"""
Ensures all reagents expected in kit are listed in Submission
@@ -933,13 +934,11 @@ class PydSubmission(BaseModel, extra='allow'):
ext_kit.get_reagents(required=True, submission_type=self.submission_type['value'])]
# NOTE: Exclude any reagenttype found in this pyd not expected in kit.
expected_check = [item.role for item in ext_kit_rtypes]
logger.debug(self.reagents)
output_reagents = [rt for rt in self.reagents if rt.role in expected_check]
missing_check = [item.role for item in output_reagents]
missing_reagents = [rt for rt in ext_kit_rtypes if rt.role not in missing_check and rt.role not in exempt]
# logger.debug(f"Missing reagents: {missing_reagents}")
missing_reagents += [rt for rt in output_reagents if rt.missing]
logger.debug(pformat(missing_reagents))
output_reagents += [rt for rt in missing_reagents if rt not in output_reagents]
# NOTE: if lists are equal return no problem
if len(missing_reagents) == 0:
@@ -956,13 +955,13 @@ class PydSubmission(BaseModel, extra='allow'):
expired = []
for reagent in self.reagents:
if reagent not in exempt:
role_expiry = ReagentRole.query(name=reagent.role).eol_ext
role_eol = ReagentRole.query(name=reagent.role).eol_ext
try:
dt = datetime.combine(reagent.expiry, datetime.max.time())
except TypeError:
continue
if datetime.now() > dt + role_expiry:
expired.append(f"{reagent.role}, {reagent.lot}: {reagent.expiry} + {role_expiry.days}")
if datetime.now() > dt + role_eol:
expired.append(f"{reagent.role}, {reagent.lot}: {reagent.expiry.date()} + {role_eol.days}")
if expired:
output = '\n'.join(expired)
result = Result(status="Warning",
@@ -996,11 +995,12 @@ class PydContact(BaseModel):
area_regex = re.compile(r"^\(?(\d{3})\)?(-| )?")
if len(value) > 8:
match = area_regex.match(value)
logger.debug(f"Match: {match.group(1)}")
# logger.debug(f"Match: {match.group(1)}")
value = area_regex.sub(f"({match.group(1).strip()}) ", value)
logger.debug(f"Output phone: {value}")
# logger.debug(f"Output phone: {value}")
return value
@report_result
def to_sql(self) -> Tuple[Contact, Report]:
"""
Converts this instance into a backend.db.models.organization. Contact instance.
@@ -1036,6 +1036,18 @@ class PydOrganization(BaseModel):
cost_centre: str
contacts: List[PydContact] | None
@field_validator("contacts", mode="before")
@classmethod
def string_to_list(cls, value):
if isinstance(value, str):
value = Contact.query(name=value)
try:
value = [value.to_pydantic()]
except AttributeError:
return None
return value
def to_sql(self) -> Organization:
"""
Converts this instance into a backend.db.models.organization.Organization instance.
@@ -1047,10 +1059,14 @@ class PydOrganization(BaseModel):
for field in self.model_fields:
match field:
case "contacts":
value = [item.to_sql() for item in getattr(self, field)]
value = getattr(self, field)
if value:
value = [item.to_sql() for item in value if item]
case _:
value = getattr(self, field)
instance.__setattr__(name=field, value=value)
logger.debug(f"Setting {field} to {value}")
if value:
setattr(instance, field, value)
return instance
@@ -1105,7 +1121,8 @@ class PydKit(BaseModel):
instance = KitType.query(name=self.name)
if instance is None:
instance = KitType(name=self.name)
[item.to_sql(instance) for item in self.reagent_roles]
for role in self.reagent_roles:
role.to_sql(instance)
return instance, report
@@ -1162,7 +1179,8 @@ class PydIridaControl(BaseModel, extra='ignore'):
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
# subtype: str #: EN-NOS, MCS-NOS, etc
subtype: Literal["ATCC49226", "ATCC49619", "EN-NOS", "EN-SSTI", "MCS-NOS", "MCS-SSTI", "SN-NOS", "SN-SSTI"]
refseq_version: str #: version of refseq used in fastq parsing
kraken2_version: str
kraken2_db_version: str
@@ -1171,6 +1189,13 @@ class PydIridaControl(BaseModel, extra='ignore'):
submission_id: int
controltype_name: str
@field_validator("refseq_version", "kraken2_version", "kraken2_db_version", mode='before')
@classmethod
def enforce_string(cls, value):
if not value:
value = ""
return value
def to_sql(self):
instance = IridaControl.query(name=self.name)
if not instance:

View File

@@ -7,7 +7,6 @@ from PyQt6.QtWidgets import QWidget
import plotly, logging
from plotly.graph_objects import Figure
import pandas as pd
from frontend.widgets.functions import select_save_file
from tools import divide_chunks
logger = logging.getLogger(f"submissions.{__name__}")
@@ -65,7 +64,8 @@ class CustomFigure(Figure):
)
assert isinstance(self, CustomFigure)
def make_plotly_buttons(self, months: int = 6) -> Generator[dict, None, None]:
@classmethod
def make_plotly_buttons(cls, months: int = 6) -> Generator[dict, None, None]:
"""
Creates html buttons to zoom in on date areas
@@ -115,7 +115,8 @@ class CustomFigure(Figure):
{"yaxis.title.text": mode},
])
def to_html(self) -> str:
@property
def html(self) -> str:
"""
Creates final html code from plotly

View File

@@ -19,10 +19,10 @@ class IridaFigure(CustomFigure):
super().__init__(df=df, modes=modes, settings=settings)
self.df = df
try:
months = int(settings['months'])
except KeyError:
months = 6
# try:
# months = int(settings['months'])
# except KeyError:
# months = 6
self.construct_chart(df=df, modes=modes, start_date=settings['start_date'], end_date=settings['end_date'])

View File

@@ -17,10 +17,10 @@ class PCRFigure(CustomFigure):
months: int = 6):
super().__init__(df=df, modes=modes, settings=settings)
self.df = df
try:
months = int(settings['months'])
except KeyError:
months = 6
# try:
# months = int(settings['months'])
# except KeyError:
# months = 6
self.construct_chart(df=df)
def construct_chart(self, df: pd.DataFrame):

View File

@@ -19,10 +19,10 @@ class TurnaroundChart(CustomFigure):
months: int = 6):
super().__init__(df=df, modes=modes, settings=settings)
self.df = df
try:
months = int(settings['months'])
except KeyError:
months = 6
# try:
# months = int(settings['months'])
# except KeyError:
# months = 6
self.construct_chart()
if threshold:
self.add_hline(y=threshold)

View File

@@ -1,6 +1,7 @@
"""
Constructs main application.
"""
import getpass
from pprint import pformat
from PyQt6.QtCore import qInstallMessageHandler
from PyQt6.QtWidgets import (
@@ -13,9 +14,10 @@ from pathlib import Path
from markdown import markdown
from __init__ import project_path
from backend import SubmissionType, Reagent, BasicSample, Organization, KitType
from tools import check_if_app, Settings, Report, jinja_template_loading, check_authorization, page_size, is_power_user
from tools import (
check_if_app, Settings, Report, jinja_template_loading, check_authorization, page_size, is_power_user, under_development
)
from .functions import select_save_file, select_open_file
# from datetime import date
from .pop_ups import HTMLPop, AlertPop
from .misc import Pagifier
import logging, webbrowser, sys, shutil
@@ -84,7 +86,8 @@ class App(QMainWindow):
maintenanceMenu.addAction(self.joinPCRAction)
editMenu.addAction(self.editReagentAction)
editMenu.addAction(self.manageOrgsAction)
# editMenu.addAction(self.manageKitsAction)
if getpass.getuser() == "lwark":
editMenu.addAction(self.manageKitsAction)
if not is_power_user():
editMenu.setEnabled(False)
@@ -119,7 +122,7 @@ class App(QMainWindow):
connect menu and tool bar item to functions
"""
self.importAction.triggered.connect(self.table_widget.formwidget.importSubmission)
self.addReagentAction.triggered.connect(self.table_widget.formwidget.new_add_reagent)
self.addReagentAction.triggered.connect(self.table_widget.formwidget.add_reagent)
self.joinExtractionAction.triggered.connect(self.table_widget.sub_wid.link_extractions)
self.joinPCRAction.triggered.connect(self.table_widget.sub_wid.link_pcr)
self.helpAction.triggered.connect(self.showAbout)
@@ -177,6 +180,11 @@ class App(QMainWindow):
dlg = SearchBox(self, object_type=BasicSample, extras=[])
dlg.exec()
@check_authorization
def edit_reagent(self, *args, **kwargs):
dlg = SearchBox(parent=self, object_type=Reagent, extras=[dict(name='Role', field="role")])
dlg.exec()
def export_ST_yaml(self):
"""
Copies submission type yaml to file system for editing and remport
@@ -191,13 +199,18 @@ class App(QMainWindow):
fname = select_save_file(obj=self, default_name="Submission Type Template.yml", extension="yml")
shutil.copyfile(yaml_path, fname)
@check_authorization
def edit_reagent(self, *args, **kwargs):
dlg = SearchBox(parent=self, object_type=Reagent, extras=[dict(name='Role', field="role")])
dlg.exec()
@check_authorization
def import_ST_yaml(self, *args, **kwargs):
"""
Imports a yml form into a submission type.
Args:
*args ():
**kwargs ():
Returns:
"""
fname = select_open_file(obj=self, file_extension="yml")
if not fname:
logger.info(f"Import cancelled.")
@@ -220,9 +233,11 @@ class App(QMainWindow):
dlg = ManagerWindow(parent=self, object_type=Organization, extras=[])
if dlg.exec():
new_org = dlg.parse_form()
new_org.save()
# logger.debug(new_org.__dict__)
def manage_kits(self):
@under_development
def manage_kits(self, *args, **kwargs):
dlg = ManagerWindow(parent=self, object_type=KitType, extras=[])
if dlg.exec():
print(dlg.parse_form())

View File

@@ -99,18 +99,24 @@ class ControlsViewer(InfoPane):
self.mode_sub_type = self.mode_sub_typer.currentText()
months = self.diff_month(self.start_date, self.end_date)
# NOTE: query all controls using the type/start and end dates from the gui
chart_settings = dict(sub_type=self.con_sub_type, start_date=self.start_date, end_date=self.end_date,
mode=self.mode,
sub_mode=self.mode_sub_type, parent=self, months=months)
chart_settings = dict(
sub_type=self.con_sub_type,
start_date=self.start_date,
end_date=self.end_date,
mode=self.mode,
sub_mode=self.mode_sub_type,
parent=self,
months=months
)
self.fig = self.archetype.instance_class.make_chart(chart_settings=chart_settings, parent=self, ctx=self.app.ctx)
self.report_obj = ChartReportMaker(df=self.fig.df, sheet_name=self.archetype.name)
if issubclass(self.fig.__class__, CustomFigure):
self.save_button.setEnabled(True)
# NOTE: construct html for webview
try:
html = self.fig.to_html()
except AttributeError:
html = ""
self.webview.setHtml(html)
# try:
# html = self.fig.html
# except AttributeError:
# html = ""
self.webview.setHtml(self.fig.html)
self.webview.update()
return report

View File

@@ -3,8 +3,9 @@ Creates forms that the user can enter equipment info into.
'''
from pprint import pformat
from PyQt6.QtCore import Qt, QSignalBlocker
from PyQt6.QtWidgets import (QDialog, QComboBox, QCheckBox,
QLabel, QWidget, QVBoxLayout, QDialogButtonBox, QGridLayout)
from PyQt6.QtWidgets import (
QDialog, QComboBox, QCheckBox, QLabel, QWidget, QVBoxLayout, QDialogButtonBox, QGridLayout
)
from backend.db.models import Equipment, BasicSubmission, Process
from backend.validators.pydant import PydEquipment, PydEquipmentRole, PydTips
import logging
@@ -36,8 +37,8 @@ class EquipmentUsage(QDialog):
self.buttonBox.rejected.connect(self.reject)
label = self.LabelRow(parent=self)
self.layout.addWidget(label)
for eq in self.opt_equipment:
widg = eq.to_form(parent=self, used=self.used_equipment)
for equipment in self.opt_equipment:
widg = equipment.to_form(parent=self, used=self.used_equipment)
self.layout.addWidget(widg)
widg.update_processes()
self.layout.addWidget(self.buttonBox)
@@ -64,6 +65,7 @@ class EquipmentUsage(QDialog):
continue
class LabelRow(QWidget):
"""Provides column headers"""
def __init__(self, parent) -> None:
super().__init__(parent)

View File

@@ -1,8 +1,11 @@
'''
"""
functions used by all windows in the application's frontend
'''
"""
from pathlib import Path
import logging
from PyQt6.QtCore import QMarginsF
from PyQt6.QtGui import QPageLayout, QPageSize
from PyQt6.QtWebEngineWidgets import QWebEngineView
from PyQt6.QtWidgets import QMainWindow, QFileDialog
logger = logging.getLogger(f"submissions.{__name__}")
@@ -60,3 +63,21 @@ def select_save_file(obj: QMainWindow, default_name: str, extension: str) -> Pat
fname = Path(QFileDialog.getSaveFileName(obj, "Save File", home_dir, filter=f"{extension}(*.{extension})")[0])
obj.last_dir = fname.parent
return fname
def save_pdf(obj: QWebEngineView, filename: Path):
"""
Handles printing to PDF
Args:
obj (): Parent object
filename (): Where to save pdf.
Returns:
None
"""
page_layout = QPageLayout()
page_layout.setPageSize(QPageSize(QPageSize.PageSizeId.A4))
page_layout.setOrientation(QPageLayout.Orientation.Portrait)
page_layout.setMargins(QMarginsF(25, 25, 25, 25))
obj.page().printToPdf(filename.absolute().__str__(), page_layout)

View File

@@ -2,15 +2,13 @@
Gel box for artic quality control
"""
from operator import itemgetter
from PyQt6.QtWidgets import (QWidget, QDialog, QGridLayout,
QLabel, QLineEdit, QDialogButtonBox,
QTextEdit, QComboBox
)
from PyQt6.QtWidgets import (
QWidget, QDialog, QGridLayout, QLabel, QLineEdit, QDialogButtonBox, QTextEdit, QComboBox
)
import pyqtgraph as pg
from PyQt6.QtGui import QIcon
from PIL import Image
import numpy as np
import logging
import logging, numpy as np
from pprint import pformat
from typing import Tuple, List
from pathlib import Path
@@ -103,7 +101,8 @@ class ControlsForm(QWidget):
except TypeError:
tt_text = None
for iii, item in enumerate(
["Negative Control Key", "Description", "Results - 65 C", "Results - 63 C", "Results - Spike"]):
["Negative Control Key", "Description", "Results - 65 C", "Results - 63 C", "Results - Spike"]
):
label = QLabel(item)
self.layout.addWidget(label, 0, iii, 1, 1)
if iii > 1:

View File

@@ -6,8 +6,8 @@ from PyQt6.QtCore import QSignalBlocker
from PyQt6.QtWebEngineWidgets import QWebEngineView
from PyQt6.QtWidgets import QWidget, QGridLayout
from tools import Report, report_result, Result
from .misc import StartEndDatePicker, save_pdf
from .functions import select_save_file
from .misc import StartEndDatePicker
from .functions import select_save_file, save_pdf
import logging
logger = logging.getLogger(f"submissions.{__name__}")
@@ -38,8 +38,7 @@ class InfoPane(QWidget):
lastmonth = self.datepicker.end_date.date().addDays(-31)
msg = f"Start date after end date is not allowed! Setting to {lastmonth.toString()}."
logger.warning(msg)
# NOTE: block signal that will rerun controls getter and set start date
# Without triggering this function again
# NOTE: block signal that will rerun controls getter and set start date without triggering this function again
with QSignalBlocker(self.datepicker.start_date) as blocker:
self.datepicker.start_date.setDate(lastmonth)
self.update_data()

View File

@@ -3,12 +3,10 @@ Contains miscellaneous widgets for frontend functions
"""
import math
from datetime import date
from PyQt6.QtGui import QPageLayout, QPageSize, QStandardItem, QIcon
from PyQt6.QtWebEngineWidgets import QWebEngineView
from PyQt6.QtGui import QStandardItem, QIcon
from PyQt6.QtWidgets import (
QLabel, QVBoxLayout,
QLineEdit, QComboBox, QDialog,
QDialogButtonBox, QDateEdit, QPushButton, QWidget, QHBoxLayout, QSizePolicy
QLabel, QLineEdit, QComboBox, QDateEdit, QPushButton, QWidget,
QHBoxLayout, QSizePolicy
)
from PyQt6.QtCore import Qt, QDate, QSize, QMarginsF
from tools import jinja_template_loading
@@ -20,96 +18,98 @@ logger = logging.getLogger(f"submissions.{__name__}")
env = jinja_template_loading()
class AddReagentForm(QDialog):
"""
dialog to add gather info about new reagent
"""
def __init__(self, reagent_lot: str | None = None, reagent_role: str | None = None, expiry: date | None = None,
reagent_name: str | None = None, kit: str | KitType | None = None) -> None:
super().__init__()
if reagent_name is None:
reagent_name = reagent_role
self.setWindowTitle("Add Reagent")
QBtn = QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel
self.buttonBox = QDialogButtonBox(QBtn)
self.buttonBox.accepted.connect(self.accept)
self.buttonBox.rejected.connect(self.reject)
# NOTE: widget to get lot info
self.name_input = QComboBox()
self.name_input.setObjectName("name")
self.name_input.setEditable(True)
self.name_input.setCurrentText(reagent_name)
self.lot_input = QLineEdit()
self.lot_input.setObjectName("lot")
self.lot_input.setText(reagent_lot)
# NOTE: widget to get expiry info
self.exp_input = QDateEdit(calendarPopup=True)
self.exp_input.setObjectName('expiry')
# NOTE: if expiry is not passed in from gui, use today
if expiry is None:
self.exp_input.setDate(QDate(1970, 1, 1))
else:
try:
self.exp_input.setDate(expiry)
except TypeError:
self.exp_input.setDate(QDate(1970, 1, 1))
# NOTE: widget to get reagent type info
self.type_input = QComboBox()
self.type_input.setObjectName('role')
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()])
# NOTE: convert input to user-friendly string?
try:
reagent_role = reagent_role.replace("_", " ").title()
except AttributeError:
reagent_role = None
# NOTE: set parsed reagent type to top of list
index = self.type_input.findText(reagent_role, Qt.MatchFlag.MatchEndsWith)
if index >= 0:
self.type_input.setCurrentIndex(index)
self.layout = QVBoxLayout()
self.layout.addWidget(QLabel("Name:"))
self.layout.addWidget(self.name_input)
self.layout.addWidget(QLabel("Lot:"))
self.layout.addWidget(self.lot_input)
self.layout.addWidget(
QLabel("Expiry:\n(use exact date on reagent.\nEOL will be calculated from kit automatically)"))
self.layout.addWidget(self.exp_input)
self.layout.addWidget(QLabel("Type:"))
self.layout.addWidget(self.type_input)
self.layout.addWidget(self.buttonBox)
self.setLayout(self.layout)
self.type_input.currentTextChanged.connect(self.update_names)
def parse_form(self) -> dict:
"""
Converts information in form to dict.
Returns:
dict: Output info
"""
return dict(name=self.name_input.currentText().strip(),
lot=self.lot_input.text().strip(),
expiry=self.exp_input.date().toPyDate(),
role=self.type_input.currentText().strip())
def update_names(self):
"""
Updates reagent names form field with examples from reagent type
"""
self.name_input.clear()
lookup = Reagent.query(role=self.type_input.currentText())
self.name_input.addItems(list(set([item.name for item in lookup])))
# class AddReagentForm(QDialog):
# """
# dialog to add gather info about new reagent (Defunct)
# """
#
# def __init__(self, reagent_lot: str | None = None, reagent_role: str | None = None, expiry: date | None = None,
# reagent_name: str | None = None, kit: str | KitType | None = None) -> None:
# super().__init__()
# if reagent_name is None:
# reagent_name = reagent_role
# self.setWindowTitle("Add Reagent")
# QBtn = QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel
# self.buttonBox = QDialogButtonBox(QBtn)
# self.buttonBox.accepted.connect(self.accept)
# self.buttonBox.rejected.connect(self.reject)
# # NOTE: widget to get lot info
# self.name_input = QComboBox()
# self.name_input.setObjectName("name")
# self.name_input.setEditable(True)
# self.name_input.setCurrentText(reagent_name)
# self.lot_input = QLineEdit()
# self.lot_input.setObjectName("lot")
# self.lot_input.setText(reagent_lot)
# # NOTE: widget to get expiry info
# self.expiry_input = QDateEdit(calendarPopup=True)
# self.expiry_input.setObjectName('expiry')
# # NOTE: if expiry is not passed in from gui, use today
# if expiry is None:
# logger.warning(f"Did not receive expiry, setting to 1970, 1, 1")
# self.expiry_input.setDate(QDate(1970, 1, 1))
# else:
# try:
# self.expiry_input.setDate(expiry)
# except TypeError:
# self.expiry_input.setDate(QDate(1970, 1, 1))
# # NOTE: widget to get reagent type info
# self.role_input = QComboBox()
# self.role_input.setObjectName('role')
# if kit:
# match kit:
# case str():
# kit = KitType.query(name=kit)
# case _:
# pass
# self.role_input.addItems([item.name for item in ReagentRole.query() if kit in item.kit_types])
# else:
# self.role_input.addItems([item.name for item in ReagentRole.query()])
# # NOTE: convert input to user-friendly string?
# try:
# reagent_role = reagent_role.replace("_", " ").title()
# except AttributeError:
# reagent_role = None
# # NOTE: set parsed reagent type to top of list
# index = self.role_input.findText(reagent_role, Qt.MatchFlag.MatchEndsWith)
# if index >= 0:
# self.role_input.setCurrentIndex(index)
# self.layout = QVBoxLayout()
# self.layout.addWidget(QLabel("Name:"))
# self.layout.addWidget(self.name_input)
# self.layout.addWidget(QLabel("Lot:"))
# self.layout.addWidget(self.lot_input)
# self.layout.addWidget(
# QLabel("Expiry:\n(use exact date on reagent.\nEOL will be calculated from kit automatically)")
# )
# self.layout.addWidget(self.expiry_input)
# self.layout.addWidget(QLabel("Type:"))
# self.layout.addWidget(self.role_input)
# self.layout.addWidget(self.buttonBox)
# self.setLayout(self.layout)
# self.role_input.currentTextChanged.connect(self.update_names)
#
# def parse_form(self) -> dict:
# """
# Converts information in form to dict.
#
# Returns:
# dict: Output info
# """
# return dict(name=self.name_input.currentText().strip(),
# lot=self.lot_input.text().strip(),
# expiry=self.expiry_input.date().toPyDate(),
# role=self.role_input.currentText().strip())
#
# def update_names(self):
# """
# Updates reagent names form field with examples from reagent type
# """
# self.name_input.clear()
# lookup = Reagent.query(role=self.role_input.currentText())
# self.name_input.addItems(list(set([item.name for item in lookup])))
#
#
class StartEndDatePicker(QWidget):
"""
custom widget to pick start and end dates for controls graphs
@@ -135,12 +135,12 @@ class StartEndDatePicker(QWidget):
return QSize(80, 20)
def save_pdf(obj: QWebEngineView, filename: Path):
page_layout = QPageLayout()
page_layout.setPageSize(QPageSize(QPageSize.PageSizeId.A4))
page_layout.setOrientation(QPageLayout.Orientation.Portrait)
page_layout.setMargins(QMarginsF(25, 25, 25, 25))
obj.page().printToPdf(filename.absolute().__str__(), page_layout)
# def save_pdf(obj: QWebEngineView, filename: Path):
# page_layout = QPageLayout()
# page_layout.setPageSize(QPageSize(QPageSize.PageSizeId.A4))
# page_layout.setOrientation(QPageLayout.Orientation.Portrait)
# page_layout.setMargins(QMarginsF(25, 25, 25, 25))
# obj.page().printToPdf(filename.absolute().__str__(), page_layout)
# NOTE: subclass

View File

@@ -1,17 +1,18 @@
"""
A widget to handle adding/updating any database object.
"""
from datetime import date
from pprint import pformat
from typing import Any, List, Tuple
from typing import Any, Tuple
from pydantic import BaseModel
from PyQt6.QtWidgets import (
QLabel, QDialog, QTableView, QWidget, QLineEdit, QGridLayout, QComboBox, QPushButton, QDialogButtonBox, QDateEdit
QLabel, QDialog, QWidget, QLineEdit, QGridLayout, QComboBox, QDialogButtonBox, QDateEdit, QSpinBox, QDoubleSpinBox
)
from sqlalchemy import String, TIMESTAMP
from sqlalchemy import String, TIMESTAMP, INTEGER, FLOAT
from sqlalchemy.orm import InstrumentedAttribute, ColumnProperty
import logging
from sqlalchemy.orm.relationships import _RelationshipDeclared
from tools import Report, Result, report_result
from tools import Report, report_result
logger = logging.getLogger(f"submissions.{__name__}")
@@ -23,7 +24,6 @@ class AddEdit(QDialog):
self.instance = instance
self.object_type = instance.__class__
self.layout = QGridLayout(self)
# logger.debug(f"Manager: {manager}")
QBtn = QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel
self.buttonBox = QDialogButtonBox(QBtn)
self.buttonBox.accepted.connect(self.accept)
@@ -36,7 +36,6 @@ class AddEdit(QDialog):
fields = {'name': fields.pop('name'), **fields}
except KeyError:
pass
# logger.debug(pformat(fields, indent=4))
height_counter = 0
for key, field in fields.items():
try:
@@ -45,9 +44,6 @@ class AddEdit(QDialog):
value = None
try:
logger.debug(f"{key} property: {type(field['class_attr'].property)}")
# widget = EditProperty(self, key=key, column_type=field.property.expression.type,
# value=getattr(self.instance, key))
# logger.debug(f"Column type: {field}, Value: {value}")
widget = EditProperty(self, key=key, column_type=field, value=value)
except AttributeError as e:
logger.error(f"Problem setting widget {key}: {e}")
@@ -64,15 +60,11 @@ class AddEdit(QDialog):
def parse_form(self) -> Tuple[BaseModel, Report]:
report = Report()
parsed = {result[0].strip(":"): result[1] for result in [item.parse_form() for item in self.findChildren(EditProperty)] if result[0]}
logger.debug(parsed)
# logger.debug(parsed)
model = self.object_type.pydantic_model
# NOTE: Hand-off to pydantic model for validation.
# NOTE: Also, why am I not just using the toSQL method here. I could write one for contacts.
model = model(**parsed)
# output, result = model.to_sql()
# report.add_result(result)
# if len(report.results) < 1:
# report.add_result(Result(msg="Added new regeant.", icon="Information", owner=__name__))
return model, report
@@ -84,7 +76,7 @@ class EditProperty(QWidget):
self.label = QLabel(key.title().replace("_", " "))
self.layout = QGridLayout()
self.layout.addWidget(self.label, 0, 0, 1, 1)
self.setObjectName(f"{key}:")
self.setObjectName(key)
match column_type['class_attr'].property:
case ColumnProperty():
self.column_property_set(column_type, value=value)
@@ -97,15 +89,15 @@ class EditProperty(QWidget):
self.setLayout(self.layout)
def relationship_property_set(self, relationship_property, value=None):
# print(relationship_property)
self.property_class = relationship_property['class_attr'].property.entity.class_
self.is_list = relationship_property['class_attr'].property.uselist
choices = [item.name for item in self.property_class.query()]
choices = [""] + [item.name for item in self.property_class.query()]
try:
instance_value = getattr(self.parent().instance, self.name)
instance_value = getattr(self.parent().instance, self.objectName())
except AttributeError:
logger.error(f"Unable to get instance {self.parent().instance} attribute: {self.name}")
logger.error(f"Unable to get instance {self.parent().instance} attribute: {self.objectName()}")
instance_value = None
# NOTE: get the value for the current instance and move it to the front.
if isinstance(instance_value, list):
instance_value = next((item.name for item in instance_value), None)
if instance_value:
@@ -120,6 +112,16 @@ class EditProperty(QWidget):
value = ""
self.widget = QLineEdit(self)
self.widget.setText(value)
case INTEGER():
if not value:
value = 1
self.widget = QSpinBox()
self.widget.setValue(value)
case FLOAT():
if not value:
value = 1.0
self.widget = QDoubleSpinBox()
self.widget.setValue(value)
case TIMESTAMP():
self.widget = QDateEdit(self, calendarPopup=True)
if not value:
@@ -129,12 +131,13 @@ class EditProperty(QWidget):
logger.error(f"{column_property} not a supported property.")
self.widget = None
try:
tooltip_text = self.parent().object_type.add_edit_tooltips[self.name]
tooltip_text = self.parent().object_type.add_edit_tooltips[self.objectName()]
self.widget.setToolTip(tooltip_text)
except KeyError:
pass
def parse_form(self):
# NOTE: Make sure there's a widget.
try:
check = self.widget
except AttributeError:
@@ -146,10 +149,12 @@ class EditProperty(QWidget):
value = self.widget.date().toPyDate()
case QComboBox():
value = self.widget.currentText()
case QSpinBox() | QDoubleSpinBox():
value = self.widget.value()
# if self.is_list:
# value = [self.property_class.query(name=prelim)]
# else:
# value = self.property_class.query(name=prelim)
case _:
value = None
return self.name, value
return self.objectName(), value

View File

@@ -1,4 +1,6 @@
from operator import itemgetter
"""
Provides a screen for managing all attributes of a database object.
"""
from typing import Any, List
from PyQt6.QtCore import QSortFilterProxyModel, Qt
from PyQt6.QtGui import QAction, QCursor
@@ -49,21 +51,22 @@ class ManagerWindow(QDialog):
self.layout.addWidget(self.sub_class, 0, 0)
else:
self.sub_class = None
# self.layout.addWidget(self.buttonBox, self.layout.rowCount(), 0)
self.options = QComboBox(self)
self.options.setObjectName("options")
self.update_options()
self.setLayout(self.layout)
self.setWindowTitle(f"Manage {self.object_type.__name__}")
def update_options(self):
def update_options(self) -> None:
"""
Changes form inputs based on sample type
"""
if self.sub_class:
self.object_type = getattr(db, self.sub_class.currentText())
options = [item.name for item in self.object_type.query()]
logger.debug(f"self.instance: {self.instance}")
if self.instance:
options.insert(0, options.pop(options.index(self.instance.name)))
self.options.clear()
self.options.addItems(options)
self.options.setEditable(False)
@@ -75,23 +78,34 @@ class ManagerWindow(QDialog):
self.add_button.clicked.connect(self.add_new)
self.update_data()
def update_data(self):
def update_data(self) -> None:
"""
Performs updating of widgets on first run and after options change.
Returns:
None
"""
# NOTE: Remove all old widgets.
deletes = [item for item in self.findChildren(EditProperty)] + \
[item for item in self.findChildren(EditRelationship)] + \
[item for item in self.findChildren(QDialogButtonBox)]
for item in deletes:
item.setParent(None)
# NOTE: Find the instance this manager will update
self.instance = self.object_type.query(name=self.options.currentText())
fields = {k: v for k, v in self.object_type.__dict__.items() if
isinstance(v, InstrumentedAttribute) and k != "id"}
for key, field in fields.items():
# logger.debug(f"Key: {key}, Value: {field}")
match field.property:
# NOTE: ColumnProperties will be directly edited.
case ColumnProperty():
# NOTE: field.property.expression.type gives db column type eg. STRING or TIMESTAMP
widget = EditProperty(self, key=key, column_type=field.property.expression.type,
value=getattr(self.instance, key))
# NOTE: RelationshipDeclareds will be given a list of existing related objects.
case _RelationshipDeclared():
if key != "submissions":
# NOTE: field.comparator.entity.class_ gives the relationship class
widget = EditRelationship(self, key=key, entity=field.comparator.entity.class_,
value=getattr(self.instance, key))
else:
@@ -100,11 +114,18 @@ class ManagerWindow(QDialog):
continue
if widget:
self.layout.addWidget(widget, self.layout.rowCount(), 0, 1, 2)
# NOTE: Add OK|Cancel to bottom of dialog.
self.layout.addWidget(self.buttonBox, self.layout.rowCount(), 0, 1, 2)
def parse_form(self):
def parse_form(self) -> Any:
"""
Returns the instance associated with this window.
Returns:
Any: The instance with updated fields.
"""
# TODO: Need Relationship property here too?
results = [item.parse_form() for item in self.findChildren(EditProperty)]
# logger.debug(results)
for result in results:
# logger.debug(result)
self.instance.__setattr__(result[0], result[1])
@@ -113,9 +134,10 @@ class ManagerWindow(QDialog):
def add_new(self):
dlg = AddEdit(parent=self, instance=self.object_type(), manager=self.object_type.__name__.lower())
if dlg.exec():
new_instance = dlg.parse_form()
# logger.debug(new_instance.__dict__)
new_pyd = dlg.parse_form()
new_instance = new_pyd.to_sql()
new_instance.save()
self.instance = new_instance
self.update_options()
@@ -222,10 +244,13 @@ class EditRelationship(QWidget):
self.data['id'] = self.data['id'].apply(str)
self.data['id'] = self.data['id'].str.zfill(4)
except KeyError as e:
logger.error(f"Could not alter id to string due to {e}")
logger.error(f"Could not alter id to string due to KeyError: {e}")
proxy_model = QSortFilterProxyModel()
proxy_model.setSourceModel(pandasModel(self.data))
self.table.setModel(proxy_model)
self.table.resizeColumnsToContents()
self.table.resizeRowsToContents()
self.table.setSortingEnabled(True)
self.table.doubleClicked.connect(self.parse_row)
def contextMenuEvent(self, event):

View File

@@ -2,9 +2,9 @@
Search box that performs fuzzy search for various object types
"""
from pprint import pformat
from typing import Tuple, Any, List
from typing import Tuple, Any, List, Generator
from pandas import DataFrame
from PyQt6.QtCore import QSortFilterProxyModel
from PyQt6.QtCore import QSortFilterProxyModel, QModelIndex
from PyQt6.QtWidgets import (
QLabel, QVBoxLayout, QDialog,
QTableView, QWidget, QLineEdit, QGridLayout, QComboBox, QDialogButtonBox
@@ -74,7 +74,6 @@ class SearchBox(QDialog):
search_fields = []
for iii, searchable in enumerate(search_fields):
widget = FieldSearch(parent=self, label=searchable['label'], field_name=searchable['field'])
# widget = FieldSearch(parent=self, label=k, field_name=v)
widget.setObjectName(searchable['field'])
self.layout.addWidget(widget, 1 + iii, 0)
widget.search_widget.textChanged.connect(self.update_data)
@@ -100,11 +99,17 @@ class SearchBox(QDialog):
# NOTE: Setting results moved to here from __init__ 202411118
self.results.setData(df=data)
def return_selected_rows(self):
rows = sorted(set(index.row() for index in
self.results.selectedIndexes()))
def return_selected_rows(self) -> Generator[dict, None, None]:
"""
Yields data from selected rows
Returns:
dict: Dictionary of column name: data
"""
rows = sorted(set(index.row() for index in self.results.selectedIndexes()))
for index in rows:
output = {column:self.results.model().data(self.results.model().index(index, ii)) for ii, column in enumerate(self.results.data.columns)}
output = {column: self.results.model().data(self.results.model().index(index, ii)) for ii, column in
enumerate(self.results.data.columns)}
yield output
@@ -130,7 +135,13 @@ class FieldSearch(QWidget):
"""
self.parent().update_data()
def parse_form(self) -> Tuple:
def parse_form(self) -> Tuple[str, str]:
"""
Gets object name and widget value.
Returns:
Tuple(str, str): Key, value to be used in constructing a dictionary.
"""
field_value = self.search_widget.text()
if field_value == "":
field_value = None
@@ -147,6 +158,7 @@ class SearchResults(QTableView):
self.context = kwargs
self.parent = parent
self.object_type = object_type
try:
self.extras = extras + self.object_type.searchables
except AttributeError:
@@ -160,7 +172,8 @@ class SearchResults(QTableView):
self.data = df
try:
self.columns_of_interest = [dict(name=item['field'], column=self.data.columns.get_loc(item['field'])) for item in self.extras]
self.columns_of_interest = [dict(name=item['field'], column=self.data.columns.get_loc(item['field'])) for
item in self.extras]
except KeyError:
self.columns_of_interest = []
try:
@@ -171,9 +184,21 @@ class SearchResults(QTableView):
proxy_model = QSortFilterProxyModel()
proxy_model.setSourceModel(pandasModel(self.data))
self.setModel(proxy_model)
self.resizeColumnsToContents()
self.resizeRowsToContents()
self.setSortingEnabled(True)
self.doubleClicked.connect(self.parse_row)
def parse_row(self, x):
def parse_row(self, x: QModelIndex) -> None:
"""
Runs the self.object_type edit from search method for row X.
Args:
x (QModelIndex): Row to be parsed.
Returns:
None
"""
context = {item['name']: x.sibling(x.row(), item['column']).data() for item in self.columns_of_interest}
try:
object = self.object_type.query(**context)

View File

@@ -8,9 +8,8 @@ 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 tools import is_power_user, jinja_template_loading, timezone
from .functions import select_save_file
from .misc import save_pdf
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
import logging
from getpass import getuser
@@ -30,13 +29,11 @@ class SubmissionDetails(QDialog):
def __init__(self, parent, sub: BasicSubmission | BasicSample | Reagent) -> None:
super().__init__(parent)
try:
self.app = parent.parent().parent().parent().parent().parent().parent()
except AttributeError:
self.app = None
self.app = get_application_from_parent(parent)
self.webview = QWebEngineView(parent=self)
self.webview.setMinimumSize(900, 500)
self.webview.setMaximumWidth(900)
# NOTE: Decide if exporting should be allowed.
self.webview.loadFinished.connect(self.activate_export)
self.layout = QGridLayout()
# NOTE: button to export a pdf version
@@ -61,9 +58,16 @@ class SubmissionDetails(QDialog):
self.sample_details(sample=sub)
case Reagent():
self.reagent_details(reagent=sub)
# NOTE: Used to maintain javascript functions.
self.webview.page().setWebChannel(self.channel)
def activate_export(self):
def activate_export(self) -> None:
"""
Determines if export pdf should be active.
Returns:
None
"""
title = self.webview.title()
self.setWindowTitle(title)
if "Submission" in title:
@@ -103,6 +107,13 @@ class SubmissionDetails(QDialog):
@pyqtSlot(str, str)
def reagent_details(self, reagent: str | Reagent, kit: str | KitType):
"""
Changes details view to summary of Reagent
Args:
kit (str | KitType): Name of kit.
reagent (str | Reagent): Lot number of the reagent
"""
if isinstance(reagent, str):
reagent = Reagent.query(lot=reagent)
if isinstance(kit, str):
@@ -124,6 +135,17 @@ class SubmissionDetails(QDialog):
@pyqtSlot(str, str, str)
def update_reagent(self, old_lot: str, new_lot: str, expiry: str):
"""
Designed to allow editing reagent in details view (depreciated)
Args:
old_lot ():
new_lot ():
expiry ():
Returns:
"""
expiry = datetime.strptime(expiry, "%Y-%m-%d")
reagent = Reagent.query(lot=old_lot)
if reagent:
@@ -157,7 +179,16 @@ class SubmissionDetails(QDialog):
self.webview.setHtml(self.html)
@pyqtSlot(str)
def sign_off(self, submission: str | BasicSubmission):
def sign_off(self, submission: str | BasicSubmission) -> None:
"""
Allows power user to signify a submission is complete.
Args:
submission (str | BasicSubmission): Submission to be completed
Returns:
None
"""
logger.info(f"Signing off on {submission} - ({getuser()})")
if isinstance(submission, str):
submission = BasicSubmission.query(rsl_plate_num=submission)
@@ -183,10 +214,7 @@ class SubmissionComment(QDialog):
def __init__(self, parent, submission: BasicSubmission) -> None:
super().__init__(parent)
try:
self.app = parent.parent().parent().parent().parent().parent().parent
except AttributeError:
pass
self.app = get_application_from_parent(parent)
self.submission = submission
self.setWindowTitle(f"{self.submission.rsl_plate_num} Submission Comment")
# NOTE: create text field

View File

@@ -65,12 +65,6 @@ class SubmissionsSheet(QTableView):
"""
def __init__(self, parent) -> None:
"""
initialize
Args:
ctx (dict): settings passed from gui
"""
super().__init__(parent)
self.app = self.parent()
self.report = Report()
@@ -107,7 +101,9 @@ class SubmissionsSheet(QTableView):
Args:
event (_type_): the item of interest
"""
# NOTE: Get current row index
id = self.selectionModel().currentIndex()
# NOTE: Convert to data in id column (i.e. column 0)
id = id.sibling(id.row(), 0).data()
submission = BasicSubmission.query(id=id)
self.menu = QMenu(self)

View File

@@ -9,16 +9,15 @@ from PyQt6.QtCore import pyqtSignal, Qt, QSignalBlocker
from .functions import select_open_file, select_save_file
import logging
from pathlib import Path
from tools import Report, Result, check_not_nan, main_form_style, report_result
from tools import Report, Result, check_not_nan, main_form_style, report_result, get_application_from_parent
from backend.excel.parser import SheetParser
from backend.validators import PydSubmission, PydReagent
from backend.db import (
KitType, Organization, SubmissionType, Reagent,
Organization, SubmissionType, Reagent,
ReagentRole, KitTypeReagentRoleAssociation, BasicSubmission
)
from pprint import pformat
from .pop_ups import QuestionAsker, AlertPop
from .misc import AddReagentForm
from .omni_add_edit import AddEdit
from typing import List, Tuple
from datetime import date
@@ -67,7 +66,6 @@ class SubmissionFormContainer(QWidget):
def __init__(self, parent: QWidget) -> None:
super().__init__(parent)
self.app = self.parent().parent()
self.report = Report()
self.setStyleSheet('background-color: light grey;')
self.setAcceptDrops(True)
# NOTE: if import_drag is emitted, importSubmission will fire
@@ -97,12 +95,12 @@ class SubmissionFormContainer(QWidget):
"""
self.app.raise_()
self.app.activateWindow()
self.report = Report()
report = Report()
self.import_submission_function(fname)
return self.report
return report
@report_result
def import_submission_function(self, fname: Path | None = None):
def import_submission_function(self, fname: Path | None = None) -> Report:
"""
Import a new submission to the app window
@@ -110,10 +108,11 @@ class SubmissionFormContainer(QWidget):
obj (QMainWindow): original app window
Returns:
Tuple[QMainWindow, dict|None]: Collection of new main app window and result dict
Report: Object to give results of import.
"""
logger.info(f"\n\nStarting Import...\n\n")
report = Report()
# NOTE: Clear any previous forms.
try:
self.form.setParent(None)
except AttributeError:
@@ -141,7 +140,16 @@ class SubmissionFormContainer(QWidget):
return report
@report_result
def new_add_reagent(self, instance: Reagent | None = None):
def add_reagent(self, instance: Reagent | None = None):
"""
Action to create new reagent in DB.
Args:
instance (Reagent | None): Blank reagent instance to be edited and then added.
Returns:
models.Reagent: the constructed reagent object to add to submission
"""
report = Report()
if not instance:
instance = Reagent()
@@ -149,48 +157,12 @@ class SubmissionFormContainer(QWidget):
if dlg.exec():
reagent = dlg.parse_form()
reagent.missing = False
# logger.debug(f"Reagent: {reagent}, result: {result}")
# report.add_result(result)
# NOTE: send reagent to db
sqlobj = reagent.to_sql()
sqlobj.save()
logger.debug(f"Reagent added!")
report.add_result(Result(owner=__name__, code=0, msg="New reagent created.", status="Information"))
# report.add_result(result)
return reagent, report
@report_result
def add_reagent(self, reagent_lot: str | None = None, reagent_role: str | None = None, expiry: date | None = None,
name: str | None = None, kit: str | KitType | None = None) -> Tuple[PydReagent, Report]:
"""
Action to create new reagent in DB.
Args:
reagent_lot (str | None, optional): Parsed reagent from import form. Defaults to None.
reagent_role (str | None, optional): Parsed reagent type from import form. Defaults to None.
expiry (date | None, optional): Parsed reagent expiry data. Defaults to None.
name (str | None, optional): Parsed reagent name. Defaults to None.
Returns:
models.Reagent: the constructed reagent object to add to submission
"""
report = Report()
if isinstance(reagent_lot, bool):
reagent_lot = ""
# NOTE: create form
dlg = AddReagentForm(reagent_lot=reagent_lot, reagent_role=reagent_role, expiry=expiry, reagent_name=name,
kit=kit)
if dlg.exec():
# NOTE: extract form info
info = dlg.parse_form()
# NOTE: create reagent object
reagent = PydReagent(ctx=self.app.ctx, **info, missing=False)
# NOTE: send reagent to db
sqlobj = reagent.to_sql()
sqlobj.save()
# report.add_result(result)
return reagent
class SubmissionFormWidget(QWidget):
update_reagent_fields = ['extraction_kit']
@@ -199,12 +171,12 @@ class SubmissionFormWidget(QWidget):
super().__init__(parent)
if disable is None:
disable = []
self.app = parent.app
self.app = get_application_from_parent(parent)
self.pyd = submission
self.missing_info = []
self.submission_type = SubmissionType.query(name=self.pyd.submission_type['value'])
st = self.submission_type.submission_class
defaults = st.get_default_info("form_recover", "form_ignore", submission_type=self.pyd.submission_type['value'])
basic_submission_class = self.submission_type.submission_class
defaults = basic_submission_class.get_default_info("form_recover", "form_ignore", submission_type=self.pyd.submission_type['value'])
self.recover = defaults['form_recover']
self.ignore = defaults['form_ignore']
self.layout = QVBoxLayout()
@@ -225,7 +197,7 @@ class SubmissionFormWidget(QWidget):
except KeyError:
value = dict(value=None, missing=True)
add_widget = self.create_widget(key=k, value=value, submission_type=self.submission_type,
sub_obj=st, disable=check)
sub_obj=basic_submission_class, disable=check)
if add_widget is not None:
self.layout.addWidget(add_widget)
if k in self.__class__.update_reagent_fields:
@@ -302,10 +274,9 @@ class SubmissionFormWidget(QWidget):
if isinstance(reagent, self.ReagentFormWidget) or isinstance(reagent, QPushButton):
reagent.setParent(None)
reagents, integrity_report, missing_reagents = self.pyd.check_kit_integrity(extraction_kit=self.extraction_kit)
logger.debug(f"Reagents: {reagents}")
# logger.debug(f"Reagents: {reagents}")
expiry_report = self.pyd.check_reagent_expiries(exempt=missing_reagents)
for reagent in reagents:
add_widget = self.ReagentFormWidget(parent=self, reagent=reagent, extraction_kit=self.extraction_kit)
self.layout.addWidget(add_widget)
report.add_result(integrity_report)
@@ -340,9 +311,12 @@ class SubmissionFormWidget(QWidget):
Returns:
List[QWidget]: Widgets matching filter
"""
query = self.findChildren(QWidget)
if object_name is not None:
query = [widget for widget in query if widget.objectName() == object_name]
if object_name:
query = self.findChildren(QWidget, name=object_name)
else:
query = self.findChildren(QWidget)
# if object_name is not None:
# query = [widget for widget in query if widget.objectName() == object_name]
return query
@report_result
@@ -581,13 +555,13 @@ class SubmissionFormWidget(QWidget):
parent.extraction_kit = add_widget.currentText()
case 'submission_category':
add_widget = MyQComboBox(scrollWidget=parent)
cats = ['Diagnostic', "Surveillance", "Research"]
cats += [item.name for item in SubmissionType.query()]
categories = ['Diagnostic', "Surveillance", "Research"]
categories += [item.name for item in SubmissionType.query()]
try:
cats.insert(0, cats.pop(cats.index(value)))
categories.insert(0, categories.pop(categories.index(value)))
except ValueError:
cats.insert(0, cats.pop(cats.index(submission_type)))
add_widget.addItems(cats)
categories.insert(0, categories.pop(categories.index(submission_type)))
add_widget.addItems(categories)
add_widget.setToolTip("Enter submission category or select from list.")
case _:
if key in sub_obj.timestamps:
@@ -655,7 +629,8 @@ class SubmissionFormWidget(QWidget):
def __init__(self, parent: QWidget, reagent: PydReagent, extraction_kit: str):
super().__init__(parent)
self.app = self.parent().parent().parent().parent().parent().parent().parent().parent()
self.parent = parent
self.app = get_application_from_parent(parent)
self.reagent = reagent
self.extraction_kit = extraction_kit
layout = QGridLayout()
@@ -684,10 +659,11 @@ class SubmissionFormWidget(QWidget):
def disable(self):
self.lot.setEnabled(self.check.isChecked())
self.label.setEnabled(self.check.isChecked())
if not any([item.lot.isEnabled() for item in self.parent().findChildren(self.__class__)]):
self.parent().disabler.checkbox.setChecked(False)
else:
self.parent().disabler.checkbox.setChecked(True)
with QSignalBlocker(self.parent.disabler.checkbox) as blocker:
if any([item.lot.isEnabled() for item in self.parent.findChildren(self.__class__)]):
self.parent.disabler.checkbox.setChecked(True)
else:
self.parent.disabler.checkbox.setChecked(False)
@report_result
def parse_form(self) -> Tuple[PydReagent | None, Report]:
@@ -703,31 +679,23 @@ class SubmissionFormWidget(QWidget):
lot = self.lot.currentText()
wanted_reagent, new = Reagent.query_or_create(lot=lot, role=self.reagent.role, expiry=self.reagent.expiry)
# NOTE: if reagent doesn't exist in database, offer to add it (uses App.add_reagent)
logger.debug(f"Wanted reagent: {wanted_reagent}, New: {new}")
# if wanted_reagent is None:
if new:
dlg = QuestionAsker(title=f"Add {lot}?",
message=f"Couldn't find reagent type {self.reagent.role}: {lot} in the database.\n\nWould you like to add it?")
if dlg.exec():
wanted_reagent = self.parent().parent().new_add_reagent(instance=wanted_reagent)
logger.debug(f"Reagent added!")
report.add_result(Result(owner=__name__, code=0, msg="New reagent created.", status="Information"))
return wanted_reagent, report
else:
# NOTE: In this case we will have an empty reagent and the submission will fail kit integrity check
return None, report
else:
# NOTE: Since this now gets passed in directly from the parser -> pyd -> form and the parser gets the name
# from the db, it should no longer be necessary to query the db with reagent/kit, but with rt name directly.
# NOTE: Since this now gets passed in directly from the parser -> pyd -> form and the parser gets the name from the db, it should no longer be necessary to query the db with reagent/kit, but with rt name directly.
rt = ReagentRole.query(name=self.reagent.role)
logger.debug(f"Reagent role: {rt}")
if rt is None:
rt = ReagentRole.query(kit_type=self.extraction_kit, reagent=wanted_reagent)
final = PydReagent(name=wanted_reagent.name, lot=wanted_reagent.lot, role=rt.name,
expiry=wanted_reagent.expiry.date(), missing=False)
logger.debug(f"Final Reagent: {final}")
return final, report
def updated(self):
@@ -781,10 +749,11 @@ class SubmissionFormWidget(QWidget):
looked_up_reg = None
if looked_up_reg:
try:
relevant_reagents.remove(str(looked_up_reg.lot))
# relevant_reagents.remove(str(looked_up_reg.lot))
relevant_reagents.insert(0, relevant_reagents.pop(relevant_reagents.index(looked_up_reg.lot)))
except ValueError as e:
logger.error(f"Error reordering relevant reagents: {e}")
relevant_reagents.insert(0, str(looked_up_reg.lot))
# relevant_reagents.insert(0, str(looked_up_reg.lot))
else:
if len(relevant_reagents) > 1:
idx = relevant_reagents.index(str(reagent.lot))

View File

@@ -32,7 +32,13 @@ class Summary(InfoPane):
self.update_data()
def update_data(self):
def update_data(self) -> None:
"""
Sets data in the info pane
Returns:
None
"""
super().update_data()
orgs = [self.org_select.itemText(i) for i in range(self.org_select.count()) if self.org_select.itemChecked(i)]
self.report_obj = ReportMaker(start_date=self.start_date, end_date=self.end_date, organizations=orgs)

View File

@@ -31,7 +31,13 @@ class TurnaroundTime(InfoPane):
self.submission_typer.currentTextChanged.connect(self.update_data)
self.update_data()
def update_data(self):
def update_data(self) -> None:
"""
Sets data in the info pane
Returns:
None
"""
super().update_data()
months = self.diff_month(self.start_date, self.end_date)
chart_settings = dict(start_date=self.start_date, end_date=self.end_date)
@@ -47,4 +53,4 @@ class TurnaroundTime(InfoPane):
else:
threshold = None
self.fig = TurnaroundChart(df=self.report_obj.df, settings=chart_settings, modes=[], threshold=threshold, months=months)
self.webview.setHtml(self.fig.to_html())
self.webview.setHtml(self.fig.html)

View File

@@ -10,11 +10,12 @@
<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>{% 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>
<!-- &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>-->
&nbsp;&nbsp;&nbsp;&nbsp;<b>{{ key | replace("_", " ") | title }}: </b>{{ value }}<br>
{% endfor %}</p>
{% if permission %}
<button type="button" id="save_btn">Save</button>
{% endif %}
<!-- {% 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>

View File

@@ -10,6 +10,8 @@ from json import JSONDecodeError
import logging, re, yaml, sys, os, stat, platform, getpass, json, numpy as np, pandas as pd
from threading import Thread
from inspect import getmembers, isfunction, stack
from types import GeneratorType
from dateutil.easter import easter
from jinja2 import Environment, FileSystemLoader
from logging import handlers
@@ -18,7 +20,7 @@ from sqlalchemy.orm import Session
from sqlalchemy import create_engine, text, MetaData
from pydantic import field_validator, BaseModel, Field
from pydantic_settings import BaseSettings, SettingsConfigDict
from typing import Any, Tuple, Literal, List
from typing import Any, Tuple, Literal, List, Generator
from __init__ import project_path
from configparser import ConfigParser
from tkinter import Tk # NOTE: This is for choosing database path before app is created.
@@ -38,7 +40,7 @@ if platform.system() == "Windows":
logger.info(f"Got platform Windows, config_dir: {os_config_dir}")
else:
os_config_dir = ".config"
logger.info(f"Got platform other, config_dir: {os_config_dir}")
logger.info(f"Got platform {platform.system()}, config_dir: {os_config_dir}")
main_aux_dir = Path.home().joinpath(f"{os_config_dir}/submissions")
@@ -58,7 +60,7 @@ main_form_style = '''
page_size = 250
def divide_chunks(input_list: list, chunk_count: int):
def divide_chunks(input_list: list, chunk_count: int) -> Generator[Any, Any, None]:
"""
Divides a list into {chunk_count} equal parts
@@ -179,7 +181,7 @@ def check_not_nan(cell_contents) -> bool:
return False
def convert_nans_to_nones(input_str) -> str | None:
def convert_nans_to_nones(input_str:str) -> str | None:
"""
Get rid of various "nan", "NAN", "NaN", etc/
@@ -289,12 +291,10 @@ class Settings(BaseSettings, extra="allow"):
@classmethod
def set_schema(cls, value):
if value is None:
# print("No value for dir path")
if check_if_app():
alembic_path = Path(sys._MEIPASS).joinpath("files", "alembic.ini")
else:
alembic_path = project_path.joinpath("alembic.ini")
# print(f"Getting alembic path: {alembic_path}")
value = cls.get_alembic_db_path(alembic_path=alembic_path, mode='schema')
if value is None:
value = "sqlite"
@@ -321,14 +321,11 @@ class Settings(BaseSettings, extra="allow"):
if value is None:
match values.data['database_schema']:
case "sqlite":
# print("No value for dir path")
if check_if_app():
alembic_path = Path(sys._MEIPASS).joinpath("files", "alembic.ini")
else:
alembic_path = project_path.joinpath("alembic.ini")
# print(f"Getting alembic path: {alembic_path}")
value = cls.get_alembic_db_path(alembic_path=alembic_path, mode='path').parent
# print(f"Using {value}")
case _:
Tk().withdraw() # we don't want a full GUI, so keep the root window from appearing
value = Path(askdirectory(
@@ -340,9 +337,7 @@ class Settings(BaseSettings, extra="allow"):
except AttributeError:
check = False
if not check:
# print(f"No directory found, using Documents/submissions")
value.mkdir(exist_ok=True)
# print(f"Final return of directory_path: {value}")
return value
@field_validator('database_path', mode="before")
@@ -360,7 +355,6 @@ class Settings(BaseSettings, extra="allow"):
alembic_path = Path(sys._MEIPASS).joinpath("files", "alembic.ini")
else:
alembic_path = project_path.joinpath("alembic.ini")
# print(f"Getting alembic path: {alembic_path}")
value = cls.get_alembic_db_path(alembic_path=alembic_path, mode='path').parent
return value
@@ -372,7 +366,6 @@ class Settings(BaseSettings, extra="allow"):
alembic_path = Path(sys._MEIPASS).joinpath("files", "alembic.ini")
else:
alembic_path = project_path.joinpath("alembic.ini")
# print(f"Getting alembic path: {alembic_path}")
value = cls.get_alembic_db_path(alembic_path=alembic_path, mode='path').stem
return value
@@ -384,9 +377,7 @@ class Settings(BaseSettings, extra="allow"):
alembic_path = Path(sys._MEIPASS).joinpath("files", "alembic.ini")
else:
alembic_path = project_path.joinpath("alembic.ini")
# print(f"Getting alembic path: {alembic_path}")
value = cls.get_alembic_db_path(alembic_path=alembic_path, mode='user')
# print(f"Got {value} for user")
return value
@field_validator("database_password", mode='before')
@@ -397,9 +388,7 @@ class Settings(BaseSettings, extra="allow"):
alembic_path = Path(sys._MEIPASS).joinpath("files", "alembic.ini")
else:
alembic_path = project_path.joinpath("alembic.ini")
# print(f"Getting alembic path: {alembic_path}")
value = cls.get_alembic_db_path(alembic_path=alembic_path, mode='pass')
# print(f"Got {value} for pass")
return value
@field_validator('database_session', mode="before")
@@ -421,7 +410,6 @@ class Settings(BaseSettings, extra="allow"):
"{{ values['database_schema'] }}://{{ value }}/{{ db_name }}?driver=ODBC+Driver+18+for+SQL+Server&TrustServerCertificate=yes&Trusted_Connection=yes"
)
case _:
# print(pprint.pprint(values.data))
tmp = jinja_template_loading().from_string(
"{% if values['database_user'] %}{{ values['database_user'] }}{% if values['database_password'] %}:{{ values['database_password'] }}{% endif %}{% endif %}@{{ values['database_path'] }}")
value = tmp.render(values=values.data)
@@ -444,7 +432,6 @@ class Settings(BaseSettings, extra="allow"):
super().__init__(*args, **kwargs)
self.set_from_db()
self.set_scripts()
# pprint(f"User settings:\n{self.__dict__}")
def set_from_db(self):
if 'pytest' in sys.modules:
@@ -453,11 +440,8 @@ class Settings(BaseSettings, extra="allow"):
teardown_scripts=dict(goodbye=None)
)
else:
# print(f"Hello from database settings getter.")
# print(self.__dict__)
session = self.database_session
metadata = MetaData()
# print(self.database_session.get_bind())
try:
metadata.reflect(bind=session.get_bind())
except AttributeError as e:
@@ -467,7 +451,6 @@ class Settings(BaseSettings, extra="allow"):
print(f"Couldn't find _configitems in {metadata.tables.keys()}.")
return
config_items = session.execute(text("SELECT * FROM _configitem")).all()
# print(f"Config: {pprint.pprint(config_items)}")
output = {}
for item in config_items:
try:
@@ -488,6 +471,7 @@ class Settings(BaseSettings, extra="allow"):
p = Path(__file__).parents[2].joinpath("scripts").absolute()
if p.__str__() not in sys.path:
sys.path.append(p.__str__())
# NOTE: Get all .py files that don't have __ in them.
modules = p.glob("[!__]*.py")
for module in modules:
mod = importlib.import_module(module.stem)
@@ -495,6 +479,7 @@ class Settings(BaseSettings, extra="allow"):
name = function[0]
func = function[1]
# NOTE: assign function based on its name being in config: startup/teardown
# NOTE: scripts must be registered using {name: Null} in the database
if name in self.startup_scripts.keys():
self.startup_scripts[name] = func
if name in self.teardown_scripts.keys():
@@ -543,14 +528,12 @@ class Settings(BaseSettings, extra="allow"):
try:
return url[:url.index("@")].split(":")[0]
except (IndexError, ValueError) as e:
# print(f"Error on user: {e}")
return None
case "pass":
url = re.sub(r"^.*//", "", url)
try:
return url[:url.index("@")].split(":")[1]
except (IndexError, ValueError) as e:
# print(f"Error on user: {e}")
return None
def save(self, settings_path: Path):
@@ -592,7 +575,6 @@ def get_config(settings_path: Path | str | None = None) -> Settings:
def join(loader, node):
seq = loader.construct_sequence(node)
return ''.join([str(i) for i in seq])
# NOTE: register the tag handler
yaml.add_constructor('!join', join)
# NOTE: make directories
@@ -624,7 +606,6 @@ def get_config(settings_path: Path | str | None = None) -> Settings:
# NOTE: copy settings to config directory
settings = Settings(**default_settings)
settings.save(settings_path=CONFIGDIR.joinpath("config.yml"))
# print(f"Default settings: {pprint.pprint(settings.__dict__)}")
return settings
else:
# NOTE: check if user defined path is directory
@@ -829,10 +810,23 @@ def setup_lookup(func):
elif v is not None:
sanitized_kwargs[k] = v
return func(*args, **sanitized_kwargs)
return wrapper
def get_application_from_parent(widget):
try:
return widget.app
except AttributeError:
logger.info("Using recursion to get application object.")
from frontend.widgets.app import App
while not isinstance(widget, App):
try:
widget = widget.parent()
except AttributeError:
return widget
return widget
class Result(BaseModel, arbitrary_types_allowed=True):
owner: str = Field(default="", validate_default=True)
code: int = Field(default=0)
@@ -937,20 +931,20 @@ def rreplace(s: str, old: str, new: str) -> str:
return (s[::-1].replace(old[::-1], new[::-1], 1))[::-1]
def remove_key_from_list_of_dicts(input: list, key: str) -> list:
def remove_key_from_list_of_dicts(input_list: list, key: str) -> list:
"""
Removes a key from all dictionaries in a list of dictionaries
Args:
input (list): Input list of dicts
input_list (list): Input list of dicts
key (str): Name of key to remove.
Returns:
list: List of updated dictionaries
"""
for item in input:
for item in input_list:
del item[key]
return input
return input_list
def yaml_regex_creator(loader, node):
@@ -963,6 +957,7 @@ def yaml_regex_creator(loader, node):
def super_splitter(ins_str: str, substring: str, idx: int) -> str:
"""
Splits string on substring at index
Args:
ins_str (str): input string
@@ -978,6 +973,20 @@ def super_splitter(ins_str: str, substring: str, idx: int) -> str:
return ins_str
def is_developer() -> bool:
"""
Checks if user is in list of super users
Returns:
bool: True if yes, False if no.
"""
try:
check = getpass.getuser() in ctx.super_users
except:
check = False
return check
def is_power_user() -> bool:
"""
Checks if user is in list of power users
@@ -1000,21 +1009,49 @@ def check_authorization(func):
func (function): Function to be used.
"""
@wraps(func)
@report_result
def wrapper(*args, **kwargs):
logger.info(f"Checking authorization")
if is_power_user():
error_msg = f"User {getpass.getuser()} is not authorized for this function."
auth_func = is_power_user
if auth_func():
return func(*args, **kwargs)
else:
logger.error(f"User {getpass.getuser()} is not authorized for this function.")
logger.error(error_msg)
report = Report()
report.add_result(
Result(owner=func.__str__(), code=1, msg="This user does not have permission for this function.",
status="warning"))
Result(owner=func.__str__(), code=1, msg=error_msg, status="warning"))
return report
return wrapper
def under_development(func):
"""
Decorator to check if user is authorized to access function
Args:
func (function): Function to be used.
"""
@wraps(func)
@report_result
def wrapper(*args, **kwargs):
logger.warning(f"This feature is under development")
if is_developer():
return func(*args, **kwargs)
else:
error_msg = f"User {getpass.getuser()} is not authorized for this function."
logger.error(error_msg)
report = Report()
report.add_result(
Result(owner=func.__str__(), code=1, msg=error_msg,
status="warning"))
return report
return wrapper
def report_result(func):
"""
Decorator to display any reports returned from a function.
@@ -1036,14 +1073,9 @@ def report_result(func):
case Report():
report = output
case tuple():
# try:
report = next((item for item in output if isinstance(item, Report)), None)
# except IndexError:
# report = None
case _:
report = Report()
# return report
# logger.info(f"Got report: {report}")
try:
results = report.results
except AttributeError:
@@ -1058,13 +1090,11 @@ def report_result(func):
logger.error(result.msg)
if output:
true_output = tuple(item for item in output if not isinstance(item, Report))
# logger.debug(f"True output: {true_output}")
if len(true_output) == 1:
true_output = true_output[0]
else:
true_output = None
return true_output
return wrapper
@@ -1084,20 +1114,19 @@ def create_holidays_for_year(year: int | None = None) -> List[date]:
offset = -d.weekday() # weekday == 0 means Monday
output = d + timedelta(offset)
return output.date()
if not year:
year = date.today().year
# Includes New Year's day for next year.
# NOTE: Includes New Year's day for next year.
holidays = [date(year, 1, 1), date(year, 7, 1), date(year, 9, 30),
date(year, 11, 11), date(year, 12, 25), date(year, 12, 26),
date(year + 1, 1, 1)]
# Labour Day
# NOTE: Labour Day
holidays.append(find_nth_monday(year, 9))
# Thanksgiving
# NOTE: Thanksgiving
holidays.append(find_nth_monday(year, 10, occurence=2))
# Victoria Day
# NOTE: Victoria Day
holidays.append(find_nth_monday(year, 5, day=25))
# Easter, etc
# NOTE: Easter, etc
holidays.append(easter(year) - timedelta(days=2))
holidays.append(easter(year) + timedelta(days=1))
return sorted(holidays)
@@ -1107,8 +1136,7 @@ class classproperty(property):
def __get__(self, owner_self, owner_cls):
return self.fget(owner_cls)
# NOTE: Monkey patching... hooray!
builtins.classproperty = classproperty
ctx = get_config(None)