diff --git a/CHANGELOG.md b/CHANGELOG.md index cee6c98..bed9412 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,13 @@ +## 202305.05 + +- Hitpicking now creates source plate map image. +- Hitpick plate map is now included in exported plate results. + +## 202305.04 + +- Added in hitpicking for plates with PCR results +- Fixed error when expiry date stored as int in excel sheet. + ## 202305.03 - Added a detailed tab to the cost report. diff --git a/README.md b/README.md index 2717a48..62b7c15 100644 --- a/README.md +++ b/README.md @@ -62,3 +62,8 @@ This is meant to import .xslx files created from the Design & Analysis Software ## Linking PCR Logs: 1. Click "Monthly" -> "Link PCR Logs". 2. Chose the .csv file taken from the PCR table runlogs folder. + +## Hitpicking: +1. Select all submissions you wish to hitpick using "Ctrl + click". All must have PCR results. +2. Right click on the last sample and select "Hitpick" from the contex menu. +3. Select location to save csv file. diff --git a/TODO.md b/TODO.md index 263355c..07caafa 100644 --- a/TODO.md +++ b/TODO.md @@ -1,3 +1,4 @@ +- [ ] Create a method for creation of hitpicking .csvs because of reasons that may or may not exist. - [x] Create a method for commenting submissions. - [x] Create barcode generator, because of reasons that may or may not exist. - [x] Move bulk of functions from frontend.__init__ to frontend.functions as __init__ is getting bloated. \ No newline at end of file diff --git a/alembic/versions/64fec6271a50_added_elution_well_to_ww_sample.py b/alembic/versions/64fec6271a50_added_elution_well_to_ww_sample.py new file mode 100644 index 0000000..7c94820 --- /dev/null +++ b/alembic/versions/64fec6271a50_added_elution_well_to_ww_sample.py @@ -0,0 +1,31 @@ +"""added elution well to ww_sample + +Revision ID: 64fec6271a50 +Revises: a31943b2284c +Create Date: 2023-05-24 14:43:25.477637 + +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import sqlite + +# revision identifiers, used by Alembic. +revision = '64fec6271a50' +down_revision = 'a31943b2284c' +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('_ww_samples', schema=None) as batch_op: + batch_op.add_column(sa.Column('elution_well', sa.String(length=8), nullable=True)) + + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('_ww_samples', schema=None) as batch_op: + batch_op.drop_column('elution_well') + # ### end Alembic commands ### diff --git a/src/submissions/__init__.py b/src/submissions/__init__.py index ab29be3..e3a8397 100644 --- a/src/submissions/__init__.py +++ b/src/submissions/__init__.py @@ -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. \ No newline at end of file diff --git a/src/submissions/backend/db/functions.py b/src/submissions/backend/db/functions.py index 01c785b..b8fcf2c 100644 --- a/src/submissions/backend/db/functions.py +++ b/src/submissions/backend/db/functions.py @@ -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() \ No newline at end of file + )).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 \ No newline at end of file diff --git a/src/submissions/backend/db/models/samples.py b/src/submissions/backend/db/models/samples.py index 2620290..ccf7f68 100644 --- a/src/submissions/backend/db/models/samples.py +++ b/src/submissions/backend/db/models/samples.py @@ -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): diff --git a/src/submissions/backend/excel/parser.py b/src/submissions/backend/excel/parser.py index 69d0aa5..3fe35e9 100644 --- a/src/submissions/backend/excel/parser.py +++ b/src/submissions/backend/excel/parser.py @@ -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}") diff --git a/src/submissions/backend/excel/reports.py b/src/submissions/backend/excel/reports.py index 2e8c429..32a30c4 100644 --- a/src/submissions/backend/excel/reports.py +++ b/src/submissions/backend/excel/reports.py @@ -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) \ No newline at end of file diff --git a/src/submissions/frontend/__init__.py b/src/submissions/frontend/__init__.py index d5dd5b4..dcfbe45 100644 --- a/src/submissions/frontend/__init__.py +++ b/src/submissions/frontend/__init__.py @@ -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$) | - # (?P^submitted_date$) | - # (?P)^submitting_lab$ | - # (?P)^samples$ | - # (?P^lot_.*$) | - # (?P^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): diff --git a/src/submissions/frontend/custom_widgets/sub_details.py b/src/submissions/frontend/custom_widgets/sub_details.py index 9240d2b..aaf8de4 100644 --- a/src/submissions/frontend/custom_widgets/sub_details.py +++ b/src/submissions/frontend/custom_widgets/sub_details.py @@ -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()) diff --git a/src/submissions/frontend/main_window_functions.py b/src/submissions/frontend/main_window_functions.py index d980616..ee43724 100644 --- a/src/submissions/frontend/main_window_functions.py +++ b/src/submissions/frontend/main_window_functions.py @@ -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$) | (?P^submitted_date$) | (?P)^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() - - - - - - - - diff --git a/src/submissions/frontend/visualizations/__init__.py b/src/submissions/frontend/visualizations/__init__.py index da0b5f0..2fa959a 100644 --- a/src/submissions/frontend/visualizations/__init__.py +++ b/src/submissions/frontend/visualizations/__init__.py @@ -2,4 +2,5 @@ Contains all operations for creating charts, graphs and visual effects. ''' from .control_charts import * -from .barcode import * \ No newline at end of file +from .barcode import * +from .plate_map import * \ No newline at end of file diff --git a/src/submissions/frontend/visualizations/plate_map.py b/src/submissions/frontend/visualizations/plate_map.py new file mode 100644 index 0000000..20e0af6 --- /dev/null +++ b/src/submissions/frontend/visualizations/plate_map.py @@ -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 \ No newline at end of file diff --git a/src/submissions/resources/arial.ttf b/src/submissions/resources/arial.ttf new file mode 100644 index 0000000..886789b Binary files /dev/null and b/src/submissions/resources/arial.ttf differ diff --git a/src/submissions/templates/submission_details.html b/src/submissions/templates/submission_details.html index 95f386c..99e6f6a 100644 --- a/src/submissions/templates/submission_details.html +++ b/src/submissions/templates/submission_details.html @@ -3,7 +3,7 @@ Submission Details for {{ sub['Plate Number'] }} - {% set excluded = ['reagents', 'samples', 'controls', 'ext_info', 'pcr_info', 'comments', 'barcode'] %} + {% set excluded = ['reagents', 'samples', 'controls', 'ext_info', 'pcr_info', 'comments', 'barcode', 'platemap'] %}

Submission Details for {{ sub['Plate Number'] }}

   

{% for key, value in sub.items() if key not in excluded %} @@ -93,5 +93,9 @@ {% endif %} {% endfor %}

{% endif %} + {% if sub['platemap'] %} +

>Plate map:

+ + {% endif %} \ No newline at end of file diff --git a/src/submissions/tools/__init__.py b/src/submissions/tools/__init__.py index c6ada80..422eab5 100644 --- a/src/submissions/tools/__init__.py +++ b/src/submissions/tools/__init__.py @@ -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""" - (?PRSL(?:-|_)?WW(?:-|_)?20\d{2}-?\d{2}-?\d{2}(?:(?:_|-)R?\d(?!\d))?)| + (?PRSL(?:-|_)?WW(?:-|_)?20\d{2}-?\d{2}-?\d{2}(?:(?:_|-)\d?(?!\d)R?\d(?!\d))?)| (?PRSL-?\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\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) \ No newline at end of file + \ No newline at end of file