hitpicking complete, pre-addition of WW-Arctic parsers and models.

This commit is contained in:
Landon Wark
2023-05-31 09:44:20 -05:00
parent 01d95e80f5
commit 1d6823705c
17 changed files with 334 additions and 247 deletions

View File

@@ -4,7 +4,7 @@ from pathlib import Path
# Version of the realpython-reader package
__project__ = "submissions"
__version__ = "202305.3b"
__version__ = "202305.4b"
__author__ = {"name":"Landon Wark", "email":"Landon.Wark@phac-aspc.gc.ca"}
__copyright__ = "2022-2023, Government of Canada"
@@ -24,7 +24,7 @@ class bcolors:
# Hello Landon, this is your past self here. I'm trying not to screw you over like I usually do, so I will
# set out the workflow I've imagined for creating new submission types.
# First of all, you will need to write new parsing methods in backend.excel.parser to pull information out of the submission form
# for the submission itself as well as for any samples you can pull out of that same sheet.
# for the submission itself as well as for any samples you can pull out of that same workbook.
# Second, you will have to update the model in backend.db.models.submissions and provide a new polymorph to the BasicSubmission object.
# The BSO should hold the majority of the general info.
# You can also update any of the parsers to pull out any custom info you need, like enforcing RSL plate numbers, scraping PCR results, etc.

View File

@@ -20,7 +20,6 @@ from getpass import getuser
import numpy as np
import yaml
from pathlib import Path
from math import ceil
logger = logging.getLogger(f"submissions.{__name__}")
@@ -136,7 +135,13 @@ def construct_submission_info(ctx:dict, info_dict:dict) -> models.BasicSubmissio
try:
field_value = lookup_kittype_by_name(ctx=ctx, name=q_str)
except (sqlite3.IntegrityError, sqlalchemy.exc.IntegrityError) as e:
logger.error(f"Hit an integrity error: {e}")
logger.error(f"Hit an integrity error looking up kit type: {e}")
logger.error(f"Details: {e.__dict__}")
if "submitter_plate_num" in e.__dict__['statement']:
msg = "SQL integrity error. Submitter plate id is a duplicate or invalid."
else:
msg = "SQL integrity error of unknown origin."
return instance, dict(code=2, message=msg)
logger.debug(f"Got {field_value} for kit {q_str}")
case "submitting_lab":
q_str = info_dict[item].replace(" ", "_").lower()
@@ -179,7 +184,7 @@ def construct_submission_info(ctx:dict, info_dict:dict) -> models.BasicSubmissio
discounts = sum(discounts)
instance.run_cost = instance.run_cost - discounts
except Exception as e:
logger.error(f"An unknown exception occurred: {e}")
logger.error(f"An unknown exception occurred when calculating discounts: {e}")
# We need to make sure there's a proper rsl plate number
logger.debug(f"We've got a total cost of {instance.run_cost}")
try:
@@ -748,10 +753,16 @@ def update_ww_sample(ctx:dict, sample_obj:dict):
ww_samp = lookup_ww_sample_by_sub_sample_rsl(ctx=ctx, sample_rsl=sample_obj['sample'], plate_rsl=sample_obj['plate_rsl'])
# ww_samp = lookup_ww_sample_by_sub_sample_well(ctx=ctx, sample_rsl=sample_obj['sample'], well_num=sample_obj['well_num'], plate_rsl=sample_obj['plate_rsl'])
if ww_samp != None:
# del sample_obj['well_number']
for key, value in sample_obj.items():
logger.debug(f"Setting {key} to {value}")
# set attribute 'key' to 'value'
setattr(ww_samp, key, value)
try:
check = getattr(ww_samp, key)
except AttributeError:
continue
if check == None:
logger.debug(f"Setting {key} to {value}")
setattr(ww_samp, key, value)
else:
logger.error(f"Unable to find sample {sample_obj['sample']}")
return
@@ -762,4 +773,29 @@ def lookup_discounts_by_org_and_kit(ctx:dict, kit_id:int, lab_id:int):
return ctx['database_session'].query(models.Discount).join(models.KitType).join(models.Organization).filter(and_(
models.KitType.id==kit_id,
models.Organization.id==lab_id
)).all()
)).all()
def hitpick_plate(submission:models.BasicSubmission, plate_number:int=0) -> list:
plate_dicto = []
for sample in submission.samples:
# have sample report back its info if it's positive, otherwise, None
samp = sample.to_hitpick()
if samp == None:
continue
else:
logger.debug(f"Item name: {samp['name']}")
# plate can handle 88 samples to leave column for controls
# if len(dicto) < 88:
this_sample = dict(
plate_number = plate_number,
sample_name = samp['name'],
column = samp['col'],
row = samp['row'],
plate_name = submission.rsl_plate_num
)
# append to plate samples
plate_dicto.append(this_sample)
# append to all samples
# image = make_plate_map(plate_dicto)
return plate_dicto

View File

@@ -4,6 +4,9 @@ All models for individual samples.
from . import Base
from sqlalchemy import Column, String, TIMESTAMP, INTEGER, ForeignKey, FLOAT, BOOLEAN, JSON
from sqlalchemy.orm import relationship
import logging
logger = logging.getLogger(f"submissions.{__name__}")
class WWSample(Base):
@@ -19,7 +22,7 @@ class WWSample(Base):
rsl_plate = relationship("Wastewater", back_populates="samples") #: relationship to parent plate
rsl_plate_id = Column(INTEGER, ForeignKey("_submissions.id", ondelete="SET NULL", name="fk_WWS_submission_id"))
collection_date = Column(TIMESTAMP) #: Date submission received
well_number = Column(String(8)) #: location on plate
well_number = Column(String(8)) #: location on 24 well plate
# The following are fields from the sample tracking excel sheet Ruth put together.
# I have no idea when they will be implemented or how.
testing_type = Column(String(64))
@@ -33,6 +36,7 @@ class WWSample(Base):
ww_seq_run_id = Column(String(64))
sample_type = Column(String(8))
pcr_results = Column(JSON)
elution_well = Column(String(8)) #: location on 96 well plate
def to_string(self) -> str:
@@ -51,6 +55,10 @@ class WWSample(Base):
Returns:
dict: well location and id NOTE: keys must sync with BCSample to_sub_dict below
"""
# well_col = self.well_number[1:]
# well_row = self.well_number[0]
# if well_col > 4:
# well
if self.ct_n1 != None and self.ct_n2 != None:
name = f"{self.ww_sample_full_id}\n\t- ct N1: {'{:.2f}'.format(self.ct_n1)} ({self.n1_status})\n\t- ct N2: {'{:.2f}'.format(self.ct_n2)} ({self.n2_status})"
else:
@@ -59,6 +67,34 @@ class WWSample(Base):
"well": self.well_number,
"name": name,
}
def to_hitpick(self) -> dict|None:
"""
Outputs a dictionary of locations if sample is positive
Returns:
dict: dictionary of sample id, row and column in elution plate
"""
# dictionary to translate row letters into numbers
row_dict = dict(A=1, B=2, C=3, D=4, E=5, F=6, G=7, H=8)
# if either n1 or n2 is positive, include this sample
try:
positive = any(["positive" in item for item in [self.n1_status, self.n2_status]])
except TypeError as e:
logger.error(f"Couldn't check positives for {self.rsl_number}. Looks like there isn't PCR data.")
return None
if positive:
try:
# The first character of the elution well is the row
well_row = row_dict[self.elution_well[0]]
# The remaining charagers are the columns
well_col = self.elution_well[1:]
except TypeError as e:
logger.error(f"This sample doesn't have elution plate info.")
return None
return dict(name=self.ww_sample_full_id, row=well_row, col=well_col)
else:
return None
class BCSample(Base):

View File

@@ -132,7 +132,10 @@ class SheetParser(object):
try:
expiry = row[3].date()
except AttributeError as e:
expiry = datetime.strptime(row[3], "%Y-%m-%d")
try:
expiry = datetime.strptime(row[3], "%Y-%m-%d")
except TypeError as e:
expiry = datetime.fromordinal(datetime(1900, 1, 1).toordinal() + row[3] - 2)
else:
logger.debug(f"Date: {row[3]}")
expiry = date.today()
@@ -378,7 +381,7 @@ class PCRParser(object):
self.samples_df['Assessment'] = well_call_df.values
except ValueError:
logger.error("Well call number doesn't match sample number")
logger.debug(f"Well call dr: {well_call_df}")
logger.debug(f"Well call df: {well_call_df}")
# iloc is [row][column]
for ii, row in self.samples_df.iterrows():
try:
@@ -387,7 +390,7 @@ class PCRParser(object):
sample_obj = dict(
sample = row['Sample'],
plate_rsl = self.plate_num,
well_num = row['Well Position']
elution_well = row['Well Position']
)
logger.debug(f"Got sample obj: {sample_obj}")
# logger.debug(f"row: {row}")

View File

@@ -213,3 +213,7 @@ def drop_reruns_from_df(ctx:dict, df: DataFrame) -> DataFrame:
return df
# else:
# return df
def make_hitpicks(input:list) -> DataFrame:
return DataFrame.from_records(input)

View File

@@ -135,6 +135,7 @@ class App(QMainWindow):
logger.debug(f"Attempting to open {url}")
webbrowser.get('windows-default').open(f"file://{url.__str__()}")
# All main window functions return a result which is reported here, unless it is None
def result_reporter(self, result:dict|None=None):
if result != None:
msg = AlertPop(message=result['message'], status=result['status'])
@@ -147,128 +148,6 @@ class App(QMainWindow):
self, result = import_submission_function(self)
logger.debug(f"Import result: {result}")
self.result_reporter(result)
# logger.debug(self.ctx)
# # initialize samples
# self.samples = []
# self.reagents = {}
# # set file dialog
# home_dir = str(Path(self.ctx["directory_path"]))
# fname = Path(QFileDialog.getOpenFileName(self, 'Open file', home_dir)[0])
# logger.debug(f"Attempting to parse file: {fname}")
# assert fname.exists()
# # create sheetparser using excel sheet and context from gui
# try:
# prsr = SheetParser(fname, **self.ctx)
# except PermissionError:
# logger.error(f"Couldn't get permission to access file: {fname}")
# return
# if prsr.sub['rsl_plate_num'] == None:
# prsr.sub['rsl_plate_num'] = RSLNamer(fname.__str__()).parsed_name
# logger.debug(f"prsr.sub = {prsr.sub}")
# # destroy any widgets from previous imports
# for item in self.table_widget.formlayout.parentWidget().findChildren(QWidget):
# item.setParent(None)
# # regex to parser out different variable types for decision making
# variable_parser = re.compile(r"""
# # (?x)
# (?P<extraction_kit>^extraction_kit$) |
# (?P<submitted_date>^submitted_date$) |
# (?P<submitting_lab>)^submitting_lab$ |
# (?P<samples>)^samples$ |
# (?P<reagent>^lot_.*$) |
# (?P<csv>^csv$)
# """, re.VERBOSE)
# for item in prsr.sub:
# logger.debug(f"Item: {item}")
# # attempt to match variable name to regex group
# try:
# mo = variable_parser.fullmatch(item).lastgroup
# except AttributeError:
# mo = "other"
# logger.debug(f"Mo: {mo}")
# match mo:
# case 'submitting_lab':
# # create label
# self.table_widget.formlayout.addWidget(QLabel(item.replace("_", " ").title()))
# logger.debug(f"{item}: {prsr.sub[item]}")
# # create combobox to hold looked up submitting labs
# add_widget = QComboBox()
# labs = [item.__str__() for item in lookup_all_orgs(ctx=self.ctx)]
# # try to set closest match to top of list
# try:
# labs = difflib.get_close_matches(prsr.sub[item], labs, len(labs), 0)
# except (TypeError, ValueError):
# pass
# # set combobox values to lookedup values
# add_widget.addItems(labs)
# case 'extraction_kit':
# # create label
# self.table_widget.formlayout.addWidget(QLabel(item.replace("_", " ").title()))
# # if extraction kit not available, all other values fail
# if not check_not_nan(prsr.sub[item]):
# msg = AlertPop(message="Make sure to check your extraction kit in the excel sheet!", status="warning")
# msg.exec()
# # create combobox to hold looked up kits
# # add_widget = KitSelector(ctx=self.ctx, submission_type=prsr.sub['submission_type'], parent=self)
# add_widget = QComboBox()
# # add_widget.currentTextChanged.connect(self.kit_reload)
# # lookup existing kits by 'submission_type' decided on by sheetparser
# uses = [item.__str__() for item in lookup_kittype_by_use(ctx=self.ctx, used_by=prsr.sub['submission_type'])]
# # if len(uses) > 0:
# add_widget.addItems(uses)
# # else:
# # add_widget.addItems(['bacterial_culture'])
# if check_not_nan(prsr.sub[item]):
# self.ext_kit = prsr.sub[item]
# else:
# self.ext_kit = add_widget.currentText()
# case 'submitted_date':
# # create label
# self.table_widget.formlayout.addWidget(QLabel(item.replace("_", " ").title()))
# # uses base calendar
# add_widget = QDateEdit(calendarPopup=True)
# # sets submitted date based on date found in excel sheet
# try:
# add_widget.setDate(prsr.sub[item])
# # if not found, use today
# except:
# add_widget.setDate(date.today())
# case 'reagent':
# # create label
# reg_label = QLabel(item.replace("_", " ").title())
# reg_label.setObjectName(f"lot_{item}_label")
# self.table_widget.formlayout.addWidget(reg_label)
# # create reagent choice widget
# add_widget = ImportReagent(ctx=self.ctx, item=item, prsr=prsr)
# self.reagents[item] = prsr.sub[item]
# case 'samples':
# # hold samples in 'self' until form submitted
# logger.debug(f"{item}: {prsr.sub[item]}")
# self.samples = prsr.sub[item]
# add_widget = None
# case 'csv':
# self.csv = prsr.sub[item]
# case _:
# # anything else gets added in as a line edit
# self.table_widget.formlayout.addWidget(QLabel(item.replace("_", " ").title()))
# add_widget = QLineEdit()
# logger.debug(f"Setting widget text to {str(prsr.sub[item]).replace('_', ' ')}")
# add_widget.setText(str(prsr.sub[item]).replace("_", " "))
# try:
# add_widget.setObjectName(item)
# logger.debug(f"Widget name set to: {add_widget.objectName()}")
# self.table_widget.formlayout.addWidget(add_widget)
# except AttributeError as e:
# logger.error(e)
# # compare self.reagents with expected reagents in kit
# if hasattr(self, 'ext_kit'):
# self.kit_integrity_completion()
# # create submission button
# # submit_btn = QPushButton("Submit")
# # submit_btn.setObjectName("submit_btn")
# # self.table_widget.formlayout.addWidget(submit_btn)
# # submit_btn.clicked.connect(self.submit_new_sample)
# logger.debug(f"Imported reagents: {self.reagents}")
def kit_reload(self):
@@ -277,17 +156,7 @@ class App(QMainWindow):
"""
self, result = kit_reload_function(self)
self.result_reporter(result)
# for item in self.table_widget.formlayout.parentWidget().findChildren(QWidget):
# # item.setParent(None)
# if isinstance(item, QLabel):
# if item.text().startswith("Lot"):
# item.setParent(None)
# else:
# logger.debug(f"Type of {item.objectName()} is {type(item)}")
# if item.objectName().startswith("lot_"):
# item.setParent(None)
# self.kit_integrity_completion()
def kit_integrity_completion(self):
"""
@@ -297,26 +166,6 @@ class App(QMainWindow):
"""
self, result = kit_integrity_completion_function(self)
self.result_reporter(result)
# logger.debug(inspect.currentframe().f_back.f_code.co_name)
# kit_widget = self.table_widget.formlayout.parentWidget().findChild(QComboBox, 'extraction_kit')
# logger.debug(f"Kit selector: {kit_widget}")
# self.ext_kit = kit_widget.currentText()
# logger.debug(f"Checking integrity of {self.ext_kit}")
# kit = lookup_kittype_by_name(ctx=self.ctx, name=self.ext_kit)
# reagents_to_lookup = [item.replace("lot_", "") for item in self.reagents]
# logger.debug(f"Reagents for lookup for {kit.name}: {reagents_to_lookup}")
# kit_integrity = check_kit_integrity(kit, reagents_to_lookup)
# if kit_integrity != None:
# msg = AlertPop(message=kit_integrity['message'], status="critical")
# msg.exec()
# for item in kit_integrity['missing']:
# self.table_widget.formlayout.addWidget(QLabel(f"Lot {item.replace('_', ' ').title()}"))
# add_widget = ImportReagent(ctx=self.ctx, item=item)
# self.table_widget.formlayout.addWidget(add_widget)
# submit_btn = QPushButton("Submit")
# submit_btn.setObjectName("lot_submit_btn")
# self.table_widget.formlayout.addWidget(submit_btn)
# submit_btn.clicked.connect(self.submit_new_sample)
def submit_new_sample(self):

View File

@@ -3,6 +3,8 @@ Contains widgets specific to the submission summary and submission details.
'''
import base64
from datetime import datetime
from io import BytesIO
import math
from PyQt6 import QtPrintSupport
from PyQt6.QtWidgets import (
QVBoxLayout, QDialog, QTableView,
@@ -10,16 +12,18 @@ from PyQt6.QtWidgets import (
QMessageBox, QFileDialog, QMenu, QLabel,
QDialogButtonBox, QToolBar, QMainWindow
)
from PyQt6.QtCore import Qt, QAbstractTableModel, QSortFilterProxyModel
from PyQt6.QtCore import Qt, QAbstractTableModel, QSortFilterProxyModel, QItemSelectionModel
from PyQt6.QtGui import QFontMetrics, QAction, QCursor, QPixmap, QPainter
from backend.db import submissions_to_df, lookup_submission_by_id, delete_submission_by_id, lookup_submission_by_rsl_num
from backend.db import submissions_to_df, lookup_submission_by_id, delete_submission_by_id, lookup_submission_by_rsl_num, hitpick_plate
# from backend.misc import hitpick_plate
from backend.excel import make_hitpicks
from jinja2 import Environment, FileSystemLoader
from xhtml2pdf import pisa
import sys
from pathlib import Path
import logging
from .pop_ups import QuestionAsker
from ..visualizations import make_plate_barcode
from .pop_ups import QuestionAsker, AlertPop
from ..visualizations import make_plate_barcode, make_plate_map
from getpass import getuser
logger = logging.getLogger(f"submissions.{__name__}")
@@ -92,6 +96,7 @@ class SubmissionsSheet(QTableView):
self.resizeColumnsToContents()
self.resizeRowsToContents()
self.setSortingEnabled(True)
self.doubleClicked.connect(self.show_details)
def setData(self) -> None:
@@ -111,10 +116,8 @@ class SubmissionsSheet(QTableView):
pass
proxyModel = QSortFilterProxyModel()
proxyModel.setSourceModel(pandasModel(self.data))
# self.model = pandasModel(self.data)
# self.setModel(self.model)
self.setModel(proxyModel)
# self.resize(800,600)
def show_details(self) -> None:
"""
@@ -124,7 +127,7 @@ class SubmissionsSheet(QTableView):
value = index.sibling(index.row(),0).data()
dlg = SubmissionDetails(ctx=self.ctx, id=value)
if dlg.exec():
pass
pass
def create_barcode(self) -> None:
index = (self.selectionModel().currentIndex())
@@ -155,14 +158,17 @@ class SubmissionsSheet(QTableView):
detailsAction = QAction('Details', self)
barcodeAction = QAction("Print Barcode", self)
commentAction = QAction("Add Comment", self)
hitpickAction = QAction("Hitpicks", self)
renameAction.triggered.connect(lambda: self.delete_item(event))
detailsAction.triggered.connect(lambda: self.show_details())
barcodeAction.triggered.connect(lambda: self.create_barcode())
commentAction.triggered.connect(lambda: self.add_comment())
hitpickAction.triggered.connect(lambda: self.hit_pick())
self.menu.addAction(detailsAction)
self.menu.addAction(renameAction)
self.menu.addAction(barcodeAction)
self.menu.addAction(commentAction)
self.menu.addAction(hitpickAction)
# add other required actions
self.menu.popup(QCursor.pos())
@@ -185,7 +191,63 @@ class SubmissionsSheet(QTableView):
self.setData()
def hit_pick(self):
"""
Extract positive samples from submissions with PCR results and export to csv.
NOTE: For this to work for arbitrary samples, positive samples must have 'positive' in their name
"""
# Get all selected rows
indices = self.selectionModel().selectedIndexes()
# convert to id numbers
indices = [index.sibling(index.row(), 0).data() for index in indices]
# biomek can handle 4 plates maximum
if len(indices) > 4:
logger.error(f"Error: Had to truncate number of plates to 4.")
indices = indices[:4]
# lookup ids in the database
subs = [lookup_submission_by_id(self.ctx, id) for id in indices]
# full list of samples
dicto = []
# list to contain plate images
images = []
for iii, sub in enumerate(subs):
# second check to make sure there aren't too many plates
if iii > 3:
logger.error(f"Error: Had to truncate number of plates to 4.")
continue
plate_dicto = hitpick_plate(submission=sub, plate_number=iii+1)
if plate_dicto == None:
continue
image = make_plate_map(plate_dicto)
images.append(image)
for item in plate_dicto:
if len(dicto) < 94:
dicto.append(item)
else:
logger.error(f"We had to truncate the number of samples to 94.")
logger.debug(f"We found {len(dicto)} to hitpick")
msg = AlertPop(message=f"We found {len(dicto)} samples to hitpick", status="INFORMATION")
msg.exec()
# convert all samples to dataframe
df = make_hitpicks(dicto)
logger.debug(f"Size of the dataframe: {df.size}")
if df.size == 0:
return
date = datetime.strftime(datetime.today(), "%Y-%m-%d")
# ask for filename and save as csv.
home_dir = Path(self.ctx["directory_path"]).joinpath(f"Hitpicks_{date}.csv").resolve().__str__()
fname = Path(QFileDialog.getSaveFileName(self, "Save File", home_dir, filter=".csv")[0])
if fname.__str__() == ".":
logger.debug("Saving csv was cancelled.")
return
df.to_csv(fname.__str__(), index=False)
# show plate maps
for image in images:
try:
image.show()
except Exception as e:
logger.error(f"Could not show image: {e}.")
class SubmissionDetails(QDialog):
"""
@@ -239,7 +301,17 @@ class SubmissionDetails(QDialog):
Renders submission to html, then creates and saves .pdf file to user selected file.
"""
template = env.get_template("submission_details.html")
# make barcode because, reasons
self.base_dict['barcode'] = base64.b64encode(make_plate_barcode(self.base_dict['Plate Number'], width=120, height=30)).decode('utf-8')
sub = lookup_submission_by_rsl_num(ctx=self.ctx, rsl_num=self.base_dict['Plate Number'])
plate_dicto = hitpick_plate(sub)
platemap = make_plate_map(plate_dicto)
logger.debug(f"platemap: {platemap}")
image_io = BytesIO()
platemap.save(image_io, 'JPEG')
platemap.save("test.jpg", 'JPEG')
self.base_dict['platemap'] = base64.b64encode(image_io.getvalue()).decode('utf-8')
logger.debug(self.base_dict)
html = template.render(sub=self.base_dict)
home_dir = Path(self.ctx["directory_path"]).joinpath(f"Submission_Details_{self.base_dict['Plate Number']}.pdf").resolve().__str__()
fname = Path(QFileDialog.getSaveFileName(self, "Save File", home_dir, filter=".pdf")[0])
@@ -269,18 +341,10 @@ class BarcodeWindow(QDialog):
# creating label
self.label = QLabel()
self.img = make_plate_barcode(rsl_num)
# logger.debug(dir(img), img.contents[0])
# fp = BytesIO().read()
# img.save(formats=['png'], fnRoot=fp)
# pixmap = QPixmap("C:\\Users\\lwark\\Documents\\python\\submissions\\src\\Drawing000.png")
self.pixmap = QPixmap()
# self.pixmap.loadFromData(self.img.asString("bmp"))
self.pixmap.loadFromData(self.img)
# adding image to label
self.label.setPixmap(self.pixmap)
# Optional, resize label to image size
# self.label.resize(self.pixmap.width(), self.pixmap.height())
# self.label.resize(200, 200)
# show all the widgets]
QBtn = QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel
self.buttonBox = QDialogButtonBox(QBtn)
@@ -300,8 +364,6 @@ class BarcodeWindow(QDialog):
adds items to menu bar
"""
toolbar = QToolBar("My main toolbar")
# self.addToolBar(toolbar)
# self.layout.setToolBar(toolbar)
toolbar.addAction(self.printAction)
@@ -321,7 +383,6 @@ class BarcodeWindow(QDialog):
def print_barcode(self):
printer = QtPrintSupport.QPrinter()
dialog = QtPrintSupport.QPrintDialog(printer)
if dialog.exec():
self.handle_paint_request(printer, self.pixmap.toImage())

View File

@@ -41,15 +41,11 @@ logger = logging.getLogger(f"submissions.{__name__}")
def import_submission_function(obj:QMainWindow) -> Tuple[QMainWindow, dict|None]:
result = None
# from .custom_widgets.misc import ImportReagent
# from .custom_widgets.pop_ups import AlertPop
logger.debug(obj.ctx)
# initialize samples
obj.samples = []
obj.reagents = {}
# set file dialog
# home_dir = str(Path(obj.ctx["directory_path"]))
# fname = Path(QFileDialog.getOpenFileName(obj, 'Open file', home_dir)[0])
fname = select_open_file(obj, extension="xlsx")
logger.debug(f"Attempting to parse file: {fname}")
if not fname.exists():
@@ -69,7 +65,6 @@ def import_submission_function(obj:QMainWindow) -> Tuple[QMainWindow, dict|None]
item.setParent(None)
# regex to parser out different variable types for decision making
variable_parser = re.compile(r"""
# (?x)
(?P<extraction_kit>^extraction_kit$) |
(?P<submitted_date>^submitted_date$) |
(?P<submitting_lab>)^submitting_lab$ |
@@ -161,13 +156,11 @@ def import_submission_function(obj:QMainWindow) -> Tuple[QMainWindow, dict|None]
if hasattr(obj, 'ext_kit'):
obj.kit_integrity_completion()
logger.debug(f"Imported reagents: {obj.reagents}")
return obj, result
def kit_reload_function(obj:QMainWindow) -> QMainWindow:
result = None
for item in obj.table_widget.formlayout.parentWidget().findChildren(QWidget):
# item.setParent(None)
if isinstance(item, QLabel):
if item.text().startswith("Lot"):
item.setParent(None)
@@ -180,20 +173,22 @@ def kit_reload_function(obj:QMainWindow) -> QMainWindow:
def kit_integrity_completion_function(obj:QMainWindow) -> QMainWindow:
result = None
# from .custom_widgets.misc import ImportReagent
# from .custom_widgets.pop_ups import AlertPop
logger.debug(inspect.currentframe().f_back.f_code.co_name)
# find the widget that contains lit info
kit_widget = obj.table_widget.formlayout.parentWidget().findChild(QComboBox, 'extraction_kit')
logger.debug(f"Kit selector: {kit_widget}")
# get current kit info
obj.ext_kit = kit_widget.currentText()
logger.debug(f"Checking integrity of {obj.ext_kit}")
# get the kit from database using current kit info
kit = lookup_kittype_by_name(ctx=obj.ctx, name=obj.ext_kit)
# get all reagents stored in the QWindow object
reagents_to_lookup = [item.replace("lot_", "") for item in obj.reagents]
logger.debug(f"Reagents for lookup for {kit.name}: {reagents_to_lookup}")
# make sure kit contains all necessary info
kit_integrity = check_kit_integrity(kit, reagents_to_lookup)
# if kit integrity comes back with an error, make widgets with missing reagents using default info
if kit_integrity != None:
# msg = AlertPop(message=kit_integrity['message'], status="critical")
# msg.exec()
result = dict(message=kit_integrity['message'], status="Warning")
for item in kit_integrity['missing']:
obj.table_widget.formlayout.addWidget(QLabel(f"Lot {item.replace('_', ' ').title()}"))
@@ -207,9 +202,9 @@ def kit_integrity_completion_function(obj:QMainWindow) -> QMainWindow:
def submit_new_sample_function(obj:QMainWindow) -> QMainWindow:
result = None
# from .custom_widgets.misc import ImportReagent
# from .custom_widgets.pop_ups import AlertPop, QuestionAsker
# extract info from the form widgets
info = extract_form_info(obj.table_widget.tab1)
# seperate out reagents
reagents = {k:v for k,v in info.items() if k.startswith("lot_")}
info = {k:v for k,v in info.items() if not k.startswith("lot_")}
logger.debug(f"Info: {info}")
@@ -268,12 +263,9 @@ def submit_new_sample_function(obj:QMainWindow) -> QMainWindow:
# reset form
for item in obj.table_widget.formlayout.parentWidget().findChildren(QWidget):
item.setParent(None)
# print(dir(obj))
if hasattr(obj, 'csv'):
dlg = QuestionAsker("Export CSV?", "Would you like to export the csv file?")
if dlg.exec():
# home_dir = Path(obj.ctx["directory_path"]).joinpath(f"{base_submission.rsl_plate_num}.csv").resolve().__str__()
# fname = Path(QFileDialog.getSaveFileName(obj, "Save File", home_dir, filter=".csv")[0])
fname = select_save_file(obj, f"{base_submission.rsl_plate_num}.csv", extension="csv")
try:
obj.csv.to_csv(fname.__str__(), index=False)
@@ -282,8 +274,8 @@ def submit_new_sample_function(obj:QMainWindow) -> QMainWindow:
return obj, result
def generate_report_function(obj:QMainWindow) -> QMainWindow:
# from .custom_widgets import ReportDatePicker
result = None
# ask for date ranges
dlg = ReportDatePicker()
if dlg.exec():
info = extract_form_info(dlg)
@@ -324,8 +316,6 @@ def generate_report_function(obj:QMainWindow) -> QMainWindow:
def add_kit_function(obj:QMainWindow) -> QMainWindow:
result = None
# setup file dialog to find yaml flie
# home_dir = str(Path(obj.ctx["directory_path"]))
# fname = Path(QFileDialog.getOpenFileName(obj, 'Open file', home_dir, filter = "yml(*.yml)")[0])
fname = select_open_file(obj, extension="yml")
assert fname.exists()
# read yaml file
@@ -340,19 +330,11 @@ def add_kit_function(obj:QMainWindow) -> QMainWindow:
return
# send to kit creator function
result = create_kit_from_yaml(ctx=obj.ctx, exp=exp)
# match result['code']:
# case 0:
# msg = AlertPop(message=result['message'], status='info')
# case 1:
# msg = AlertPop(message=result['message'], status='critical')
# msg.exec()
return obj, result
def add_org_function(obj:QMainWindow) -> QMainWindow:
result = None
# setup file dialog to find yaml flie
# home_dir = str(Path(obj.ctx["directory_path"]))
# fname = Path(QFileDialog.getOpenFileName(obj, 'Open file', home_dir, filter = "yml(*.yml)")[0])
fname = select_open_file(obj, extension="yml")
assert fname.exists()
# read yaml file
@@ -367,12 +349,6 @@ def add_org_function(obj:QMainWindow) -> QMainWindow:
return obj, result
# send to kit creator function
result = create_org_from_yaml(ctx=obj.ctx, org=org)
# match result['code']:
# case 0:
# msg = AlertPop(message=result['message'], status='information')
# case 1:
# msg = AlertPop(message=result['message'], status='critical')
# msg.exec()
return obj, result
def controls_getter_function(obj:QMainWindow) -> QMainWindow:
@@ -391,7 +367,7 @@ def controls_getter_function(obj:QMainWindow) -> QMainWindow:
obj.table_widget.datepicker.start_date.setDate(threemonthsago)
obj._controls_getter()
return obj, result
# convert to python useable date object
# convert to python useable date object
obj.start_date = obj.table_widget.datepicker.start_date.date().toPyDate()
obj.end_date = obj.table_widget.datepicker.end_date.date().toPyDate()
obj.con_type = obj.table_widget.control_typer.currentText()
@@ -412,8 +388,18 @@ def controls_getter_function(obj:QMainWindow) -> QMainWindow:
return obj, result
def chart_maker_function(obj:QMainWindow) -> QMainWindow:
"""
create html chart for controls reporting
Args:
obj (QMainWindow): original MainWindow
Returns:
QMainWindow: MainWindow with control display updates
"""
result = None
logger.debug(f"Control getter context: \n\tControl type: {obj.con_type}\n\tMode: {obj.mode}\n\tStart Date: {obj.start_date}\n\tEnd Date: {obj.end_date}")
# set the subtype for kraken
if obj.table_widget.sub_typer.currentText() == "":
obj.subtype = None
else:
@@ -425,7 +411,7 @@ def chart_maker_function(obj:QMainWindow) -> QMainWindow:
if controls == None:
fig = None
else:
# change each control to list of dicts
# change each control to list of dictionaries
data = [control.convert_by_mode(mode=obj.mode) for control in controls]
# flatten data to one dimensional list
data = [item for sublist in data for item in sublist]
@@ -646,7 +632,8 @@ def import_pcr_results_function(obj:QMainWindow) -> QMainWindow:
logger.debug(f"Existing {type(sub.pcr_info)}: {sub.pcr_info}")
logger.debug(f"Inserting {type(json.dumps(parser.pcr))}: {json.dumps(parser.pcr)}")
obj.ctx["database_session"].commit()
logger.debug(f"Got {len(parser.samples)} to update!")
logger.debug(f"Got {len(parser.samples)} samples to update!")
logger.debug(f"Parser samples: {parser.samples}")
for sample in parser.samples:
logger.debug(f"Running update on: {sample['sample']}")
sample['plate_rsl'] = sub.rsl_plate_num
@@ -655,11 +642,3 @@ def import_pcr_results_function(obj:QMainWindow) -> QMainWindow:
return obj, result
# dlg.exec()

View File

@@ -2,4 +2,5 @@
Contains all operations for creating charts, graphs and visual effects.
'''
from .control_charts import *
from .barcode import *
from .barcode import *
from .plate_map import *

View File

@@ -0,0 +1,80 @@
from pathlib import Path
import sys
from PIL import Image, ImageDraw, ImageFont
import numpy as np
from tools import check_if_app
import logging
logger = logging.getLogger(f"submissions.{__name__}")
def make_plate_map(sample_list:list) -> Image:
"""
Makes a pillow image of a plate from hitpicks
Args:
sample_list (list): list of positive sample dictionaries from the hitpicks
Returns:
Image: Image of the 96 well plate with positive samples in red.
"""
# If we can't get a plate number, do nothing
try:
plate_num = sample_list[0]['plate_name']
except IndexError as e:
logger.error(f"Couldn't get a plate number. Will not make plate.")
return None
except TypeError as e:
logger.error(f"No samples for this plate. Nothing to do.")
return None
# Make a 8 row, 12 column, 3 color ints array, filled with white by default
grid = np.full((8,12,3),255, dtype=np.uint8)
# Go through samples and change its row/column to red
for sample in sample_list:
grid[int(sample['row'])-1][int(sample['column'])-1] = [255,0,0]
# Create image from the grid
img = Image.fromarray(grid).resize((1200, 800), resample=Image.NEAREST)
# create a drawer over the image
draw = ImageDraw.Draw(img)
# draw grid over the image
y_start = 0
y_end = img.height
step_size = int(img.width / 12)
for x in range(0, img.width, step_size):
line = ((x, y_start), (x, y_end))
draw.line(line, fill=128)
x_start = 0
x_end = img.width
step_size = int(img.height / 8)
for y in range(0, img.height, step_size):
line = ((x_start, y), (x_end, y))
draw.line(line, fill=128)
del draw
old_size = img.size
new_size = (1300, 900)
# create a new, larger white image to hold the annotations
new_img = Image.new("RGB", new_size, "White")
box = tuple((n - o) // 2 for n, o in zip(new_size, old_size))
# paste plate map into the new image
new_img.paste(img, box)
# create drawer over the new image
draw = ImageDraw.Draw(new_img)
# font = ImageFont.truetype("sans-serif.ttf", 16)
if check_if_app():
font_path = Path(sys._MEIPASS).joinpath("files", "resources")
else:
font_path = Path(__file__).parents[2].joinpath('resources').absolute()
logger.debug(f"Font path: {font_path}")
font = ImageFont.truetype(font_path.joinpath('arial.ttf').__str__(), 32)
row_dict = ["A", "B", "C", "D", "E", "F", "G", "H"]
# write the plate number on the image
draw.text((100, 850),plate_num,(0,0,0),font=font)
# write column numbers
for num in range(1,13):
x = (num * 100) - 10
draw.text((x, 0), str(num), (0,0,0),font=font)
# write row letters
for num in range(1,9):
letter = row_dict[num-1]
y = (num * 100) - 10
draw.text((10, y), letter, (0,0,0),font=font)
return new_img

Binary file not shown.

View File

@@ -3,7 +3,7 @@
<head>
<title>Submission Details for {{ sub['Plate Number'] }}</title>
</head>
{% set excluded = ['reagents', 'samples', 'controls', 'ext_info', 'pcr_info', 'comments', 'barcode'] %}
{% set excluded = ['reagents', 'samples', 'controls', 'ext_info', 'pcr_info', 'comments', 'barcode', 'platemap'] %}
<body>
<h2><u>Submission Details for {{ sub['Plate Number'] }}</u></h2>&nbsp;&nbsp;&nbsp;<img align='right' height="30px" width="120px" src="data:image/jpeg;base64,{{ sub['barcode'] | safe }}">
<p>{% for key, value in sub.items() if key not in excluded %}
@@ -93,5 +93,9 @@
{% endif %}
{% endfor %}</p>
{% endif %}
{% if sub['platemap'] %}
<h3><u>>Plate map:</u></h3>
<img height="300px" width="650px" src="data:image/jpeg;base64,{{ sub['platemap'] | safe }}">
{% endif %}
</body>
</html>

View File

@@ -92,14 +92,12 @@ def check_kit_integrity(sub:BasicSubmission|KitType, reagenttypes:list|None=None
check = set(ext_kit_rtypes) == set(reagenttypes)
logger.debug(f"Checking if reagents match kit contents: {check}")
# what reagent types are in both lists?
# common = list(set(ext_kit_rtypes).intersection(reagenttypes))
missing = list(set(ext_kit_rtypes).difference(reagenttypes))
logger.debug(f"Missing reagents types: {missing}")
# if lists are equal return no problem
if len(missing)==0:
result = None
else:
# missing = [x for x in ext_kit_rtypes if x not in common]
result = {'message' : f"The submission you are importing is missing some reagents expected by the kit.\n\nIt looks like you are missing: {[item.upper() for item in missing]}\n\nAlternatively, you may have set the wrong extraction kit.\n\nThe program will populate lists using existing reagents.\n\nPlease make sure you check the lots carefully!", 'missing': missing}
return result
@@ -168,7 +166,6 @@ class RSLNamer(object):
Object that will enforce proper formatting on RSL plate names.
"""
def __init__(self, instr:str):
# self.parsed_name, self.submission_type = self.retrieve_rsl_number(instr)
self.retrieve_rsl_number(in_str=instr)
if self.submission_type != None:
parser = getattr(self, f"enforce_{self.submission_type}")
@@ -195,12 +192,13 @@ class RSLNamer(object):
return
logger.debug(f"Attempting match of {in_str}")
regex = re.compile(r"""
(?P<wastewater>RSL(?:-|_)?WW(?:-|_)?20\d{2}-?\d{2}-?\d{2}(?:(?:_|-)R?\d(?!\d))?)|
(?P<wastewater>RSL(?:-|_)?WW(?:-|_)?20\d{2}-?\d{2}-?\d{2}(?:(?:_|-)\d?(?!\d)R?\d(?!\d))?)|
(?P<bacterial_culture>RSL-?\d{2}-?\d{4})
""", flags = re.IGNORECASE | re.VERBOSE)
m = regex.search(in_str)
try:
self.parsed_name = m.group().upper()
logger.debug(f"Got parsed submission name: {self.parsed_name}")
self.submission_type = m.lastgroup
except AttributeError as e:
logger.critical("No RSL plate number found or submission type found!")
@@ -210,11 +208,8 @@ class RSLNamer(object):
"""
Uses regex to enforce proper formatting of wastewater samples
"""
# self.parsed_name = re.sub(r"(\d)-(\d)", "\1\2", self.parsed_name)
# year = str(date.today().year)[:2]
self.parsed_name = re.sub(r"PCR(-|_)", "", self.parsed_name)
self.parsed_name = self.parsed_name.replace("RSLWW", "RSL-WW")
# .replace(f"WW{year}", f"WW-{year}")
self.parsed_name = re.sub(r"WW(\d{4})", r"WW-\1", self.parsed_name, flags=re.IGNORECASE)
self.parsed_name = re.sub(r"(\d{4})-(\d{2})-(\d{2})", r"\1\2\3", self.parsed_name)
@@ -222,14 +217,6 @@ class RSLNamer(object):
"""
Uses regex to enforce proper formatting of bacterial culture samples
"""
# year = str(date.today().year)[2:]
# self.parsed_name = self.parsed_name.replace(f"RSL{year}", f"RSL-{year}")
# reg_year = re.compile(fr"{year}(?P<rsl>\d\d\d\d)")
self.parsed_name = re.sub(r"RSL(\d{2})", r"RSL-\1", self.parsed_name, flags=re.IGNORECASE)
self.parsed_name = re.sub(r"RSL-(\d{2})(\d{4})", r"RSL-\1-\2", self.parsed_name, flags=re.IGNORECASE)
# year = regex.group('year')
# rsl = regex.group('rsl')
# self.parsed_name = re.sub(fr"{year}(\d\d\d\d)", fr"{year}-\1", self.parsed_name)
# plate_search = reg_year.search(self.parsed_name)
# if plate_search != None:
# self.parsed_name = re.sub(reg_year, f"{year}-{plate_search.group('rsl')}", self.parsed_name)