diff --git a/CHANGELOG.md b/CHANGELOG.md index e9af99f..932b130 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/None b/None deleted file mode 100644 index e69de29..0000000 diff --git a/TODO.md b/TODO.md index 54952ed..bd5f948 100644 --- a/TODO.md +++ b/TODO.md @@ -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. diff --git a/alembic/versions/36a47d8837ca_adding_in_equipment.py b/alembic/versions/36a47d8837ca_adding_in_equipment.py new file mode 100644 index 0000000..1a6335d --- /dev/null +++ b/alembic/versions/36a47d8837ca_adding_in_equipment.py @@ -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 ### diff --git a/alembic/versions/3e94fecbbe91_adding_times_to_equipsubassoc.py b/alembic/versions/3e94fecbbe91_adding_times_to_equipsubassoc.py new file mode 100644 index 0000000..3f80514 --- /dev/null +++ b/alembic/versions/3e94fecbbe91_adding_times_to_equipsubassoc.py @@ -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 ### diff --git a/alembic/versions/761baf9d7842_adding_equipment_clustering.py b/alembic/versions/761baf9d7842_adding_equipment_clustering.py new file mode 100644 index 0000000..3b1e6fa --- /dev/null +++ b/alembic/versions/761baf9d7842_adding_equipment_clustering.py @@ -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 ### diff --git a/alembic/versions/cd11db3794ed_adding_static_option_to_equipstassoc.py b/alembic/versions/cd11db3794ed_adding_static_option_to_equipstassoc.py new file mode 100644 index 0000000..6cee413 --- /dev/null +++ b/alembic/versions/cd11db3794ed_adding_static_option_to_equipstassoc.py @@ -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 ### diff --git a/alembic/versions/cd5c225b5d2a_adding_process_to_equipsubassoc.py b/alembic/versions/cd5c225b5d2a_adding_process_to_equipsubassoc.py new file mode 100644 index 0000000..262e7a7 --- /dev/null +++ b/alembic/versions/cd5c225b5d2a_adding_process_to_equipsubassoc.py @@ -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 ### diff --git a/src/submissions/__init__.py b/src/submissions/__init__.py index 7bf8cba..aaf6e4f 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__ = "202312.2b" +__version__ = "202312.3b" __author__ = {"name":"Landon Wark", "email":"Landon.Wark@phac-aspc.gc.ca"} __copyright__ = "2022-2023, Government of Canada" diff --git a/src/submissions/backend/db/models/kits.py b/src/submissions/backend/db/models/kits.py index 77e5fdd..e426874 100644 --- a/src/submissions/backend/db/models/kits.py +++ b/src/submissions/backend/db/models/kits.py @@ -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"" - 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. @@ -242,6 +243,10 @@ class ReagentType(BaseClass): case _: 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): """ @@ -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"" @@ -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"" + + @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() diff --git a/src/submissions/backend/db/models/submissions.py b/src/submissions/backend/db/models/submissions.py index bef369e..58bfcfc 100644 --- a/src/submissions/backend/db/models/submissions.py +++ b/src/submissions/backend/db/models/submissions.py @@ -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) @@ -750,7 +765,8 @@ class BasicSubmission(BaseClass): code = 1 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 "(?PRSL(?:-|_)?WW(?:-|_)?20\d{2}-?\d{2}-?\d{2}(?:(_|-)?\d?([^_0123456789]|$)R?\d?)?)" + # return "(?PRSL(?:-|_)?WW(?:-|_)?20\d{2}-?\d{2}-?\d{2}(?:(_|-)?\d?([^_0123456789\s]|$)R?\d?)?)" + return "(?PRSL(?:-|_)?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"} - diff --git a/src/submissions/backend/excel/parser.py b/src/submissions/backend/excel/parser.py index 9e51c86..6433680 100644 --- a/src/submissions/backend/excel/parser.py +++ b/src/submissions/backend/excel/parser.py @@ -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]: diff --git a/src/submissions/backend/validators/pydant.py b/src/submissions/backend/validators/pydant.py index d4b9817..93095e9 100644 --- a/src/submissions/backend/validators/pydant.py +++ b/src/submissions/backend/validators/pydant.py @@ -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) \ No newline at end of file diff --git a/src/submissions/frontend/widgets/app.py b/src/submissions/frontend/widgets/app.py index 6894ae7..4672398 100644 --- a/src/submissions/frontend/widgets/app.py +++ b/src/submissions/frontend/widgets/app.py @@ -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) \ No newline at end of file diff --git a/src/submissions/frontend/widgets/equipment_usage.py b/src/submissions/frontend/widgets/equipment_usage.py new file mode 100644 index 0000000..fee48fe --- /dev/null +++ b/src/submissions/frontend/widgets/equipment_usage.py @@ -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 diff --git a/src/submissions/frontend/widgets/kit_creator.py b/src/submissions/frontend/widgets/kit_creator.py index f2d60c3..3e281f9 100644 --- a/src/submissions/frontend/widgets/kit_creator.py +++ b/src/submissions/frontend/widgets/kit_creator.py @@ -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) diff --git a/src/submissions/frontend/widgets/pop_ups.py b/src/submissions/frontend/widgets/pop_ups.py index ac6c7d6..b8f7acc 100644 --- a/src/submissions/frontend/widgets/pop_ups.py +++ b/src/submissions/frontend/widgets/pop_ups.py @@ -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 diff --git a/src/submissions/frontend/widgets/submission_table.py b/src/submissions/frontend/widgets/submission_table.py index f9cebaf..35e46b3 100644 --- a/src/submissions/frontend/widgets/submission_table.py +++ b/src/submissions/frontend/widgets/submission_table.py @@ -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) diff --git a/src/submissions/frontend/widgets/submission_type_creator.py b/src/submissions/frontend/widgets/submission_type_creator.py new file mode 100644 index 0000000..00f5ef2 --- /dev/null +++ b/src/submissions/frontend/widgets/submission_type_creator.py @@ -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() + ) \ No newline at end of file diff --git a/src/submissions/frontend/widgets/submission_widget.py b/src/submissions/frontend/widgets/submission_widget.py index edd9b53..a9ee97c 100644 --- a/src/submissions/frontend/widgets/submission_widget.py +++ b/src/submissions/frontend/widgets/submission_widget.py @@ -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) diff --git a/src/submissions/templates/submission_details.html b/src/submissions/templates/submission_details.html index 40f4515..d1aee72 100644 --- a/src/submissions/templates/submission_details.html +++ b/src/submissions/templates/submission_details.html @@ -32,10 +32,10 @@ visibility: visible; font-size: large; } - + Submission Details for {{ sub['Plate Number'] }} - {% 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'] %}

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

   {% if sub['barcode'] %}{% endif %}

{% for key, value in sub.items() if key not in excluded %} @@ -45,6 +45,12 @@

{% for item in sub['reagents'] %}     {{ item['type'] }}: {{ item['lot'] }} (EXP: {{ item['expiry'] }})
{% endfor %}

+ {% if sub['equipment'] %} +

Equipment:

+

{% for item in sub['equipment'] %} +     {{ item['name'] }}: {{ item['asset_number']|replace('\n\t', '
        ') }}
+ {% endfor %}

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

Samples:

{% for item in sub['samples'] %} @@ -106,4 +112,4 @@ {% endif %} - \ No newline at end of file + diff --git a/src/submissions/tools.py b/src/submissions/tools.py index aa6cef1..72cb71f 100644 --- a/src/submissions/tools.py +++ b/src/submissions/tools.py @@ -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)