Moments before disaster.

This commit is contained in:
lwark
2024-12-06 12:02:39 -06:00
parent 5fc02ffeec
commit 80527355d1
23 changed files with 157 additions and 325 deletions

View File

@@ -3,4 +3,5 @@ database_path: null
database_schema: null
database_user: null
database_password: null
database_name: null
database_name: null
logging_enabled: false

View File

@@ -1,10 +1,8 @@
"""
All database related operations.
"""
import sqlalchemy.orm
from sqlalchemy import event, inspect
from sqlalchemy.engine import Engine
from tools import ctx
@@ -48,7 +46,11 @@ def update_log(mapper, connection, target):
hist = attr.load_history()
if not hist.has_changes():
continue
if attr.key == "custom":
continue
added = [str(item) for item in hist.added]
if attr.key in ['submission_sample_associations', 'submission_reagent_associations']:
added = ['Numbers truncated for space purposes.']
deleted = [str(item) for item in hist.deleted]
change = dict(field=attr.key, added=added, deleted=deleted)
# logger.debug(f"Adding: {pformat(change)}")
@@ -69,6 +71,6 @@ def update_log(mapper, connection, target):
else:
logger.info(f"No changes detected, not updating logs.")
# if ctx.database_schema == "sqlite":
# if ctx.logging_enabled:
event.listen(LogMixin, 'after_update', update_log, propagate=True)
event.listen(LogMixin, 'after_insert', update_log, propagate=True)

View File

@@ -1414,25 +1414,6 @@ class Equipment(BaseClass):
if extraction_kit and extraction_kit not in process.kit_types:
continue
yield process
# processes = (process for process in self.processes if submission_type in process.submission_types)
# match extraction_kit:
# case str():
# # logger.debug(f"Filtering processes by extraction_kit str {extraction_kit}")
# processes = (process for process in processes if
# extraction_kit in [kit.name for kit in process.kit_types])
# case KitType():
# # logger.debug(f"Filtering processes by extraction_kit KitType {extraction_kit}")
# processes = (process for process in processes if extraction_kit in process.kit_types)
# case _:
# pass
# # NOTE: Convert to strings
# # processes = [process.name for process in processes]
# # assert all([isinstance(process, str) for process in processes])
# # if len(processes) == 0:
# # processes = ['']
# # return processes
# for process in processes:
# yield process.name
@classmethod
@setup_lookup
@@ -1650,25 +1631,6 @@ class EquipmentRole(BaseClass):
if extraction_kit and extraction_kit not in process.kit_types:
continue
yield process.name
# if submission_type is not None:
# # logger.debug("Getting all processes for this EquipmentRole")
# processes = [process for process in self.processes if submission_type in process.submission_types]
# else:
# processes = self.processes
# match extraction_kit:
# case str():
# # logger.debug(f"Filtering processes by extraction_kit str {extraction_kit}")
# processes = [item for item in processes if extraction_kit in [kit.name for kit in item.kit_types]]
# case KitType():
# # logger.debug(f"Filtering processes by extraction_kit KitType {extraction_kit}")
# processes = [item for item in processes if extraction_kit in [kit for kit in item.kit_types]]
# case _:
# pass
# output = [item.name for item in processes]
# if len(output) == 0:
# return ['']
# else:
# return output
def to_export_dict(self, submission_type: SubmissionType, kit_type: KitType):
"""
@@ -1730,9 +1692,8 @@ class SubmissionEquipmentAssociation(BaseClass):
@classmethod
@setup_lookup
def query(cls, equipment_id: int, submission_id: int, role: str | None = None, limit: int = 0, **kwargs) -> Any | \
List[
Any]:
def query(cls, equipment_id: int, submission_id: int, 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)
query = query.filter(cls.submission_id == submission_id)
@@ -1777,44 +1738,22 @@ class SubmissionTypeEquipmentRoleAssociation(BaseClass):
raise ValueError(f'Invalid required value {value}. Must be 0 or 1.')
return value
def get_all_processes(self, extraction_kit: KitType | str | None = None) -> List[Process]:
"""
Get all processes associated with this SubmissionTypeEquipmentRole
Args:
extraction_kit (KitType | str | None, optional): KitType of interest. Defaults to None.
Returns:
List[Process]: All associated processes
"""
processes = [equipment.get_processes(self.submission_type) for equipment in self.equipment_role.instances]
# NOTE: flatten list
processes = [item for items in processes for item in items if item is not None]
match extraction_kit:
case str():
# logger.debug(f"Filtering Processes by extraction_kit str {extraction_kit}")
processes = [item for item in processes if extraction_kit in [kit.name for kit in item.kit_type]]
case KitType():
# logger.debug(f"Filtering Processes by extraction_kit KitType {extraction_kit}")
processes = [item for item in processes if extraction_kit in [kit for kit in item.kit_type]]
case _:
pass
return processes
@check_authorization
def save(self):
super().save()
def to_export_dict(self, extraction_kit: KitType) -> dict:
def to_export_dict(self, extraction_kit: KitType | str) -> dict:
"""
Creates dictionary for exporting to yml used in new SubmissionType Construction
Args:
kit_type (KitType): KitType of interest.
extraction_kit (KitType | str): KitType of interest.
Returns:
dict: Dictionary containing relevant info for SubmissionType construction
"""
if isinstance(extraction_kit, str):
extraction_kit = KitType.query(name=extraction_kit)
base_dict = {k: v for k, v in self.equipment_role.to_export_dict(submission_type=self.submission_type,
kit_type=extraction_kit).items()}
base_dict['static'] = self.static
@@ -2013,8 +1952,8 @@ class SubmissionTipsAssociation(BaseClass):
@classmethod
@setup_lookup
def query(cls, tip_id: int, role: str, submission_id: int | None = None, limit: int = 0, **kwargs) -> Any | List[
Any]:
def query(cls, tip_id: int, role: str, submission_id: int | None = None, limit: int = 0, **kwargs) \
-> Any | List[Any]:
query: Query = cls.__database_session__.query(cls)
query = query.filter(cls.tip_id == tip_id)
if submission_id is not None:

View File

@@ -2,13 +2,10 @@
Models for the main submission and sample types.
"""
from __future__ import annotations
# import sys
# import types
# import zipfile
from copy import deepcopy
from getpass import getuser
import logging, uuid, tempfile, re, base64, numpy as np, pandas as pd, types, sys
from zipfile import ZipFile
from zipfile import ZipFile, BadZipfile
from tempfile import TemporaryDirectory, TemporaryFile
from operator import itemgetter
from pprint import pformat
@@ -20,7 +17,6 @@ from sqlalchemy.ext.associationproxy import association_proxy
from sqlalchemy.exc import OperationalError as AlcOperationalError, IntegrityError as AlcIntegrityError, StatementError, \
ArgumentError
from sqlite3 import OperationalError as SQLOperationalError, IntegrityError as SQLIntegrityError
# import pandas as pd
from openpyxl import Workbook
from openpyxl.drawing.image import Image as OpenpyxlImage
from tools import row_map, setup_lookup, jinja_template_loading, rreplace, row_keys, check_key_or_attr, Result, Report, \
@@ -1276,7 +1272,7 @@ class BasicSubmission(BaseClass, LogMixin):
if msg.exec():
try:
self.backup(fname=fname, full_backup=True)
except zipfile.BadZipfile:
except BadZipfile:
logger.error("Couldn't open zipfile for writing.")
self.__database_session__.delete(self)
try:
@@ -2261,7 +2257,7 @@ class WastewaterArtic(BasicSubmission):
# Sample Classes
class BasicSample(BaseClass):
class BasicSample(BaseClass, LogMixin):
"""
Base of basic sample which polymorphs into BCSample and WWSample
"""

View File

@@ -1,8 +1,7 @@
'''
contains parser objects for pulling values from client generated submission sheets.
'''
import json
import sys
import logging
from copy import copy
from getpass import getuser
from pprint import pformat
@@ -11,7 +10,6 @@ from openpyxl import load_workbook, Workbook
from pathlib import Path
from backend.db.models import *
from backend.validators import PydSubmission, RSLNamer
import logging, re
from collections import OrderedDict
from tools import check_not_nan, is_missing, check_key_or_attr
@@ -195,7 +193,7 @@ class InfoParser(object):
ws = self.xl[sheet]
relevant = []
for k, v in self.map.items():
# NOTE: If the value is hardcoded put it in the dictionary directly.
# NOTE: If the value is hardcoded put it in the dictionary directly. Ex. Artic kit
if k == "custom":
continue
if isinstance(v, str):
@@ -230,7 +228,7 @@ class InfoParser(object):
case "submitted_date":
value, missing = is_missing(value)
logger.debug(f"Parsed submitted date: {value}")
# NOTE: is field a JSON?
# NOTE: is field a JSON? Includes: Extraction info, PCR info, comment, custom
case thing if thing in self.sub_object.jsons():
value, missing = is_missing(value)
if missing: continue
@@ -300,11 +298,11 @@ class ReagentParser(object):
del reagent_map['info']
except KeyError:
pass
logger.debug(f"Reagent map: {pformat(reagent_map)}")
# logger.debug(f"Reagent map: {pformat(reagent_map)}")
# NOTE: If reagent map is empty, maybe the wrong kit was given, check if there's only one kit for that submission type and use it if so.
if not reagent_map:
temp_kit_object = self.submission_type_obj.get_default_kit()
logger.debug(f"Temp kit: {temp_kit_object}")
# logger.debug(f"Temp kit: {temp_kit_object}")
if temp_kit_object:
self.kit_object = temp_kit_object
# reagent_map = {k: v for k, v in self.kit_object.construct_xl_map_for_use(submission_type)}
@@ -333,7 +331,7 @@ class ReagentParser(object):
for sheet in self.xl.sheetnames:
ws = self.xl[sheet]
relevant = {k.strip(): v for k, v in self.map.items() if sheet in self.map[k]['sheet']}
logger.debug(f"relevant map for {sheet}: {pformat(relevant)}")
# logger.debug(f"relevant map for {sheet}: {pformat(relevant)}")
if relevant == {}:
continue
for item in relevant:
@@ -499,8 +497,7 @@ class SampleParser(object):
yield new
else:
merge_on_id = self.sample_info_map['lookup_table']['merge_on_id']
# plate_map_samples = sorted(copy(self.plate_map_samples), key=lambda d: d['id'])
# lookup_samples = sorted(copy(self.lookup_samples), key=lambda d: d[merge_on_id])
logger.info(f"Merging sample info using {merge_on_id}")
plate_map_samples = sorted(copy(self.plate_map_samples), key=itemgetter('id'))
lookup_samples = sorted(copy(self.lookup_samples), key=itemgetter(merge_on_id))
for ii, psample in enumerate(plate_map_samples):
@@ -517,20 +514,9 @@ class SampleParser(object):
logger.warning(f"Match for {psample['id']} not direct, running search.")
searchables = [(jj, sample) for jj, sample in enumerate(lookup_samples)
if merge_on_id in sample.keys()]
# for jj, lsample in enumerate(lookup_samples):
# try:
# check = lsample[merge_on_id] == psample['id']
# except KeyError:
# check = False
# if check:
# new = lsample | psample
# lookup_samples[jj] = {}
# break
# else:
# new = psample
jj, new = next(((jj, lsample | psample) for jj, lsample in searchables
if lsample[merge_on_id] == psample['id']), (-1, psample))
logger.debug(f"Assigning from index {jj} - {new}")
# logger.debug(f"Assigning from index {jj} - {new}")
if jj >= 0:
lookup_samples[jj] = {}
if not check_key_or_attr(key='submitter_id', interest=new, check_none=True):
@@ -554,7 +540,7 @@ class EquipmentParser(object):
xl (Workbook): Openpyxl workbook from submitted excel file.
submission_type (str | SubmissionType): Type of submission expected (Wastewater, Bacterial Culture, etc.)
"""
logger.debug("\n\nHello from EquipmentParser!\n\n")
logger.info("\n\nHello from EquipmentParser!\n\n")
if isinstance(submission_type, str):
submission_type = SubmissionType.query(name=submission_type)
self.submission_type = submission_type
@@ -568,7 +554,6 @@ class EquipmentParser(object):
Returns:
List[dict]: List of locations
"""
# return {k: v for k, v in self.submission_type.construct_equipment_map()}
return {k: v for k, v in self.submission_type.construct_field_map("equipment")}
def get_asset_number(self, input: str) -> str:
@@ -638,7 +623,7 @@ class TipParser(object):
xl (Workbook): Openpyxl workbook from submitted excel file.
submission_type (str | SubmissionType): Type of submission expected (Wastewater, Bacterial Culture, etc.)
"""
logger.debug("\n\nHello from TipParser!\n\n")
logger.info("\n\nHello from TipParser!\n\n")
if isinstance(submission_type, str):
submission_type = SubmissionType.query(name=submission_type)
self.submission_type = submission_type
@@ -652,7 +637,6 @@ class TipParser(object):
Returns:
List[dict]: List of locations
"""
# return {k: v for k, v in self.submission_type.construct_tips_map()}
return {k: v for k, v in self.submission_type.construct_field_map("tip")}
def parse_tips(self) -> List[dict]:

View File

@@ -2,7 +2,6 @@
Contains functions for generating summary reports
'''
from pprint import pformat
from pandas import DataFrame, ExcelWriter
import logging
from pathlib import Path
@@ -72,7 +71,6 @@ class ReportMaker(object):
for row in df.iterrows():
# logger.debug(f"Row {ii}: {row}")
lab = row[0][0]
# logger.debug(type(row))
# logger.debug(f"Old lab: {old_lab}, Current lab: {lab}")
# logger.debug(f"Name: {row[0][1]}")
data = [item for item in row[1]]
@@ -151,7 +149,16 @@ class TurnaroundMaker(object):
self.df = DataFrame.from_records(records)
@classmethod
def build_record(cls, sub):
def build_record(cls, sub: BasicSubmission) -> dict:
"""
Build a turnaround dictionary from a submission
Args:
sub (BasicSubmission): The submission to be processed.
Returns:
"""
days, tat_ok = sub.get_turnaround_time()
return dict(name=str(sub.rsl_plate_num), days=days, submitted_date=sub.submitted_date,
completed_date=sub.completed_date, acceptable=tat_ok)
@@ -170,4 +177,4 @@ class TurnaroundMaker(object):
self.writer = ExcelWriter(filename.with_suffix(".xlsx"), engine='openpyxl')
self.df.to_excel(self.writer, sheet_name="Turnaround")
# logger.debug(f"Writing report to: {filename}")
self.writer.close()
self.writer.close()

View File

@@ -41,21 +41,11 @@ class SheetWriter(object):
self.sub[k] = v['value']
else:
self.sub[k] = v
# logger.debug(f"\n\nWriting to {submission.filepath.__str__()}\n\n")
# if self.filepath.stem.startswith("tmp"):
# template = self.submission_type.template_file
# workbook = load_workbook(BytesIO(template))
# else:
# try:
# workbook = load_workbook(self.filepath)
# except Exception as e:
# logger.error(f"Couldn't open workbook due to {e}")
template = self.submission_type.template_file
if not template:
logger.error(f"No template file found, falling back to Bacterial Culture")
template = SubmissionType.retrieve_template_file()
workbook = load_workbook(BytesIO(template))
# self.workbook = workbook
self.xl = workbook
self.write_info()
self.write_reagents()
@@ -152,11 +142,9 @@ class InfoWriter(object):
try:
dicto['locations'] = info_map[k]
except KeyError:
# continue
pass
dicto['value'] = v
if len(dicto) > 0:
# output[k] = dicto
yield k, dicto
def write_info(self) -> Workbook:
@@ -279,7 +267,6 @@ class SampleWriter(object):
self.sample_map = submission_type.construct_sample_map()['lookup_table']
# NOTE: exclude any samples without a submission rank.
samples = [item for item in self.reconcile_map(sample_list) if item['submission_rank'] > 0]
# self.samples = sorted(samples, key=lambda k: k['submission_rank'])
self.samples = sorted(samples, key=itemgetter('submission_rank'))
def reconcile_map(self, sample_list: list) -> Generator[dict, None, None]:
@@ -368,7 +355,8 @@ class EquipmentWriter(object):
logger.error(f"No {equipment['role']} in {pformat(equipment_map)}")
# logger.debug(f"{equipment['role']} map: {mp_info}")
placeholder = copy(equipment)
if mp_info == {}:
# if mp_info == {}:
if not mp_info:
for jj, (k, v) in enumerate(equipment.items(), start=1):
dicto = dict(value=v, row=ii, column=jj)
placeholder[k] = dicto

View File

@@ -2,8 +2,7 @@
Contains pydantic models and accompanying validators
'''
from __future__ import annotations
import sys
import uuid, re, logging, csv
import uuid, re, logging, csv, sys
from pydantic import BaseModel, field_validator, Field, model_validator
from datetime import date, datetime, timedelta
from dateutil.parser import parse
@@ -165,13 +164,7 @@ class PydReagent(BaseModel):
report.add_result(Result(owner=__name__, code=0, msg="New reagent created.", status="Information"))
else:
if submission is not None and reagent not in submission.reagents:
# assoc = SubmissionReagentAssociation(reagent=reagent, submission=submission)
# assoc.comments = self.comment
submission.update_reagentassoc(reagent=reagent, role=self.role)
# else:
# assoc = None
# add end-of-life extension from reagent type to expiry date
# NOTE: this will now be done only in the reporting phase to account for potential changes in end-of-life extensions
return reagent, report
@@ -191,11 +184,7 @@ class PydSample(BaseModel, extra='allow'):
for k, v in data.model_extra.items():
if k in model.timestamps():
if isinstance(v, str):
# try:
v = datetime.strptime(v, "%Y-%m-%d")
# except ValueError:
# logger.warning(f"Attribute {k} value {v} for sample {data.submitter_id} could not be coerced into date. Setting to None.")
# v = None
data.__setattr__(k, v)
# logger.debug(f"Data coming out of validation: {pformat(data)}")
return data
@@ -379,7 +368,6 @@ class PydEquipment(BaseModel, extra='ignore'):
role=self.role, limit=1)
except TypeError as e:
logger.error(f"Couldn't get association due to {e}, returning...")
# return equipment, None
assoc = None
if assoc is None:
assoc = SubmissionEquipmentAssociation(submission=submission, equipment=equipment)
@@ -830,11 +818,6 @@ class PydSubmission(BaseModel, extra='allow'):
logger.debug(f"Checking reagent {reagent.lot}")
reagent, _ = reagent.toSQL(submission=instance)
# logger.debug(f"Association: {assoc}")
# if assoc is not None: # and assoc not in instance.submission_reagent_associations:
# if assoc not in instance.submission_reagent_associations:
# instance.submission_reagent_associations.append(assoc)
# else:
# logger.warning(f"Reagent association {assoc} is already present in {instance.submission_reagent_associations}")
case "samples":
for sample in self.samples:
sample, associations, _ = sample.toSQL(submission=instance)
@@ -871,7 +854,6 @@ class PydSubmission(BaseModel, extra='allow'):
logger.warning(f"Tips association {association} is already present in {instance}")
case item if item in instance.timestamps():
logger.warning(f"Incoming timestamp key: {item}, with value: {value}")
# value = value.replace(tzinfo=timezone)
if isinstance(value, date):
value = datetime.combine(value, datetime.min.time())
value = value.replace(tzinfo=timezone)
@@ -903,7 +885,6 @@ class PydSubmission(BaseModel, extra='allow'):
if check:
try:
instance.set_attribute(key=key, value=value)
# instance.update({key:value})
except AttributeError as e:
logger.error(f"Could not set attribute: {key} to {value} due to: \n\n {e}")
continue

View File

@@ -40,7 +40,6 @@ class IridaFigure(CustomFigure):
Returns:
Figure: output stacked bar chart.
"""
# fig = Figure()
for ii, mode in enumerate(modes):
if "count" in mode:
df[mode] = pd.to_numeric(df[mode], errors='coerce')

View File

@@ -1,8 +1,10 @@
"""
Construct turnaround time charts
"""
from pprint import pformat
from . import CustomFigure
import plotly.express as px
import pandas as pd
import numpy as np
from PyQt6.QtWidgets import QWidget
import logging
@@ -25,7 +27,6 @@ class TurnaroundChart(CustomFigure):
self.construct_chart()
if threshold:
self.add_hline(y=threshold)
# self.update_xaxes()
self.update_layout(showlegend=False)
def construct_chart(self, df: pd.DataFrame | None = None):

View File

@@ -1,7 +1,6 @@
"""
Constructs main application.
"""
import os
from pprint import pformat
from PyQt6.QtCore import qInstallMessageHandler
from PyQt6.QtWidgets import (
@@ -18,12 +17,11 @@ from tools import check_if_app, Settings, Report, jinja_template_loading, check_
from .functions import select_save_file, select_open_file
from datetime import date
from .pop_ups import HTMLPop, AlertPop
from .misc import LogParser, Pagifier
from .misc import Pagifier
import logging, webbrowser, sys, shutil
from .submission_table import SubmissionsSheet
from .submission_widget import SubmissionFormContainer
from .controls_chart import ControlsViewer
# from .sample_search import SampleSearchBox
from .summary import Summary
from .turnaround import TurnaroundTime
from .omni_search import SearchBox
@@ -84,7 +82,7 @@ class App(QMainWindow):
fileMenu.addAction(self.importAction)
fileMenu.addAction(self.yamlExportAction)
fileMenu.addAction(self.yamlImportAction)
methodsMenu.addAction(self.searchLog)
# methodsMenu.addAction(self.searchLog)
methodsMenu.addAction(self.searchSample)
maintenanceMenu.addAction(self.joinExtractionAction)
maintenanceMenu.addAction(self.joinPCRAction)
@@ -98,8 +96,8 @@ class App(QMainWindow):
toolbar = QToolBar("My main toolbar")
self.addToolBar(toolbar)
toolbar.addAction(self.addReagentAction)
toolbar.addAction(self.addKitAction)
toolbar.addAction(self.addOrgAction)
# toolbar.addAction(self.addKitAction)
# toolbar.addAction(self.addOrgAction)
def _createActions(self):
"""
@@ -108,13 +106,13 @@ class App(QMainWindow):
# logger.debug(f"Creating actions...")
self.importAction = QAction("&Import Submission", self)
self.addReagentAction = QAction("Add Reagent", self)
self.addKitAction = QAction("Import Kit", self)
self.addOrgAction = QAction("Import Org", self)
# self.addKitAction = QAction("Import Kit", self)
# self.addOrgAction = QAction("Import Org", self)
self.joinExtractionAction = QAction("Link Extraction Logs")
self.joinPCRAction = QAction("Link PCR Logs")
self.helpAction = QAction("&About", self)
self.docsAction = QAction("&Docs", self)
self.searchLog = QAction("Search Log", self)
# self.searchLog = QAction("Search Log", self)
self.searchSample = QAction("Search Sample", self)
self.githubAction = QAction("Github", self)
self.yamlExportAction = QAction("Export Type Example", self)
@@ -132,14 +130,13 @@ class App(QMainWindow):
self.joinPCRAction.triggered.connect(self.table_widget.sub_wid.link_pcr)
self.helpAction.triggered.connect(self.showAbout)
self.docsAction.triggered.connect(self.openDocs)
self.searchLog.triggered.connect(self.runSearch)
# self.searchLog.triggered.connect(self.runSearch)
self.searchSample.triggered.connect(self.runSampleSearch)
self.githubAction.triggered.connect(self.openGithub)
self.yamlExportAction.triggered.connect(self.export_ST_yaml)
self.yamlImportAction.triggered.connect(self.import_ST_yaml)
self.table_widget.pager.current_page.textChanged.connect(self.update_data)
self.editReagentAction.triggered.connect(self.edit_reagent)
self.destroyed.connect(self.final_commit)
def showAbout(self):
"""
@@ -180,15 +177,14 @@ class App(QMainWindow):
instr = HTMLPop(html=html, title="Instructions")
instr.exec()
def runSearch(self):
dlg = LogParser(self)
dlg.exec()
# def runSearch(self):
# dlg = LogParser(self)
# dlg.exec()
def runSampleSearch(self):
"""
Create a search for samples.
"""
# dlg = SampleSearchBox(self)
dlg = SearchBox(self, object_type=BasicSample, extras=[])
dlg.exec()
@@ -244,7 +240,6 @@ class App(QMainWindow):
st = SubmissionType.import_from_json(filepath=fname)
if st:
# NOTE: Do not delete the print statement below.
# print(pformat(st.to_export_dict()))
choice = input("Save the above submission type? [y/N]: ")
if choice.lower() == "y":
pass
@@ -254,9 +249,6 @@ class App(QMainWindow):
def update_data(self):
self.table_widget.sub_wid.setData(page=self.table_widget.pager.page_anchor, page_size=page_size)
def final_commit(self):
logger.debug("Running final commit")
self.ctx.database_session.commit()
class AddSubForm(QWidget):

View File

@@ -10,9 +10,10 @@ from PyQt6.QtWidgets import (
from PyQt6.QtCore import QSignalBlocker
from backend.db import ControlType, IridaControl
import logging
from tools import Report, report_result
from tools import Report, report_result, Result
from frontend.visualizations import CustomFigure
from .misc import StartEndDatePicker
from .info_tab import InfoPane
logger = logging.getLogger(f"submissions.{__name__}")
@@ -37,11 +38,11 @@ class ControlsViewer(QWidget):
# NOTE: fetch types of controls
con_sub_types = [item for item in self.archetype.targets.keys()]
self.control_sub_typer.addItems(con_sub_types)
# NOTE: create custom widget to get types of analysis
# NOTE: create custom widget to get types of analysis -- disabled by PCR control
self.mode_typer = QComboBox()
mode_types = IridaControl.get_modes()
self.mode_typer.addItems(mode_types)
# NOTE: create custom widget to get subtypes of analysis
# NOTE: create custom widget to get subtypes of analysis -- disabled by PCR control
self.mode_sub_typer = QComboBox()
self.mode_sub_typer.setEnabled(False)
# NOTE: add widgets to tab2 layout
@@ -83,15 +84,15 @@ class ControlsViewer(QWidget):
pass
# NOTE: correct start date being more recent than end date and rerun
if self.datepicker.start_date.date() > self.datepicker.end_date.date():
logger.warning("Start date after end date is not allowed!")
threemonthsago = self.datepicker.end_date.date().addDays(-60)
# NOTE: block signal that will rerun controls getter and set start date
# Without triggering this function again
msg = f"Start date after end date is not allowed! Setting to {threemonthsago.toString()}."
logger.warning(msg)
# 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(threemonthsago)
self.controls_getter()
self.report.add_result(report)
return
self.controls_getter_function()
report.add_result(Result(owner=self.__str__(), msg=msg, status="Warning"))
return report
# NOTE: convert to python useable date objects
self.start_date = self.datepicker.start_date.date().toPyDate()
self.end_date = self.datepicker.end_date.date().toPyDate()
@@ -167,4 +168,3 @@ class ControlsViewer(QWidget):
self.webengineview.update()
# logger.debug("Figure updated... I hope.")
return report

View File

@@ -1,7 +1,6 @@
'''
Creates forms that the user can enter equipment info into.
'''
import time
from pprint import pformat
from PyQt6.QtCore import Qt, QSignalBlocker
from PyQt6.QtWidgets import (QDialog, QComboBox, QCheckBox,

View File

@@ -66,7 +66,6 @@ class GelBox(QDialog):
layout.addWidget(self.imv, 0, 1, 20, 20)
# NOTE: setting this widget as central widget of the main window
try:
# control_info = sorted(self.submission.gel_controls, key=lambda d: d['location'])
control_info = sorted(self.submission.gel_controls, key=itemgetter('location'))
except KeyError:
control_info = None
@@ -79,7 +78,6 @@ class GelBox(QDialog):
layout.addWidget(self.buttonBox, 23, 1, 1, 1)
self.setLayout(layout)
def parse_form(self) -> Tuple[str, str | Path, list]:
"""
Get relevant values from self/form
@@ -124,7 +122,6 @@ class ControlsForm(QWidget):
self.layout.addWidget(label, iii, 1, 1, 1)
for iii in range(3):
for jjj in range(3):
# widge = QLineEdit()
widge = QComboBox()
widge.addItems(['Neg', 'Pos'])
widge.setCurrentIndex(0)

View File

@@ -1,6 +1,11 @@
"""
A pane to show info e.g. cost reports and turnaround times.
TODO: Can I merge this with the controls chart pane?
"""
from PyQt6.QtCore import QSignalBlocker
from PyQt6.QtWebEngineWidgets import QWebEngineView
from PyQt6.QtWidgets import QWidget, QGridLayout, QPushButton
from tools import Report
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
import logging
@@ -17,22 +22,29 @@ class InfoPane(QWidget):
self.report = Report()
self.datepicker = StartEndDatePicker(default_start=-31)
self.webview = QWebEngineView()
self.datepicker.start_date.dateChanged.connect(self.date_changed)
self.datepicker.end_date.dateChanged.connect(self.date_changed)
self.datepicker.start_date.dateChanged.connect(self.update_data)
self.datepicker.end_date.dateChanged.connect(self.update_data)
self.layout = QGridLayout(self)
self.layout.addWidget(self.datepicker, 0, 0, 1, 2)
self.save_excel_button = QPushButton("Save Excel", parent=self)
self.save_excel_button.pressed.connect(self.save_excel)
self.save_pdf_button = QPushButton("Save PDF", parent=self)
self.save_pdf_button.pressed.connect(self.save_pdf)
self.layout.addWidget(self.save_excel_button, 0, 2, 1, 1)
self.layout.addWidget(self.save_pdf_button, 0, 3, 1, 1)
self.layout.addWidget(self.webview, 2, 0, 1, 4)
self.setLayout(self.layout)
def date_changed(self):
@report_result
def update_data(self, *args, **kwargs):
report = Report()
self.start_date = self.datepicker.start_date.date().toPyDate()
self.end_date = self.datepicker.end_date.date().toPyDate()
if self.datepicker.start_date.date() > self.datepicker.end_date.date():
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
with QSignalBlocker(self.datepicker.start_date) as blocker:
self.datepicker.start_date.setDate(lastmonth)
self.update_data()
report.add_result(Result(owner=self.__str__(), msg=msg, status="Warning"))
return report
def save_excel(self):
fname = select_save_file(self, default_name=f"Report {self.start_date.strftime('%Y%m%d')} - {self.end_date.strftime('%Y%m%d')}", extension="xlsx")
@@ -42,4 +54,10 @@ class InfoPane(QWidget):
fname = select_save_file(obj=self,
default_name=f"Report {self.start_date.strftime('%Y%m%d')} - {self.end_date.strftime('%Y%m%d')}",
extension="pdf")
save_pdf(obj=self.webview, filename=fname)
save_pdf(obj=self.webview, filename=fname)
def save_png(self):
fname = select_save_file(obj=self,
default_name=f"Plotly {self.start_date.strftime('%Y%m%d')} - {self.end_date.strftime('%Y%m%d')}",
extension="png")
self.fig.write_image(fname.absolute().__str__(), engine="kaleido")

View File

@@ -14,8 +14,6 @@ from PyQt6.QtCore import Qt, QDate, QSize, QMarginsF
from tools import jinja_template_loading
from backend.db.models import *
import logging
from .pop_ups import AlertPop
from .functions import select_open_file
logger = logging.getLogger(f"submissions.{__name__}")
@@ -114,55 +112,6 @@ class AddReagentForm(QDialog):
self.name_input.addItems(list(set([item.name for item in lookup])))
class LogParser(QDialog):
def __init__(self, parent):
super().__init__(parent)
self.app = self.parent()
self.filebutton = QPushButton(self)
self.filebutton.setText("Import File")
self.phrase_looker = QComboBox(self)
self.phrase_looker.setEditable(True)
self.btn = QPushButton(self)
self.btn.setText("Search")
self.layout = QFormLayout(self)
self.layout.addRow(self.tr("&File:"), self.filebutton)
self.layout.addRow(self.tr("&Search Term:"), self.phrase_looker)
self.layout.addRow(self.btn)
self.filebutton.clicked.connect(self.filelookup)
self.btn.clicked.connect(self.runsearch)
self.setMinimumWidth(400)
def filelookup(self):
"""
Select file to search
"""
self.fname = select_open_file(self, "tabular")
def runsearch(self):
"""
Gets total/percent occurences of string in tabular file.
"""
count: int = 0
total: int = 0
# logger.debug(f"Current search term: {self.phrase_looker.currentText()}")
try:
with open(self.fname, "r") as f:
for chunk in readInChunks(fileObj=f):
total += len(chunk)
for line in chunk:
if self.phrase_looker.currentText().lower() in line.lower():
count += 1
percent = (count / total) * 100
msg = f"I found {count} instances of the search phrase out of {total} = {percent:.2f}%."
status = "Information"
except AttributeError:
msg = f"No file was selected."
status = "Error"
dlg = AlertPop(message=msg, status=status)
dlg.exec()
class StartEndDatePicker(QWidget):
"""
custom widget to pick start and end dates for controls graphs

View File

@@ -16,6 +16,9 @@ logger = logging.getLogger(f"submissions.{__name__}")
class SearchBox(QDialog):
"""
The full search widget.
"""
def __init__(self, parent, object_type: Any, extras: List[str], **kwargs):
super().__init__(parent)
@@ -36,7 +39,7 @@ class SearchBox(QDialog):
else:
self.sub_class = None
self.results = SearchResults(parent=self, object_type=self.object_type, extras=self.extras, **kwargs)
logger.debug(f"results: {self.results}")
# logger.debug(f"results: {self.results}")
self.layout.addWidget(self.results, 5, 0)
self.setLayout(self.layout)
self.setWindowTitle(f"Search {self.object_type.__name__}")
@@ -51,6 +54,7 @@ class SearchBox(QDialog):
# logger.debug(deletes)
for item in deletes:
item.setParent(None)
# NOTE: Handle any subclasses
if not self.sub_class:
self.update_data()
else:
@@ -89,6 +93,9 @@ class SearchBox(QDialog):
class FieldSearch(QWidget):
"""
Search bar.
"""
def __init__(self, parent, label, field_name):
super().__init__(parent)
@@ -115,6 +122,9 @@ class FieldSearch(QWidget):
class SearchResults(QTableView):
"""
Results table.
"""
def __init__(self, parent: SearchBox, object_type: Any, extras: List[str], **kwargs):
super().__init__()
@@ -146,7 +156,6 @@ class SearchResults(QTableView):
context = {item['name']: x.sibling(x.row(), item['column']).data() for item in self.columns_of_interest}
logger.debug(f"Context: {context}")
try:
# object = self.object_type.query(**{self.object_type.searchables: context[self.object_type.searchables]})
object = self.object_type.query(**context)
except KeyError:
object = None

View File

@@ -42,7 +42,7 @@ class SubmissionDetails(QDialog):
# NOTE: button to export a pdf version
self.btn = QPushButton("Export PDF")
self.btn.setFixedWidth(775)
self.btn.clicked.connect(self.export)
self.btn.clicked.connect(self.save_pdf)
self.back = QPushButton("Back")
self.back.setFixedWidth(100)
# self.back.clicked.connect(self.back_function)
@@ -181,7 +181,7 @@ class SubmissionDetails(QDialog):
submission.save()
self.submission_details(submission=self.rsl_plate_num)
def export(self):
def save_pdf(self):
"""
Renders submission to html, then creates and saves .pdf file to user selected file.
"""

View File

@@ -83,6 +83,7 @@ class SubmissionsSheet(QTableView):
self.resizeRowsToContents()
self.setSortingEnabled(True)
self.doubleClicked.connect(lambda x: BasicSubmission.query(id=x.sibling(x.row(), 0).data()).show_details(self))
# NOTE: Have to run native query here because mine just returns results?
self.total_count = BasicSubmission.__database_session__.query(BasicSubmission).count()
def setData(self, page: int = 1, page_size: int = 250) -> None:

View File

@@ -1,15 +1,13 @@
'''
Contains all submission related frontend functions
'''
import sys
from PyQt6.QtWidgets import (
QWidget, QPushButton, QVBoxLayout,
QComboBox, QDateEdit, QLineEdit, QLabel
)
from PyQt6.QtCore import pyqtSignal, Qt
from . import select_open_file, select_save_file
import logging, difflib
import logging
from pathlib import Path
from tools import Report, Result, check_not_nan, main_form_style, report_result
from backend.excel.parser import SheetParser
@@ -187,6 +185,8 @@ class SubmissionFormContainer(QWidget):
class SubmissionFormWidget(QWidget):
update_reagent_fields = ['extraction_kit']
def __init__(self, parent: QWidget, submission: PydSubmission, disable: list | None = None) -> None:
super().__init__(parent)
# logger.debug(f"Disable: {disable}")
@@ -203,7 +203,7 @@ 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}")
# logger.debug(f"Creating widget: {k}")
if k in self.ignore:
logger.warning(f"{k} in form_ignore {self.ignore}, not creating widget")
continue
@@ -225,10 +225,10 @@ class SubmissionFormWidget(QWidget):
sub_obj=st, disable=check)
if add_widget is not None:
self.layout.addWidget(add_widget)
if k == "extraction_kit":
# if k == "extraction_kit":
if k in self.__class__.update_reagent_fields:
add_widget.input.currentTextChanged.connect(self.scrape_reagents)
self.setStyleSheet(main_form_style)
# self.scrape_reagents(self.pyd.extraction_kit)
self.scrape_reagents(self.extraction_kit)
def create_widget(self, key: str, value: dict | PydReagent, submission_type: str | SubmissionType | None = None,
@@ -293,9 +293,8 @@ class SubmissionFormWidget(QWidget):
if isinstance(reagent, self.ReagentFormWidget) or isinstance(reagent, QPushButton):
reagent.setParent(None)
reagents, integrity_report = self.pyd.check_kit_integrity(extraction_kit=self.extraction_kit)
logger.debug(f"Got reagents: {pformat(reagents)}")
# logger.debug(f"Got reagents: {pformat(reagents)}")
for reagent in reagents:
# add_widget = self.ReagentFormWidget(parent=self, reagent=reagent, extraction_kit=self.pyd.extraction_kit)
add_widget = self.ReagentFormWidget(parent=self, reagent=reagent, extraction_kit=self.extraction_kit)
self.layout.addWidget(add_widget)
report.add_result(integrity_report)
@@ -334,10 +333,6 @@ class SubmissionFormWidget(QWidget):
query = [widget for widget in query if widget.objectName() == object_name]
return query
# def update_pyd(self):
# results = self.parse_form()
# logger.debug(pformat(results))
@report_result
def submit_new_sample_function(self, *args) -> Report:
"""
@@ -538,19 +533,14 @@ class SubmissionFormWidget(QWidget):
except (TypeError, KeyError):
pass
obj = parent.parent().parent()
logger.debug(f"Object: {obj}")
logger.debug(f"Parent: {parent.parent()}")
# logger.debug(f"Object: {obj}")
# logger.debug(f"Parent: {parent.parent()}")
# logger.debug(f"Creating widget for: {key}")
match key:
case 'submitting_lab':
add_widget = MyQComboBox(scrollWidget=parent)
# NOTE: lookup organizations suitable for submitting_lab (ctx: self.InfoItem.SubmissionFormWidget.SubmissionFormContainer.AddSubForm )
labs = [item.name for item in Organization.query()]
# NOTE: try to set closest match to top of list
# try:
# labs = difflib.get_close_matches(value, labs, len(labs), 0)
# except (TypeError, ValueError):
# pass
if isinstance(value, dict):
value = value['value']
if isinstance(value, Organization):
@@ -559,7 +549,7 @@ class SubmissionFormWidget(QWidget):
looked_up_lab = Organization.query(name=value, limit=1)
except AttributeError:
looked_up_lab = None
logger.debug(f"\n\nLooked up lab: {looked_up_lab}")
# logger.debug(f"\n\nLooked up lab: {looked_up_lab}")
if looked_up_lab:
try:
labs.remove(str(looked_up_lab.name))
@@ -579,7 +569,6 @@ class SubmissionFormWidget(QWidget):
add_widget = MyQComboBox(scrollWidget=parent)
# NOTE: lookup existing kits by 'submission_type' decided on by sheetparser
# logger.debug(f"Looking up kits used for {submission_type}")
# uses = [item.name for item in KitType.query(used_for=submission_type)]
uses = [item.name for item in submission_type.kit_types]
obj.uses = uses
# logger.debug(f"Kits received for {submission_type}: {uses}")

View File

@@ -1,12 +1,11 @@
from PyQt6.QtCore import QSignalBlocker
from PyQt6.QtWebEngineWidgets import QWebEngineView
"""
Pane to hold information e.g. cost summary.
"""
from .info_tab import InfoPane
from PyQt6.QtWidgets import QWidget, QGridLayout, QPushButton, QLabel
from PyQt6.QtWidgets import QWidget, QLabel, QPushButton
from backend.db import Organization
from backend.excel import ReportMaker
from tools import Report
from .misc import StartEndDatePicker, save_pdf, CheckableComboBox
from .functions import select_save_file
from .misc import CheckableComboBox
import logging
logger = logging.getLogger(f"submissions.{__name__}")
@@ -16,32 +15,27 @@ class Summary(InfoPane):
def __init__(self, parent: QWidget) -> None:
super().__init__(parent)
self.save_excel_button = QPushButton("Save Excel", parent=self)
self.save_excel_button.pressed.connect(self.save_excel)
self.save_pdf_button = QPushButton("Save PDF", parent=self)
self.save_pdf_button.pressed.connect(self.save_pdf)
self.layout.addWidget(self.save_excel_button, 0, 2, 1, 1)
self.layout.addWidget(self.save_pdf_button, 0, 3, 1, 1)
self.org_select = CheckableComboBox()
self.org_select.setEditable(False)
self.org_select.addItem("Select", header=True)
for org in [org.name for org in Organization.query()]:
self.org_select.addItem(org)
self.org_select.model().itemChanged.connect(self.date_changed)
self.org_select.model().itemChanged.connect(self.update_data)
self.layout.addWidget(QLabel("Client"), 1, 0, 1, 1)
self.layout.addWidget(self.org_select, 1, 1, 1, 3)
self.date_changed()
self.update_data()
def date_changed(self):
def update_data(self):
super().update_data()
orgs = [self.org_select.itemText(i) for i in range(self.org_select.count()) if self.org_select.itemChecked(i)]
if self.datepicker.start_date.date() > self.datepicker.end_date.date():
logger.warning("Start date after end date is not allowed!")
lastmonth = self.datepicker.end_date.date().addDays(-31)
# 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.date_changed()
return
# NOTE: convert to python useable date objects
# self.start_date = self.datepicker.start_date.date().toPyDate()
# self.end_date = self.datepicker.end_date.date().toPyDate()
super().date_changed()
logger.debug(f"Getting report from {self.start_date} to {self.end_date} using {orgs}")
# logger.debug(f"Getting report from {self.start_date} to {self.end_date} using {orgs}")
self.report_obj = ReportMaker(start_date=self.start_date, end_date=self.end_date, organizations=orgs)
self.webview.setHtml(self.report_obj.html)
if self.report_obj.subs:
@@ -50,13 +44,3 @@ class Summary(InfoPane):
else:
self.save_pdf_button.setEnabled(False)
self.save_excel_button.setEnabled(False)
# def save_excel(self):
# fname = select_save_file(self, default_name=f"Report {self.start_date.strftime('%Y%m%d')} - {self.end_date.strftime('%Y%m%d')}", extension="xlsx")
# self.report_obj.write_report(fname, obj=self)
#
# def save_pdf(self):
# fname = select_save_file(obj=self,
# default_name=f"Report {self.start_date.strftime('%Y%m%d')} - {self.end_date.strftime('%Y%m%d')}",
# extension="pdf")
# save_pdf(obj=self.webview, filename=fname)

View File

@@ -15,28 +15,25 @@ class TurnaroundTime(InfoPane):
def __init__(self, parent: QWidget):
super().__init__(parent)
self.chart = None
self.save_button = QPushButton("Save Chart", parent=self)
self.save_button.pressed.connect(self.save_png)
self.layout.addWidget(self.save_button, 0, 2, 1, 1)
self.export_button = QPushButton("Save Data", parent=self)
self.export_button.pressed.connect(self.save_excel)
self.layout.addWidget(self.export_button, 0, 3, 1, 1)
self.fig = None
self.report_object = None
self.submission_typer = QComboBox(self)
subs = ["Any"] + [item.name for item in SubmissionType.query()]
subs = ["All"] + [item.name for item in SubmissionType.query()]
self.submission_typer.addItems(subs)
self.layout.addWidget(self.submission_typer, 1, 1, 1, 3)
self.submission_typer.currentTextChanged.connect(self.date_changed)
self.date_changed()
self.submission_typer.currentTextChanged.connect(self.update_data)
self.update_data()
def date_changed(self):
if self.datepicker.start_date.date() > self.datepicker.end_date.date():
logger.warning("Start date after end date is not allowed!")
lastmonth = self.datepicker.end_date.date().addDays(-31)
# 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.date_changed()
return
super().date_changed()
def update_data(self):
super().update_data()
chart_settings = dict(start_date=self.start_date, end_date=self.end_date)
if self.submission_typer.currentText() == "Any":
if self.submission_typer.currentText() == "All":
submission_type = None
subtype_obj = None
else:
@@ -47,5 +44,5 @@ class TurnaroundTime(InfoPane):
threshold = subtype_obj.defaults['turnaround_time'] + 0.5
else:
threshold = None
self.chart = TurnaroundChart(df=self.report_obj.df, settings=chart_settings, modes=[], threshold=threshold)
self.webview.setHtml(self.chart.to_html())
self.fig = TurnaroundChart(df=self.report_obj.df, settings=chart_settings, modes=[], threshold=threshold)
self.webview.setHtml(self.fig.to_html())

View File

@@ -3,13 +3,11 @@ Contains miscellaenous functions used by both frontend and backend.
'''
from __future__ import annotations
import json
import pprint
from datetime import date, datetime, timedelta
from json import JSONDecodeError
from pprint import pprint
import numpy as np
import logging, re, yaml, sys, os, stat, platform, getpass, inspect
import pandas as pd
import logging, re, yaml, sys, os, stat, platform, getpass, inspect, json, pandas as pd
from dateutil.easter import easter
from jinja2 import Environment, FileSystemLoader
from logging import handlers
@@ -20,7 +18,6 @@ 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
# print(inspect.stack()[1])
from __init__ import project_path
from configparser import ConfigParser
from tkinter import Tk # NOTE: This is for choosing database path before app is created.
@@ -261,6 +258,7 @@ class Settings(BaseSettings, extra="allow"):
submission_types: dict | None = None
database_session: Session | None = None
package: Any | None = None
logging_enabled: bool = Field(default=False)
model_config = SettingsConfigDict(env_file_encoding='utf-8')
@@ -422,6 +420,7 @@ class Settings(BaseSettings, extra="allow"):
super().__init__(*args, **kwargs)
self.set_from_db()
pprint(f"User settings:\n{self.__dict__}")
def set_from_db(self):
if 'pytest' in sys.modules:
@@ -819,7 +818,7 @@ class Result(BaseModel, arbitrary_types_allowed=True):
self.owner = inspect.stack()[1].function
def report(self):
from frontend.widgets.misc import AlertPop
from frontend.widgets.pop_ups import AlertPop
return AlertPop(message=self.msg, status=self.status, owner=self.owner)