Addition of Equipment and SubmissionType creation.

This commit is contained in:
Landon Wark
2023-12-20 12:54:37 -06:00
parent 0dd51827a0
commit 0d64095e42
22 changed files with 755 additions and 123 deletions

View File

@@ -1,5 +1,12 @@
## 202312.03
- Enabled creation of new submission types in gui.
- Enabled Equipment addition.
## 202312.02
- Bug fixes for switching kits
## 202312.01
- Control samples info now available in plate map.

0
None
View File

View File

@@ -1,9 +1,14 @@
- [ ] Update Artic and add in equipment listings... *sigh*.
- [x] Fix WastewaterAssociations not in Session error.
- Done... I think?
- [x] Fix submitted date always being today.
- this is an issue with the way client is filling in form with =TODAY()
- [x] SubmissionReagentAssociation.query
- [x] Move as much from db.functions to objects as possible.
- [x] Clean up DB objects after failed test fix.
- [x] Fix tests.
- [x] Fix pydant.PydSample.handle_duplicate_samples?
- [ ] See if the number of queries in BasicSubmission functions (and others) can be trimmed down.
- [x] See if the number of queries in BasicSubmission functions (and others) can be trimmed down.
- [x] Document code
- [x] Create a result object to facilitate returning function results.
- [x] Refactor main_window_functions into as many objects (forms, etc.) as possible to clean it up.

View File

@@ -0,0 +1,52 @@
"""Adding in Equipment
Revision ID: 36a47d8837ca
Revises: 238c3c3e5863
Create Date: 2023-12-12 09:16:09.559753
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '36a47d8837ca'
down_revision = '238c3c3e5863'
branch_labels = None
depends_on = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('_equipment',
sa.Column('id', sa.INTEGER(), nullable=False),
sa.Column('name', sa.String(length=64), nullable=True),
sa.Column('nickname', sa.String(length=64), nullable=True),
sa.Column('asset_number', sa.String(length=16), nullable=True),
sa.PrimaryKeyConstraint('id')
)
op.create_table('_submissiontype_equipment',
sa.Column('equipment_id', sa.INTEGER(), nullable=False),
sa.Column('submission_id', sa.INTEGER(), nullable=False),
sa.Column('uses', sa.JSON(), nullable=True),
sa.ForeignKeyConstraint(['equipment_id'], ['_equipment.id'], ),
sa.ForeignKeyConstraint(['submission_id'], ['_submission_types.id'], ),
sa.PrimaryKeyConstraint('equipment_id', 'submission_id')
)
op.create_table('_equipment_submissions',
sa.Column('equipment_id', sa.INTEGER(), nullable=False),
sa.Column('submission_id', sa.INTEGER(), nullable=False),
sa.Column('comments', sa.String(length=1024), nullable=True),
sa.ForeignKeyConstraint(['equipment_id'], ['_equipment.id'], ),
sa.ForeignKeyConstraint(['submission_id'], ['_submissions.id'], ),
sa.PrimaryKeyConstraint('equipment_id', 'submission_id')
)
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table('_equipment_submissions')
op.drop_table('_submissiontype_equipment')
op.drop_table('_equipment')
# ### end Alembic commands ###

View File

@@ -0,0 +1,34 @@
"""Adding times to equipSubAssoc
Revision ID: 3e94fecbbe91
Revises: cd5c225b5d2a
Create Date: 2023-12-15 09:38:33.931976
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '3e94fecbbe91'
down_revision = 'cd5c225b5d2a'
branch_labels = None
depends_on = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('_equipment_submissions', schema=None) as batch_op:
batch_op.add_column(sa.Column('start_time', sa.TIMESTAMP(), nullable=True))
batch_op.add_column(sa.Column('end_time', sa.TIMESTAMP(), nullable=True))
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('_equipment_submissions', schema=None) as batch_op:
batch_op.drop_column('end_time')
batch_op.drop_column('start_time')
# ### end Alembic commands ###

View File

@@ -0,0 +1,32 @@
"""Adding equipment clustering
Revision ID: 761baf9d7842
Revises: 3e94fecbbe91
Create Date: 2023-12-18 14:31:21.533319
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '761baf9d7842'
down_revision = '3e94fecbbe91'
branch_labels = None
depends_on = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('_equipment', schema=None) as batch_op:
batch_op.add_column(sa.Column('cluster_name', sa.String(length=16), nullable=True))
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('_equipment', schema=None) as batch_op:
batch_op.drop_column('cluster_name')
# ### end Alembic commands ###

View File

@@ -0,0 +1,32 @@
"""Adding static option to equipSTASsoc
Revision ID: cd11db3794ed
Revises: 36a47d8837ca
Create Date: 2023-12-12 14:47:20.924443
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'cd11db3794ed'
down_revision = '36a47d8837ca'
branch_labels = None
depends_on = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('_submissiontype_equipment', schema=None) as batch_op:
batch_op.add_column(sa.Column('static', sa.INTEGER(), nullable=True))
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('_submissiontype_equipment', schema=None) as batch_op:
batch_op.drop_column('static')
# ### end Alembic commands ###

View File

@@ -0,0 +1,32 @@
"""Adding process to equipSubAssoc
Revision ID: cd5c225b5d2a
Revises: cd11db3794ed
Create Date: 2023-12-15 09:13:23.492512
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'cd5c225b5d2a'
down_revision = 'cd11db3794ed'
branch_labels = None
depends_on = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('_equipment_submissions', schema=None) as batch_op:
batch_op.add_column(sa.Column('process', sa.String(length=64), nullable=True))
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('_equipment_submissions', schema=None) as batch_op:
batch_op.drop_column('process')
# ### end Alembic commands ###

View File

@@ -4,7 +4,7 @@ from pathlib import Path
# Version of the realpython-reader package
__project__ = "submissions"
__version__ = "202312.2b"
__version__ = "202312.3b"
__author__ = {"name":"Landon Wark", "email":"Landon.Wark@phac-aspc.gc.ca"}
__copyright__ = "2022-2023, Government of Canada"

View File

@@ -10,6 +10,7 @@ import logging
from tools import check_authorization, setup_lookup, query_return, Report, Result, Settings
from typing import List
from pandas import ExcelFile
from pathlib import Path
from . import Base, BaseClass, Organization
logger = logging.getLogger(f'submissions.{__name__}')
@@ -55,7 +56,7 @@ class KitType(BaseClass):
def __repr__(self) -> str:
return f"<KitType({self.name})>"
def get_reagents(self, required:bool=False, submission_type:str|SubmissionType|None=None) -> list:
def get_reagents(self, required:bool=False, submission_type:str|SubmissionType|None=None) -> List[ReagentType]:
"""
Return ReagentTypes linked to kit through KitTypeReagentTypeAssociation.
@@ -243,6 +244,10 @@ class ReagentType(BaseClass):
pass
return query_return(query=query, limit=limit)
def to_pydantic(self):
from backend.validators.pydant import PydReagent
return PydReagent(lot=None, type=self.name, name=self.name, expiry=date.today())
class KitTypeReagentTypeAssociation(BaseClass):
"""
table containing reagenttype/kittype associations
@@ -583,6 +588,14 @@ class SubmissionType(BaseClass):
kit_types = association_proxy("submissiontype_kit_associations", "kit_type") #: Proxy of kittype association
submissiontype_equipment_associations = relationship(
"SubmissionTypeEquipmentAssociation",
back_populates="submission_type",
cascade="all, delete-orphan"
)
equipment = association_proxy("submissiontype_equipment_associations", "equipment")
def __repr__(self) -> str:
return f"<SubmissionType({self.name})>"
@@ -595,6 +608,35 @@ class SubmissionType(BaseClass):
"""
return ExcelFile(self.template_file).sheet_names
def set_template_file(self, filepath:Path|str):
if isinstance(filepath, str):
filepath = Path(filepath)
with open (filepath, "rb") as f:
data = f.read()
self.template_file = data
self.save()
def get_equipment(self) -> list:
from backend.validators.pydant import PydEquipmentPool
# if static:
# return [item.equipment.to_pydantic() for item in self.submissiontype_equipment_associations if item.static==1]
# else:
preliminary1 = [item.equipment.to_pydantic(static=item.static) for item in self.submissiontype_equipment_associations]# if item.static==0]
preliminary2 = [item.equipment.to_pydantic(static=item.static) for item in self.submissiontype_equipment_associations]# if item.static==0]
output = []
pools = list(set([item.pool_name for item in preliminary1 if item.pool_name != None]))
for pool in pools:
c_ = []
for item in preliminary1:
if item.pool_name == pool:
c_.append(item)
preliminary2.remove(item)
if len(c_) > 0:
output.append(PydEquipmentPool(name=pool, equipment=c_))
for item in preliminary2:
output.append(item)
return output
@classmethod
@setup_lookup
def query(cls,
@@ -772,4 +814,145 @@ class SubmissionReagentAssociation(BaseClass):
# limit = query.count()
return query_return(query=query, limit=limit)
def to_sub_dict(self, extraction_kit):
output = self.reagent.to_sub_dict(extraction_kit)
output['comments'] = self.comments
return output
class Equipment(BaseClass):
# Currently abstract until ready to implement
# __abstract__ = True
__tablename__ = "_equipment"
id = Column(INTEGER, primary_key=True)
name = Column(String(64))
nickname = Column(String(64))
asset_number = Column(String(16))
pool_name = Column(String(16))
equipment_submission_associations = relationship(
"SubmissionEquipmentAssociation",
back_populates="equipment",
cascade="all, delete-orphan",
)
submissions = association_proxy("equipment_submission_associations", "submission")
equipment_submissiontype_associations = relationship(
"SubmissionTypeEquipmentAssociation",
back_populates="equipment",
cascade="all, delete-orphan",
)
submission_types = association_proxy("equipment_submission_associations", "submission_type")
def __repr__(self):
return f"<Equipment({self.name})>"
@classmethod
@setup_lookup
def query(cls,
name:str|None=None,
nickname:str|None=None,
asset_number:str|None=None,
limit:int=0
) -> Equipment|List[Equipment]:
query = cls.__database_session__.query(cls)
match name:
case str():
query = query.filter(cls.name==name)
limit = 1
case _:
pass
match nickname:
case str():
query = query.filter(cls.nickname==nickname)
limit = 1
case _:
pass
match asset_number:
case str():
query = query.filter(cls.asset_number==asset_number)
limit = 1
case _:
pass
return query_return(query=query, limit=limit)
def to_pydantic(self, static):
from backend.validators.pydant import PydEquipment
return PydEquipment(static=static, **self.__dict__)
def save(self):
self.__database_session__.add(self)
self.__database_session__.commit()
class SubmissionEquipmentAssociation(BaseClass):
# Currently abstract until ready to implement
# __abstract__ = True
__tablename__ = "_equipment_submissions"
equipment_id = Column(INTEGER, ForeignKey("_equipment.id"), primary_key=True) #: id of associated equipment
submission_id = Column(INTEGER, ForeignKey("_submissions.id"), primary_key=True) #: id of associated submission
process = Column(String(64)) #: name of the process run on this equipment
start_time = Column(TIMESTAMP)
end_time = Column(TIMESTAMP)
comments = Column(String(1024))
submission = relationship("BasicSubmission", back_populates="submission_equipment_associations") #: associated submission
equipment = relationship(Equipment, back_populates="equipment_submission_associations") #: associated submission
def __init__(self, submission, equipment):
self.submission = submission
self.equipment = equipment
def to_sub_dict(self) -> dict:
output = dict(name=self.equipment.name, asset_number=self.equipment.asset_number, comment=self.comments)
return output
def save(self):
self.__database_session__.add(self)
self.__database_session__.commit()
class SubmissionTypeEquipmentAssociation(BaseClass):
# __abstract__ = True
__tablename__ = "_submissiontype_equipment"
equipment_id = Column(INTEGER, ForeignKey("_equipment.id"), primary_key=True) #: id of associated equipment
submissiontype_id = Column(INTEGER, ForeignKey("_submission_types.id"), primary_key=True) #: id of associated submission
uses = Column(JSON) #: locations of equipment on the submission type excel sheet.
static = Column(INTEGER, default=1) #: if 1 this piece of equipment will always be used, otherwise it will need to be selected from list?
submission_type = relationship(SubmissionType, back_populates="submissiontype_equipment_associations") #: associated submission
equipment = relationship(Equipment, back_populates="equipment_submissiontype_associations") #: associated equipment
@validates('static')
def validate_age(self, key, value):
"""
Ensures only 1 & 0 used in 'static'
Args:
key (str): name of attribute
value (_type_): value of attribute
Raises:
ValueError: Raised if bad value given
Returns:
_type_: value
"""
if not 0 <= value < 2:
raise ValueError(f'Invalid required value {value}. Must be 0 or 1.')
return value
def save(self):
self.__database_session__.add(self)
self.__database_session__.commit()

View File

@@ -5,7 +5,7 @@ from __future__ import annotations
from getpass import getuser
import math, json, logging, uuid, tempfile, re, yaml
from pprint import pformat
from . import Reagent, SubmissionType, KitType, Organization
from . import Reagent, SubmissionType, KitType, Organization, Equipment, SubmissionEquipmentAssociation
from sqlalchemy import Column, String, TIMESTAMP, INTEGER, ForeignKey, JSON, FLOAT, case
from sqlalchemy.orm import relationship, validates, Query
from json.decoder import JSONDecodeError
@@ -69,6 +69,13 @@ class BasicSubmission(BaseClass):
# to "keyword" attribute
reagents = association_proxy("submission_reagent_associations", "reagent") #: Association proxy to SubmissionSampleAssociation.samples
submission_equipment_associations = relationship(
"SubmissionEquipmentAssociation",
back_populates="submission",
cascade="all, delete-orphan"
)
equipment = association_proxy("submission_equipment_associations", "equipment")
# Allows for subclassing into ex. BacterialCulture, Wastewater, etc.
__mapper_args__ = {
"polymorphic_identity": "Basic Submission",
@@ -124,7 +131,7 @@ class BasicSubmission(BaseClass):
# Updated 2023-09 to use the extraction kit to pull reagents.
if full_data:
try:
reagents = [item.to_sub_dict(extraction_kit=self.extraction_kit) for item in self.reagents]
reagents = [item.to_sub_dict(extraction_kit=self.extraction_kit) for item in self.submission_reagent_associations]
except Exception as e:
logger.error(f"We got an error retrieving reagents: {e}")
reagents = None
@@ -138,6 +145,13 @@ class BasicSubmission(BaseClass):
except Exception as e:
logger.error(f"Error setting comment: {self.comment}")
comments = None
try:
equipment = [item.to_sub_dict() for item in self.submission_equipment_associations]
if len(equipment) == 0:
equipment = None
except Exception as e:
logger.error(f"Error setting equipment: {self.equipment}")
equipment = None
output = {
"id": self.id,
"Plate Number": self.rsl_plate_num,
@@ -153,7 +167,8 @@ class BasicSubmission(BaseClass):
"reagents": reagents,
"samples": samples,
"extraction_info": ext_info,
"comment": comments
"comment": comments,
"equipment": equipment
}
return output
@@ -447,7 +462,7 @@ class BasicSubmission(BaseClass):
logger.debug(f"Got {len(subs)} submissions.")
df = pd.DataFrame.from_records(subs)
# Exclude sub information
for item in ['controls', 'extraction_info', 'pcr_info', 'comment', 'comments', 'samples', 'reagents']:
for item in ['controls', 'extraction_info', 'pcr_info', 'comment', 'comments', 'samples', 'reagents', 'equipment']:
try:
df = df.drop(item, axis=1)
except:
@@ -520,7 +535,7 @@ class BasicSubmission(BaseClass):
_type_: _description_
"""
# assoc = SubmissionSampleAssociation.query(submission=self, sample=sample, limit=1)
assoc = [item.sample for item in self.submission_sample_associations if item.sample==sample][0]
assoc = [item for item in self.submission_sample_associations if item.sample==sample][0]
for k,v in input_dict.items():
try:
setattr(assoc, k, v)
@@ -751,6 +766,7 @@ class BasicSubmission(BaseClass):
msg = "This submission already exists.\nWould you like to overwrite?"
return instance, code, msg
# Below are the custom submission types
class BacterialCulture(BasicSubmission):
@@ -877,6 +893,12 @@ class BacterialCulture(BasicSubmission):
template += "_{{ submitting_lab }}_{{ submitter_plate_num }}"
return template
@classmethod
def parse_info(cls, input_dict: dict, xl: pd.ExcelFile | None = None) -> dict:
input_dict = super().parse_info(input_dict, xl)
input_dict['submitted_date']['missing'] = True
return input_dict
class Wastewater(BasicSubmission):
"""
derivative submission type from BasicSubmission
@@ -1009,7 +1031,8 @@ class Wastewater(BasicSubmission):
Returns:
str: String for regex construction
"""
return "(?P<Wastewater>RSL(?:-|_)?WW(?:-|_)?20\d{2}-?\d{2}-?\d{2}(?:(_|-)?\d?([^_0123456789]|$)R?\d?)?)"
# return "(?P<Wastewater>RSL(?:-|_)?WW(?:-|_)?20\d{2}-?\d{2}-?\d{2}(?:(_|-)?\d?([^_0123456789\s]|$)R?\d?)?)"
return "(?P<Wastewater>RSL(?:-|_)?WW(?:-|_)?20\d{2}-?\d{2}-?\d{2}(?:(_|-)?\d?([^_0123456789\s]|$)?R?\d?)?)"
class WastewaterArtic(BasicSubmission):
"""
@@ -1416,7 +1439,9 @@ class BasicSample(BaseClass):
return instance
def save(self):
raise AttributeError(f"Save not implemented for {self.__class__}")
# raise AttributeError(f"Save not implemented for {self.__class__}")
self.__database_session__.add(self)
self.__database_session__.commit()
def delete(self):
raise AttributeError(f"Delete not implemented for {self.__class__}")
@@ -1735,4 +1760,3 @@ class WastewaterAssociation(SubmissionSampleAssociation):
pcr_results = Column(JSON) #: imported PCR status from QuantStudio
__mapper_args__ = {"polymorphic_identity": "Wastewater Association", "polymorphic_load": "inline"}

View File

@@ -13,7 +13,7 @@ import logging, re
from collections import OrderedDict
from datetime import date
from dateutil.parser import parse, ParserError
from tools import check_not_nan, convert_nans_to_nones, Settings
from tools import check_not_nan, convert_nans_to_nones, Settings, is_missing
logger = logging.getLogger(f"submissions.{__name__}")
@@ -186,23 +186,15 @@ class InfoParser(object):
value = df.iat[relevant[item]['row']-1, relevant[item]['column']-1]
match item:
case "submission_type":
value, missing = is_missing(value)
value = value.title()
case _:
pass
value, missing = is_missing(value)
logger.debug(f"Setting {item} on {sheet} to {value}")
if check_not_nan(value):
if value != "None":
try:
dicto[item] = dict(value=value, missing=False)
except (KeyError, IndexError):
continue
else:
try:
dicto[item] = dict(value=value, missing=True)
except (KeyError, IndexError):
continue
else:
dicto[item] = dict(value=convert_nans_to_nones(value), missing=True)
try:
dicto[item] = dict(value=value, missing=missing)
except (KeyError, IndexError):
continue
return self.custom_parser(input_dict=dicto, xl=self.xl)
class ReagentParser(object):
@@ -293,7 +285,9 @@ class SampleParser(object):
self.xl = xl
self.submission_type = submission_type
sample_info_map = self.fetch_sample_info_map(submission_type=submission_type)
logger.debug(f"sample_info_map: {sample_info_map}")
self.plate_map = self.construct_plate_map(plate_map_location=sample_info_map['plate_map'])
logger.debug(f"plate_map: {self.plate_map}")
self.lookup_table = self.construct_lookup_table(lookup_table_location=sample_info_map['lookup_table'])
if "plates" in sample_info_map:
self.plates = sample_info_map['plates']
@@ -332,10 +326,12 @@ class SampleParser(object):
Returns:
pd.DataFrame: Plate map grid
"""
logger.debug(f"Plate map location: {plate_map_location}")
df = self.xl.parse(plate_map_location['sheet'], header=None, dtype=object)
df = df.iloc[plate_map_location['start_row']-1:plate_map_location['end_row'], plate_map_location['start_column']-1:plate_map_location['end_column']]
df = pd.DataFrame(df.values[1:], columns=df.iloc[0])
df = df.set_index(df.columns[0])
logger.debug(f"Vanilla platemap: {df}")
# custom_mapper = get_polymorphic_subclass(models.BasicSubmission, self.submission_type)
custom_mapper = BasicSubmission.find_polymorphic_subclass(polymorphic_identity=self.submission_type)
df = custom_mapper.custom_platemap(self.xl, df)
@@ -440,6 +436,7 @@ class SampleParser(object):
"""
result = None
new_samples = []
logger.debug(f"Starting samples: {pformat(self.samples)}")
for ii, sample in enumerate(self.samples):
# try:
# if sample['submitter_id'] in [check_sample['sample'].submitter_id for check_sample in new_samples]:

View File

@@ -127,9 +127,10 @@ class PydReagent(BaseModel):
reagent.name = value
case "comment":
continue
assoc = SubmissionReagentAssociation(reagent=reagent, submission=submission)
assoc.comments = self.comment
reagent.reagent_submission_associations.append(assoc)
if submission != None:
assoc = SubmissionReagentAssociation(reagent=reagent, submission=submission)
assoc.comments = self.comment
reagent.reagent_submission_associations.append(assoc)
# add end-of-life extension from reagent type to expiry date
# NOTE: this will now be done only in the reporting phase to account for potential changes in end-of-life extensions
return reagent, report
@@ -199,7 +200,8 @@ class PydSample(BaseModel, extra='allow'):
row=row, column=column)
try:
instance.sample_submission_associations.append(association)
except IntegrityError:
except IntegrityError as e:
logger.error(f"Could not attach submission sample association due to: {e}")
instance.metadata.session.rollback()
return instance, report
@@ -420,13 +422,18 @@ class PydSubmission(BaseModel, extra='allow'):
if isinstance(value, dict):
value = value['value']
logger.debug(f"Setting {key} to {value}")
try:
instance.set_attribute(key=key, value=value)
except AttributeError as e:
logger.debug(f"Could not set attribute: {key} to {value} due to: \n\n {e}")
continue
except KeyError:
continue
match key:
case "samples":
for sample in self.samples:
sample, _ = sample.toSQL(submission=instance)
case _:
try:
instance.set_attribute(key=key, value=value)
except AttributeError as e:
logger.debug(f"Could not set attribute: {key} to {value} due to: \n\n {e}")
continue
except KeyError:
continue
try:
logger.debug(f"Calculating costs for procedure...")
instance.calculate_base_cost()
@@ -735,4 +742,35 @@ class PydKit(BaseModel):
[item.toSQL(instance) for item in self.reagent_types]
return instance, report
class PydEquipment(BaseModel, extra='ignore'):
name: str
nickname: str|None
asset_number: str
pool_name: str|None
static: bool|int
@field_validator("static")
@classmethod
def to_boolean(cls, value):
match value:
case int():
if value == 0:
return False
else:
return True
case _:
return value
def toForm(self, parent):
from frontend.widgets.equipment_usage import EquipmentCheckBox
return EquipmentCheckBox(parent=parent, equipment=self)
class PydEquipmentPool(BaseModel):
name: str
equipment: List[PydEquipment]
def toForm(self, parent):
from frontend.widgets.equipment_usage import PoolComboBox
return PoolComboBox(parent=parent, pool=self)

View File

@@ -18,6 +18,8 @@ from .submission_table import SubmissionsSheet
from .submission_widget import SubmissionFormContainer
from .controls_chart import ControlsViewer
from .kit_creator import KitAdder
from .submission_type_creator import SubbmissionTypeAdder
logger = logging.getLogger(f'submissions.{__name__}')
logger.info("Hello, I am a logger")
@@ -207,11 +209,13 @@ class AddSubForm(QWidget):
self.tab1 = QWidget()
self.tab2 = QWidget()
self.tab3 = QWidget()
self.tab4 = QWidget()
self.tabs.resize(300,200)
# Add tabs
self.tabs.addTab(self.tab1,"Submissions")
self.tabs.addTab(self.tab2,"Controls")
self.tabs.addTab(self.tab3, "Add Kit")
self.tabs.addTab(self.tab3, "Add SubmissionType")
self.tabs.addTab(self.tab4, "Add Kit")
# Create submission adder form
self.formwidget = SubmissionFormContainer(self)
self.formlayout = QVBoxLayout(self)
@@ -238,10 +242,14 @@ class AddSubForm(QWidget):
self.tab2.layout.addWidget(self.controls_viewer)
self.tab2.setLayout(self.tab2.layout)
# create custom widget to add new tabs
adder = KitAdder(self)
ST_adder = SubbmissionTypeAdder(self)
self.tab3.layout = QVBoxLayout(self)
self.tab3.layout.addWidget(adder)
self.tab3.layout.addWidget(ST_adder)
self.tab3.setLayout(self.tab3.layout)
kit_adder = KitAdder(self)
self.tab4.layout = QVBoxLayout(self)
self.tab4.layout.addWidget(kit_adder)
self.tab4.setLayout(self.tab4.layout)
# add tabs to main widget
self.layout.addWidget(self.tabs)
self.setLayout(self.layout)

View File

@@ -0,0 +1,89 @@
from PyQt6.QtCore import Qt
from PyQt6.QtWidgets import (QDialog, QComboBox, QCheckBox,
QLabel, QWidget, QHBoxLayout,
QVBoxLayout, QDialogButtonBox)
from backend.db.models import SubmissionType
from backend.validators.pydant import PydEquipment, PydEquipmentPool
class EquipmentUsage(QDialog):
def __init__(self, parent, submission_type:SubmissionType|str) -> QDialog:
super().__init__(parent)
self.setWindowTitle("Equipment Checklist")
if isinstance(submission_type, str):
submission_type = SubmissionType.query(name=submission_type)
# self.static_equipment = submission_type.get_equipment()
self.opt_equipment = submission_type.get_equipment()
self.layout = QVBoxLayout()
self.setLayout(self.layout)
self.populate_form()
def populate_form(self):
QBtn = QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel
self.buttonBox = QDialogButtonBox(QBtn)
self.buttonBox.accepted.connect(self.accept)
self.buttonBox.rejected.connect(self.reject)
for eq in self.opt_equipment:
self.layout.addWidget(eq.toForm(parent=self))
self.layout.addWidget(self.buttonBox)
def parse_form(self):
output = []
for widget in self.findChildren(QWidget):
match widget:
case (EquipmentCheckBox()|PoolComboBox()) :
output.append(widget.parse_form())
case _:
pass
return [item for item in output if item != None]
class EquipmentCheckBox(QWidget):
def __init__(self, parent, equipment:PydEquipment) -> None:
super().__init__(parent)
self.layout = QHBoxLayout()
self.label = QLabel()
self.label.setMaximumWidth(125)
self.label.setMinimumWidth(125)
self.check = QCheckBox()
if equipment.static:
self.check.setChecked(True)
# self.check.setEnabled(False)
if equipment.nickname != None:
text = f"{equipment.name} ({equipment.nickname})"
else:
text = equipment.name
self.setObjectName(equipment.name)
self.label.setText(text)
self.layout.addWidget(self.label)
self.layout.addWidget(self.check)
self.setLayout(self.layout)
def parse_form(self) -> str|None:
if self.check.isChecked():
return self.objectName()
else:
return None
class PoolComboBox(QWidget):
def __init__(self, parent, pool:PydEquipmentPool) -> None:
super().__init__(parent)
self.layout = QHBoxLayout()
# label = QLabel()
# label.setText(pool.name)
self.box = QComboBox()
self.box.setMaximumWidth(125)
self.box.setMinimumWidth(125)
self.box.addItems([item.name for item in pool.equipment])
self.check = QCheckBox()
# self.layout.addWidget(label)
self.layout.addWidget(self.box)
self.layout.addWidget(self.check)
self.setLayout(self.layout)
def parse_form(self) -> str:
if self.check.isChecked():
return self.box.currentText()
else:
return None

View File

@@ -82,7 +82,7 @@ class KitAdder(QWidget):
print(self.app)
# get bottommost row
maxrow = self.grid.rowCount()
reg_form = ReagentTypeForm()
reg_form = ReagentTypeForm(parent=self)
reg_form.setObjectName(f"ReagentForm_{maxrow}")
# self.grid.addWidget(reg_form, maxrow + 1,0,1,2)
self.grid.addWidget(reg_form, maxrow,0,1,4)
@@ -139,8 +139,8 @@ class ReagentTypeForm(QWidget):
"""
custom widget to add information about a new reagenttype
"""
def __init__(self) -> None:
super().__init__()
def __init__(self, parent) -> None:
super().__init__(parent)
grid = QGridLayout()
self.setLayout(grid)
grid.addWidget(QLabel("Reagent Type Name"),0,0)

View File

@@ -53,7 +53,7 @@ class KitSelector(QDialog):
super().__init__()
self.setWindowTitle(title)
self.widget = QComboBox()
kits = [item.__str__() for item in KitType.query()]
kits = [item.name for item in KitType.query()]
self.widget.addItems(kits)
self.widget.setEditable(False)
# set yes/no buttons

View File

@@ -15,11 +15,12 @@ from PyQt6.QtWidgets import (
from PyQt6.QtWebEngineWidgets import QWebEngineView
from PyQt6.QtCore import Qt, QAbstractTableModel, QSortFilterProxyModel
from PyQt6.QtGui import QAction, QCursor, QPixmap, QPainter
from backend.db.models import BasicSubmission
from backend.db.models import BasicSubmission, Equipment, SubmissionEquipmentAssociation
from backend.excel import make_report_html, make_report_xlsx
from tools import check_if_app, Report, Result, jinja_template_loading, get_first_blank_df_row, row_map
from xhtml2pdf import pisa
from .pop_ups import QuestionAsker
from .equipment_usage import EquipmentUsage
from ..visualizations import make_plate_barcode, make_plate_map, make_plate_map_html
from .functions import select_save_file, select_open_file
from .misc import ReportDatePicker
@@ -159,22 +160,44 @@ class SubmissionsSheet(QTableView):
# barcodeAction = QAction("Print Barcode", self)
commentAction = QAction("Add Comment", self)
backupAction = QAction("Backup", self)
equipAction = QAction("Add Equipment", 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())
backupAction.triggered.connect(lambda: self.regenerate_submission_form())
equipAction.triggered.connect(lambda: self.add_equipment())
# 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(backupAction)
self.menu.addAction(equipAction)
# self.menu.addAction(hitpickAction)
# add other required actions
self.menu.popup(QCursor.pos())
def add_equipment(self):
index = (self.selectionModel().currentIndex())
value = index.sibling(index.row(),0).data()
self.add_equipment_function(rsl_plate_id=value)
def add_equipment_function(self, rsl_plate_id):
submission = BasicSubmission.query(id=rsl_plate_id)
submission_type = submission.submission_type_name
dlg = EquipmentUsage(parent=self, submission_type=submission_type)
if dlg.exec():
equipment = dlg.parse_form()
for equip in equipment:
e = Equipment.query(name=equip)
assoc = SubmissionEquipmentAssociation(submission=submission, equipment=e)
# submission.submission_equipment_associations.append(assoc)
logger.debug(f"Appending SubmissionEquipmentAssociation: {assoc}")
# submission.save()
assoc.save()
def delete_item(self, event):
"""
Confirms user deletion and sends id to backend for deletion.
@@ -193,65 +216,6 @@ class SubmissionsSheet(QTableView):
return
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_submissions(ctx=self.ctx, id=id) for id in indices]
# subs = [BasicSubmission.query(id=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 = sub.hitpick_plate(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")
# # convert all samples to dataframe
# df = make_hitpicks(dicto)
# df = df[df.positive != False]
# logger.debug(f"Size of the dataframe: {df.shape[0]}")
# msg = AlertPop(message=f"We found {df.shape[0]} samples to hitpick", status="INFORMATION")
# msg.exec()
# 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}.")
def link_extractions(self):
self.link_extractions_function()
self.app.report.add_result(self.report)

View File

@@ -0,0 +1,118 @@
from PyQt6.QtCore import Qt
from PyQt6.QtWidgets import (
QWidget, QVBoxLayout, QScrollArea,
QGridLayout, QPushButton, QLabel,
QLineEdit, QComboBox, QDoubleSpinBox,
QSpinBox, QDateEdit
)
from sqlalchemy import FLOAT, INTEGER
from sqlalchemy.orm.attributes import InstrumentedAttribute
from backend.db import SubmissionType, Equipment, SubmissionTypeEquipmentAssociation, BasicSubmission
from backend.validators import PydReagentType, PydKit
import logging
from pprint import pformat
from tools import Report
from typing import Tuple
from .functions import select_open_file
logger = logging.getLogger(f"submissions.{__name__}")
class SubbmissionTypeAdder(QWidget):
def __init__(self, parent) -> None:
super().__init__(parent)
self.report = Report()
self.app = parent.parent()
self.template_path = ""
main_box = QVBoxLayout(self)
scroll = QScrollArea(self)
main_box.addWidget(scroll)
scroll.setWidgetResizable(True)
scrollContent = QWidget(scroll)
self.grid = QGridLayout()
scrollContent.setLayout(self.grid)
# insert submit button at top
self.submit_btn = QPushButton("Submit")
self.grid.addWidget(self.submit_btn,0,0,1,1)
self.grid.addWidget(QLabel("Submission Type Name:"),2,0)
# widget to get kit name
self.st_name = QLineEdit()
self.st_name.setObjectName("submission_type_name")
self.grid.addWidget(self.st_name,2,1,1,2)
self.grid.addWidget(QLabel("Template File"),3,0)
template_selector = QPushButton("Select")
self.grid.addWidget(template_selector,3,1)
self.template_label = QLabel("None")
self.grid.addWidget(self.template_label,3,2)
# self.grid.addWidget(QLabel("Used For Submission Type:"),3,0)
# widget to get uses of kit
exclude = ['id', 'submitting_lab_id', 'extraction_kit_id', 'reagents_id', 'extraction_info', 'pcr_info', 'run_cost']
self.columns = {key:value for key, value in BasicSubmission.__dict__.items() if isinstance(value, InstrumentedAttribute)}
self.columns = {key:value for key, value in self.columns.items() if hasattr(value, "type") and key not in exclude}
for iii, key in enumerate(self.columns):
idx = iii + 4
# convert field name to human readable.
# field_name = key
# self.grid.addWidget(QLabel(field_name),idx,0)
# print(self.columns[key].type)
# match self.columns[key].type:
# case FLOAT():
# add_widget = QDoubleSpinBox()
# add_widget.setMinimum(0)
# add_widget.setMaximum(9999)
# case INTEGER():
# add_widget = QSpinBox()
# add_widget.setMinimum(0)
# add_widget.setMaximum(9999)
# case _:
# add_widget = QLineEdit()
# add_widget.setObjectName(key)
self.grid.addWidget(InfoWidget(parent=self, key=key), idx,0,1,3)
scroll.setWidget(scrollContent)
self.submit_btn.clicked.connect(self.submit)
template_selector.clicked.connect(self.get_template_path)
def submit(self):
info = self.parse_form()
ST = SubmissionType(name=self.st_name.text(), info_map=info)
with open(self.template_path, "rb") as f:
ST.template_file = f.read()
logger.debug(ST.__dict__)
def parse_form(self):
widgets = [widget for widget in self.findChildren(QWidget) if isinstance(widget, InfoWidget)]
return [{widget.objectName():widget.parse_form()} for widget in widgets]
def get_template_path(self):
self.template_path = select_open_file(obj=self, file_extension="xlsx")
self.template_label.setText(self.template_path.__str__())
class InfoWidget(QWidget):
def __init__(self, parent: QWidget, key) -> None:
super().__init__(parent)
grid = QGridLayout()
self.setLayout(grid)
grid.addWidget(QLabel(key.replace("_", " ").title()),0,0,1,4)
self.setObjectName(key)
grid.addWidget(QLabel("Sheet Names (comma seperated):"),1,0)
self.sheet = QLineEdit()
self.sheet.setObjectName("sheets")
grid.addWidget(self.sheet, 1,1,1,3)
grid.addWidget(QLabel("Row:"),2,0,alignment=Qt.AlignmentFlag.AlignRight)
self.row = QSpinBox()
self.row.setObjectName("row")
grid.addWidget(self.row,2,1)
grid.addWidget(QLabel("Column:"),2,2,alignment=Qt.AlignmentFlag.AlignRight)
self.column = QSpinBox()
self.column.setObjectName("column")
grid.addWidget(self.column,2,3)
def parse_form(self):
return dict(
sheets = self.sheet.text().split(","),
row = self.row.value(),
column = self.column.value()
)

View File

@@ -62,12 +62,13 @@ class SubmissionFormContainer(QWidget):
self.app.result_reporter()
def scrape_reagents(self, *args, **kwargs):
print(f"\n\n{inspect.stack()[1].function}\n\n")
self.scrape_reagents_function(args[0])
caller = inspect.stack()[1].function.__repr__().replace("'", "")
logger.debug(f"Args: {args}, kwargs: {kwargs}")
self.scrape_reagents_function(args[0], caller=caller)
self.kit_integrity_completion()
self.app.report.add_result(self.report)
self.report = Report()
match inspect.stack()[1].function:
match inspect.stack()[1].function.__repr__():
case "import_submission_function":
pass
case _:
@@ -83,7 +84,7 @@ class SubmissionFormContainer(QWidget):
self.kit_integrity_completion_function()
self.app.report.add_result(self.report)
self.report = Report()
match inspect.stack()[1].function:
match inspect.stack()[1].function.__repr__():
case "import_submission_function":
pass
case _:
@@ -161,7 +162,7 @@ class SubmissionFormContainer(QWidget):
logger.debug(f"Outgoing report: {self.report.results}")
logger.debug(f"All attributes of submission container:\n{pformat(self.__dict__)}")
def scrape_reagents_function(self, extraction_kit:str):
def scrape_reagents_function(self, extraction_kit:str, caller:str|None=None):
"""
Extracted scrape reagents function that will run when
form 'extraction_kit' widget is updated.
@@ -173,6 +174,9 @@ class SubmissionFormContainer(QWidget):
Returns:
Tuple[QMainWindow, dict]: Updated application and result
"""
self.form.reagents = []
logger.debug(f"\n\n{caller}\n\n")
# assert caller == "import_submission_function"
report = Report()
logger.debug(f"Extraction kit: {extraction_kit}")
# obj.reagents = []
@@ -195,7 +199,15 @@ class SubmissionFormContainer(QWidget):
# obj.reagents.append(reagent)
# else:
# obj.missing_reagents.append(reagent)
self.form.reagents = self.prsr.sub['reagents']
match caller:
case "import_submission_function":
self.form.reagents = self.prsr.sub['reagents']
case _:
already_have = [reagent for reagent in self.prsr.sub['reagents'] if not reagent.missing]
names = list(set([item.type for item in already_have]))
logger.debug(f"reagents: {already_have}")
reagents = [item.to_pydantic() for item in KitType.query(name=extraction_kit).get_reagents(submission_type=self.pyd.submission_type) if item.name not in names]
self.form.reagents = already_have + reagents
# logger.debug(f"Imported reagents: {obj.reagents}")
# logger.debug(f"Missing reagents: {obj.missing_reagents}")
self.report.add_result(report)
@@ -221,6 +233,7 @@ class SubmissionFormContainer(QWidget):
self.ext_kit = kit_widget.currentText()
# for reagent in obj.pyd.reagents:
for reagent in self.form.reagents:
logger.debug(f"Creating widget for {reagent}")
add_widget = ReagentFormWidget(parent=self, reagent=reagent, extraction_kit=self.ext_kit)
# add_widget.setParent(sub_form_container.form)
self.form.layout().addWidget(add_widget)

View File

@@ -32,10 +32,10 @@
visibility: visible;
font-size: large;
}
</style>
</style>
<title>Submission Details for {{ sub['Plate Number'] }}</title>
</head>
{% set excluded = ['reagents', 'samples', 'controls', 'extraction_info', 'pcr_info', 'comment', 'barcode', 'platemap', 'export_map'] %}
{% set excluded = ['reagents', 'samples', 'controls', 'extraction_info', 'pcr_info', 'comment', 'barcode', 'platemap', 'export_map', 'equipment'] %}
<body>
<h2><u>Submission Details for {{ sub['Plate Number'] }}</u></h2>&nbsp;&nbsp;&nbsp;{% if sub['barcode'] %}<img align='right' height="30px" width="120px" src="data:image/jpeg;base64,{{ sub['barcode'] | safe }}">{% endif %}
<p>{% for key, value in sub.items() if key not in excluded %}
@@ -45,6 +45,12 @@
<p>{% for item in sub['reagents'] %}
&nbsp;&nbsp;&nbsp;&nbsp;<b>{{ item['type'] }}</b>: {{ item['lot'] }} (EXP: {{ item['expiry'] }})<br>
{% endfor %}</p>
{% if sub['equipment'] %}
<h3><u>Equipment:</u></h3>
<p>{% for item in sub['equipment'] %}
&nbsp;&nbsp;&nbsp;&nbsp;<b>{{ item['name'] }}:</b> {{ item['asset_number']|replace('\n\t', '<br>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;') }}<br>
{% endfor %}</p>
{% endif %}
{% if sub['samples'] %}
<h3><u>Samples:</u></h3>
<p>{% for item in sub['samples'] %}

View File

@@ -3,7 +3,6 @@ Contains miscellaenous functions used by both frontend and backend.
'''
from __future__ import annotations
from pathlib import Path
import re
import numpy as np
import logging, re, yaml, sys, os, stat, platform, getpass, inspect
import pandas as pd
@@ -99,13 +98,6 @@ def check_regex_match(pattern:str, check:str) -> bool:
except TypeError:
return False
# def massage_common_reagents(reagent_name:str):
# logger.debug(f"Attempting to massage {reagent_name}")
# if reagent_name.endswith("water") or "H2O" in reagent_name.upper():
# reagent_name = "molecular_grade_water"
# reagent_name = reagent_name.replace("µ", "u")
# return reagent_name
class GroupWriteRotatingFileHandler(handlers.RotatingFileHandler):
def doRollover(self):
@@ -143,7 +135,7 @@ class Settings(BaseSettings):
Pydantic model to hold settings
Raises:
FileNotFoundError: _description_
FileNotFoundError: Error if database not found.
"""
directory_path: Path
@@ -516,4 +508,10 @@ def readInChunks(fileObj, chunkSize=2048):
def get_first_blank_df_row(df:pd.DataFrame) -> int:
return len(df) + 1
def is_missing(value:Any) -> Tuple[Any, bool]:
if check_not_nan(value):
return value, False
else:
return convert_nans_to_nones(value), True
ctx = get_config(None)