Various bug fixes and streamlining.

This commit is contained in:
lwark
2024-10-03 15:09:41 -05:00
parent acab9d0f4c
commit c5470b9062
22 changed files with 222 additions and 380 deletions

View File

@@ -1,9 +1,13 @@
"""
Functions for constructing controls graphs using plotly.
"""
from copy import deepcopy
from pprint import pformat
import plotly
import plotly.express as px
import pandas as pd
from PyQt6.QtWidgets import QWidget
from plotly.graph_objects import Figure
import logging
from tools import get_unique_values_in_df_column, divide_chunks
@@ -14,7 +18,7 @@ logger = logging.getLogger(f"submissions.{__name__}")
class CustomFigure(Figure):
def __init__(self, df: pd.DataFrame, modes: list, ytitle: str | None = None):
def __init__(self, df: pd.DataFrame, modes: list, ytitle: str | None = None, parent: QWidget | None = None):
super().__init__()
self.construct_chart(df=df, modes=modes)
self.generic_figure_markers(modes=modes, ytitle=ytitle)
@@ -140,7 +144,7 @@ class CustomFigure(Figure):
{"yaxis.title.text": mode},
])
def save_figure(self, group_name: str = "plotly_output"):
def save_figure(self, group_name: str = "plotly_output", parent:QWidget|None=None):
"""
Writes plotly figure to html file.
@@ -150,12 +154,10 @@ class CustomFigure(Figure):
fig (Figure): input figure object
group_name (str): controltype
"""
output = select_save_file(None, default_name=group_name, extension="html")
with open(output, "w") as f:
try:
f.write(self.to_html())
except AttributeError:
logger.error(f"The following figure was a string: {self}")
output = select_save_file(obj=parent, default_name=group_name, extension="png")
self.write_image(output.absolute().__str__(), engine="kaleido")
def to_html(self) -> str:
"""

View File

@@ -189,8 +189,8 @@ class App(QMainWindow):
"""
month = date.today().strftime("%Y-%m")
current_month_bak = Path(self.ctx.backup_path).joinpath(f"submissions_backup-{month}").resolve()
logger.debug(f"Here is the db directory: {self.ctx.database_path}")
logger.debug(f"Here is the backup directory: {self.ctx.backup_path}")
logger.info(f"Here is the db directory: {self.ctx.database_path}")
logger.info(f"Here is the backup directory: {self.ctx.backup_path}")
match self.ctx.database_schema:
case "sqlite":
db_path = self.ctx.database_path.joinpath(self.ctx.database_name).with_suffix(".db")
@@ -206,15 +206,17 @@ class App(QMainWindow):
current_month_bak = current_month_bak.with_suffix(".psql")
def export_ST_yaml(self):
"""
Copies submission type yaml to file system for editing and remport
Returns:
None
"""
if check_if_app():
yaml_path = Path(sys._MEIPASS).joinpath("resources", "viral_culture.yml")
else:
yaml_path = project_path.joinpath("src", "submissions", "resources", "viral_culture.yml")
# with open(yaml_path, "r") as f:
# data = yaml.safe_load(f)
fname = select_save_file(obj=self, default_name="Submission Type Template.yml", extension="yml")
# with open(fname, "w") as f:
# yaml.safe_dump(data=data, stream=f)
shutil.copyfile(yaml_path, fname)
@check_authorization
@@ -230,7 +232,6 @@ class App(QMainWindow):
print(pformat(st.to_export_dict()))
choice = input("Save the above submission type? [y/N]: ")
if choice.lower() == "y":
# st.save()
pass
else:
logger.warning("Save of submission type cancelled.")

View File

@@ -2,12 +2,13 @@
Handles display of control charts
"""
import re
import sys
from datetime import timedelta
from typing import Tuple
from PyQt6.QtWebEngineWidgets import QWebEngineView
from PyQt6.QtWidgets import (
QWidget, QVBoxLayout, QComboBox, QHBoxLayout,
QDateEdit, QLabel, QSizePolicy
QDateEdit, QLabel, QSizePolicy, QPushButton
)
from PyQt6.QtCore import QSignalBlocker
from backend.db import ControlType, Control
@@ -15,11 +16,11 @@ from PyQt6.QtCore import QDate, QSize
import logging
from pandas import DataFrame
from tools import Report, Result, get_unique_values_in_df_column, Settings, report_result
# from backend.excel.reports import convert_data_list_to_df
from frontend.visualizations.control_charts import CustomFigure
logger = logging.getLogger(f"submissions.{__name__}")
class ControlsViewer(QWidget):
def __init__(self, parent: QWidget) -> None:
@@ -29,7 +30,7 @@ class ControlsViewer(QWidget):
self.report = Report()
self.datepicker = ControlsDatePicker()
self.webengineview = QWebEngineView()
# set tab2 layout
# NOTE: set tab2 layout
self.layout = QVBoxLayout(self)
self.control_typer = QComboBox()
# NOTE: fetch types of controls
@@ -54,18 +55,22 @@ class ControlsViewer(QWidget):
self.mode_typer.currentIndexChanged.connect(self.controls_getter)
self.datepicker.start_date.dateChanged.connect(self.controls_getter)
self.datepicker.end_date.dateChanged.connect(self.controls_getter)
self.datepicker.save_button.pressed.connect(self.save_chart_function)
def save_chart_function(self):
self.fig.save_figure(parent=self)
def controls_getter(self):
"""
Lookup controls from database and send to chartmaker
"""
"""
self.controls_getter_function()
@report_result
def controls_getter_function(self):
"""
Get controls based on start/end dates
"""
"""
report = Report()
# NOTE: subtype defaults to disabled
try:
@@ -96,7 +101,7 @@ class ControlsViewer(QWidget):
sub_types = []
if sub_types != []:
# NOTE: block signal that will rerun controls getter and update sub_typer
with QSignalBlocker(self.sub_typer) as blocker:
with QSignalBlocker(self.sub_typer) as blocker:
self.sub_typer.addItems(sub_types)
self.sub_typer.setEnabled(True)
self.sub_typer.currentTextChanged.connect(self.chart_maker)
@@ -109,8 +114,8 @@ class ControlsViewer(QWidget):
def chart_maker(self):
"""
Creates plotly charts for webview
"""
self.chart_maker_function()
"""
self.chart_maker_function()
@report_result
def chart_maker_function(self):
@@ -122,7 +127,7 @@ class ControlsViewer(QWidget):
Returns:
Tuple[QMainWindow, dict]: Collection of new main app window and result dict
"""
"""
report = Report()
# logger.debug(f"Control getter context: \n\tControl type: {self.con_type}\n\tMode: {self.mode}\n\tStart
# Date: {self.start_date}\n\tEnd Date: {self.end_date}") NOTE: set the subtype for kraken
@@ -136,6 +141,7 @@ class ControlsViewer(QWidget):
# NOTE: if no data found from query set fig to none for reporting in webview
if controls is None:
fig = None
self.datepicker.save_button.setEnabled(False)
else:
# NOTE: change each control to list of dictionaries
data = [control.convert_by_mode(mode=self.mode) for control in controls]
@@ -153,8 +159,10 @@ class ControlsViewer(QWidget):
title = f"{self.mode} - {self.subtype}"
# NOTE: send dataframe to chart maker
df, modes = self.prep_df(ctx=self.app.ctx, df=df)
fig = CustomFigure(df=df, ytitle=title, modes=modes)
fig = CustomFigure(df=df, ytitle=title, modes=modes, parent=self)
self.datepicker.save_button.setEnabled(True)
# logger.debug(f"Updating figure...")
self.fig = fig
# NOTE: construct html for webview
html = fig.to_html()
# logger.debug(f"The length of html code is: {len(html)}")
@@ -179,6 +187,11 @@ class ControlsViewer(QWidget):
df = DataFrame.from_records(input_df)
safe = ['name', 'submitted_date', 'genus', 'target']
for column in df.columns:
if column not in safe:
if self.subtype is not None and column != self.subtype:
continue
else:
safe.append(column)
if "percent" in column:
# count_col = [item for item in df.columns if "count" in item][0]
try:
@@ -187,9 +200,9 @@ class ControlsViewer(QWidget):
continue
# NOTE: The actual percentage from kraken was off due to exclusion of NaN, recalculating.
df[column] = 100 * df[count_col] / df.groupby('name')[count_col].transform('sum')
if column not in safe:
if self.subtype is not None and column != self.subtype:
del df[column]
logger.debug(df)
logger.debug(safe)
df = df[[c for c in df.columns if c in safe]]
# NOTE: move date of sample submitted on same date as previous ahead one.
df = self.displace_date(df=df)
# NOTE: ad hoc method to make data labels more accurate.
@@ -229,12 +242,13 @@ class ControlsViewer(QWidget):
# NOTE: get submitted dates for each control
dict_list = [dict(name=item, date=df[df.name == item].iloc[0]['submitted_date']) for item in
sorted(df['name'].unique())]
previous_dates = []
for _, item in enumerate(dict_list):
previous_dates = set()
# for _, item in enumerate(dict_list):
for item in dict_list:
df, previous_dates = self.check_date(df=df, item=item, previous_dates=previous_dates)
return df
def check_date(self, df: DataFrame, item: dict, previous_dates: list) -> Tuple[DataFrame, list]:
def check_date(self, df: DataFrame, item: dict, previous_dates: set) -> Tuple[DataFrame, list]:
"""
Checks if an items date is already present in df and adjusts df accordingly
@@ -250,7 +264,7 @@ class ControlsViewer(QWidget):
check = item['date'] in previous_dates
except IndexError:
check = False
previous_dates.append(item['date'])
previous_dates.add(item['date'])
if check:
# logger.debug(f"We found one! Increment date!\n\t{item['date']} to {item['date'] + timedelta(days=1)}")
# NOTE: get df locations where name == item name
@@ -273,7 +287,7 @@ class ControlsViewer(QWidget):
df, previous_dates = self.check_date(df, item, previous_dates)
return df, previous_dates
def prep_df(self, ctx: Settings, df: DataFrame) -> DataFrame:
def prep_df(self, ctx: Settings, df: DataFrame) -> Tuple[DataFrame, list]:
"""
Constructs figures based on parsed pandas dataframe.
@@ -285,27 +299,17 @@ class ControlsViewer(QWidget):
Returns:
Figure: Plotly figure
"""
# from backend.excel import drop_reruns_from_df
# converts starred genera to normal and splits off list of starred
genera = []
# NOTE: converts starred genera to normal and splits off list of starred
if df.empty:
return None
for item in df['genus'].to_list():
try:
if item[-1] == "*":
genera.append(item[-1])
else:
genera.append("")
except IndexError:
genera.append("")
df['genus'] = df['genus'].replace({'\*': ''}, regex=True).replace({"NaN": "Unknown"})
df['genera'] = genera
df['genera'] = [item[-1] if item and item[-1] == "*" else "" for item in df['genus'].to_list()]
# NOTE: remove original runs, using reruns if applicable
df = self.drop_reruns_from_df(ctx=ctx, df=df)
# NOTE: sort by and exclude from
sorts = ['submitted_date', "target", "genus"]
exclude = ['name', 'genera']
modes = [item for item in df.columns if item not in sorts and item not in exclude] # and "_hashes" not in item]
modes = [item for item in df.columns if item not in sorts and item not in exclude]
# NOTE: Set descending for any columns that have "{mode}" in the header.
ascending = [False if item == "target" else True for item in sorts]
df = df.sort_values(by=sorts, ascending=ascending)
@@ -327,23 +331,26 @@ class ControlsViewer(QWidget):
if 'rerun_regex' in ctx:
sample_names = get_unique_values_in_df_column(df, column_name="name")
rerun_regex = re.compile(fr"{ctx.rerun_regex}")
for sample in sample_names:
if rerun_regex.search(sample):
first_run = re.sub(rerun_regex, "", sample)
df = df.drop(df[df.name == first_run].index)
exclude = [re.sub(rerun_regex, "", sample) for sample in sample_names if rerun_regex.search(sample)]
df = df[df.name not in exclude]
# for sample in sample_names:
# if rerun_regex.search(sample):
# first_run = re.sub(rerun_regex, "", sample)
# df = df.drop(df[df.name == first_run].index)
return df
class ControlsDatePicker(QWidget):
"""
custom widget to pick start and end dates for controls graphs
"""
"""
def __init__(self) -> None:
super().__init__()
self.start_date = QDateEdit(calendarPopup=True)
# NOTE: start date is two months prior to end date by default
twomonthsago = QDate.currentDate().addDays(-60)
self.start_date.setDate(twomonthsago)
sixmonthsago = QDate.currentDate().addDays(-180)
self.start_date.setDate(sixmonthsago)
self.end_date = QDateEdit(calendarPopup=True)
self.end_date.setDate(QDate.currentDate())
self.layout = QHBoxLayout()
@@ -353,6 +360,8 @@ class ControlsDatePicker(QWidget):
self.layout.addWidget(self.end_date)
self.setLayout(self.layout)
self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
self.save_button = QPushButton("Save Chart", parent=self)
self.layout.addWidget(self.save_button)
def sizeHint(self) -> QSize:
return QSize(80,20)
return QSize(80, 20)

View File

@@ -8,7 +8,7 @@ from PyQt6.QtWidgets import (QDialog, QComboBox, QCheckBox,
from backend.db.models import Equipment, BasicSubmission, Process
from backend.validators.pydant import PydEquipment, PydEquipmentRole, PydTips
import logging
from typing import List
from typing import List, Generator
logger = logging.getLogger(f"submissions.{__name__}")
@@ -45,26 +45,26 @@ class EquipmentUsage(QDialog):
widg.update_processes()
self.layout.addWidget(self.buttonBox)
def parse_form(self) -> List[PydEquipment]:
def parse_form(self) -> Generator[PydEquipment, None, None]:
"""
Pull info from all RoleComboBox widgets
Returns:
List[PydEquipment]: All equipment pulled from widgets
"""
output = []
for widget in self.findChildren(QWidget):
match widget:
case RoleComboBox():
if widget.check.isChecked():
output.append(widget.parse_form())
item = widget.parse_form()
if item:
yield item
else:
continue
else:
continue
case _:
pass
# logger.debug(f"parsed output of Equsage form: {pformat(output)}")
try:
return [item.strip() for item in output if item is not None]
except AttributeError:
return [item for item in output if item is not None]
continue
class LabelRow(QWidget):
@@ -93,14 +93,10 @@ class RoleComboBox(QWidget):
def __init__(self, parent, role: PydEquipmentRole, used: list) -> None:
super().__init__(parent)
# self.layout = QHBoxLayout()
self.layout = QGridLayout()
self.role = role
self.check = QCheckBox()
# if role.name in used:
self.check.setChecked(False)
# else:
# self.check.setChecked(True)
self.check.stateChanged.connect(self.toggle_checked)
self.box = QComboBox()
self.box.setMaximumWidth(200)
@@ -129,7 +125,6 @@ class RoleComboBox(QWidget):
"""
equip = self.box.currentText()
# logger.debug(f"Updating equipment: {equip}")
# equip2 = [item for item in self.role.equipment if item.name == equip][0]
equip2 = next((item for item in self.role.equipment if item.name == equip), self.role.equipment[0])
# logger.debug(f"Using: {equip2}")
self.process.clear()
@@ -158,7 +153,10 @@ class RoleComboBox(QWidget):
widget.setMinimumWidth(200)
widget.setMaximumWidth(200)
self.layout.addWidget(widget, 0, 4)
widget.setEnabled(self.check.isChecked())
try:
widget.setEnabled(self.check.isChecked())
except NameError:
pass
def parse_form(self) -> PydEquipment | None:
"""

View File

@@ -74,7 +74,7 @@ class GelBox(QDialog):
self.buttonBox = QDialogButtonBox(QBtn)
self.buttonBox.accepted.connect(self.accept)
self.buttonBox.rejected.connect(self.reject)
layout.addWidget(self.buttonBox, 23, 1, 1, 1) #, alignment=Qt.AlignmentFlag.AlignTop)
layout.addWidget(self.buttonBox, 23, 1, 1, 1)
self.setLayout(layout)
@@ -135,7 +135,7 @@ class ControlsForm(QWidget):
self.layout.addWidget(self.comment_field, 1, 5, 4, 1)
self.setLayout(self.layout)
def parse_form(self) -> List[dict]:
def parse_form(self) -> Tuple[List[dict], str]:
"""
Pulls the controls statuses from the form.
@@ -145,11 +145,7 @@ class ControlsForm(QWidget):
output = []
for le in self.findChildren(QComboBox):
label = [item.strip() for item in le.objectName().split(" : ")]
try:
# dicto = [item for item in output if item['name'] == label[0]][0]
dicto = next(item for item in output if item['name'] == label[0])
except StopIteration:
dicto = dict(name=label[0], values=[])
dicto = next((item for item in output if item['name'] == label[0]), dict(name=label[0], values=[]))
dicto['values'].append(dict(name=label[1], value=le.currentText()))
if label[0] not in [item['name'] for item in output]:
output.append(dicto)

View File

@@ -2,11 +2,10 @@
Contains dialogs for notification and prompting.
'''
from PyQt6.QtWidgets import (
QLabel, QVBoxLayout, QDialog,
QLabel, QVBoxLayout, QDialog,
QDialogButtonBox, QMessageBox, QComboBox
)
from PyQt6.QtWebEngineWidgets import QWebEngineView
from PyQt6.QtCore import Qt
from tools import jinja_template_loading
import logging
from backend.db import models
@@ -20,8 +19,9 @@ env = jinja_template_loading()
class QuestionAsker(QDialog):
"""
dialog to ask yes/no questions
"""
def __init__(self, title:str, message:str):
"""
def __init__(self, title: str, message: str):
super().__init__()
self.setWindowTitle(title)
# NOTE: set yes/no buttons
@@ -40,8 +40,10 @@ class QuestionAsker(QDialog):
class AlertPop(QMessageBox):
"""
Dialog to show an alert.
"""
def __init__(self, message:str, status:Literal['Information', 'Question', 'Warning', 'Critical'], owner:str|None=None):
"""
def __init__(self, message: str, status: Literal['Information', 'Question', 'Warning', 'Critical'],
owner: str | None = None):
super().__init__()
# NOTE: select icon by string
icon = getattr(QMessageBox.Icon, status)
@@ -49,9 +51,10 @@ class AlertPop(QMessageBox):
self.setInformativeText(message)
self.setWindowTitle(f"{owner} - {status.title()}")
class HTMLPop(QDialog):
def __init__(self, html:str, owner:str|None=None, title:str="python"):
def __init__(self, html: str, owner: str | None = None, title: str = "python"):
super().__init__()
self.webview = QWebEngineView(parent=self)
@@ -66,14 +69,18 @@ class HTMLPop(QDialog):
class ObjectSelector(QDialog):
"""
dialog to input BaseClass type manually
"""
def __init__(self, title:str, message:str, obj_type:str|type[models.BaseClass]):
"""
def __init__(self, title: str, message: str, obj_type: str | type[models.BaseClass], values: list | None = None):
super().__init__()
self.setWindowTitle(title)
self.widget = QComboBox()
if isinstance(obj_type, str):
obj_type: models.BaseClass = getattr(models, obj_type)
items = [item.name for item in obj_type.query()]
if values:
items = values
else:
if isinstance(obj_type, str):
obj_type: models.BaseClass = getattr(models, obj_type)
items = [item.name for item in obj_type.query()]
self.widget.addItems(items)
self.widget.setEditable(False)
# NOTE: set yes/no buttons
@@ -95,5 +102,5 @@ class ObjectSelector(QDialog):
Returns:
str: KitType as str
"""
"""
return self.widget.currentText()

View File

@@ -55,6 +55,7 @@ class SearchBox(QDialog):
widget = FieldSearch(parent=self, label=item['label'], field_name=item['field'])
self.layout.addWidget(widget, start_row+iii, 0)
widget.search_widget.textChanged.connect(self.update_data)
self.update_data()
def parse_form(self) -> dict:
"""
@@ -73,7 +74,8 @@ class SearchBox(QDialog):
# logger.debug(f"Running update_data with sample type: {self.type}")
fields = self.parse_form()
# logger.debug(f"Got fields: {fields}")
sample_list_creator = self.type.fuzzy_search(sample_type=self.type, **fields)
# sample_list_creator = self.type.fuzzy_search(sample_type=self.type, **fields)
sample_list_creator = self.type.fuzzy_search(**fields)
data = self.type.samples_to_df(sample_list=sample_list_creator)
# logger.debug(f"Data: {data}")
self.results.setData(df=data)

View File

@@ -9,7 +9,6 @@ from PyQt6.QtWebEngineWidgets import QWebEngineView
from PyQt6.QtWebChannel import QWebChannel
from PyQt6.QtCore import Qt, pyqtSlot, QMarginsF
from jinja2 import TemplateNotFound
from backend.db.models import BasicSubmission, BasicSample, Reagent, KitType
from tools import is_power_user, html_to_pdf, jinja_template_loading
from .functions import select_save_file
@@ -18,9 +17,8 @@ import logging
from getpass import getuser
from datetime import datetime
from pprint import pformat
from typing import List
from backend.excel.writer import DocxWriter
logger = logging.getLogger(f"submissions.{__name__}")

View File

@@ -11,8 +11,6 @@ from backend.excel import ReportMaker
from tools import Report, Result, report_result
from .functions import select_save_file, select_open_file
from .misc import ReportDatePicker
import pandas as pd
from openpyxl.worksheet.worksheet import Worksheet
logger = logging.getLogger(f"submissions.{__name__}")
@@ -222,10 +220,6 @@ class SubmissionsSheet(QTableView):
# NOTE: if imported submission doesn't exist move on to next run
if sub is None:
continue
# try:
# logger.debug(f"Found submission: {sub.rsl_plate_num}")
# except AttributeError:
# continue
sub.set_attribute('pcr_info', new_run)
# NOTE: check if pcr_info already exists
sub.save()

View File

@@ -9,7 +9,7 @@ from PyQt6.QtWidgets import (
)
from PyQt6.QtCore import pyqtSignal, Qt
from . import select_open_file, select_save_file
import logging, difflib, inspect
import logging, difflib
from pathlib import Path
from tools import Report, Result, check_not_nan, main_form_style, report_result, check_regex_match
from backend.excel.parser import SheetParser
@@ -163,7 +163,7 @@ class SubmissionFormContainer(QWidget):
# NOTE: create form
dlg = AddReagentForm(reagent_lot=reagent_lot, reagent_role=reagent_role, expiry=expiry, reagent_name=name)
if dlg.exec():
# extract form info
# NOTE: extract form info
info = dlg.parse_form()
# logger.debug(f"Reagent info: {info}")
# NOTE: create reagent object
@@ -180,7 +180,6 @@ class SubmissionFormWidget(QWidget):
def __init__(self, parent: QWidget, submission: PydSubmission, disable: list | None = None) -> None:
super().__init__(parent)
# self.report = Report()
# logger.debug(f"Disable: {disable}")
if disable is None:
disable = []
@@ -268,7 +267,6 @@ class SubmissionFormWidget(QWidget):
Tuple[QMainWindow, dict]: Updated application and result
"""
extraction_kit = args[0]
# caller = inspect.stack()[1].function.__repr__().replace("'", "")
report = Report()
# logger.debug(f"Extraction kit: {extraction_kit}")
# NOTE: Remove previous reagent widgets