diff --git a/docs/source/conf.py b/docs/source/conf.py index bbc9ccb..bd4833d 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -34,7 +34,7 @@ templates_path = ['_templates'] exclude_patterns = [] sys.path.insert(0, Path(__file__).absolute().resolve().parents[2].joinpath("src").__str__()) -sys.path.insert(0, Path(__file__).absolute().resolve().parents[2].joinpath("src/submissions").__str__()) +sys.path.insert(0, Path(__file__).absolute().resolve().parents[2].joinpath("src/procedure").__str__()) # -- Options for HTML output ------------------------------------------------- # https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output @@ -44,4 +44,4 @@ html_theme = 'alabaster' html_static_path = ['_static'] -# autodoc_mock_imports = ["backend.db.models.submissions"] \ No newline at end of file +# autodoc_mock_imports = ["backend.db.models.procedure"] \ No newline at end of file diff --git a/src/scripts/goodbye.py b/src/scripts/goodbye.py index dbc80c3..dd1a8bd 100644 --- a/src/scripts/goodbye.py +++ b/src/scripts/goodbye.py @@ -15,7 +15,7 @@ def goodbye(ctx): """ -For scripts to be run, they must be added to the _configitem.startup_scripts or _configitem.teardown_scripts +For scripts to be procedure, they must be added to the _configitem.startup_scripts or _configitem.teardown_scripts rows as a key: value (name: null) entry in the JSON. ex: {"goodbye": null, "backup_database": null} The program will overwrite null with the actual function upon startup. diff --git a/src/scripts/hello.py b/src/scripts/hello.py index efb7cb7..5ee230a 100644 --- a/src/scripts/hello.py +++ b/src/scripts/hello.py @@ -15,7 +15,7 @@ def hello(ctx) -> None: """ -For scripts to be run, they must be added to the _configitem.startup_scripts or _configitem.teardown_scripts +For scripts to be procedure, they must be added to the _configitem.startup_scripts or _configitem.teardown_scripts rows as a key: value (name: null) entry in the JSON. ex: {"hello": null, "import_irida": null} The program will overwrite null with the actual function upon startup. diff --git a/src/scripts/import_irida.py b/src/scripts/import_irida.py index 3e51c4e..6cd7924 100644 --- a/src/scripts/import_irida.py +++ b/src/scripts/import_irida.py @@ -5,29 +5,29 @@ from tools import Settings from sqlalchemy.orm import Session -logger = logging.getLogger(f"submissions.{__name__}") +logger = logging.getLogger(f"procedure.{__name__}") def import_irida(ctx: Settings): """ - Grabs Irida controls from secondary database. + Grabs Irida control from secondary database. Args: ctx (Settings): Settings inherited from app. """ - from backend import BasicSample + from backend import Sample from backend.db import IridaControl, ControlType # NOTE: Because the main session will be busy in another thread, this requires a new session. new_session = Session(ctx.database_session.get_bind()) ct = new_session.query(ControlType).filter(ControlType.name == "Irida Control").first() existing_controls = [item.name for item in new_session.query(IridaControl)] prm_list = ", ".join([f"'{thing}'" for thing in existing_controls]) - ctrl_db_path = ctx.directory_path.joinpath("submissions_parser_output", "submissions.db") + ctrl_db_path = ctx.directory_path.joinpath("submissions_parser_output", "procedure.db") try: conn = sqlite3.connect(ctrl_db_path) except AttributeError as e: logger.error(f"Error, could not import from irida due to {e}") return - sql = "SELECT name, submitted_date, run_id, contains, matches, kraken, subtype, refseq_version, " \ + sql = "SELECT name, submitted_date, procedure_id, contains, matches, kraken, subtype, refseq_version, " \ "kraken2_version, kraken2_db_version, sample_id FROM _iridacontrol INNER JOIN _control on _control.id " \ f"= _iridacontrol.id WHERE _control.name NOT IN ({prm_list})" cursor = conn.execute(sql) @@ -49,15 +49,15 @@ def import_irida(ctx: Settings): record['submitted_date'] = datetime.strptime(record['submitted_date'], "%Y-%m-%d %H:%M:%S.%f") assert isinstance(record['submitted_date'], datetime) instance = IridaControl(controltype=ct, **record) - sample = new_session.query(BasicSample).filter(BasicSample.submitter_id == instance.name).first() + sample = new_session.query(Sample).filter(Sample.sample_id == instance.name).first() if sample: instance.sample = sample try: - instance.submission = sample.submissions[0] + instance.clientsubmission = sample.procedure[0] except IndexError: logger.error(f"Could not get sample for {sample}") - instance.submission = None - # instance.run = sample.run[0] + instance.clientsubmission = None + # instance.procedure = sample.procedure[0] new_session.add(instance) new_session.commit() new_session.close() diff --git a/src/submissions/__init__.py b/src/submissions/__init__.py index 49dc299..514c958 100644 --- a/src/submissions/__init__.py +++ b/src/submissions/__init__.py @@ -22,7 +22,7 @@ def get_week_of_month() -> int: # Automatically completes project info for help menu and compiling. -__project__ = "submissions" +__project__ = "procedure" __version__ = f"{year}{str(month).zfill(2)}.{get_week_of_month()}b" __author__ = {"name": "Landon Wark", "email": "Landon.Wark@phac-aspc.gc.ca"} __copyright__ = f"2022-{year}, Government of Canada" diff --git a/src/submissions/backend/db/__init__.py b/src/submissions/backend/db/__init__.py index aff8dc6..e362802 100644 --- a/src/submissions/backend/db/__init__.py +++ b/src/submissions/backend/db/__init__.py @@ -55,7 +55,7 @@ def update_log(mapper, connection, target): continue added = [str(item) for item in hist.added] # NOTE: Attributes left out to save space - # if attr.key in ['artic_technician', 'submission_sample_associations', 'submission_reagent_associations', + # if attr.key in ['artic_technician', 'clientsubmissionsampleassociation', 'submission_reagent_associations', # 'submission_equipment_associations', 'submission_tips_associations', 'contact_id', 'gel_info', # 'gel_controls', 'source_plates']: if attr.key in LogMixin.tracking_exclusion: diff --git a/src/submissions/backend/db/models/__init__.py b/src/submissions/backend/db/models/__init__.py index ce3187c..9b7dbea 100644 --- a/src/submissions/backend/db/models/__init__.py +++ b/src/submissions/backend/db/models/__init__.py @@ -3,6 +3,8 @@ Contains all models for sqlalchemy """ from __future__ import annotations import sys, logging + +from dateutil.parser import parse from pandas import DataFrame from pydantic import BaseModel from sqlalchemy import Column, INTEGER, String, JSON @@ -21,7 +23,7 @@ if 'pytest' in sys.modules: # NOTE: For inheriting in LogMixin Base: DeclarativeMeta = declarative_base() -logger = logging.getLogger(f"submissions.{__name__}") +logger = logging.getLogger(f"procedure.{__name__}") class BaseClass(Base): @@ -33,12 +35,12 @@ class BaseClass(Base): __table_args__ = {'extend_existing': True} #: NOTE Will only add new columns singles = ['id'] - omni_removes = ["id", 'runs', "omnigui_class_dict", "omnigui_instance_dict"] + omni_removes = ["id", 'run', "omnigui_class_dict", "omnigui_instance_dict"] omni_sort = ["name"] omni_inheritable = [] searchables = [] - misc_info = Column(JSON) + _misc_info = Column(JSON) def __repr__(self) -> str: try: @@ -122,6 +124,10 @@ class BaseClass(Base): from test_settings import ctx return ctx.backup_path + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self._misc_info = dict() + @classproperty def jsons(cls) -> List[str]: """ @@ -130,7 +136,10 @@ class BaseClass(Base): Returns: List[str]: List of column names """ - return [item.name for item in cls.__table__.columns if isinstance(item.type, JSON)] + try: + return [item.name for item in cls.__table__.columns if isinstance(item.type, JSON)] + except AttributeError: + return [] @classproperty def timestamps(cls) -> List[str]: @@ -140,7 +149,10 @@ class BaseClass(Base): Returns: List[str]: List of column names """ - return [item.name for item in cls.__table__.columns if isinstance(item.type, TIMESTAMP)] + try: + return [item.name for item in cls.__table__.columns if isinstance(item.type, TIMESTAMP)] + except AttributeError: + return [] @classmethod def get_default_info(cls, *args) -> dict | list | str: @@ -155,7 +167,7 @@ class BaseClass(Base): return dict(singles=singles) @classmethod - def find_regular_subclass(cls, name: str|None = None) -> Any: + def find_regular_subclass(cls, name: str | None = None) -> Any: """ Args: name (str): name of subclass of interest. @@ -198,11 +210,11 @@ class BaseClass(Base): @classmethod def results_to_df(cls, objects: list | None = None, **kwargs) -> DataFrame: """ - Converts class sub_dicts into a Dataframe for all controls of the class. + Converts class sub_dicts into a Dataframe for all control of the class. Args: objects (list): Objects to be converted to dataframe. - **kwargs (): Arguments necessary for the to_sub_dict method. eg extraction_kit=X + **kwargs (): Arguments necessary for the to_sub_dict method. eg kittype=X Returns: Dataframe @@ -219,6 +231,24 @@ class BaseClass(Base): records = [{k: v['instance_attr'] for k, v in obj.omnigui_instance_dict.items()} for obj in objects] return DataFrame.from_records(records) + @classmethod + def query_or_create(cls, **kwargs) -> Tuple[Any, bool]: + new = False + allowed = [k for k, v in cls.__dict__.items() if isinstance(v, InstrumentedAttribute) + and not isinstance(v.property, _RelationshipDeclared)] + sanitized_kwargs = {k: v for k, v in kwargs.items() if k in allowed} + + logger.debug(f"Sanitized kwargs: {sanitized_kwargs}") + instance = cls.query(**sanitized_kwargs) + if not instance or isinstance(instance, list): + instance = cls() + new = True + for k, v in sanitized_kwargs.items(): + logger.debug(f"QorC Setting {k} to {v}") + setattr(instance, k, v) + logger.info(f"Instance from query or create: {instance}, new: {new}") + return instance, new + @classmethod def query(cls, **kwargs) -> Any | List[Any]: """ @@ -227,6 +257,8 @@ class BaseClass(Base): Returns: Any | List[Any]: Result of query execution. """ + if "name" in kwargs.keys(): + kwargs['limit'] = 1 return cls.execute_query(**kwargs) @classmethod @@ -243,16 +275,17 @@ class BaseClass(Base): Any | List[Any]: Single result if limit = 1 or List if other. """ # logger.debug(f"Kwargs: {kwargs}") - if model is None: - model = cls + # if model is None: + # model = cls # logger.debug(f"Model: {model}") if query is None: - query: Query = cls.__database_session__.query(model) - singles = model.get_default_info('singles') + query: Query = cls.__database_session__.query(cls) + singles = cls.get_default_info('singles') for k, v in kwargs.items(): + logger.info(f"Using key: {k} with value: {v}") try: - attr = getattr(model, k) + attr = getattr(cls, k) # NOTE: account for attrs that use list. if attr.property.uselist: query = query.filter(attr.contains(v)) @@ -341,6 +374,26 @@ class BaseClass(Base): """ return dict() + @classproperty + def details_template(cls) -> Template: + """ + Get the details jinja template for the correct class + + Args: + base_dict (dict): incoming dictionary of Submission fields + + Returns: + Tuple(dict, Template): (Updated dictionary, Template to be rendered) + """ + env = jinja_template_loading() + temp_name = f"{cls.__name__.lower()}_details.html" + try: + template = env.get_template(temp_name) + except TemplateNotFound as e: + # logger.error(f"Couldn't find template {e}") + template = env.get_template("details.html") + return template + def check_all_attributes(self, attributes: dict) -> bool: """ Checks this instance against a dictionary of attributes to determine if they are a match. @@ -405,15 +458,29 @@ class BaseClass(Base): """ Custom dunder method to handle potential list relationship issues. """ + # logger.debug(f"Attempting to set: {key} to {value}") + if key.startswith("_"): + return super().__setattr__(key, value) + try: + check = not hasattr(self, key) + except: + return + if check: + try: + json.dumps(value) + except TypeError: + value = str(value) + self._misc_info.update({key: value}) + return try: field_type = getattr(self.__class__, key) except AttributeError: return super().__setattr__(key, value) if isinstance(field_type, InstrumentedAttribute): - logger.debug(f"{key} is an InstrumentedAttribute.") + # logger.debug(f"{key} is an InstrumentedAttribute.") match field_type.property: case ColumnProperty(): - logger.debug(f"Setting ColumnProperty to {value}") + # logger.debug(f"Setting ColumnProperty to {value}") return super().__setattr__(key, value) case _RelationshipDeclared(): logger.debug(f"{self.__class__.__name__} Setting _RelationshipDeclared for {key} to {value}") @@ -446,10 +513,13 @@ class BaseClass(Base): try: return super().__setattr__(key, value) except AttributeError: - logger.debug(f"Possible attempt to set relationship to simple var type.") + logger.debug(f"Possible attempt to set relationship {key} to simple var type. {value}") relationship_class = field_type.property.entity.entity value = relationship_class.query(name=value) - return super().__setattr__(key, value) + try: + return super().__setattr__(key, value) + except AttributeError: + return super().__setattr__(key, None) case _: return super().__setattr__(key, value) else: @@ -458,7 +528,7 @@ class BaseClass(Base): def delete(self): logger.error(f"Delete has not been implemented for {self.__class__.__name__}") - def rectify_query_date(input_date, eod: bool = False) -> str: + def rectify_query_date(input_date: datetime, eod: bool = False) -> str: """ Converts input into a datetime string for querying purposes @@ -486,8 +556,7 @@ class BaseClass(Base): class LogMixin(Base): - - tracking_exclusion: ClassVar = ['artic_technician', 'submission_sample_associations', + tracking_exclusion: ClassVar = ['artic_technician', 'clientsubmissionsampleassociation', 'submission_reagent_associations', 'submission_equipment_associations', 'submission_tips_associations', 'contact_id', 'gel_info', 'gel_controls', 'source_plates'] @@ -540,13 +609,12 @@ class ConfigItem(BaseClass): from .controls import * -# NOTE: import order must go: orgs, kit, runs due to circular import issues +# NOTE: import order must go: orgs, kittype, run due to circular import issues from .organizations import * -from .runs import * from .kits import * from .submissions import * from .audit import AuditLog -# NOTE: Add a creator to the run for reagent association. Assigned here due to circular import constraints. +# NOTE: Add a creator to the procedure for reagent association. Assigned here due to circular import constraints. # https://docs.sqlalchemy.org/en/20/orm/extensions/associationproxy.html#sqlalchemy.ext.associationproxy.association_proxy.params.creator -Procedure.reagents.creator = lambda reg: ProcedureReagentAssociation(reagent=reg) +# Procedure.reagents.creator = lambda reg: ProcedureReagentAssociation(reagent=reg) diff --git a/src/submissions/backend/db/models/controls.py b/src/submissions/backend/db/models/controls.py index c043150..923b6ee 100644 --- a/src/submissions/backend/db/models/controls.py +++ b/src/submissions/backend/db/models/controls.py @@ -27,7 +27,7 @@ class ControlType(BaseClass): id = Column(INTEGER, primary_key=True) #: primary key name = Column(String(255), unique=True) #: controltype name (e.g. Irida Control) targets = Column(JSON) #: organisms checked for - controls = relationship("Control", back_populates="controltype") #: control samples created of this type. + control = relationship("Control", back_populates="controltype") #: control sample created of this type. @classmethod @setup_lookup @@ -59,16 +59,16 @@ class ControlType(BaseClass): Get subtypes associated with this controltype (currently used only for Kraken) Args: - mode (str): analysis mode sub_type + mode (str): analysis mode submissiontype Returns: List[str]: list of subtypes available """ - if not self.controls: + if not self.control: return # NOTE: Get first instance since all should have same subtypes # NOTE: Get mode of instance - jsoner = getattr(self.controls[0], mode) + jsoner = getattr(self.control[0], mode) try: # NOTE: Pick genera (all should have same subtypes) genera = list(jsoner.keys())[0] @@ -79,7 +79,7 @@ class ControlType(BaseClass): return subtypes @property - def instance_class(self) -> Control: + def control_class(self) -> Control: """ Retrieves the Control class associated with this controltype @@ -119,27 +119,27 @@ class Control(BaseClass): id = Column(INTEGER, primary_key=True) #: primary key controltype_name = Column(String, ForeignKey("_controltype.name", ondelete="SET NULL", - name="fk_BC_subtype_name")) #: name of joined run type - controltype = relationship("ControlType", back_populates="controls", + name="fk_BC_subtype_name")) #: name of joined procedure type + controltype = relationship("ControlType", back_populates="control", foreign_keys=[controltype_name]) #: reference to parent control type name = Column(String(255), unique=True) #: Sample ID - sample_id = Column(String, ForeignKey("_basicsample.id", ondelete="SET NULL", - name="fk_Cont_sample_id")) #: name of joined run type - sample = relationship("BasicSample", back_populates="control") #: This control's run sample + sample_id = Column(String, ForeignKey("_sample.id", ondelete="SET NULL", + name="fk_Cont_sample_id")) #: name of joined procedure type + sample = relationship("Sample", back_populates="control") #: This control's procedure sample submitted_date = Column(TIMESTAMP) #: Date submitted to Robotics - procedure_id = Column(INTEGER, ForeignKey("_procedure.id")) #: parent run id - procedure = relationship("Procedure", back_populates="controls", - foreign_keys=[procedure_id]) #: parent run + procedure_id = Column(INTEGER, ForeignKey("_procedure.id")) #: parent procedure id + procedure = relationship("Procedure", back_populates="control", + foreign_keys=[procedure_id]) #: parent procedure - __mapper_args__ = { - "polymorphic_identity": "Basic Control", - "polymorphic_on": case( - (controltype_name == "PCR Control", "PCR Control"), - (controltype_name == "Irida Control", "Irida Control"), - else_="Basic Control" - ), - "with_polymorphic": "*", - } + # __mapper_args__ = { + # "polymorphic_identity": "Basic Control", + # "polymorphic_on": case( + # (controltype_name == "PCR Control", "PCR Control"), + # (controltype_name == "Irida Control", "Irida Control"), + # else_="Basic Control" + # ), + # "with_polymorphic": "*", + # } def __repr__(self) -> str: return f"<{self.controltype_name}({self.name})>" @@ -284,448 +284,448 @@ class Control(BaseClass): self.__database_session__.commit() -class PCRControl(Control): - """ - Class made to hold info from Design & Analysis software. - """ - - id = Column(INTEGER, ForeignKey('_control.id'), primary_key=True) - subtype = Column(String(16)) #: PC or NC - target = Column(String(16)) #: N1, N2, etc. - ct = Column(FLOAT) #: PCR result - reagent_lot = Column(String(64), ForeignKey("_reagent.lot", ondelete="SET NULL", - name="fk_reagent_lot")) - reagent = relationship("Reagent", foreign_keys=reagent_lot) #: reagent used for this control - - __mapper_args__ = dict(polymorphic_identity="PCR Control", - polymorphic_load="inline", - inherit_condition=(id == Control.id)) - - def to_sub_dict(self) -> dict: - """ - Creates dictionary of fields for this object. - - Returns: - dict: Output dict of name, ct, subtype, target, reagent_lot and submitted_date - """ - return dict( - name=self.name, - ct=self.ct, - subtype=self.subtype, - target=self.target, - reagent_lot=self.reagent_lot, - submitted_date=self.submitted_date.date() - ) - - @classmethod - @report_result - def make_chart(cls, parent, chart_settings: dict, ctx: Settings) -> Tuple[Report, "PCRFigure"]: - """ - Creates a PCRFigure. Overrides parent - - Args: - parent (__type__): Widget to contain the chart. - chart_settings (dict): settings passed down from chart widget - ctx (Settings): settings passed down from gui. Not used here. - - Returns: - Tuple[Report, "PCRFigure"]: Report of status and resulting figure. - """ - from frontend.visualizations.pcr_charts import PCRFigure - parent.mode_typer.clear() - parent.mode_typer.setEnabled(False) - report = Report() - controls = cls.query(proceduretype=chart_settings['sub_type'], start_date=chart_settings['start_date'], - end_date=chart_settings['end_date']) - data = [control.to_sub_dict() for control in controls] - df = DataFrame.from_records(data) - # NOTE: Get all PCR controls with ct over 0 - try: - df = df[df.ct > 0.0] - except AttributeError: - df = df - fig = PCRFigure(df=df, modes=[], settings=chart_settings) - return report, fig - - def to_pydantic(self): - from backend.validators import PydPCRControl - return PydPCRControl(**self.to_sub_dict(), - controltype_name=self.controltype_name, - submission_id=self.submission_id) - - -class IridaControl(Control): - subtyping_allowed = ['kraken'] - - id = Column(INTEGER, ForeignKey('_control.id'), primary_key=True) - contains = Column(JSON) #: unstructured hashes in contains.tsv for each organism - matches = Column(JSON) #: unstructured hashes in matches.tsv for each organism - kraken = Column(JSON) #: unstructured output from kraken_report - subtype = Column(String(16), nullable=False) #: EN-NOS, MCS-NOS, etc - refseq_version = Column(String(16)) #: version of refseq used in fastq parsing - kraken2_version = Column(String(16)) #: version of kraken2 used in fastq parsing - kraken2_db_version = Column(String(32)) #: folder name of kraken2 db - sample_id = Column(INTEGER, - ForeignKey("_basicsample.id", ondelete="SET NULL", name="cont_BCS_id")) #: sample id key - - __mapper_args__ = dict(polymorphic_identity="Irida Control", - polymorphic_load="inline", - inherit_condition=(id == Control.id)) - - @property - def targets(self): - if self.controltype.targets: - return list(itertools.chain.from_iterable([value for key, value in self.controltype.targets.items() - if key == self.subtype])) - else: - return ["None"] - - @validates("subtype") - def enforce_subtype_literals(self, key: str, value: str) -> str: - """ - Validates sub_type field with acceptable values - - Args: - key (str): Field name - value (str): Field Value - - Raises: - KeyError: Raised if value is not in the acceptable list. - - Returns: - str: Validated string. - """ - acceptables = ['ATCC49226', 'ATCC49619', 'EN-NOS', "EN-SSTI", "MCS-NOS", "MCS-SSTI", "SN-NOS", "SN-SSTI"] - if value.upper() not in acceptables: - raise KeyError(f"Sub-type must be in {acceptables}") - return value - - def to_sub_dict(self) -> dict: - """ - Converts object into convenient dictionary for use in run summary - - Returns: - dict: output dictionary containing: Name, Type, Targets, Top Kraken results - """ - try: - kraken = self.kraken - except TypeError: - kraken = {} - try: - kraken_cnt_total = sum([item['kraken_count'] for item in kraken.values()]) - except AttributeError: - kraken_cnt_total = 0 - try: - new_kraken = [dict(name=key, kraken_count=value['kraken_count'], - kraken_percent=f"{value['kraken_count'] / kraken_cnt_total:0.2%}", - target=key in self.controltype.targets) - for key, value in kraken.items()] - new_kraken = sorted(new_kraken, key=itemgetter('kraken_count'), reverse=True)[0:10] - except (AttributeError, ZeroDivisionError): - new_kraken = [] - output = dict( - name=self.name, - type=self.controltype.name, - targets=", ".join(self.targets), - kraken=new_kraken - ) - return output - - def convert_by_mode(self, control_sub_type: str, mode: Literal['kraken', 'matches', 'contains'], - consolidate: bool = False) -> Generator[dict, None, None]: - """ - split this instance into analysis types ('kraken', 'matches', 'contains') for controls graphs - - Args: - consolidate (bool): whether to merge all off-target genera. Defaults to False - control_sub_type (str): control subtype, 'MCS-NOS', etc. - mode (Literal['kraken', 'matches', 'contains']): analysis type, 'contains', etc. - - Returns: - List[dict]: list of records - """ - try: - data = self.__getattribute__(mode) - except TypeError: - data = {} - if data is None: - data = {} - # NOTE: Data truncation and consolidation. - if "kraken" in mode: - data = {k: v for k, v in sorted(data.items(), key=lambda d: d[1][f"{mode}_count"], reverse=True)[:50]} - else: - if consolidate: - on_tar = {k: v for k, v in data.items() if k.strip("*") in self.controltype.targets[control_sub_type]} - off_tar = sum(v[f'{mode}_ratio'] for k, v in data.items() if - k.strip("*") not in self.controltype.targets[control_sub_type]) - on_tar['Off-target'] = {f"{mode}_ratio": off_tar} - data = on_tar - for genus in data: - _dict = dict( - name=self.name, - submitted_date=self.submitted_date, - genus=genus, - target='Target' if genus.strip("*") in self.controltype.targets[control_sub_type] else "Off-target" - ) - for key in data[genus]: - _dict[key] = data[genus][key] - yield _dict - - @classproperty - def modes(cls) -> List[str]: - """ - Get all control modes from database - - Returns: - List[str]: List of control mode names. - """ - try: - cols = [item.name for item in list(cls.__table__.columns) if isinstance(item.type, JSON)] - except AttributeError as e: - logger.error(f"Failed to get available modes from db: {e}") - cols = [] - return cols - - @classmethod - def make_parent_buttons(cls, parent: QWidget) -> None: - """ - Creates buttons for controlling - - Args: - parent (QWidget): chart holding widget to add buttons to. - - """ - super().make_parent_buttons(parent=parent) - rows = parent.layout.rowCount() - 2 - # NOTE: check box for consolidating off-target items - checker = QCheckBox(parent) - checker.setChecked(True) - checker.setObjectName("irida_check") - checker.setToolTip("Pools off-target genera to save time.") - parent.layout.addWidget(QLabel("Consolidate Off-targets"), rows, 0, 1, 1) - parent.layout.addWidget(checker, rows, 1, 1, 2) - checker.checkStateChanged.connect(parent.update_data) - - @classmethod - @report_result - def make_chart(cls, chart_settings: dict, parent, ctx) -> Tuple[Report, "IridaFigure" | None]: - """ - Creates a IridaFigure. Overrides parent - - Args: - parent (__type__): Widget to contain the chart. - chart_settings (dict): settings passed down from chart widget - ctx (Settings): settings passed down from gui. - - Returns: - Tuple[Report, "IridaFigure"]: Report of status and resulting figure. - """ - from frontend.visualizations import IridaFigure - try: - checker = parent.findChild(QCheckBox, name="irida_check") - if chart_settings['mode'] == "kraken": - checker.setEnabled(False) - checker.setChecked(False) - else: - checker.setEnabled(True) - consolidate = checker.isChecked() - except AttributeError: - consolidate = False - report = Report() - controls = cls.query(subtype=chart_settings['sub_type'], start_date=chart_settings['start_date'], - end_date=chart_settings['end_date']) - if not controls: - report.add_result(Result(status="Critical", msg="No controls found in given date range.")) - return report, None - # NOTE: change each control to list of dictionaries - data = [control.convert_by_mode(control_sub_type=chart_settings['sub_type'], mode=chart_settings['mode'], - consolidate=consolidate) for - control in controls] - # NOTE: flatten data to one dimensional list - # data = [item for sublist in data for item in sublist] - data = flatten_list(data) - if not data: - report.add_result(Result(status="Critical", msg="No data found for controls in given date range.")) - return report, None - df = cls.convert_data_list_to_df(input_df=data, sub_mode=chart_settings['sub_mode']) - if chart_settings['sub_mode'] is None: - title = chart_settings['sub_mode'] - else: - title = f"{chart_settings['mode']} - {chart_settings['sub_mode']}" - # NOTE: send dataframe to chart maker - df, modes = cls.prep_df(ctx=ctx, df=df) - fig = IridaFigure(df=df, ytitle=title, modes=modes, parent=parent, - settings=chart_settings) - return report, fig - - @classmethod - def convert_data_list_to_df(cls, input_df: list[dict], sub_mode) -> DataFrame: - """ - Convert list of control records to dataframe - - Args: - input_df (list[dict]): list of dictionaries containing records - sub_mode (str | None, optional): sub_type of run type. Defaults to None. - - Returns: - DataFrame: dataframe of controls - """ - df = DataFrame.from_records(input_df) - safe = ['name', 'submitted_date', 'genus', 'target'] - for column in df.columns: - if column not in safe: - if sub_mode is not None and column != sub_mode: - continue - else: - safe.append(column) - if "percent" in column: - try: - count_col = next(item for item in df.columns if "count" in item) - except StopIteration: - continue - # NOTE: The actual percentage from kraken was off due to exclusion of NaN, recalculating. - df[column] = 100 * df[count_col] / df.groupby('name')[count_col].transform('sum') - df = df[[c for c in df.columns if c in safe]] - # NOTE: move date of sample submitted on same date as previous ahead one. - df = cls.displace_date(df=df) - # NOTE: ad hoc method to make data labels more accurate. - df = cls.df_column_renamer(df=df) - return df - - @classmethod - def df_column_renamer(cls, df: DataFrame) -> DataFrame: - """ - Ad hoc function I created to clarify some fields - - Args: - df (DataFrame): input dataframe - - Returns: - DataFrame: dataframe with 'clarified' column names - """ - df = df[df.columns.drop(list(df.filter(regex='_hashes')))] - return df.rename(columns={ - "contains_ratio": "contains_shared_hashes_ratio", - "matches_ratio": "matches_shared_hashes_ratio", - "kraken_count": "kraken2_read_count_(top_50)", - "kraken_percent": "kraken2_read_percent_(top_50)" - }) - - @classmethod - def displace_date(cls, df: DataFrame) -> DataFrame: - """ - This function serves to split samples that were submitted on the same date by incrementing dates. - It will shift the date forward by one day if it is the same day as an existing date in a list. - - Args: - df (DataFrame): input dataframe composed of control records - - Returns: - DataFrame: output dataframe with dates incremented. - """ - # NOTE: get submitted dates for each control - dict_list = [dict(name=item, date=df[df.name == item].iloc[0]['submitted_date']) for item in - sorted(df['name'].unique())] - previous_dates = set() - for item in dict_list: - df, previous_dates = cls.check_date(df=df, item=item, previous_dates=previous_dates) - return df - - @classmethod - def check_date(cls, df: DataFrame, item: dict, previous_dates: set) -> Tuple[DataFrame, list]: - """ - Checks if an items date is already present in df and adjusts df accordingly - - Args: - df (DataFrame): input dataframe - item (dict): control for checking - previous_dates (list): list of dates found in previous controls - - Returns: - Tuple[DataFrame, list]: Output dataframe and appended list of previous dates - """ - try: - check = item['date'] in previous_dates - except IndexError: - check = False - previous_dates.add(item['date']) - if check: - # NOTE: get df locations where name == item name - mask = df['name'] == item['name'] - # NOTE: increment date in dataframe - df.loc[mask, 'submitted_date'] = df.loc[mask, 'submitted_date'].apply(lambda x: x + timedelta(days=1)) - item['date'] += timedelta(days=1) - passed = False - else: - passed = True - # NOTE: if run didn't lead to changed date, return values - if passed: - return df, previous_dates - # NOTE: if date was changed, rerun with new date - else: - logger.warning(f"Date check failed, running recursion.") - df, previous_dates = cls.check_date(df, item, previous_dates) - return df, previous_dates - - @classmethod - def prep_df(cls, ctx: Settings, df: DataFrame) -> Tuple[DataFrame | None, list]: - """ - Constructs figures based on parsed pandas dataframe. - - Args: - ctx (Settings): settings passed down from gui - df (pd.DataFrame): input dataframe - ytitle (str | None, optional): title for the y-axis. Defaults to None. - - Returns: - Figure: Plotly figure - """ - # NOTE: converts starred genera to normal and splits off list of starred - if df.empty: - return None, [] - df['genus'] = df['genus'].replace({'\*': ''}, regex=True).replace({"NaN": "Unknown"}) - df['genera'] = [item[-1] if item and item[-1] == "*" else "" for item in df['genus'].to_list()] - # NOTE: remove original runs, using reruns if applicable - df = cls.drop_reruns_from_df(ctx=ctx, df=df) - # NOTE: sort by and exclude from - sorts = ['submitted_date', "target", "genus"] - exclude = ['name', 'genera'] - modes = [item for item in df.columns if item not in sorts and item not in exclude] - # NOTE: Set descending for any columns that have "{mode}" in the header. - ascending = [False if item == "target" else True for item in sorts] - df = df.sort_values(by=sorts, ascending=ascending) - # NOTE: actual chart construction is done by - return df, modes - - @classmethod - def drop_reruns_from_df(cls, ctx: Settings, df: DataFrame) -> DataFrame: - """ - Removes semi-duplicates from dataframe after finding sequencing repeats. - - Args: - ctx (Settings): settings passed from gui - df (DataFrame): initial dataframe - - Returns: - DataFrame: dataframe with originals removed in favour of repeats. - """ - if 'rerun_regex' in ctx.model_extra: - sample_names = get_unique_values_in_df_column(df, column_name="name") - rerun_regex = re.compile(fr"{ctx.rerun_regex}") - exclude = [re.sub(rerun_regex, "", sample) for sample in sample_names if rerun_regex.search(sample)] - df = df[~df.name.isin(exclude)] - return df - - def to_pydantic(self) -> "PydIridaControl": - """ - Constructs a pydantic version of this object. - - Returns: - PydIridaControl: This object as a pydantic model. - """ - from backend.validators import PydIridaControl - return PydIridaControl(**self.__dict__) - - @property - def is_positive_control(self): - return not self.subtype.lower().startswith("en") +# class PCRControl(Control): +# """ +# Class made to hold info from Design & Analysis software. +# """ +# +# id = Column(INTEGER, ForeignKey('_control.id'), primary_key=True) +# subtype = Column(String(16)) #: PC or NC +# target = Column(String(16)) #: N1, N2, etc. +# ct = Column(FLOAT) #: PCR result +# reagent_lot = Column(String(64), ForeignKey("_reagent.lot", ondelete="SET NULL", +# name="fk_reagent_lot")) +# reagent = relationship("Reagent", foreign_keys=reagent_lot) #: reagent used for this control +# +# __mapper_args__ = dict(polymorphic_identity="PCR Control", +# polymorphic_load="inline", +# inherit_condition=(id == Control.id)) +# +# def to_sub_dict(self) -> dict: +# """ +# Creates dictionary of fields for this object. +# +# Returns: +# dict: Output dict of name, ct, subtype, target, reagent_lot and submitted_date +# """ +# return dict( +# name=self.name, +# ct=self.ct, +# subtype=self.subtype, +# target=self.target, +# reagent_lot=self.reagent_lot, +# submitted_date=self.submitted_date.date() +# ) +# +# @classmethod +# @report_result +# def make_chart(cls, parent, chart_settings: dict, ctx: Settings) -> Tuple[Report, "PCRFigure"]: +# """ +# Creates a PCRFigure. Overrides parent +# +# Args: +# parent (__type__): Widget to contain the chart. +# chart_settings (dict): settings passed down from chart widget +# ctx (Settings): settings passed down from gui. Not used here. +# +# Returns: +# Tuple[Report, "PCRFigure"]: Report of status and resulting figure. +# """ +# from frontend.visualizations.pcr_charts import PCRFigure +# parent.mode_typer.clear() +# parent.mode_typer.setEnabled(False) +# report = Report() +# control = cls.query(proceduretype=chart_settings['submissiontype'], start_date=chart_settings['start_date'], +# end_date=chart_settings['end_date']) +# data = [control.to_sub_dict() for control in control] +# df = DataFrame.from_records(data) +# # NOTE: Get all PCR control with ct over 0 +# try: +# df = df[df.ct > 0.0] +# except AttributeError: +# df = df +# fig = PCRFigure(df=df, modes=[], settings=chart_settings) +# return report, fig +# +# def to_pydantic(self): +# from backend.validators import PydPCRControl +# return PydPCRControl(**self.to_sub_dict(), +# controltype_name=self.controltype_name, +# clientsubmission_id=self.clientsubmission_id) +# +# +# class IridaControl(Control): +# subtyping_allowed = ['kraken'] +# +# id = Column(INTEGER, ForeignKey('_control.id'), primary_key=True) +# contains = Column(JSON) #: unstructured hashes in contains.tsv for each organism +# matches = Column(JSON) #: unstructured hashes in matches.tsv for each organism +# kraken = Column(JSON) #: unstructured output from kraken_report +# subtype = Column(String(16), nullable=False) #: EN-NOS, MCS-NOS, etc +# refseq_version = Column(String(16)) #: version of refseq used in fastq parsing +# kraken2_version = Column(String(16)) #: version of kraken2 used in fastq parsing +# kraken2_db_version = Column(String(32)) #: folder name of kraken2 db +# sample_id = Column(INTEGER, +# ForeignKey("_basicsample.id", ondelete="SET NULL", name="cont_BCS_id")) #: sample id key +# +# __mapper_args__ = dict(polymorphic_identity="Irida Control", +# polymorphic_load="inline", +# inherit_condition=(id == Control.id)) +# +# @property +# def targets(self): +# if self.controltype.targets: +# return list(itertools.chain.from_iterable([value for key, value in self.controltype.targets.items() +# if key == self.subtype])) +# else: +# return ["None"] +# +# @validates("subtype") +# def enforce_subtype_literals(self, key: str, value: str) -> str: +# """ +# Validates submissiontype field with acceptable values +# +# Args: +# key (str): Field name +# value (str): Field Value +# +# Raises: +# KeyError: Raised if value is not in the acceptable list. +# +# Returns: +# str: Validated string. +# """ +# acceptables = ['ATCC49226', 'ATCC49619', 'EN-NOS', "EN-SSTI", "MCS-NOS", "MCS-SSTI", "SN-NOS", "SN-SSTI"] +# if value.upper() not in acceptables: +# raise KeyError(f"Sub-type must be in {acceptables}") +# return value +# +# def to_sub_dict(self) -> dict: +# """ +# Converts object into convenient dictionary for use in procedure summary +# +# Returns: +# dict: output dictionary containing: Name, Type, Targets, Top Kraken results +# """ +# try: +# kraken = self.kraken +# except TypeError: +# kraken = {} +# try: +# kraken_cnt_total = sum([item['kraken_count'] for item in kraken.values()]) +# except AttributeError: +# kraken_cnt_total = 0 +# try: +# new_kraken = [dict(name=key, kraken_count=value['kraken_count'], +# kraken_percent=f"{value['kraken_count'] / kraken_cnt_total:0.2%}", +# target=key in self.controltype.targets) +# for key, value in kraken.items()] +# new_kraken = sorted(new_kraken, key=itemgetter('kraken_count'), reverse=True)[0:10] +# except (AttributeError, ZeroDivisionError): +# new_kraken = [] +# output = dict( +# name=self.name, +# type=self.controltype.name, +# targets=", ".join(self.targets), +# kraken=new_kraken +# ) +# return output +# +# def convert_by_mode(self, control_sub_type: str, mode: Literal['kraken', 'matches', 'contains'], +# consolidate: bool = False) -> Generator[dict, None, None]: +# """ +# split this instance into analysis types ('kraken', 'matches', 'contains') for control graphs +# +# Args: +# consolidate (bool): whether to merge all off-target genera. Defaults to False +# control_sub_type (str): control subtype, 'MCS-NOS', etc. +# mode (Literal['kraken', 'matches', 'contains']): analysis type, 'contains', etc. +# +# Returns: +# List[dict]: list of records +# """ +# try: +# data = self.__getattribute__(mode) +# except TypeError: +# data = {} +# if data is None: +# data = {} +# # NOTE: Data truncation and consolidation. +# if "kraken" in mode: +# data = {k: v for k, v in sorted(data.items(), key=lambda d: d[1][f"{mode}_count"], reverse=True)[:50]} +# else: +# if consolidate: +# on_tar = {k: v for k, v in data.items() if k.strip("*") in self.controltype.targets[control_sub_type]} +# off_tar = sum(v[f'{mode}_ratio'] for k, v in data.items() if +# k.strip("*") not in self.controltype.targets[control_sub_type]) +# on_tar['Off-target'] = {f"{mode}_ratio": off_tar} +# data = on_tar +# for genus in data: +# _dict = dict( +# name=self.name, +# submitted_date=self.submitted_date, +# genus=genus, +# target='Target' if genus.strip("*") in self.controltype.targets[control_sub_type] else "Off-target" +# ) +# for key in data[genus]: +# _dict[key] = data[genus][key] +# yield _dict +# +# @classproperty +# def modes(cls) -> List[str]: +# """ +# Get all control modes from database +# +# Returns: +# List[str]: List of control mode names. +# """ +# try: +# cols = [item.name for item in list(cls.__table__.columns) if isinstance(item.type, JSON)] +# except AttributeError as e: +# logger.error(f"Failed to get available modes from db: {e}") +# cols = [] +# return cols +# +# @classmethod +# def make_parent_buttons(cls, parent: QWidget) -> None: +# """ +# Creates buttons for controlling +# +# Args: +# parent (QWidget): chart holding widget to add buttons to. +# +# """ +# super().make_parent_buttons(parent=parent) +# rows = parent.layout.rowCount() - 2 +# # NOTE: check box for consolidating off-target items +# checker = QCheckBox(parent) +# checker.setChecked(True) +# checker.setObjectName("irida_check") +# checker.setToolTip("Pools off-target genera to save time.") +# parent.layout.addWidget(QLabel("Consolidate Off-targets"), rows, 0, 1, 1) +# parent.layout.addWidget(checker, rows, 1, 1, 2) +# checker.checkStateChanged.connect(parent.update_data) +# +# @classmethod +# @report_result +# def make_chart(cls, chart_settings: dict, parent, ctx) -> Tuple[Report, "IridaFigure" | None]: +# """ +# Creates a IridaFigure. Overrides parent +# +# Args: +# parent (__type__): Widget to contain the chart. +# chart_settings (dict): settings passed down from chart widget +# ctx (Settings): settings passed down from gui. +# +# Returns: +# Tuple[Report, "IridaFigure"]: Report of status and resulting figure. +# """ +# from frontend.visualizations import IridaFigure +# try: +# checker = parent.findChild(QCheckBox, name="irida_check") +# if chart_settings['mode'] == "kraken": +# checker.setEnabled(False) +# checker.setChecked(False) +# else: +# checker.setEnabled(True) +# consolidate = checker.isChecked() +# except AttributeError: +# consolidate = False +# report = Report() +# control = cls.query(subtype=chart_settings['submissiontype'], start_date=chart_settings['start_date'], +# end_date=chart_settings['end_date']) +# if not control: +# report.add_result(Result(status="Critical", msg="No control found in given date range.")) +# return report, None +# # NOTE: change each control to list of dictionaries +# data = [control.convert_by_mode(control_sub_type=chart_settings['submissiontype'], mode=chart_settings['mode'], +# consolidate=consolidate) for +# control in control] +# # NOTE: flatten data to one dimensional list +# # data = [item for sublist in data for item in sublist] +# data = flatten_list(data) +# if not data: +# report.add_result(Result(status="Critical", msg="No data found for control in given date range.")) +# return report, None +# df = cls.convert_data_list_to_df(input_df=data, sub_mode=chart_settings['sub_mode']) +# if chart_settings['sub_mode'] is None: +# title = chart_settings['sub_mode'] +# else: +# title = f"{chart_settings['mode']} - {chart_settings['sub_mode']}" +# # NOTE: send dataframe to chart maker +# df, modes = cls.prep_df(ctx=ctx, df=df) +# fig = IridaFigure(df=df, ytitle=title, modes=modes, parent=parent, +# settings=chart_settings) +# return report, fig +# +# @classmethod +# def convert_data_list_to_df(cls, input_df: list[dict], sub_mode) -> DataFrame: +# """ +# Convert list of control records to dataframe +# +# Args: +# input_df (list[dict]): list of dictionaries containing records +# sub_mode (str | None, optional): submissiontype of procedure type. Defaults to None. +# +# Returns: +# DataFrame: dataframe of control +# """ +# df = DataFrame.from_records(input_df) +# safe = ['name', 'submitted_date', 'genus', 'target'] +# for column in df.columns: +# if column not in safe: +# if sub_mode is not None and column != sub_mode: +# continue +# else: +# safe.append(column) +# if "percent" in column: +# try: +# count_col = next(item for item in df.columns if "count" in item) +# except StopIteration: +# continue +# # NOTE: The actual percentage from kraken was off due to exclusion of NaN, recalculating. +# df[column] = 100 * df[count_col] / df.groupby('name')[count_col].transform('sum') +# df = df[[c for c in df.columns if c in safe]] +# # NOTE: move date of sample submitted on same date as previous ahead one. +# df = cls.displace_date(df=df) +# # NOTE: ad hoc method to make data labels more accurate. +# df = cls.df_column_renamer(df=df) +# return df +# +# @classmethod +# def df_column_renamer(cls, df: DataFrame) -> DataFrame: +# """ +# Ad hoc function I created to clarify some fields +# +# Args: +# df (DataFrame): input dataframe +# +# Returns: +# DataFrame: dataframe with 'clarified' column names +# """ +# df = df[df.columns.drop(list(df.filter(regex='_hashes')))] +# return df.rename(columns={ +# "contains_ratio": "contains_shared_hashes_ratio", +# "matches_ratio": "matches_shared_hashes_ratio", +# "kraken_count": "kraken2_read_count_(top_50)", +# "kraken_percent": "kraken2_read_percent_(top_50)" +# }) +# +# @classmethod +# def displace_date(cls, df: DataFrame) -> DataFrame: +# """ +# This function serves to split sample that were submitted on the same date by incrementing dates. +# It will shift the date forward by one day if it is the same day as an existing date in a list. +# +# Args: +# df (DataFrame): input dataframe composed of control records +# +# Returns: +# DataFrame: output dataframe with dates incremented. +# """ +# # NOTE: get submitted dates for each control +# dict_list = [dict(name=item, date=df[df.name == item].iloc[0]['submitted_date']) for item in +# sorted(df['name'].unique())] +# previous_dates = set() +# for item in dict_list: +# df, previous_dates = cls.check_date(df=df, item=item, previous_dates=previous_dates) +# return df +# +# @classmethod +# def check_date(cls, df: DataFrame, item: dict, previous_dates: set) -> Tuple[DataFrame, list]: +# """ +# Checks if an items date is already present in df and adjusts df accordingly +# +# Args: +# df (DataFrame): input dataframe +# item (dict): control for checking +# previous_dates (list): list of dates found in previous control +# +# Returns: +# Tuple[DataFrame, list]: Output dataframe and appended list of previous dates +# """ +# try: +# check = item['date'] in previous_dates +# except IndexError: +# check = False +# previous_dates.add(item['date']) +# if check: +# # NOTE: get df locations where name == item name +# mask = df['name'] == item['name'] +# # NOTE: increment date in dataframe +# df.loc[mask, 'submitted_date'] = df.loc[mask, 'submitted_date'].apply(lambda x: x + timedelta(days=1)) +# item['date'] += timedelta(days=1) +# passed = False +# else: +# passed = True +# # NOTE: if procedure didn't lead to changed date, return values +# if passed: +# return df, previous_dates +# # NOTE: if date was changed, rerun with new date +# else: +# logger.warning(f"Date check failed, running recursion.") +# df, previous_dates = cls.check_date(df, item, previous_dates) +# return df, previous_dates +# +# @classmethod +# def prep_df(cls, ctx: Settings, df: DataFrame) -> Tuple[DataFrame | None, list]: +# """ +# Constructs figures based on parsed pandas dataframe. +# +# Args: +# ctx (Settings): settings passed down from gui +# df (pd.DataFrame): input dataframe +# ytitle (str | None, optional): title for the y-axis. Defaults to None. +# +# Returns: +# Figure: Plotly figure +# """ +# # NOTE: converts starred genera to normal and splits off list of starred +# if df.empty: +# return None, [] +# df['genus'] = df['genus'].replace({'\*': ''}, regex=True).replace({"NaN": "Unknown"}) +# df['genera'] = [item[-1] if item and item[-1] == "*" else "" for item in df['genus'].to_list()] +# # NOTE: remove original run, using reruns if applicable +# df = cls.drop_reruns_from_df(ctx=ctx, df=df) +# # NOTE: sort by and exclude from +# sorts = ['submitted_date', "target", "genus"] +# exclude = ['name', 'genera'] +# modes = [item for item in df.columns if item not in sorts and item not in exclude] +# # NOTE: Set descending for any columns that have "{mode}" in the header. +# ascending = [False if item == "target" else True for item in sorts] +# df = df.sort_values(by=sorts, ascending=ascending) +# # NOTE: actual chart construction is done by +# return df, modes +# +# @classmethod +# def drop_reruns_from_df(cls, ctx: Settings, df: DataFrame) -> DataFrame: +# """ +# Removes semi-duplicates from dataframe after finding sequencing repeats. +# +# Args: +# ctx (Settings): settings passed from gui +# df (DataFrame): initial dataframe +# +# Returns: +# DataFrame: dataframe with originals removed in favour of repeats. +# """ +# if 'rerun_regex' in ctx.model_extra: +# sample_names = get_unique_values_in_df_column(df, column_name="name") +# rerun_regex = re.compile(fr"{ctx.rerun_regex}") +# exclude = [re.sub(rerun_regex, "", sample) for sample in sample_names if rerun_regex.search(sample)] +# df = df[~df.name.isin(exclude)] +# return df +# +# def to_pydantic(self) -> "PydIridaControl": +# """ +# Constructs a pydantic version of this object. +# +# Returns: +# PydIridaControl: This object as a pydantic model. +# """ +# from backend.validators import PydIridaControl +# return PydIridaControl(**self.__dict__) +# +# @property +# def is_positive_control(self): +# return not self.subtype.lower().startswith("en") diff --git a/src/submissions/backend/db/models/kits.py b/src/submissions/backend/db/models/kits.py index fb9fae0..2f3897c 100644 --- a/src/submissions/backend/db/models/kits.py +++ b/src/submissions/backend/db/models/kits.py @@ -1,5 +1,5 @@ """ -All kit and reagent related models +All kittype and reagent related models """ from __future__ import annotations import json, zipfile, yaml, logging, re, sys @@ -17,61 +17,53 @@ from tools import check_authorization, setup_lookup, Report, Result, check_regex from typing import List, Literal, Generator, Any, Tuple from pandas import ExcelFile from pathlib import Path -from . import Base, BaseClass, Organization, LogMixin, ProcedureType +from . import Base, BaseClass, ClientLab, LogMixin from io import BytesIO -logger = logging.getLogger(f'submissions.{__name__}') +logger = logging.getLogger(f'procedure.{__name__}') -reagentroles_reagents = Table( - "_reagentroles_reagents", +reagentrole_reagent = Table( + "_reagentrole_reagent", Base.metadata, Column("reagent_id", INTEGER, ForeignKey("_reagent.id")), Column("reagentrole_id", INTEGER, ForeignKey("_reagentrole.id")), extend_existing=True ) -equipmentroles_equipment = Table( - "_equipmentroles_equipment", +equipmentrole_equipment = Table( + "_equipmentrole_equipment", Base.metadata, Column("equipment_id", INTEGER, ForeignKey("_equipment.id")), - Column("equipmentroles_id", INTEGER, ForeignKey("_equipmentrole.id")), + Column("equipmentrole_id", INTEGER, ForeignKey("_equipmentrole.id")), extend_existing=True ) -equipment_processes = Table( - "_equipment_processes", +equipment_process = Table( + "_equipment_process", Base.metadata, Column("process_id", INTEGER, ForeignKey("_process.id")), Column("equipment_id", INTEGER, ForeignKey("_equipment.id")), extend_existing=True ) -equipmentroles_processes = Table( - "_equipmentroles_processes", +equipmentrole_process = Table( + "_equipmentrole_process", Base.metadata, Column("process_id", INTEGER, ForeignKey("_process.id")), Column("equipmentrole_id", INTEGER, ForeignKey("_equipmentrole.id")), extend_existing=True ) -submissiontypes_processes = Table( - "_submissiontypes_processes", +kittype_process = Table( + "_kittype_process", Base.metadata, Column("process_id", INTEGER, ForeignKey("_process.id")), - Column("equipmentroles_id", INTEGER, ForeignKey("_submissiontype.id")), + Column("kittype_id", INTEGER, ForeignKey("_kittype.id")), extend_existing=True ) -kittypes_processes = Table( - "_kittypes_processes", - Base.metadata, - Column("process_id", INTEGER, ForeignKey("_process.id")), - Column("kit_id", INTEGER, ForeignKey("_kittype.id")), - extend_existing=True -) - -tiproles_tips = Table( - "_tiproles_tips", +tiprole_tips = Table( + "_tiprole_tips", Base.metadata, Column("tiprole_id", INTEGER, ForeignKey("_tiprole.id")), Column("tips_id", INTEGER, ForeignKey("_tips.id")), @@ -94,49 +86,66 @@ equipment_tips = Table( extend_existing=True ) -kittypes_runs = Table( - "_kittypes_runs", +kittype_procedure = Table( + "_kittype_procedure", Base.metadata, - Column("_basicrun_id", INTEGER, ForeignKey("_basicrun.id")), + Column("procedure_id", INTEGER, ForeignKey("_procedure.id")), Column("kittype_id", INTEGER, ForeignKey("_kittype.id")), extend_existing=True ) +proceduretype_process = Table( + "_proceduretype_process", + Base.metadata, + Column("process_id", INTEGER, ForeignKey("_process.id")), + Column("proceduretype_id", INTEGER, ForeignKey("_proceduretype.id")), + extend_existing=True +) + +submissiontype_proceduretype = Table( + "_submissiontype_proceduretype", + Base.metadata, + Column("submissiontype_id", INTEGER, ForeignKey("_submissiontype.id")), + Column("proceduretype_id", INTEGER, ForeignKey("_proceduretype.id")), + extend_existing=True +) + class KitType(BaseClass): """ - Base of kits used in run processing + Base of kits used in procedure processing """ - omni_sort = BaseClass.omni_sort + ["kit_submissiontype_associations", "kit_reagentrole_associations", "processes"] + omni_sort = BaseClass.omni_sort + ["kittypesubmissiontypeassociations", "kittypereagentroleassociation", + "process"] id = Column(INTEGER, primary_key=True) #: primary key - name = Column(String(64), unique=True) #: name of kit - runs = relationship("BasicRun", back_populates="kittypes", - secondary=kittypes_runs) #: runs this kit was used for - processes = relationship("Process", back_populates="kit_types", - secondary=kittypes_processes) #: equipment processes used by this kit + name = Column(String(64), unique=True) #: name of kittype + procedure = relationship("Procedure", back_populates="kittype", + secondary=kittype_procedure) #: run this kittype was used for + process = relationship("Process", back_populates="kittype", + secondary=kittype_process) #: equipment process used by this kittype - kit_reagentrole_associations = relationship( + kittypereagentroleassociation = relationship( "KitTypeReagentRoleAssociation", - back_populates="kit_type", + back_populates="kittype", cascade="all, delete-orphan", ) # NOTE: creator function: https://stackoverflow.com/questions/11091491/keyerror-when-adding-objects-to-sqlalchemy-association-object/11116291#11116291 - reagent_roles = association_proxy("kit_reagentrole_associations", "reagent_role", - creator=lambda RT: KitTypeReagentRoleAssociation( - reagent_role=RT)) #: Association proxy to KitTypeReagentRoleAssociation + reagentrole = association_proxy("kittypereagentroleassociation", "reagentrole", + creator=lambda RT: KitTypeReagentRoleAssociation( + reagentrole=RT)) #: Association proxy to KitTypeReagentRoleAssociation - kit_submissiontype_associations = relationship( - "SubmissionTypeKitTypeAssociation", - back_populates="kit_type", + kittypeproceduretypeassociation = relationship( + "ProcedureTypeKitTypeAssociation", + back_populates="kittype", cascade="all, delete-orphan", ) #: Relation to SubmissionType - used_for = association_proxy("kit_submissiontype_associations", "submission_type", - creator=lambda ST: SubmissionTypeKitTypeAssociation( - submission_type=ST)) #: Association proxy to SubmissionTypeKitTypeAssociation + proceduretype = association_proxy("kittypeproceduretypeassociation", "proceduretype", + creator=lambda ST: ProcedureTypeKitTypeAssociation( + submissiontype=ST)) #: Association proxy to SubmissionTypeKitTypeAssociation @classproperty def aliases(cls) -> List[str]: @@ -146,76 +155,71 @@ class KitType(BaseClass): Returns: List[str]: List of names """ - return super().aliases + [cls.query_alias, "kit_types", "kit_type"] - - @hybrid_property - def submissiontype(self): - """Alias used_for field to allow query with SubmissionType query alias""" - return self.used_for + return super().aliases + [cls.query_alias, "kittype", "kittype"] def get_reagents(self, required_only: bool = False, - submission_type: str | SubmissionType | None = None + proceduretype: str | SubmissionType | None = None ) -> Generator[ReagentRole, None, None]: """ - Return ReagentTypes linked to kit through KitTypeReagentTypeAssociation. + Return ReagentTypes linked to kittype through KitTypeReagentTypeAssociation. Args: required_only (bool, optional): If true only return required types. Defaults to False. - submission_type (str | Submissiontype | None, optional): Submission type to narrow results. Defaults to None. + proceduretype (str | Submissiontype | None, optional): Submission type to narrow results. Defaults to None. Returns: - Generator[ReagentRole, None, None]: List of reagent roles linked to this kit. + Generator[ReagentRole, None, None]: List of reagent roles linked to this kittype. """ - match submission_type: - case SubmissionType(): - relevant_associations = [item for item in self.kit_reagentrole_associations if - item.submission_type == submission_type] + match proceduretype: + case ProcedureType(): + relevant_associations = [item for item in self.kittypereagentroleassociation if + item.proceduretype == proceduretype] case str(): - relevant_associations = [item for item in self.kit_reagentrole_associations if - item.submission_type.name == submission_type] + relevant_associations = [item for item in self.kittypereagentroleassociation if + item.proceduretype.name == proceduretype] case _: - relevant_associations = [item for item in self.kit_reagentrole_associations] + relevant_associations = [item for item in self.kittypereagentroleassociation] if required_only: return (item.reagent_role for item in relevant_associations if item.required == 1) else: return (item.reagent_role for item in relevant_associations) - def construct_xl_map_for_use(self, submission_type: str | SubmissionType) -> Tuple[dict | None, KitType]: + def construct_xl_map_for_use(self, proceduretype: str | SubmissionType) -> Tuple[dict | None, KitType]: """ Creates map of locations in Excel workbook for a SubmissionType Args: - submission_type (str | SubmissionType): Submissiontype.name + proceduretype (str | SubmissionType): Submissiontype.name Returns: Generator[(str, str), None, None]: Tuple containing information locations. """ new_kit = self - # NOTE: Account for submission_type variable type. - match submission_type: + # NOTE: Account for proceduretype variable type. + match proceduretype: case str(): - # logger.debug(f"Query for {submission_type}") - submission_type = SubmissionType.query(name=submission_type) + # logger.debug(f"Query for {proceduretype}") + proceduretype = ProcedureType.query(name=proceduretype) case SubmissionType(): pass case _: - raise ValueError(f"Wrong variable type: {type(submission_type)} used!") - # logger.debug(f"Submission type: {submission_type}, Kit: {self}") - assocs = [item for item in self.kit_reagentrole_associations if item.submission_type == submission_type] + raise ValueError(f"Wrong variable type: {type(proceduretype)} used!") + # logger.debug(f"Submission type: {proceduretype}, Kit: {self}") + assocs = [item for item in self.kittypereagentroleassociation if item.proceduretype == proceduretype] # logger.debug(f"Associations: {assocs}") - # NOTE: rescue with run type's default kit. + # NOTE: rescue with procedure type's default kittype. if not assocs: logger.error( - f"No associations found with {self}. Attempting rescue with default kit: {submission_type.default_kit}") - new_kit = submission_type.default_kit + f"No associations found with {self}. Attempting rescue with default kittype: {proceduretype.default_kit}") + new_kit = proceduretype.default_kit if not new_kit: from frontend.widgets.pop_ups import ObjectSelector dlg = ObjectSelector( title="Select Kit", - message="Could not find reagents for this run type/kit type combo.\nSelect new kit.", + message="Could not find reagents for this procedure type/kittype type combo.\nSelect new kittype.", obj_type=self.__class__, - values=[kit.name for kit in submission_type.kit_types] + values=[kit.name for kit in proceduretype.kittype] ) if dlg.exec(): dlg_result = dlg.parse_form() @@ -224,8 +228,8 @@ class KitType(BaseClass): # logger.debug(f"Query result: {new_kit}") else: return None, new_kit - assocs = [item for item in new_kit.kit_reagentrole_associations if item.submission_type == submission_type] - output = {assoc.reagent_role.name: assoc.uses for assoc in assocs} + assocs = [item for item in new_kit.kittypereagentroleassociation if item.proceduretype == proceduretype] + output = {assoc.reagentrole.name: assoc.uses for assoc in assocs} # logger.debug(f"Output: {output}") return output, new_kit @@ -247,7 +251,7 @@ class KitType(BaseClass): @setup_lookup def query(cls, name: str = None, - submissiontype: str | SubmissionType | None = None, + proceduretype: str | ProcedureType | None = None, id: int | None = None, limit: int = 0, **kwargs @@ -256,8 +260,8 @@ class KitType(BaseClass): Lookup a list of or single KitType. Args: - name (str, optional): Name of desired kit (returns single instance). Defaults to None. - submissiontype (str | Submissiontype | None, optional): Submission type the kit is used for. Defaults to None. + name (str, optional): Name of desired kittype (returns single instance). Defaults to None. + proceduretype (str | ProcedureType | None, optional): Submission type the kittype is used for. Defaults to None. id (int | None, optional): Kit id in the database. Defaults to None. limit (int, optional): Maximum number of results to return (0 = all). Defaults to 0. @@ -265,11 +269,11 @@ class KitType(BaseClass): KitType|List[KitType]: KitType(s) of interest. """ query: Query = cls.__database_session__.query(cls) - match submissiontype: + match proceduretype: case str(): - query = query.filter(cls.submissiontype.any(name=submissiontype)) - case SubmissionType(): - query = query.filter(cls.submissiontype.contains(submissiontype)) + query = query.filter(cls.proceduretype.any(name=proceduretype)) + case ProcedureType(): + query = query.filter(cls.proceduretype.contains(proceduretype)) case _: pass match name: @@ -293,41 +297,41 @@ class KitType(BaseClass): def save(self): super().save() - # def to_export_dict(self, submission_type: SubmissionType) -> dict: + # def to_export_dict(self, proceduretype: SubmissionType) -> dict: # """ # Creates dictionary for exporting to yml used in new SubmissionType Construction # # Args: - # submission_type (SubmissionType): SubmissionType of interest. + # proceduretype (SubmissionType): SubmissionType of interest. # # Returns: # dict: Dictionary containing relevant info for SubmissionType construction # """ - # base_dict = dict(name=self.name, reagent_roles=[], equipment_roles=[]) - # for key, value in self.construct_xl_map_for_use(submission_type=submission_type): + # base_dict = dict(name=self.name, reagent_roles=[], equipmentrole=[]) + # for key, value in self.construct_xl_map_for_use(proceduretype=proceduretype): # try: - # assoc = next(item for item in self.kit_reagentrole_associations if item.reagent_role.name == key) + # assoc = next(item for item in self.kit_reagentrole_associations if item.reagentrole.name == key) # except StopIteration as e: # continue # for kk, vv in assoc.to_export_dict().items(): # value[kk] = vv # base_dict['reagent_roles'].append(value) - # for key, value in submission_type.construct_field_map("equipment"): + # for key, value in proceduretype.construct_field_map("equipment"): # try: - # assoc = next(item for item in submission_type.submissiontype_equipmentrole_associations if - # item.equipment_role.name == key) + # assoc = next(item for item in proceduretype.proceduretypeequipmentroleassociation if + # item.equipmentrole.name == key) # except StopIteration: # continue - # for kk, vv in assoc.to_export_dict(extraction_kit=self).items(): + # for kk, vv in assoc.to_export_dict(kittype=self).items(): # value[kk] = vv - # base_dict['equipment_roles'].append(value) + # base_dict['equipmentrole'].append(value) # return base_dict # @classmethod - # def import_from_yml(cls, submission_type: str | SubmissionType, filepath: Path | str | None = None, + # def import_from_yml(cls, proceduretype: str | SubmissionType, filepath: Path | str | None = None, # import_dict: dict | None = None) -> KitType: - # if isinstance(submission_type, str): - # submission_type = SubmissionType.query(name=submission_type) + # if isinstance(proceduretype, str): + # proceduretype = SubmissionType.query(name=proceduretype) # if filepath: # yaml.add_constructor("!regex", yaml_regex_creator) # if isinstance(filepath, str): @@ -342,75 +346,75 @@ class KitType(BaseClass): # import_dict = yaml.load(stream=f, Loader=yaml.Loader) # else: # raise Exception(f"Filetype {filepath.suffix} not supported.") - # new_kit = KitType.query(name=import_dict['kit_type']['name']) + # new_kit = KitType.query(name=import_dict['kittype']['name']) # if not new_kit: - # new_kit = KitType(name=import_dict['kit_type']['name']) - # for role in import_dict['kit_type']['reagent_roles']: - # new_role = ReagentRole.query(name=role['role']) + # new_kit = KitType(name=import_dict['kittype']['name']) + # for reagentrole in import_dict['kittype']['reagent_roles']: + # new_role = ReagentRole.query(name=reagentrole['reagentrole']) # if new_role: - # check = input(f"Found existing role: {new_role.name}. Use this? [Y/n]: ") + # check = input(f"Found existing reagentrole: {new_role.name}. Use this? [Y/n]: ") # if check.lower() == "n": # new_role = None # else: # pass # if not new_role: - # eol = timedelta(role['extension_of_life']) - # new_role = ReagentRole(name=role['role'], eol_ext=eol) - # uses = dict(expiry=role['expiry'], lot=role['lot'], name=role['name'], sheet=role['sheet']) - # ktrr_assoc = KitTypeReagentRoleAssociation(kit_type=new_kit, reagent_role=new_role, uses=uses) - # ktrr_assoc.submission_type = submission_type - # ktrr_assoc.required = role['required'] + # eol = timedelta(reagentrole['extension_of_life']) + # new_role = ReagentRole(name=reagentrole['reagentrole'], eol_ext=eol) + # uses = dict(expiry=reagentrole['expiry'], lot=reagentrole['lot'], name=reagentrole['name'], sheet=reagentrole['sheet']) + # ktrr_assoc = KitTypeReagentRoleAssociation(kittype=new_kit, reagentrole=new_role, uses=uses) + # ktrr_assoc.proceduretype = proceduretype + # ktrr_assoc.required = reagentrole['required'] # ktst_assoc = SubmissionTypeKitTypeAssociation( - # kit_type=new_kit, - # submission_type=submission_type, + # kittype=new_kit, + # proceduretype=proceduretype, # mutable_cost_sample=import_dict['mutable_cost_sample'], # mutable_cost_column=import_dict['mutable_cost_column'], # constant_cost=import_dict['constant_cost'] # ) - # for role in import_dict['kit_type']['equipment_roles']: - # new_role = EquipmentRole.query(name=role['role']) + # for reagentrole in import_dict['kittype']['equipmentrole']: + # new_role = EquipmentRole.query(name=reagentrole['reagentrole']) # if new_role: - # check = input(f"Found existing role: {new_role.name}. Use this? [Y/n]: ") + # check = input(f"Found existing reagentrole: {new_role.name}. Use this? [Y/n]: ") # if check.lower() == "n": # new_role = None # else: # pass # if not new_role: - # new_role = EquipmentRole(name=role['role']) - # for equipment in Equipment.assign_equipment(equipment_role=new_role): - # new_role.controls.append(equipment) - # ster_assoc = SubmissionTypeEquipmentRoleAssociation(submission_type=submission_type, - # equipment_role=new_role) + # new_role = EquipmentRole(name=reagentrole['reagentrole']) + # for equipment in Equipment.assign_equipment(equipmentrole=new_role): + # new_role.control.append(equipment) + # ster_assoc = ProcedureTypeEquipmentRoleAssociation(proceduretype=proceduretype, + # equipmentrole=new_role) # try: - # uses = dict(name=role['name'], process=role['process'], sheet=role['sheet'], - # static=role['static']) + # uses = dict(name=reagentrole['name'], process=reagentrole['process'], sheet=reagentrole['sheet'], + # static=reagentrole['static']) # except KeyError: # uses = None # ster_assoc.uses = uses - # for process in role['processes']: + # for process in reagentrole['process']: # new_process = Process.query(name=process) # if not new_process: # new_process = Process(name=process) - # new_process.submission_types.append(submission_type) - # new_process.kit_types.append(new_kit) - # new_process.equipment_roles.append(new_role) + # new_process.proceduretype.append(proceduretype) + # new_process.kittype.append(new_kit) + # new_process.equipmentrole.append(new_role) # return new_kit def to_omni(self, expand: bool = False) -> "OmniKitType": from backend.validators.omni_gui_objects import OmniKitType if expand: - processes = [item.to_omni() for item in self.processes] - kit_reagentrole_associations = [item.to_omni() for item in self.kit_reagentrole_associations] - kit_submissiontype_associations = [item.to_omni() for item in self.kit_submissiontype_associations] + processes = [item.to_omni() for item in self.process] + kittypereagentroleassociation = [item.to_omni() for item in self.kittypereagentroleassociation] + kittypeproceduretypeassociation = [item.to_omni() for item in self.kittypeproceduretypeassociation] else: processes = [item.name for item in self.processes] - kit_reagentrole_associations = [item.name for item in self.kit_reagentrole_associations] - kit_submissiontype_associations = [item.name for item in self.kit_submissiontype_associations] + kittypereagentroleassociation = [item.name for item in self.kittypereagentroleassociation] + kittypeproceduretypeassociation = [item.name for item in self.kittypeproceduretypeassociation] data = dict( name=self.name, processes=processes, - kit_reagentrole_associations=kit_reagentrole_associations, - kit_submissiontype_associations=kit_submissiontype_associations + kit_reagentrole_associations=kittypereagentroleassociation, + kit_submissiontype_associations=kittypeproceduretypeassociation ) # logger.debug(f"Creating omni for {pformat(data)}") return OmniKitType(instance_object=self, **data) @@ -423,21 +427,21 @@ class ReagentRole(BaseClass): skip_on_edit = False id = Column(INTEGER, primary_key=True) #: primary key - name = Column(String(64)) #: name of role reagent plays - instances = relationship("Reagent", back_populates="role", - secondary=reagentroles_reagents) #: concrete controls of this reagent type + name = Column(String(64)) #: name of reagentrole reagent plays + reagent = relationship("Reagent", back_populates="reagentrole", + secondary=reagentrole_reagent) #: concrete control of this reagent type eol_ext = Column(Interval()) #: extension of life interval - reagentrole_kit_associations = relationship( + reagentrolekittypeassociation = relationship( "KitTypeReagentRoleAssociation", - back_populates="reagent_role", + back_populates="reagentrole", cascade="all, delete-orphan", ) #: Relation to KitTypeReagentTypeAssociation # creator function: https://stackoverflow.com/questions/11091491/keyerror-when-adding-objects-to-sqlalchemy-association-object/11116291#11116291 - kit_types = association_proxy("reagentrole_kit_associations", "kit_type", - creator=lambda kit: KitTypeReagentRoleAssociation( - kit_type=kit)) #: Association proxy to KitTypeReagentRoleAssociation + kittype = association_proxy("reagentrolekittypeassociation", "kittype", + creator=lambda kit: KitTypeReagentRoleAssociation( + kittype=kit)) #: Association proxy to KitTypeReagentRoleAssociation @classmethod def query_or_create(cls, **kwargs) -> Tuple[ReagentRole, bool]: @@ -474,14 +478,14 @@ class ReagentRole(BaseClass): limit (int, optional): maxmimum number of results to return (0 = all). Defaults to 0. Raises: - ValueError: Raised if only kit_type or reagent, not both, given. + ValueError: Raised if only kittype or reagent, not both, given. Returns: ReagentRole|List[ReagentRole]: ReagentRole or list of ReagentRoles matching filter. """ query: Query = cls.__database_session__.query(cls) if (kittype is not None and reagent is None) or (reagent is not None and kittype is None): - raise ValueError("Cannot filter without both reagent and kit type.") + raise ValueError("Cannot filter without both reagent and kittype type.") elif kittype is None and reagent is None: pass else: @@ -496,8 +500,8 @@ class ReagentRole(BaseClass): case _: pass assert reagent.role - # NOTE: Get all roles common to the reagent and the kit. - result = set(kittype.reagent_roles).intersection(reagent.role) + # NOTE: Get all roles common to the reagent and the kittype. + result = set(kittype.reagentrole).intersection(reagent.role) return next((item for item in result), None) match name: case str(): @@ -521,22 +525,13 @@ class ReagentRole(BaseClass): PydReagent: PydReagent representation of this object. """ from backend.validators.pydant import PydReagent - return PydReagent(lot=None, role=self.name, name=self.name, expiry=date.today()) - - # def to_export_dict(self) -> dict: - # """ - # Creates dictionary for exporting to yml used in new SubmissionType Construction - # - # Returns: - # dict: Dictionary containing relevant info for SubmissionType construction - # """ - # return dict(role=self.name, extension_of_life=self.eol_ext.days) + return PydReagent(lot=None, reagentrole=self.name, name=self.name, expiry=date.today()) @check_authorization def save(self): super().save() - def to_omni(self, expand: bool=False): + def to_omni(self, expand: bool = False): from backend.validators.omni_gui_objects import OmniReagentRole logger.debug(f"Constructing OmniReagentRole with name {self.name}") return OmniReagentRole(instance_object=self, name=self.name, eol_ext=self.eol_ext) @@ -548,58 +543,53 @@ class Reagent(BaseClass, LogMixin): """ id = Column(INTEGER, primary_key=True) #: primary key - role = relationship("ReagentRole", back_populates="controls", - secondary=reagentroles_reagents) #: joined parent reagent type - role_id = Column(INTEGER, ForeignKey("_reagentrole.id", ondelete='SET NULL', - name="fk_reagent_role_id")) #: id of parent reagent type + reagentrole = relationship("ReagentRole", back_populates="reagent", + secondary=reagentrole_reagent) #: joined parent reagent type + reagentrole_id = Column(INTEGER, ForeignKey("_reagentrole.id", ondelete='SET NULL', + name="fk_REG_reagent_role_id")) #: id of parent reagent type name = Column(String(64)) #: reagent name lot = Column(String(64)) #: lot number of reagent expiry = Column(TIMESTAMP) #: expiry date - extended by eol_ext of parent programmatically - reagent_submission_associations = relationship( - "RunReagentAssociation", + reagentprocedureassociation = relationship( + "ProcedureReagentAssociation", back_populates="reagent", cascade="all, delete-orphan", - ) #: Relation to SubmissionSampleAssociation + ) #: Relation to ClientSubmissionSampleAssociation - submissions = association_proxy("reagent_submission_associations", "run", - creator=lambda sub: RunReagentAssociation( - submission=sub)) #: Association proxy to SubmissionSampleAssociation.samples + procedures = association_proxy("reagentprocedureassociation", "procedure", + creator=lambda procedure: ProcedureReagentAssociation( + procedure=procedure)) #: Association proxy to ClientSubmissionSampleAssociation.sample def __repr__(self): if self.name: name = f"" else: - name = f"" + name = f"" return name @classproperty def searchables(cls): return [dict(label="Lot", field="lot")] - @hybrid_property - def reagentrole(self): - """Alias role field to allow query with ReagentRole query alias""" - return self.role - - def to_sub_dict(self, extraction_kit: KitType = None, full_data: bool = False, **kwargs) -> dict: + def to_sub_dict(self, kittype: KitType = None, full_data: bool = False, **kwargs) -> dict: """ dictionary containing values necessary for gui Args: - extraction_kit (KitType, optional): KitType to use to get reagent type. Defaults to None. - full_data (bool, optional): Whether to include submissions in data for details. Defaults to False. + kittype (KitType, optional): KitType to use to get reagent type. Defaults to None. + full_data (bool, optional): Whether to include procedure in data for details. Defaults to False. Returns: dict: representation of the reagent's attributes """ - if extraction_kit is not None: + if kittype is not None: # NOTE: Get the intersection of this reagent's ReagentType and all ReagentTypes in KitType - reagent_role = next((item for item in set(self.role).intersection(extraction_kit.reagent_roles)), - self.role[0]) + reagent_role = next((item for item in set(self.reagentrole).intersection(kittype.reagentrole)), + self.reagentrole[0]) else: try: - reagent_role = self.role[0] + reagent_role = self.reagentrole[0] except IndexError: reagent_role = None try: @@ -619,14 +609,14 @@ class Reagent(BaseClass, LogMixin): place_holder = place_holder.strftime("%Y-%m-%d") output = dict( name=self.name, - role=rtype, + reagentrole=rtype, lot=self.lot, expiry=place_holder, missing=False ) if full_data: - output['submissions'] = [sub.rsl_plate_num for sub in self.submissions] - output['excluded'] = ['missing', 'submissions', 'excluded', 'editable'] + output['procedure'] = [sub.rsl_plate_num for sub in self.procedures] + output['excluded'] = ['missing', 'procedure', 'excluded', 'editable'] output['editable'] = ['lot', 'expiry'] return output @@ -661,9 +651,9 @@ class Reagent(BaseClass, LogMixin): sanitized_kwargs = {k: v for k, v in kwargs.items() if k not in disallowed} instance = cls.query(**sanitized_kwargs) if not instance or isinstance(instance, list): - if "role" not in kwargs: + if "reagentrole" not in kwargs: try: - kwargs['role'] = kwargs['name'] + kwargs['reagentrole'] = kwargs['name'] except KeyError: pass instance = PydReagent(**kwargs) @@ -676,7 +666,7 @@ class Reagent(BaseClass, LogMixin): @setup_lookup def query(cls, id: int | None = None, - role: str | ReagentRole | None = None, + reagentrole: str | ReagentRole | None = None, lot: str | None = None, name: str | None = None, limit: int = 0, @@ -702,11 +692,11 @@ class Reagent(BaseClass, LogMixin): limit = 1 case _: pass - match role: + match reagentrole: case str(): - query = query.join(cls.role).filter(ReagentRole.name == role) + query = query.join(cls.reagentrole).filter(ReagentRole.name == reagentrole) case ReagentRole(): - query = query.filter(cls.role.contains(role)) + query = query.filter(cls.reagentrole.contains(reagentrole)) case _: pass match name: @@ -728,7 +718,7 @@ class Reagent(BaseClass, LogMixin): match key: case "lot": value = value.upper() - case "role": + case "reagentrole": match value: case ReagentRole(): role = value @@ -737,7 +727,7 @@ class Reagent(BaseClass, LogMixin): case _: return if role and role not in self.role: - self.role.append(role) + self.reagentrole.append(role) return case "comment": return @@ -770,7 +760,7 @@ class Reagent(BaseClass, LogMixin): @classproperty def add_edit_tooltips(self): return dict( - expiry="Use exact date on reagent.\nEOL will be calculated from kit automatically" + expiry="Use exact date on reagent.\nEOL will be calculated from kittype automatically" ) @@ -782,11 +772,13 @@ class Discount(BaseClass): skip_on_edit = True id = Column(INTEGER, primary_key=True) #: primary key - kit = relationship("KitType") #: joined parent reagent type - kit_id = Column(INTEGER, ForeignKey("_kittype.id", ondelete='SET NULL', name="fk_kit_type_id")) #: id of joined kit - client = relationship("Organization") #: joined client lab - client_id = Column(INTEGER, - ForeignKey("_organization.id", ondelete='SET NULL', name="fk_org_id")) #: id of joined client + kittype = relationship("KitType") #: joined parent reagent type + kittype_id = Column(INTEGER, ForeignKey("_kittype.id", ondelete='SET NULL', + name="fk_DIS_kit_type_id")) #: id of joined kittype + clientlab = relationship("ClientLab") #: joined client lab + clientlab_id = Column(INTEGER, + ForeignKey("_clientlab.id", ondelete='SET NULL', + name="fk_DIS_org_id")) #: id of joined client name = Column(String(128)) #: Short description amount = Column(FLOAT(2)) #: Dollar amount of discount @@ -800,32 +792,32 @@ class Discount(BaseClass): @classmethod @setup_lookup def query(cls, - organization: Organization | str | int | None = None, + clientlab: ClientLab | str | int | None = None, kittype: KitType | str | int | None = None, ) -> Discount | List[Discount]: """ - Lookup discount objects (union of kit and organization) + Lookup discount objects (union of kittype and clientlab) Args: - organization (models.Organization | str | int): Organization receiving discount. - kit_type (models.KitType | str | int): Kit discount received on. + clientlab (models.ClientLab | str | int): ClientLab receiving discount. + kittype (models.KitType | str | int): Kit discount received on. Returns: models.Discount|List[models.Discount]: Discount(s) of interest. """ query: Query = cls.__database_session__.query(cls) - match organization: - case Organization(): - query = query.filter(cls.client == Organization) + match clientlab: + case ClientLab(): + query = query.filter(cls.clientlab == clientlab) case str(): - query = query.join(Organization).filter(Organization.name == organization) + query = query.join(ClientLab).filter(ClientLab.name == clientlab) case int(): - query = query.join(Organization).filter(Organization.id == organization) + query = query.join(ClientLab).filter(ClientLab.id == clientlab) case _: pass match kittype: case KitType(): - query = query.filter(cls.kit == kittype) + query = query.filter(cls.kittype == kittype) case str(): query = query.join(KitType).filter(KitType.name == kittype) case int(): @@ -841,244 +833,19 @@ class Discount(BaseClass): class SubmissionType(BaseClass): """ - Abstract of types of submissions. + Abstract of types of procedure. """ id = Column(INTEGER, primary_key=True) #: primary key - name = Column(String(128), unique=True) #: name of run type + name = Column(String(128), unique=True) #: name of procedure type info_map = Column(JSON) #: Where parsable information is found in the excel workbook corresponding to this type. - defaults = Column(JSON) #: Basic information about this run type - clientsubmissions = relationship("ClientSubmission", back_populates="submission_type") #: Concrete controls of this type. + defaults = Column(JSON) #: Basic information about this procedure type + clientsubmission = relationship("ClientSubmission", + back_populates="submissiontype") #: Concrete control of this type. template_file = Column(BLOB) #: Blank form for this type stored as binary. - processes = relationship("Process", back_populates="submission_types", - secondary=submissiontypes_processes) #: Relation to equipment processes used for this type. sample_map = Column(JSON) #: Where sample information is found in the excel sheet corresponding to this type. - - """ - Example info_map (Bacterial Culture) - NOTE: read locations will be appended to write locations. - - { - "comment": { - "read": [ - { - "column": 2, - "row": 34, - "sheet": "Sample List" - } - ], - "write": [] - }, - "contact": { - "read": [ - { - "column": 2, - "row": 4, - "sheet": "Sample List" - } - ], - "write": [] - }, - "contact_phone": { - "read": [], - "write": [ - { - "column": 2, - "row": 5, - "sheet": "Sample List" - } - ] - }, - "cost_centre": { - "read": [ - { - "column": 2, - "row": 6, - "sheet": "Sample List" - } - ], - "write": [] - }, - "custom": {}, - "extraction_kit": { - "read": [ - { - "column": 4, - "row": 5, - "sheet": "Sample List" - } - ], - "write": [] - }, - "rsl_plate_num": { - "read": [ - { - "column": 2, - "row": 13, - "sheet": "Sample List" - } - ], - "write": [] - }, - "sample_count": { - "read": [ - { - "column": 4, - "row": 4, - "sheet": "Sample List" - } - ], - "write": [] - }, - "signed_by": { - "read": [], - "write": [ - { - "column": 2, - "row": 15, - "sheet": "Sample List" - } - ] - }, - "submission_category": { - "read": [ - { - "column": 4, - "row": 6, - "sheet": "Sample List" - } - ], - "write": [] - }, - "submission_type": { - "read": [ - { - "column": 4, - "row": 3, - "sheet": "Sample List" - } - ], - "write": [] - }, - "submitted_date": { - "read": [ - { - "column": 2, - "row": 3, - "sheet": "Sample List" - } - ], - "write": [] - }, - "submitter_plate_num": { - "read": [ - { - "column": 2, - "row": 2, - "sheet": "Sample List" - } - ], - "write": [] - }, - "submitting_lab": { - "read": [ - { - "column": 4, - "row": 2, - "sheet": "Sample List" - } - ], - "write": [] - }, - "technician": { - "read": [ - { - "column": 2, - "row": 14, - "sheet": "Sample List" - } - ], - "write": [] - } - } - """ - - """ - Example defaults (for Bacterial Culture) - - { - "abbreviation": "BC", - "details_ignore": [ - "controls" - ], - "form_ignore": [ - "controls", - "cost_centre" - ], - "regex": "(?PRSL(?:-|_)?BC(?:-|_)?20\\d{2}-?\\d{2}-?\\d{2}(?:(_|-)?\\d?([^_0123456789\\sA-QS-Z]|$)?R?\\d?)?)", - "sample_type": "Bacterial Culture Sample", - "turnaround_time": 3 - } - """ - - """ - Example sample_map (Bacterial Culture) - - { - "lookup_table": { - "end_row": 132, - "merge_on_id": "submitter_id", - "sample_columns": { - "column": 6, - "concentration": 4, - "organism": 3, - "row": 5, - "submitter_id": 2 - }, - "sheet": "Sample List", - "start_row": 37 - }, - "plate_map": { - "end_column": 13, - "end_row": 14, - "sheet": "Plate Map", - "start_column": 2, - "start_row": 7 - } - } - """ - - submissiontype_kit_associations = relationship( - "SubmissionTypeKitTypeAssociation", - back_populates="submission_type", - cascade="all, delete-orphan", - ) #: Association of kittypes - - kit_types = association_proxy("submissiontype_kit_associations", "kit_type", - creator=lambda kit: SubmissionTypeKitTypeAssociation( - kit_type=kit)) #: Proxy of kittype association - - submissiontype_equipmentrole_associations = relationship( - "SubmissionTypeEquipmentRoleAssociation", - back_populates="submission_type", - cascade="all, delete-orphan" - ) #: Association of equipmentroles - - equipment = association_proxy("submissiontype_equipmentrole_associations", "equipment_role", - creator=lambda eq: SubmissionTypeEquipmentRoleAssociation( - equipment_role=eq)) #: Proxy of equipmentrole associations - - submissiontype_kit_rt_associations = relationship( - "KitTypeReagentRoleAssociation", - back_populates="submission_type", - cascade="all, delete-orphan" - ) #: triple association of KitTypes, ReagentTypes, SubmissionTypes - - submissiontype_tiprole_associations = relationship( - "SubmissionTypeTipRoleAssociation", - back_populates="submission_type", - cascade="all, delete-orphan" - ) #: Association of tiproles + proceduretype = relationship("ProcedureType", back_populates="submissiontype", + secondary=submissiontype_proceduretype) #: run this kittype was used for def __repr__(self) -> str: """ @@ -1087,14 +854,6 @@ class SubmissionType(BaseClass): """ return f"" - @hybrid_property - def kittype(self): - return self.kit_types - - @hybrid_property - def process(self): - return self.processes - @classproperty def aliases(cls) -> List[str]: """ @@ -1103,11 +862,11 @@ class SubmissionType(BaseClass): Returns: List[str]: List of names """ - return super().aliases + ["submission_types", "submission_type"] + return super().aliases + ["submissiontypes"] @classproperty def omni_removes(cls): - return super().omni_removes + ["defaults", "controls"] + return super().omni_removes + ["defaults"] @classproperty def basic_template(cls) -> bytes: @@ -1195,64 +954,6 @@ class SubmissionType(BaseClass): fmap = {} yield getattr(item, f"{field}_role").name, fmap - @property - def default_kit(self) -> KitType | None: - """ - If only one kits exists for this Submission Type, return it. - - Returns: - KitType | None: - """ - if len(self.kit_types) == 1: - return self.kit_types[0] - else: - return None - - def get_equipment(self, extraction_kit: str | KitType | None = None) -> Generator['PydEquipmentRole', None, None]: - """ - Returns PydEquipmentRole of all equipment associated with this SubmissionType - - Returns: - Generator['PydEquipmentRole', None, None]: List of equipment roles - """ - return (item.to_pydantic(submission_type=self, extraction_kit=extraction_kit) for item in self.equipment) - - def get_processes_for_role(self, equipment_role: str | EquipmentRole, kit: str | KitType | None = None) -> list: - """ - Get processes associated with this SubmissionType for an EquipmentRole - - Args: - equipment_role (str | EquipmentRole): EquipmentRole of interest - kit (str | KitType | None, optional): Kit of interest. Defaults to None. - - Raises: - TypeError: Raised if wrong type given for equipmentrole - - Returns: - list: list of associated processes - """ - match equipment_role: - case str(): - relevant = [item.get_all_processes(kit) for item in self.submissiontype_equipmentrole_associations if - item.equipment_role.name == equipment_role] - case EquipmentRole(): - relevant = [item.get_all_processes(kit) for item in self.submissiontype_equipmentrole_associations if - item.equipment_role == equipment_role] - case _: - raise TypeError(f"Type {type(equipment_role)} is not allowed") - return list(set([item for items in relevant for item in items if item is not None])) - - @property - def submission_class(self) -> "BasicRun": - """ - Gets run class associated with this run type. - - Returns: - BasicRun: Submission class - """ - from .submissions import BasicRun - return BasicRun.find_polymorphic_subclass(polymorphic_identity=self.name) - @classmethod def query_or_create(cls, **kwargs) -> Tuple[SubmissionType, bool]: new = False @@ -1276,10 +977,10 @@ class SubmissionType(BaseClass): **kwargs ) -> SubmissionType | List[SubmissionType]: """ - Lookup run type in the database by a number of parameters + Lookup procedure type in the database by a number of parameters Args: - name (str | None, optional): Name of run type. Defaults to None. + name (str | None, optional): Name of procedure type. Defaults to None. key (str | None, optional): A key present in the info-map to lookup. Defaults to None. limit (int, optional): Maximum number of results to return (0 = all). Defaults to 0. @@ -1301,95 +1002,25 @@ class SubmissionType(BaseClass): pass return cls.execute_query(query=query, limit=limit) - # def to_export_dict(self): - # """ - # Creates dictionary for exporting to yml used in new SubmissionType Construction - # - # Returns: - # dict: Dictionary containing relevant info for SubmissionType construction - # """ - # base_dict = dict(name=self.name) - # base_dict['info'] = self.construct_info_map(mode='export') - # base_dict['defaults'] = self.defaults - # # base_dict['samples'] = self.construct_sample_map() - # base_dict['samples'] = self.sample_map - # base_dict['kits'] = [item.to_export_dict() for item in self.submissiontype_kit_associations] - # return base_dict - @check_authorization def save(self): """ - Adds this controls to the database and commits. + Adds this control to the database and commits. """ super().save() - @classmethod - @check_authorization - def import_from_json(cls, filepath: Path | str) -> SubmissionType: - """ - Creates a new SubmissionType from a yml file - - Args: - filepath (Path | str): Input yml file. - - Raises: - Exception: Raised if filetype is not a yml or json - - Returns: - SubmissionType: Created SubmissionType - """ - full = True - yaml.add_constructor("!regex", yaml_regex_creator) - if isinstance(filepath, str): - filepath = Path(filepath) - with open(filepath, "r") as f: - if filepath.suffix == ".json": - import_dict = json.load(fp=f) - elif filepath.suffix == ".yml": - import_dict = yaml.load(stream=f, Loader=yaml.Loader) - else: - raise Exception(f"Filetype {filepath.suffix} not supported.") - try: - submission_type = cls.query(name=import_dict['name']) - except KeyError: - logger.error(f"Submission type has no name") - submission_type = None - full = False - if full: - if submission_type: - return submission_type - submission_type = cls() - submission_type.name = import_dict['name'] - submission_type.info_map = import_dict['info'] - submission_type.sample_map = import_dict['samples'] - submission_type.defaults = import_dict['defaults'] - for kit in import_dict['kits']: - new_kit = KitType.import_from_yml(submission_type=submission_type, import_dict=kit) - if 'orgs' in import_dict.keys(): - logger.info("Found Organizations to be imported.") - Organization.import_from_yml(filepath=filepath) - return submission_type - def to_omni(self, expand: bool = False): from backend.validators.omni_gui_objects import OmniSubmissionType try: template_file = self.template_file except AttributeError: template_file = bytes() - if expand: - try: - processes = [item.to_omni() for item in self.processes] - except AttributeError: - processes = [] - else: - processes = [item.name for item in self.processes] return OmniSubmissionType( instance_object=self, name=self.name, info_map=self.info_map, defaults=self.defaults, template_file=template_file, - processes=processes, sample_map=self.sample_map ) @@ -1399,34 +1030,214 @@ class SubmissionType(BaseClass): return dicto +class ProcedureType(BaseClass): + id = Column(INTEGER, primary_key=True) + name = Column(String(64)) + reagent_map = Column(JSON) + plate_size = Column(INTEGER, default=0) + + procedure = relationship("Procedure", + back_populates="proceduretype") #: Concrete control of this type. + + process = relationship("Process", back_populates="proceduretype", + secondary=proceduretype_process) #: Relation to equipment process used for this type. + + submissiontype = relationship("SubmissionType", back_populates="proceduretype", + secondary=submissiontype_proceduretype) #: run this kittype was used for + + proceduretypekittypeassociation = relationship( + "ProcedureTypeKitTypeAssociation", + back_populates="proceduretype", + cascade="all, delete-orphan", + ) #: Association of kittypes + + kittype = association_proxy("proceduretypekittypeassociation", "kittype", + creator=lambda kit: ProcedureTypeKitTypeAssociation( + kittype=kit)) #: Proxy of kittype association + + proceduretypeequipmentroleassociation = relationship( + "ProcedureTypeEquipmentRoleAssociation", + back_populates="proceduretype", + cascade="all, delete-orphan" + ) #: Association of equipmentroles + + equipment = association_proxy("proceduretypeequipmentroleassociation", "equipmentrole", + creator=lambda eq: ProcedureTypeEquipmentRoleAssociation( + equipment_role=eq)) #: Proxy of equipmentrole associations + + kittypereagentroleassociation = relationship( + "KitTypeReagentRoleAssociation", + back_populates="proceduretype", + cascade="all, delete-orphan" + ) #: triple association of KitTypes, ReagentTypes, SubmissionTypes + + proceduretypetiproleassociation = relationship( + "ProcedureTypeTipRoleAssociation", + back_populates="proceduretype", + cascade="all, delete-orphan" + ) #: Association of tiproles + + def construct_field_map(self, field: Literal['equipment', 'tip']) -> Generator[(str, dict), None, None]: + """ + Make a map of all locations for tips or equipment. + + Args: + field (Literal['equipment', 'tip']): the field to construct a map for + + Returns: + Generator[(str, dict), None, None]: Generator composing key, locations for each item in the map + """ + for item in self.__getattribute__(f"proceduretype{field}role_associations"): + fmap = item.uses + if fmap is None: + fmap = {} + yield getattr(item, f"{field}_role").name, fmap + + @property + def default_kit(self) -> KitType | None: + """ + If only one kits exists for this Submission Type, return it. + + Returns: + KitType | None: + """ + if len(self.kittype) == 1: + return self.kittype[0] + else: + return None + + def get_equipment(self, kittype: str | KitType | None = None) -> Generator['PydEquipmentRole', None, None]: + """ + Returns PydEquipmentRole of all equipment associated with this SubmissionType + + Returns: + Generator['PydEquipmentRole', None, None]: List of equipment roles + """ + return (item.to_pydantic(proceduretype=self, kittype=kittype) for item in self.equipment) + + def get_processes_for_role(self, equipmentrole: str | EquipmentRole, kittype: str | KitType | None = None) -> list: + """ + Get process associated with this SubmissionType for an EquipmentRole + + Args: + equipmentrole (str | EquipmentRole): EquipmentRole of interest + kittype (str | KitType | None, optional): Kit of interest. Defaults to None. + + Raises: + TypeError: Raised if wrong type given for equipmentrole + + Returns: + list: list of associated process + """ + match equipmentrole: + case str(): + relevant = [item.get_all_processes(kittype) for item in self.proceduretypeequipmentroleassociation if + item.equipmentrole.name == equipmentrole] + case EquipmentRole(): + relevant = [item.get_all_processes(kittype) for item in self.proceduretypeequipmentroleassociation if + item.equipmentrole == equipmentrole] + case _: + raise TypeError(f"Type {type(equipmentrole)} is not allowed") + return list(set([item for items in relevant for item in items if item is not None])) + + +class Procedure(BaseClass): + id = Column(INTEGER, primary_key=True) + name = Column(String, unique=True) + repeat = Column(INTEGER, nullable=False) + technician = Column(JSON) #: name of processing tech(s) + proceduretype_id = Column(INTEGER, ForeignKey("_proceduretype.id", ondelete="SET NULL", + name="fk_PRO_proceduretype_id")) #: client lab id from _organizations)) + proceduretype = relationship("ProcedureType", back_populates="procedure") + run_id = Column(INTEGER, ForeignKey("_run.id", ondelete="SET NULL", + name="fk_PRO_basicrun_id")) #: client lab id from _organizations)) + run = relationship("Run", back_populates="procedure") + kittype_id = Column(INTEGER, ForeignKey("_kittype.id", ondelete="SET NULL", + name="fk_PRO_kittype_id")) #: client lab id from _organizations)) + kittype = relationship("KitType", back_populates="procedure") + control = relationship("Control", back_populates="procedure", uselist=True) #: A control sample added to procedure + + procedurereagentassociation = relationship( + "ProcedureReagentAssociation", + back_populates="procedure", + cascade="all, delete-orphan", + ) #: Relation to ProcedureReagentAssociation + + reagents = association_proxy("procedurereagentassociation", + "reagent", creator=lambda reg: ProcedureReagentAssociation(reagent=reg)) #: Association proxy to RunReagentAssociation.reagent + + procedureequipmentassociation = relationship( + "ProcedureEquipmentAssociation", + back_populates="procedure", + cascade="all, delete-orphan" + ) #: Relation to Equipment + + equipment = association_proxy("procedureequipmentassociation", + "equipment") #: Association proxy to RunEquipmentAssociation.equipment + + proceduretipsassociation = relationship( + "ProcedureTipsAssociation", + back_populates="procedure", + cascade="all, delete-orphan") + + tips = association_proxy("proceduretipsassociation", + "tips") + + @validates('repeat') + def validate_repeat(self, key, value): + if value > 1: + value = 1 + if value < 0: + value = 0 + return value + + @classmethod + @setup_lookup + def query(cls, id: int|None = None, name: str | None = None, limit: int = 0, **kwargs) -> Procedure | List[Procedure]: + query: Query = cls.__database_session__.query(cls) + match id: + case int(): + query = query.filter(cls.id == id) + limit = 1 + case _: + pass + match name: + case str(): + query = query.filter(cls.name == name) + limit = 1 + case _: + pass + return cls.execute_query(query=query, limit=limit) + + class ProcedureTypeKitTypeAssociation(BaseClass): """ - Abstract of relationship between kits and their run type. + Abstract of relationship between kits and their procedure type. """ - omni_removes = BaseClass.omni_removes + ["procedure_type_id", "procedure_id"] + omni_removes = BaseClass.omni_removes + ["proceduretype_id", "kittype_id"] omni_sort = ["proceduretype", "kittype"] level = 2 proceduretype_id = Column(INTEGER, ForeignKey("_proceduretype.id"), - primary_key=True) #: id of joined run type - kittype_id = Column(INTEGER, ForeignKey("_kittype.id"), primary_key=True) #: id of joined kit + primary_key=True) #: id of joined procedure type + kittype_id = Column(INTEGER, ForeignKey("_kittype.id"), primary_key=True) #: id of joined kittype mutable_cost_column = Column( FLOAT(2)) #: dollar amount per 96 well plate that can change with number of columns (reagents, tips, etc) mutable_cost_sample = Column( - FLOAT(2)) #: dollar amount that can change with number of samples (reagents, tips, etc) + FLOAT(2)) #: dollar amount that can change with number of sample (reagents, tips, etc) constant_cost = Column(FLOAT(2)) #: dollar amount per plate that will remain constant (plates, man hours, etc) - kittype = relationship(KitType, back_populates="kittype_proceduretype_associations") #: joined kittype + kittype = relationship(KitType, back_populates="kittypeproceduretypeassociation") #: joined kittype # reference to the "SubmissionType" object proceduretype = relationship(ProcedureType, - back_populates="proceduretype_kittype_associations") #: joined run type + back_populates="proceduretypekittypeassociation") #: joined procedure type - def __init__(self, kit_type=None, submission_type=None, + def __init__(self, kittype=None, proceduretype=None, mutable_cost_column: int = 0.00, mutable_cost_sample: int = 0.00, constant_cost: int = 0.00): - self.kit_type = kit_type - self.submission_type = submission_type + self.kittype = kittype + self.proceduretype = proceduretype self.mutable_cost_column = mutable_cost_column self.mutable_cost_sample = mutable_cost_sample self.constant_cost = constant_cost @@ -1437,40 +1248,24 @@ class ProcedureTypeKitTypeAssociation(BaseClass): str: Representation of this object """ try: - submission_type_name = self.submission_type.name + proceduretype_name = self.proceduretype.name except AttributeError: - submission_type_name = "None" + proceduretype_name = "None" try: - kit_type_name = self.kit_type.name + kittype_name = self.kittype.name except AttributeError: - kit_type_name = "None" - return f"" - - @hybrid_property - def kittype(self): - return self.kit_type - - @kittype.setter - def kittype(self, value): - self.kit_type = value - - @hybrid_property - def submissiontype(self): - return self.submission_type - - @submissiontype.setter - def submissiontype(self, value): - self.submission_type = value + kittype_name = "None" + return f"" @property def name(self): try: - return f"{self.submission_type.name} -> {self.kit_type.name}" + return f"{self.proceduretype.name} -> {self.kittype.name}" except AttributeError: return "Blank SubmissionTypeKitTypeAssociation" @classmethod - def query_or_create(cls, **kwargs) -> Tuple[SubmissionTypeKitTypeAssociation, bool]: + def query_or_create(cls, **kwargs) -> Tuple[ProcedureTypeKitTypeAssociation, bool]: new = False disallowed = ['expiry'] sanitized_kwargs = {k: v for k, v in kwargs.items() if k not in disallowed} @@ -1480,60 +1275,47 @@ class ProcedureTypeKitTypeAssociation(BaseClass): new = True for k, v in sanitized_kwargs.items(): setattr(instance, k, v) - logger.info(f"Instance from SubmissionTypeKitTypeAssociation query or create: {instance}") + logger.info(f"Instance from ProcedureTypeKitTypeAssociation query or create: {instance}") return instance, new @classmethod @setup_lookup def query(cls, - submissiontype: SubmissionType | str | int | None = None, + proceduretype: ProcedureType | str | int | None = None, kittype: KitType | str | int | None = None, limit: int = 0, **kwargs - ) -> SubmissionTypeKitTypeAssociation | List[SubmissionTypeKitTypeAssociation]: + ) -> ProcedureTypeKitTypeAssociation | List[ProcedureTypeKitTypeAssociation]: """ Lookup SubmissionTypeKitTypeAssociations of interest. Args: - submission_type (SubmissionType | str | int | None, optional): Identifier of run type. Defaults to None. - kit_type (KitType | str | int | None, optional): Identifier of kit type. Defaults to None. + proceduretype (ProcedureType | str | int | None, optional): Identifier of procedure type. Defaults to None. + kittype (KitType | str | int | None, optional): Identifier of kittype type. Defaults to None. limit (int, optional): Maximum number of results to return (0 = all). Defaults to 0. Returns: SubmissionTypeKitTypeAssociation|List[SubmissionTypeKitTypeAssociation]: SubmissionTypeKitTypeAssociation(s) of interest """ query: Query = cls.__database_session__.query(cls) - match submissiontype: - case SubmissionType(): - query = query.filter(cls.submission_type == submissiontype) + match proceduretype: + case ProcedureType(): + query = query.filter(cls.proceduretype == proceduretype) case str(): - query = query.join(SubmissionType).filter(SubmissionType.name == submissiontype) + query = query.join(ProcedureType).filter(ProcedureType.name == proceduretype) case int(): - query = query.join(SubmissionType).filter(SubmissionType.id == submissiontype) + query = query.join(ProcedureType).filter(ProcedureType.id == proceduretype) match kittype: case KitType(): - query = query.filter(cls.kit_type == kittype) + query = query.filter(cls.kittype == kittype) case str(): query = query.join(KitType).filter(KitType.name == kittype) case int(): query = query.join(KitType).filter(KitType.id == kittype) - if kittype is not None and submissiontype is not None: + if kittype is not None and proceduretype is not None: limit = 1 - # limit = query.count() return cls.execute_query(query=query, limit=limit) - # def to_export_dict(self): - # """ - # Creates a dictionary of relevant values in this object. - # - # Returns: - # dict: dictionary of Association and related kittype - # """ - # exclude = ['_sa_instance_state', 'submission_types_id', 'kits_id', 'submission_type', 'kit_type'] - # base_dict = {k: v for k, v in self.__dict__.items() if k not in exclude} - # base_dict['kit_type'] = self.kit_type.to_export_dict(submission_type=self.submission_type) - # return base_dict - def to_omni(self, expand: bool = False): from backend.validators.omni_gui_objects import OmniSubmissionTypeKitTypeAssociation if expand: @@ -1565,60 +1347,44 @@ class KitTypeReagentRoleAssociation(BaseClass): """ omni_removes = BaseClass.omni_removes + ["submission_type_id", "kits_id", "reagent_roles_id", "last_used"] - omni_sort = ["submission_type", "kit_type", "reagent_role", "required", "uses"] - omni_inheritable = ["submission_type", "kit_type"] + omni_sort = ["proceduretype", "kittype", "reagentrole", "required", "uses"] + omni_inheritable = ["proceduretype", "kittype"] - reagent_roles_id = Column(INTEGER, ForeignKey("_reagentrole.id"), - primary_key=True) #: id of associated reagent type - kits_id = Column(INTEGER, ForeignKey("_kittype.id"), primary_key=True) #: id of associated reagent type - submission_type_id = Column(INTEGER, ForeignKey("_submissiontype.id"), primary_key=True) - uses = Column(JSON) #: map to location on excel sheets of different run types - required = Column(INTEGER) #: whether the reagent type is required for the kit (Boolean 1 or 0) + reagentrole_id = Column(INTEGER, ForeignKey("_reagentrole.id"), + primary_key=True) #: id of associated reagent type + kittype_id = Column(INTEGER, ForeignKey("_kittype.id"), primary_key=True) #: id of associated reagent type + proceduretype_id = Column(INTEGER, ForeignKey("_proceduretype.id"), primary_key=True) + uses = Column(JSON) #: map to location on excel sheets of different procedure types + required = Column(INTEGER) #: whether the reagent type is required for the kittype (Boolean 1 or 0) last_used = Column(String(32)) #: last used lot number of this type of reagent - kit_type = relationship(KitType, - back_populates="kit_reagentrole_associations") #: relationship to associated KitType + kittype = relationship(KitType, + back_populates="kittypereagentroleassociation") #: relationship to associated KitType # NOTE: reference to the "ReagentType" object - reagent_role = relationship(ReagentRole, - back_populates="reagentrole_kit_associations") #: relationship to associated ReagentType + reagentrole = relationship(ReagentRole, + back_populates="reagentrolekittypeassociation") #: relationship to associated ReagentType # NOTE: reference to the "SubmissionType" object - submission_type = relationship(SubmissionType, - back_populates="submissiontype_kit_rt_associations") #: relationship to associated SubmissionType + proceduretype = relationship(ProcedureType, + back_populates="kittypereagentroleassociation") #: relationship to associated SubmissionType - def __init__(self, kit_type=None, reagent_role=None, uses=None, required=1): - self.kit_type = kit_type - self.reagent_role = reagent_role + def __init__(self, kittype=None, reagentrole=None, uses=None, required=1): + self.kittype = kittype + self.reagentrole = reagentrole self.uses = uses self.required = required def __repr__(self) -> str: - return f"" + return f"" @property def name(self): try: - return f"{self.kit_type.name} -> {self.reagent_role.name}" + return f"{self.kittype.name} -> {self.reagentrole.name}" except AttributeError: return "Blank KitTypeReagentRole" - @hybrid_property - def submissiontype(self): - return self.submission_type - - @submissiontype.setter - def submissiontype(self, value): - self.submission_type = value - - @hybrid_property - def kittype(self): - return self.kit_type - - @kittype.setter - def kittype(self, value): - self.kit_type = value - @validates('required') def validate_required(self, key, value): """ @@ -1671,20 +1437,17 @@ class KitTypeReagentRoleAssociation(BaseClass): for k, v in sanitized_kwargs.items(): logger.debug(f"Key: {k} has value: {v}") match k: - case "kittype" | "kit_type": - k = "kit_type" + case "kittype": if isinstance(v, str): v = KitType.query(name=v) else: v = v.instance_object - case "proceduretype" | "submission_type": - k = "submission_type" + case "proceduretype": if isinstance(v, str): v = SubmissionType.query(name=v) else: v = v.instance_object - case "reagentrole" | "reagent_role": - k = "reagent_role" + case "reagentrole": if isinstance(v, str): v = ReagentRole.query(name=v) else: @@ -1700,7 +1463,7 @@ class KitTypeReagentRoleAssociation(BaseClass): def query(cls, kittype: KitType | str | None = None, reagentrole: ReagentRole | str | None = None, - submissiontype: SubmissionType | str | None = None, + proceduretype: ProcedureType | str | None = None, limit: int = 0, **kwargs ) -> KitTypeReagentRoleAssociation | List[KitTypeReagentRoleAssociation]: @@ -1708,8 +1471,8 @@ class KitTypeReagentRoleAssociation(BaseClass): Lookup junction of ReagentType and KitType Args: - kit_type (models.KitType | str | None): KitType of interest. - reagent_role (models.ReagentType | str | None): ReagentType of interest. + kittype (models.KitType | str | None): KitType of interest. + reagentrole (models.ReagentType | str | None): ReagentType of interest. limit (int, optional): Maximum number of results to return (0 = all). Defaults to 0. Returns: @@ -1730,11 +1493,11 @@ class KitTypeReagentRoleAssociation(BaseClass): query = query.join(ReagentRole).filter(ReagentRole.name == reagentrole) case _: pass - match submissiontype: - case SubmissionType(): - query = query.filter(cls.submission_type == submissiontype) + match proceduretype: + case ProcedureType(): + query = query.filter(cls.proceduretype == proceduretype) case str(): - query = query.join(SubmissionType).filter(SubmissionType.name == submissiontype) + query = query.join(ProcedureType).filter(ProcedureType.name == proceduretype) case _: pass pass @@ -1744,12 +1507,12 @@ class KitTypeReagentRoleAssociation(BaseClass): def get_all_relevant_reagents(self) -> Generator[Reagent, None, None]: """ - Creates a generator that will resolve in to a list filling the role associated with this object. + Creates a generator that will resolve in to a list filling the reagentrole associated with this object. Returns: Generator: Generates of reagents. """ - reagents = self.reagent_role.controls + reagents = self.reagentrole.control try: regex = self.uses['exclude_regex'] except KeyError: @@ -1778,26 +1541,26 @@ class KitTypeReagentRoleAssociation(BaseClass): def to_omni(self, expand: bool = False) -> "OmniReagentRole": from backend.validators.omni_gui_objects import OmniKitTypeReagentRoleAssociation try: - eol_ext = self.reagent_role.eol_ext + eol_ext = self.reagentrole.eol_ext except AttributeError: eol_ext = timedelta(days=0) if expand: try: - submission_type = self.submission_type.to_omni() + submission_type = self.proceduretype.to_omni() except AttributeError: submission_type = "" try: - kit_type = self.kit_type.to_omni() + kit_type = self.kittype.to_omni() except AttributeError: kit_type = "" try: - reagent_role = self.reagent_role.to_omni() + reagent_role = self.reagentrole.to_omni() except AttributeError: reagent_role = "" else: - submission_type = self.submission_type.name - kit_type = self.kit_type.name - reagent_role = self.reagent_role.name + submission_type = self.proceduretype.name + kit_type = self.kittype.name + reagent_role = self.reagentrole.name return OmniKitTypeReagentRoleAssociation( instance_object=self, reagent_role=reagent_role, @@ -1811,20 +1574,20 @@ class KitTypeReagentRoleAssociation(BaseClass): class ProcedureReagentAssociation(BaseClass): """ - table containing run/reagent associations + table containing procedure/reagent associations DOC: https://docs.sqlalchemy.org/en/14/orm/extensions/associationproxy.html """ skip_on_edit = True reagent_id = Column(INTEGER, ForeignKey("_reagent.id"), primary_key=True) #: id of associated reagent - procedure_id = Column(INTEGER, ForeignKey("_procedure.id"), primary_key=True) #: id of associated run + procedure_id = Column(INTEGER, ForeignKey("_procedure.id"), primary_key=True) #: id of associated procedure comments = Column(String(1024)) #: Comments about reagents procedure = relationship("Procedure", - back_populates="procedure_reagent_associations") #: associated run + back_populates="procedurereagentassociation") #: associated procedure - reagent = relationship(Reagent, back_populates="reagent_procedure_associations") #: associated reagent + reagent = relationship(Reagent, back_populates="reagentprocedureassociation") #: associated reagent def __repr__(self) -> str: """ @@ -1832,37 +1595,36 @@ class ProcedureReagentAssociation(BaseClass): str: Representation of this RunReagentAssociation """ try: - return f"" + return f"" except AttributeError: - logger.error(f"Reagent {self.reagent.lot} run association {self.reagent_id} has no submissions!") - return f"" + logger.error(f"Reagent {self.reagent.lot} procedure association {self.reagent_id} has no procedure!") + return f"" - def __init__(self, reagent=None, submission=None): + def __init__(self, reagent=None, procedure=None): if isinstance(reagent, list): logger.warning(f"Got list for reagent. Likely no lot was provided. Using {reagent[0]}") reagent = reagent[0] self.reagent = reagent - self.submission = submission + self.procedure = procedure self.comments = "" @classmethod @setup_lookup def query(cls, - run: "BasicRun" | str | int | None = None, + procedure: Procedure | str | int | None = None, reagent: Reagent | str | None = None, - limit: int = 0) -> RunReagentAssociation | List[RunReagentAssociation]: + limit: int = 0) -> ProcedureReagentAssociation | List[ProcedureReagentAssociation]: """ Lookup SubmissionReagentAssociations of interest. Args: - run (BasicRun | str | int | None, optional): Identifier of joined run. Defaults to None. + procedure (Procedure | str | int | None, optional): Identifier of joined procedure. Defaults to None. reagent (Reagent | str | None, optional): Identifier of joined reagent. Defaults to None. limit (int, optional): Maximum number of results to return (0 = all). Defaults to 0. Returns: RunReagentAssociation|List[RunReagentAssociation]: SubmissionReagentAssociation(s) of interest """ - from . import BasicRun query: Query = cls.__database_session__.query(cls) match reagent: case Reagent() | str(): @@ -1871,35 +1633,35 @@ class ProcedureReagentAssociation(BaseClass): query = query.filter(cls.reagent == reagent) case _: pass - match run: - case BasicRun() | str(): - if isinstance(run, str): - run = BasicRun.query(rsl_plate_num=run) - query = query.filter(cls.run == run) + match procedure: + case Procedure() | str(): + if isinstance(procedure, str): + procedure = Procedure.query(name=procedure) + query = query.filter(cls.procedure == procedure) case int(): - run = BasicRun.query(id=run) - query = query.join(BasicRun).filter(BasicRun.id == run) + # procedure = Procedure.query(id=procedure) + query = query.join(Procedure).filter(Procedure.id == procedure) case _: pass return cls.execute_query(query=query, limit=limit) - def to_sub_dict(self, extraction_kit) -> dict: + def to_sub_dict(self, kittype) -> dict: """ Converts this RunReagentAssociation (and associated Reagent) to dict Args: - extraction_kit (_type_): Extraction kit of interest + kittype (_type_): Extraction kittype of interest Returns: dict: This RunReagentAssociation as dict """ - output = self.reagent.to_sub_dict(extraction_kit) + output = self.reagent.to_sub_dict(kittype) output['comments'] = self.comments return output - def to_pydantic(self, extraction_kit: KitType): + def to_pydantic(self, kittype: KitType): from backend.validators import PydReagent - return PydReagent(**self.to_sub_dict(extraction_kit=extraction_kit)) + return PydReagent(**self.to_sub_dict(kittype=kittype)) class Equipment(BaseClass, LogMixin): @@ -1911,59 +1673,59 @@ class Equipment(BaseClass, LogMixin): name = Column(String(64)) #: equipment name nickname = Column(String(64)) #: equipment nickname asset_number = Column(String(16)) #: Given asset number (corpo nickname if you will) - roles = relationship("EquipmentRole", back_populates="controls", - secondary=equipmentroles_equipment) #: relation to EquipmentRoles - processes = relationship("Process", back_populates="equipment", - secondary=equipment_processes) #: relation to Processes + equipmentrole = relationship("EquipmentRole", back_populates="equipment", + secondary=equipmentrole_equipment) #: relation to EquipmentRoles + process = relationship("Process", back_populates="equipment", + secondary=equipment_process) #: relation to Processes tips = relationship("Tips", back_populates="equipment", secondary=equipment_tips) #: relation to Processes - equipment_submission_associations = relationship( - "RunEquipmentAssociation", + equipmentprocedureassociation = relationship( + "ProcedureEquipmentAssociation", back_populates="equipment", cascade="all, delete-orphan", ) #: Association with BasicRun - submissions = association_proxy("equipment_submission_associations", - "run") #: proxy to equipment_submission_associations.run + procedure = association_proxy("equipmentprocedureassociation", + "procedure") #: proxy to equipmentprocedureassociation.procedure def to_dict(self, processes: bool = False) -> dict: """ This Equipment as a dictionary Args: - processes (bool, optional): Whether to include processes. Defaults to False. + processes (bool, optional): Whether to include process. Defaults to False. Returns: dict: Dictionary representation of this equipment """ if not processes: - return {k: v for k, v in self.__dict__.items() if k != 'processes'} + return {k: v for k, v in self.__dict__.items() if k != 'process'} else: return {k: v for k, v in self.__dict__.items()} - def get_processes(self, submission_type: str | SubmissionType | None = None, - extraction_kit: str | KitType | None = None, - equipment_role: str | EquipmentRole | None = None) -> Generator[Process, None, None]: + def get_processes(self, proceduretype: str | ProcedureType | None = None, + kittype: str | KitType | None = None, + equipmentrole: str | EquipmentRole | None = None) -> Generator[Process, None, None]: """ - Get all processes associated with this Equipment for a given SubmissionType + Get all process associated with this Equipment for a given SubmissionType Args: - submission_type (SubmissionType): SubmissionType of interest - extraction_kit (str | KitType | None, optional): KitType to filter by. Defaults to None. + proceduretype (ProcedureType): SubmissionType of interest + kittype (str | KitType | None, optional): KitType to filter by. Defaults to None. Returns: List[Process]: List of process names """ - if isinstance(submission_type, str): - submission_type = SubmissionType.query(name=submission_type) - if isinstance(extraction_kit, str): - extraction_kit = KitType.query(name=extraction_kit) + if isinstance(proceduretype, str): + proceduretype = ProcedureType.query(name=proceduretype) + if isinstance(kittype, str): + kittype = KitType.query(name=kittype) for process in self.processes: - if submission_type not in process.submission_types: + if proceduretype not in process.proceduretype: continue - if extraction_kit and extraction_kit not in process.kit_types: + if kittype and kittype not in process.kittype: continue - if equipment_role and equipment_role not in process.equipment_roles: + if equipmentrole and equipmentrole not in process.equipmentrole: continue yield process @@ -2008,22 +1770,22 @@ class Equipment(BaseClass, LogMixin): pass return cls.execute_query(query=query, limit=limit) - def to_pydantic(self, submission_type: SubmissionType, extraction_kit: str | KitType | None = None, - role: str = None) -> "PydEquipment": + def to_pydantic(self, proceduretype: ProcedureType, kittype: str | KitType | None = None, + equipmentrole: str = None) -> "PydEquipment": """ Creates PydEquipment of this Equipment Args: - submission_type (SubmissionType): Relevant SubmissionType - extraction_kit (str | KitType | None, optional): Relevant KitType. Defaults to None. + proceduretype (ProcedureType): Relevant SubmissionType + kittype (str | KitType | None, optional): Relevant KitType. Defaults to None. Returns: PydEquipment: pydantic equipment object """ from backend.validators.pydant import PydEquipment - processes = self.get_processes(submission_type=submission_type, extraction_kit=extraction_kit, - equipment_role=role) - return PydEquipment(processes=processes, role=role, + processes = self.get_processes(proceduretype=proceduretype, kittype=kittype, + equipmentrole=equipmentrole) + return PydEquipment(processes=processes, role=equipmentrole, **self.to_dict(processes=False)) @classproperty @@ -2043,21 +1805,21 @@ class Equipment(BaseClass, LogMixin): re.VERBOSE) @classmethod - def assign_equipment(cls, equipment_role: EquipmentRole | str) -> List[Equipment]: + def assign_equipment(cls, equipmentrole: EquipmentRole | str) -> List[Equipment]: """ Creates a list of equipment from user input to be used in Submission Type creation Args: - equipment_role (EquipmentRole): Equipment role to be added to. + equipmentrole (EquipmentRole): Equipment reagentrole to be added to. Returns: List[Equipment]: User selected equipment. """ - if isinstance(equipment_role, str): - equipment_role = EquipmentRole.query(name=equipment_role) + if isinstance(equipmentrole, str): + equipmentrole = EquipmentRole.query(name=equipmentrole) equipment = cls.query() options = "\n".join([f"{ii}. {item.name}" for ii, item in enumerate(equipment)]) - choices = input(f"Enter equipment numbers to add to {equipment_role.name} (space separated):\n{options}\n\n") + choices = input(f"Enter equipment numbers to add to {equipmentrole.name} (space separated):\n{options}\n\n") output = [] for choice in choices.split(" "): try: @@ -2072,7 +1834,7 @@ class Equipment(BaseClass, LogMixin): dictionary containing values necessary for gui Args: - full_data (bool, optional): Whether to include submissions in data for details. Defaults to False. + full_data (bool, optional): Whether to include procedure in data for details. Defaults to False. Returns: dict: representation of the equipment's attributes @@ -2087,32 +1849,33 @@ class Equipment(BaseClass, LogMixin): asset_number=self.asset_number ) if full_data: - subs = [dict(plate=item.submission.rsl_plate_num, process=item.process.name, sub_date=item.submission.submitted_date) - if item.process else dict(plate=item.submission.rsl_plate_num, process="NA") - for item in self.equipment_submission_associations] - output['submissions'] = sorted(subs, key=itemgetter("sub_date"), reverse=True) - output['excluded'] = ['missing', 'submissions', 'excluded', 'editable'] + subs = [dict(plate=item.procedure.procedure.rsl_plate_num, process=item.process.name, + sub_date=item.procedure.procedure.start_date) + if item.process else dict(plate=item.procedure.procedure.rsl_plate_num, process="NA") + for item in self.equipmentprocedureassociation] + output['procedure'] = sorted(subs, key=itemgetter("sub_date"), reverse=True) + output['excluded'] = ['missing', 'procedure', 'excluded', 'editable'] return output - @classproperty - def details_template(cls) -> Template: - """ - Get the details jinja template for the correct class - - Args: - base_dict (dict): incoming dictionary of Submission fields - - Returns: - Tuple(dict, Template): (Updated dictionary, Template to be rendered) - """ - env = jinja_template_loading() - temp_name = f"{cls.__name__.lower()}_details.html" - try: - template = env.get_template(temp_name) - except TemplateNotFound as e: - logger.error(f"Couldn't find template {e}") - template = env.get_template("equipment_details.html") - return template + # @classproperty + # def details_template(cls) -> Template: + # """ + # Get the details jinja template for the correct class + # + # Args: + # base_dict (dict): incoming dictionary of Submission fields + # + # Returns: + # Tuple(dict, Template): (Updated dictionary, Template to be rendered) + # """ + # env = jinja_template_loading() + # temp_name = f"{cls.__name__.lower()}_details.html" + # try: + # template = env.get_template(temp_name) + # except TemplateNotFound as e: + # logger.error(f"Couldn't find template {e}") + # template = env.get_template("equipment_details.html") + # return template class EquipmentRole(BaseClass): @@ -2122,19 +1885,19 @@ class EquipmentRole(BaseClass): id = Column(INTEGER, primary_key=True) #: Role id, primary key name = Column(String(32)) #: Common name - instances = relationship("Equipment", back_populates="roles", - secondary=equipmentroles_equipment) #: Concrete controls (Equipment) of role - processes = relationship("Process", back_populates='equipment_roles', - secondary=equipmentroles_processes) #: Associated Processes + equipment = relationship("Equipment", back_populates="equipmentrole", + secondary=equipmentrole_equipment) #: Concrete control (Equipment) of reagentrole + process = relationship("Process", back_populates='equipmentrole', + secondary=equipmentrole_process) #: Associated Processes - equipmentrole_submissiontype_associations = relationship( - "SubmissionTypeEquipmentRoleAssociation", - back_populates="equipment_role", + equipmentroleproceduretypeassociation = relationship( + "ProcedureTypeEquipmentRoleAssociation", + back_populates="equipmentrole", cascade="all, delete-orphan", ) #: relation to SubmissionTypes - submission_types = association_proxy("equipmentrole_submissiontype_associations", - "submission_type") #: proxy to equipmentrole_submissiontype_associations.submission_type + proceduretype = association_proxy("equipmentroleproceduretypeassociation", + "proceduretype") #: proxy to equipmentroleproceduretypeassociation.proceduretype def to_dict(self) -> dict: """ @@ -2143,25 +1906,25 @@ class EquipmentRole(BaseClass): Returns: dict: This EquipmentRole dict """ - return {key: value for key, value in self.__dict__.items() if key != "processes"} + return {key: value for key, value in self.__dict__.items() if key != "process"} - def to_pydantic(self, submission_type: SubmissionType, - extraction_kit: str | KitType | None = None) -> "PydEquipmentRole": + def to_pydantic(self, proceduretype: ProcedureType, + kittype: str | KitType | None = None) -> "PydEquipmentRole": """ Creates a PydEquipmentRole of this EquipmentRole Args: - submission_type (SubmissionType): SubmissionType of interest - extraction_kit (str | KitType | None, optional): KitType of interest. Defaults to None. + proceduretype (SubmissionType): SubmissionType of interest + kittype (str | KitType | None, optional): KitType of interest. Defaults to None. Returns: PydEquipmentRole: This EquipmentRole as PydEquipmentRole """ from backend.validators.pydant import PydEquipmentRole - equipment = [item.to_pydantic(submission_type=submission_type, extraction_kit=extraction_kit) for item in - self.instances] + equipment = [item.to_pydantic(proceduretype=proceduretype, kittype=kittype) for item in + self.equipment] pyd_dict = self.to_dict() - pyd_dict['processes'] = self.get_processes(submission_type=submission_type, extraction_kit=extraction_kit) + pyd_dict['process'] = self.get_processes(proceduretype=proceduretype, kittype=kittype) return PydEquipmentRole(equipment=equipment, **pyd_dict) @classmethod @@ -2185,8 +1948,7 @@ class EquipmentRole(BaseClass): id: int | None = None, limit: int = 0, **kwargs - ) -> EquipmentRole | List[ - EquipmentRole]: + ) -> EquipmentRole | List[EquipmentRole]: """ Lookup Equipment roles. @@ -2213,26 +1975,26 @@ class EquipmentRole(BaseClass): pass return cls.execute_query(query=query, limit=limit) - def get_processes(self, submission_type: str | SubmissionType | None, - extraction_kit: str | KitType | None = None) -> Generator[Process, None, None]: + def get_processes(self, proceduretype: str | ProcedureType | None, + kittype: str | KitType | None = None) -> Generator[Process, None, None]: """ - Get processes used by this EquipmentRole + Get process used by this EquipmentRole Args: - submission_type (str | SubmissionType | None): SubmissionType of interest - extraction_kit (str | KitType | None, optional): KitType of interest. Defaults to None. + proceduretype (str | SubmissionType | None): SubmissionType of interest + kittype (str | KitType | None, optional): KitType of interest. Defaults to None. Returns: - List[Process]: List of processes + List[Process]: List of process """ - if isinstance(submission_type, str): - submission_type = SubmissionType.query(name=submission_type) - if isinstance(extraction_kit, str): - extraction_kit = KitType.query(name=extraction_kit) + if isinstance(proceduretype, str): + proceduretype = SubmissionType.query(name=proceduretype) + if isinstance(kittype, str): + kittype = KitType.query(name=kittype) for process in self.processes: - if submission_type and submission_type not in process.submission_types: + if proceduretype and proceduretype not in process.proceduretype: continue - if extraction_kit and extraction_kit not in process.kit_types: + if kittype and kittype not in process.kittype: continue yield process.name @@ -2241,32 +2003,32 @@ class EquipmentRole(BaseClass): return OmniEquipmentRole(instance_object=self, name=self.name) -class RunEquipmentAssociation(BaseClass): +class ProcedureEquipmentAssociation(BaseClass): """ Abstract association between BasicRun and Equipment """ equipment_id = Column(INTEGER, ForeignKey("_equipment.id"), primary_key=True) #: id of associated equipment - run_id = Column(INTEGER, ForeignKey("_basicrun.id"), primary_key=True) #: id of associated run - role = Column(String(64), primary_key=True) #: name of the role the equipment fills + procedure_id = Column(INTEGER, ForeignKey("_procedure.id"), primary_key=True) #: id of associated procedure + equipmentrole = Column(String(64), primary_key=True) #: name of the reagentrole the equipment fills process_id = Column(INTEGER, ForeignKey("_process.id", ondelete="SET NULL", name="SEA_Process_id")) #: Foreign key of process id start_time = Column(TIMESTAMP) #: start time of equipment use end_time = Column(TIMESTAMP) #: end time of equipment use comments = Column(String(1024)) #: comments about equipment - run = relationship("BasicRun", - back_populates="run_equipment_associations") #: associated run + procedure = relationship(Procedure, + back_populates="procedureequipmentassociation") #: associated procedure - equipment = relationship(Equipment, back_populates="equipment_run_associations") #: associated equipment + equipment = relationship(Equipment, back_populates="equipmentprocedureassociation") #: associated equipment def __repr__(self) -> str: - return f"" + return f"" - def __init__(self, run, equipment, role: str = "None"): - self.run = run + def __init__(self, procedure, equipment, equipmentrole: str = "None"): + self.run = procedure self.equipment = equipment - self.role = role + self.equipmentrole = equipmentrole @property def process(self): @@ -2284,7 +2046,7 @@ class RunEquipmentAssociation(BaseClass): except AttributeError: process = "No process found" output = dict(name=self.equipment.name, asset_number=self.equipment.asset_number, comment=self.comments, - processes=[process], role=self.role, nickname=self.equipment.nickname) + processes=[process], role=self.equipmentrole, nickname=self.equipment.nickname) return output def to_pydantic(self) -> "PydEquipment": @@ -2299,41 +2061,33 @@ class RunEquipmentAssociation(BaseClass): @classmethod @setup_lookup - def query(cls, equipment_id: int | None = None, run_id: int | None = None, role: str | None = None, + def query(cls, equipment_id: int | None = None, run_id: int | None = None, equipmentrole: str | None = None, limit: int = 0, **kwargs) \ -> Any | List[Any]: query: Query = cls.__database_session__.query(cls) query = query.filter(cls.equipment_id == equipment_id) query = query.filter(cls.run_id == run_id) - if role is not None: - query = query.filter(cls.role == role) + if equipmentrole is not None: + query = query.filter(cls.equipmentrole == equipmentrole) return cls.execute_query(query=query, limit=limit, **kwargs) -class SubmissionTypeEquipmentRoleAssociation(BaseClass): +class ProcedureTypeEquipmentRoleAssociation(BaseClass): """ Abstract association between SubmissionType and EquipmentRole """ equipmentrole_id = Column(INTEGER, ForeignKey("_equipmentrole.id"), primary_key=True) #: id of associated equipment - submissiontype_id = Column(INTEGER, ForeignKey("_submissiontype.id"), - primary_key=True) #: id of associated run - uses = Column(JSON) #: locations of equipment on the run type excel sheet. + proceduretype_id = Column(INTEGER, ForeignKey("_proceduretype.id"), + primary_key=True) #: id of associated procedure + uses = Column(JSON) #: locations of equipment on the procedure 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_equipmentrole_associations") #: associated run + proceduretype = relationship(ProcedureType, + back_populates="proceduretypeequipmentroleassociation") #: associated procedure - equipment_role = relationship(EquipmentRole, - back_populates="equipmentrole_submissiontype_associations") #: associated equipment - - @hybrid_property - def submissiontype(self): - return self.submission_type - - @hybrid_property - def equipmentrole(self): - return self.equipment_role + equipmentrole = relationship(EquipmentRole, + back_populates="equipmentroleproceduretypeassociation") #: associated equipment @validates('static') def validate_static(self, key, value): @@ -2368,18 +2122,18 @@ class Process(BaseClass): id = Column(INTEGER, primary_key=True) #: Process id, primary key name = Column(String(64), unique=True) #: Process name - submission_types = relationship("SubmissionType", back_populates='processes', - secondary=submissiontypes_processes) #: relation to SubmissionType - equipment = relationship("Equipment", back_populates='processes', - secondary=equipment_processes) #: relation to Equipment - equipment_roles = relationship("EquipmentRole", back_populates='processes', - secondary=equipmentroles_processes) #: relation to EquipmentRoles - submissions = relationship("RunEquipmentAssociation", - backref='process') #: relation to RunEquipmentAssociation - kit_types = relationship("KitType", back_populates='processes', - secondary=kittypes_processes) #: relation to KitType - tip_roles = relationship("TipRole", back_populates='processes', - secondary=process_tiprole) #: relation to KitType + proceduretype = relationship("ProcedureType", back_populates='process', + secondary=proceduretype_process) #: relation to SubmissionType + equipment = relationship("Equipment", back_populates='process', + secondary=equipment_process) #: relation to Equipment + equipmentrole = relationship("EquipmentRole", back_populates='process', + secondary=equipmentrole_process) #: relation to EquipmentRoles + procedure = relationship("ProcedureEquipmentAssociation", + backref='process') #: relation to RunEquipmentAssociation + kittype = relationship("KitType", back_populates='process', + secondary=kittype_process) #: relation to KitType + tiprole = relationship("TipRole", back_populates='process', + secondary=process_tiprole) #: relation to KitType def set_attribute(self, key, value): match key: @@ -2390,28 +2144,28 @@ class Process(BaseClass): if value not in field: field.append(value) - @classmethod - def query_or_create(cls, **kwargs) -> Tuple[Process, bool]: - new = False - disallowed = ['expiry'] - sanitized_kwargs = {k: v for k, v in kwargs.items() if k not in disallowed} - instance = cls.query(**sanitized_kwargs) - if not instance or isinstance(instance, list): - instance = cls() - new = True - for k, v in sanitized_kwargs.items(): - setattr(instance, k, v) - logger.info(f"Instance from query or create: {instance}") - return instance, new + # @classmethod + # def query_or_create(cls, **kwargs) -> Tuple[Process, bool]: + # new = False + # disallowed = ['expiry'] + # sanitized_kwargs = {k: v for k, v in kwargs.items() if k not in disallowed} + # instance = cls.query(**sanitized_kwargs) + # if not instance or isinstance(instance, list): + # instance = cls() + # new = True + # for k, v in sanitized_kwargs.items(): + # setattr(instance, k, v) + # logger.info(f"Instance from query or create: {instance}") + # return instance, new @classmethod @setup_lookup def query(cls, name: str | None = None, id: int | None = None, - submissiontype: str | SubmissionType | None = None, + proceduretype: str | ProcedureType | None = None, kittype: str | KitType | None = None, - equipmentrole: str | KitType | None = None, + equipmentrole: str | EquipmentRole | None = None, limit: int = 0, **kwargs) -> Process | List[Process]: """ @@ -2426,28 +2180,28 @@ class Process(BaseClass): Process|List[Process]: Process(es) matching criteria """ query = cls.__database_session__.query(cls) - match submissiontype: + match proceduretype: case str(): - submissiontype = SubmissionType.query(name=submissiontype) - query = query.filter(cls.submission_types.contains(submissiontype)) - case SubmissionType(): - query = query.filter(cls.submission_types.contains(submissiontype)) + proceduretype = ProcedureType.query(name=proceduretype) + query = query.filter(cls.proceduretype.contains(proceduretype)) + case ProcedureType(): + query = query.filter(cls.proceduretype.contains(proceduretype)) case _: pass match kittype: case str(): kittype = KitType.query(name=kittype) - query = query.filter(cls.kit_types.contains(kittype)) + query = query.filter(cls.kittype.contains(kittype)) case KitType(): - query = query.filter(cls.kit_types.contains(kittype)) + query = query.filter(cls.kittype.contains(kittype)) case _: pass match equipmentrole: case str(): equipmentrole = EquipmentRole.query(name=equipmentrole) - query = query.filter(cls.equipment_roles.contains(equipmentrole)) + query = query.filter(cls.equipmentrole.contains(equipmentrole)) case EquipmentRole(): - query = query.filter(cls.equipment_roles.contains(equipmentrole)) + query = query.filter(cls.equipmentrole.contains(equipmentrole)) case _: pass match name: @@ -2471,19 +2225,19 @@ class Process(BaseClass): def to_omni(self, expand: bool = False): from backend.validators.omni_gui_objects import OmniProcess if expand: - submission_types = [item.to_omni() for item in self.submission_types] - equipment_roles = [item.to_omni() for item in self.equipment_roles] - tip_roles = [item.to_omni() for item in self.tip_roles] + proceduretype = [item.to_omni() for item in self.proceduretype] + equipmentrole = [item.to_omni() for item in self.equipmentrole] + tiprole = [item.to_omni() for item in self.tiprole] else: - submission_types = [item.name for item in self.submission_types] - equipment_roles = [item.name for item in self.equipment_roles] - tip_roles = [item.name for item in self.tip_roles] + proceduretype = [item.name for item in self.proceduretype] + equipmentrole = [item.name for item in self.equipmentrole] + tiprole = [item.name for item in self.tiprole] return OmniProcess( instance_object=self, name=self.name, - submission_types=submission_types, - equipment_roles=equipment_roles, - tip_roles=tip_roles + proceduretype=proceduretype, + equipmentrole=equipmentrole, + tiprole=tiprole ) def to_sub_dict(self, full_data: bool = False, **kwargs) -> dict: @@ -2491,7 +2245,7 @@ class Process(BaseClass): dictionary containing values necessary for gui Args: - full_data (bool, optional): Whether to include submissions in data for details. Defaults to False. + full_data (bool, optional): Whether to include procedure in data for details. Defaults to False. Returns: dict: representation of the equipment's attributes @@ -2500,67 +2254,64 @@ class Process(BaseClass): name=self.name, ) if full_data: - subs = [dict(plate=sub.submission.rsl_plate_num, equipment=sub.equipment.name, sub_date=sub.submission.submitted_date) for sub in self.submissions] - output['submissions'] = sorted(subs, key=itemgetter("sub_date"), reverse=True) - output['excluded'] = ['missing', 'submissions', 'excluded', 'editable'] + subs = [dict(plate=sub.run.rsl_plate_num, equipment=sub.equipment.name, + submitted_date=sub.run.clientsubmission.submitted_date) for sub in self.procedure] + output['procedure'] = sorted(subs, key=itemgetter("submitted_date"), reverse=True) + output['excluded'] = ['missing', 'procedure', 'excluded', 'editable'] return output - @classproperty - def details_template(cls) -> Template: - """ - Get the details jinja template for the correct class - - Args: - base_dict (dict): incoming dictionary of Submission fields - - Returns: - Tuple(dict, Template): (Updated dictionary, Template to be rendered) - """ - env = jinja_template_loading() - temp_name = f"{cls.__name__.lower()}_details.html" - try: - template = env.get_template(temp_name) - except TemplateNotFound as e: - logger.error(f"Couldn't find template {e}") - template = env.get_template("process_details.html") - return template + # @classproperty + # def details_template(cls) -> Template: + # """ + # Get the details jinja template for the correct class + # + # Args: + # base_dict (dict): incoming dictionary of Submission fields + # + # Returns: + # Tuple(dict, Template): (Updated dictionary, Template to be rendered) + # """ + # env = jinja_template_loading() + # temp_name = f"{cls.__name__.lower()}_details.html" + # try: + # template = env.get_template(temp_name) + # except TemplateNotFound as e: + # logger.error(f"Couldn't find template {e}") + # template = env.get_template("process_details.html") + # return template class TipRole(BaseClass): """ - An abstract role that a tip fills during a process + An abstract reagentrole that a tip fills during a process """ id = Column(INTEGER, primary_key=True) #: primary key name = Column(String(64)) #: name of reagent type - instances = relationship("Tips", back_populates="role", - secondary=tiproles_tips) #: concrete controls of this reagent type - processes = relationship("Process", back_populates="tip_roles", secondary=process_tiprole) + tips = relationship("Tips", back_populates="tiprole", + secondary=tiprole_tips) #: concrete control of this reagent type + process = relationship("Process", back_populates="tiprole", secondary=process_tiprole) - tiprole_submissiontype_associations = relationship( - "SubmissionTypeTipRoleAssociation", - back_populates="tip_role", + tiproleproceduretypeassociation = relationship( + "ProcedureTypeTipRoleAssociation", + back_populates="tiprole", cascade="all, delete-orphan" - ) #: associated run + ) #: associated procedure - submission_types = association_proxy("tiprole_submissiontype_associations", "submission_type") + proceduretype = association_proxy("tiproleproceduretypeassociation", "proceduretype") - @hybrid_property - def tips(self): - return self.instances - - @classmethod - def query_or_create(cls, **kwargs) -> Tuple[TipRole, bool]: - new = False - disallowed = ['expiry'] - sanitized_kwargs = {k: v for k, v in kwargs.items() if k not in disallowed} - instance = cls.query(**sanitized_kwargs) - if not instance or isinstance(instance, list): - instance = cls() - new = True - for k, v in sanitized_kwargs.items(): - setattr(instance, k, v) - logger.info(f"Instance from query or create: {instance}") - return instance, new + # @classmethod + # def query_or_create(cls, **kwargs) -> Tuple[TipRole, bool]: + # new = False + # disallowed = ['expiry'] + # sanitized_kwargs = {k: v for k, v in kwargs.items() if k not in disallowed} + # instance = cls.query(**sanitized_kwargs) + # if not instance or isinstance(instance, list): + # instance = cls() + # new = True + # for k, v in sanitized_kwargs.items(): + # setattr(instance, k, v) + # logger.info(f"Instance from query or create: {instance}") + # return instance, new @classmethod @setup_lookup @@ -2596,39 +2347,35 @@ class Tips(BaseClass, LogMixin): A concrete instance of tips. """ id = Column(INTEGER, primary_key=True) #: primary key - role = relationship("TipRole", back_populates="controls", - secondary=tiproles_tips) #: joined parent reagent type - role_id = Column(INTEGER, ForeignKey("_tiprole.id", ondelete='SET NULL', - name="fk_tip_role_id")) #: id of parent reagent type + tiprole = relationship("TipRole", back_populates="tips", + secondary=tiprole_tips) #: joined parent reagent type + tiprole_id = Column(INTEGER, ForeignKey("_tiprole.id", ondelete='SET NULL', + name="fk_tip_role_id")) #: id of parent reagent type name = Column(String(64)) #: tip common name lot = Column(String(64)) #: lot number of tips equipment = relationship("Equipment", back_populates="tips", - secondary=equipment_tips) #: associated run - tips_submission_associations = relationship( - "SubmissionTipsAssociation", + secondary=equipment_tips) #: associated procedure + tipsprocedureassociation = relationship( + "ProcedureTipsAssociation", back_populates="tips", cascade="all, delete-orphan" - ) #: associated run + ) #: associated procedure - submissions = association_proxy("tips_submission_associations", 'run') + procedure = association_proxy("tipsprocedureassociation", 'procedure') - @hybrid_property - def tiprole(self): - return self.role - - @classmethod - def query_or_create(cls, **kwargs) -> Tuple[Tips, bool]: - new = False - disallowed = ['expiry'] - sanitized_kwargs = {k: v for k, v in kwargs.items() if k not in disallowed} - instance = cls.query(**sanitized_kwargs) - if not instance or isinstance(instance, list): - instance = cls() - new = True - for k, v in sanitized_kwargs.items(): - setattr(instance, k, v) - logger.info(f"Instance from query or create: {instance}") - return instance, new + # @classmethod + # def query_or_create(cls, **kwargs) -> Tuple[Tips, bool]: + # new = False + # disallowed = ['expiry'] + # sanitized_kwargs = {k: v for k, v in kwargs.items() if k not in disallowed} + # instance = cls.query(**sanitized_kwargs) + # if not instance or isinstance(instance, list): + # instance = cls() + # new = True + # for k, v in sanitized_kwargs.items(): + # setattr(instance, k, v) + # logger.info(f"Instance from query or create: {instance}") + # return instance, new @classmethod def query(cls, name: str | None = None, lot: str | None = None, limit: int = 0, **kwargs) -> Tips | List[Tips]: @@ -2673,7 +2420,7 @@ class Tips(BaseClass, LogMixin): dictionary containing values necessary for gui Args: - full_data (bool, optional): Whether to include submissions in data for details. Defaults to False. + full_data (bool, optional): Whether to include procedure in data for details. Defaults to False. Returns: dict: representation of the equipment's attributes @@ -2683,55 +2430,47 @@ class Tips(BaseClass, LogMixin): lot=self.lot, ) if full_data: - subs = [dict(plate=item.submission.rsl_plate_num, role=item.role_name, sub_date=item.submission.submitted_date) - for item in self.tips_submission_associations] - output['submissions'] = sorted(subs, key=itemgetter("sub_date"), reverse=True) - output['excluded'] = ['missing', 'submissions', 'excluded', 'editable'] + subs = [ + dict(plate=item.procedure.procedure.rsl_plate_num, role=item.role_name, sub_date=item.procedure.procedure.clientsubmission.submitted_date) + for item in self.tipsprocedureassociation] + output['procedure'] = sorted(subs, key=itemgetter("sub_date"), reverse=True) + output['excluded'] = ['missing', 'procedure', 'excluded', 'editable'] return output - @classproperty - def details_template(cls) -> Template: - """ - Get the details jinja template for the correct class - - Args: - base_dict (dict): incoming dictionary of Submission fields - - Returns: - Tuple(dict, Template): (Updated dictionary, Template to be rendered) - """ - env = jinja_template_loading() - temp_name = f"{cls.__name__.lower()}_details.html" - try: - template = env.get_template(temp_name) - except TemplateNotFound as e: - logger.error(f"Couldn't find template {e}") - template = env.get_template("tips_details.html") - return template + # @classproperty + # def details_template(cls) -> Template: + # """ + # Get the details jinja template for the correct class + # + # Args: + # base_dict (dict): incoming dictionary of Submission fields + # + # Returns: + # Tuple(dict, Template): (Updated dictionary, Template to be rendered) + # """ + # env = jinja_template_loading() + # temp_name = f"{cls.__name__.lower()}_details.html" + # try: + # template = env.get_template(temp_name) + # except TemplateNotFound as e: + # logger.error(f"Couldn't find template {e}") + # template = env.get_template("tips_details.html") + # return template -class SubmissionTypeTipRoleAssociation(BaseClass): +class ProcedureTypeTipRoleAssociation(BaseClass): """ Abstract association between SubmissionType and TipRole """ tiprole_id = Column(INTEGER, ForeignKey("_tiprole.id"), primary_key=True) #: id of associated equipment - submissiontype_id = Column(INTEGER, ForeignKey("_submissiontype.id"), - primary_key=True) #: id of associated run - uses = Column(JSON) #: locations of equipment on the run 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_tiprole_associations") #: associated run - tip_role = relationship(TipRole, - back_populates="tiprole_submissiontype_associations") #: associated equipment - - @hybrid_property - def submissiontype(self): - return self.submission_type - - @hybrid_property - def tiprole(self): - return self.tip_role + proceduretype_id = Column(INTEGER, ForeignKey("_proceduretype.id"), + primary_key=True) #: id of associated procedure + uses = Column(JSON) #: locations of equipment on the procedure 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? + proceduretype = relationship(ProcedureType, + back_populates="proceduretypetiproleassociation") #: associated procedure + tiprole = relationship(TipRole, + back_populates="tiproleproceduretypeassociation") #: associated equipment @check_authorization def save(self): @@ -2741,16 +2480,16 @@ class SubmissionTypeTipRoleAssociation(BaseClass): pass -class RunTipsAssociation(BaseClass): +class ProcedureTipsAssociation(BaseClass): """ - Association between a concrete run instance and concrete tips + Association between a concrete procedure instance and concrete tips """ - tip_id = Column(INTEGER, ForeignKey("_tips.id"), primary_key=True) #: id of associated equipment - run_id = Column(INTEGER, ForeignKey("_basicrun.id"), primary_key=True) #: id of associated run - run = relationship("BasicRun", - back_populates="run_tips_associations") #: associated run + tips_id = Column(INTEGER, ForeignKey("_tips.id"), primary_key=True) #: id of associated equipment + procedure_id = Column(INTEGER, ForeignKey("_procedure.id"), primary_key=True) #: id of associated procedure + procedure = relationship("Procedure", + back_populates="proceduretipsassociation") #: associated procedure tips = relationship(Tips, - back_populates="tips_run_associations") #: associated equipment + back_populates="tipsprocedureassociation") #: associated equipment role_name = Column(String(32), primary_key=True) #, ForeignKey("_tiprole.name")) def to_sub_dict(self) -> dict: @@ -2764,19 +2503,20 @@ class RunTipsAssociation(BaseClass): @classmethod @setup_lookup - def query(cls, tip_id: int, role: str, run_id: int | None = None, limit: int = 0, **kwargs) \ + def query(cls, tips_id: int, role_name: str, procedure_id: int | None = None, limit: int = 0, **kwargs) \ -> Any | List[Any]: query: Query = cls.__database_session__.query(cls) - query = query.filter(cls.tip_id == tip_id) - if run_id is not None: - query = query.filter(cls.run_id == run_id) - query = query.filter(cls.role_name == role) + query = query.filter(cls.tips_id == tips_id) + if procedure_id is not None: + query = query.filter(cls.procedure_id == procedure_id) + query = query.filter(cls.role_name == role_name) return cls.execute_query(query=query, limit=limit, **kwargs) + # TODO: fold this into the BaseClass.query_or_create ? @classmethod def query_or_create(cls, tips, run, role: str, **kwargs): kwargs['limit'] = 1 - instance = cls.query(tip_id=tips.id, role=role, run_id=run.id, **kwargs) + instance = cls.query(tips_id=tips.id, role_name=role, procedure_id=run.id, **kwargs) if instance is None: instance = cls(run=run, tips=tips, role_name=role) return instance @@ -2784,3 +2524,172 @@ class RunTipsAssociation(BaseClass): def to_pydantic(self): from backend.validators import PydTips return PydTips(name=self.tips.name, lot=self.tips.lot, role=self.role_name) + +# +# class ProcedureType(BaseClass): +# id = Column(INTEGER, primary_key=True) +# name = Column(String(64)) +# reagent_map = Column(JSON) +# +# procedure = relationship("Procedure", +# back_populates="proceduretype") #: Concrete control of this type. +# +# process = relationship("Process", back_populates="proceduretype", +# secondary=proceduretype_process) #: Relation to equipment process used for this type. +# +# proceduretypekittypeassociation = relationship( +# "ProcedureTypeKitTypeAssociation", +# back_populates="proceduretype", +# cascade="all, delete-orphan", +# ) #: Association of kittypes +# +# kittype = association_proxy("proceduretypekittypeassociation", "kittype", +# creator=lambda kit: ProcedureTypeKitTypeAssociation( +# kittype=kit)) #: Proxy of kittype association +# +# proceduretypeequipmentroleassociation = relationship( +# "ProcedureTypeEquipmentRoleAssociation", +# back_populates="proceduretype", +# cascade="all, delete-orphan" +# ) #: Association of equipmentroles +# +# equipment = association_proxy("proceduretypeequipmentroleassociation", "equipmentrole", +# creator=lambda eq: ProcedureTypeEquipmentRoleAssociation( +# equipment_role=eq)) #: Proxy of equipmentrole associations +# +# kittypereagentroleassociation = relationship( +# "KitTypeReagentRoleAssociation", +# back_populates="proceduretype", +# cascade="all, delete-orphan" +# ) #: triple association of KitTypes, ReagentTypes, SubmissionTypes +# +# proceduretypetiproleassociation = relationship( +# "ProcedureTypeTipRoleAssociation", +# back_populates="proceduretype", +# cascade="all, delete-orphan" +# ) #: Association of tiproles +# +# def construct_field_map(self, field: Literal['equipment', 'tip']) -> Generator[(str, dict), None, None]: +# """ +# Make a map of all locations for tips or equipment. +# +# Args: +# field (Literal['equipment', 'tip']): the field to construct a map for +# +# Returns: +# Generator[(str, dict), None, None]: Generator composing key, locations for each item in the map +# """ +# for item in self.__getattribute__(f"proceduretype{field}role_associations"): +# fmap = item.uses +# if fmap is None: +# fmap = {} +# yield getattr(item, f"{field}_role").name, fmap +# +# @property +# def default_kit(self) -> KitType | None: +# """ +# If only one kits exists for this Submission Type, return it. +# +# Returns: +# KitType | None: +# """ +# if len(self.kittype) == 1: +# return self.kittype[0] +# else: +# return None +# +# def get_equipment(self, kittype: str | KitType | None = None) -> Generator['PydEquipmentRole', None, None]: +# """ +# Returns PydEquipmentRole of all equipment associated with this SubmissionType +# +# Returns: +# Generator['PydEquipmentRole', None, None]: List of equipment roles +# """ +# return (item.to_pydantic(proceduretype=self, kittype=kittype) for item in self.equipment) +# +# def get_processes_for_role(self, equipmentrole: str | EquipmentRole, kittype: str | KitType | None = None) -> list: +# """ +# Get process associated with this SubmissionType for an EquipmentRole +# +# Args: +# equipmentrole (str | EquipmentRole): EquipmentRole of interest +# kittype (str | KitType | None, optional): Kit of interest. Defaults to None. +# +# Raises: +# TypeError: Raised if wrong type given for equipmentrole +# +# Returns: +# list: list of associated process +# """ +# match equipmentrole: +# case str(): +# relevant = [item.get_all_processes(kittype) for item in self.proceduretypeequipmentroleassociation if +# item.equipmentrole.name == equipmentrole] +# case EquipmentRole(): +# relevant = [item.get_all_processes(kittype) for item in self.proceduretypeequipmentroleassociation if +# item.equipmentrole == equipmentrole] +# case _: +# raise TypeError(f"Type {type(equipmentrole)} is not allowed") +# return list(set([item for items in relevant for item in items if item is not None])) +# +# +# class Procedure(BaseClass): +# id = Column(INTEGER, primary_key=True) +# name = Column(String, unique=True) +# technician = Column(JSON) #: name of processing tech(s) +# proceduretype_id = Column(INTEGER, ForeignKey("_proceduretype.id", ondelete="SET NULL", +# name="fk_PRO_proceduretype_id")) #: client lab id from _organizations)) +# proceduretype = relationship("ProcedureType", back_populates="procedure") +# run_id = Column(INTEGER, ForeignKey("_run.id", ondelete="SET NULL", +# name="fk_PRO_basicrun_id")) #: client lab id from _organizations)) +# run = relationship("Run", back_populates="procedure") +# kittype_id = Column(INTEGER, ForeignKey("_kittype.id", ondelete="SET NULL", +# name="fk_PRO_kittype_id")) #: client lab id from _organizations)) +# kittype = relationship("KitType", back_populates="procedure") +# +# control = relationship("Control", back_populates="procedure", +# uselist=True) #: A control sample added to procedure +# +# procedurereagentassociations = relationship( +# "ProcedureReagentAssociation", +# back_populates="procedure", +# cascade="all, delete-orphan", +# ) #: Relation to ProcedureReagentAssociation +# +# reagents = association_proxy("procedurereagentassociations", +# "reagent") #: Association proxy to RunReagentAssociation.reagent +# +# procedureequipmentassociations = relationship( +# "ProcedureEquipmentAssociation", +# back_populates="procedure", +# cascade="all, delete-orphan" +# ) #: Relation to Equipment +# +# equipment = association_proxy("procedureequipmentassociations", +# "equipment") #: Association proxy to RunEquipmentAssociation.equipment +# +# proceduretipsassociations = relationship( +# "ProcedureTipsAssociation", +# back_populates="procedure", +# cascade="all, delete-orphan") +# +# tips = association_proxy("proceduretipsassociations", +# "tips") +# +# @classmethod +# @setup_lookup +# def query(cls, id: int|None = None, name: str | None = None, limit: int = 0, **kwargs) -> Procedure | List[Procedure]: +# query: Query = cls.__database_session__.query(cls) +# match id: +# case int(): +# query = query.filter(cls.id == id) +# limit = 1 +# case _: +# pass +# match name: +# case str(): +# query = query.filter(cls.name == name) +# limit = 1 +# case _: +# pass +# return cls.execute_query(query=query, limit=limit) diff --git a/src/submissions/backend/db/models/organizations.py b/src/submissions/backend/db/models/organizations.py index 3beab4a..e72dae8 100644 --- a/src/submissions/backend/db/models/organizations.py +++ b/src/submissions/backend/db/models/organizations.py @@ -14,32 +14,27 @@ from typing import List, Tuple logger = logging.getLogger(f"submissions.{__name__}") -# table containing organization/contact relationship -orgs_contacts = Table( - "_orgs_contacts", +# table containing clientlab/contact relationship +clientlab_contact = Table( + "_clientlab_contact", Base.metadata, - Column("org_id", INTEGER, ForeignKey("_organization.id")), + Column("clientlab_id", INTEGER, ForeignKey("_clientlab.id")), Column("contact_id", INTEGER, ForeignKey("_contact.id")), extend_existing=True ) -class Organization(BaseClass): +class ClientLab(BaseClass): """ - Base of organization + Base of clientlab """ id = Column(INTEGER, primary_key=True) #: primary key - name = Column(String(64)) #: organization name - submissions = relationship("ClientSubmission", - back_populates="submitting_lab") #: submissions this organization has submitted + name = Column(String(64)) #: clientlab name + clientsubmission = relationship("ClientSubmission", back_populates="clientlab") #: procedure this clientlab has submitted cost_centre = Column(String()) #: cost centre used by org for payment - contacts = relationship("Contact", back_populates="organization", - secondary=orgs_contacts) #: contacts involved with this org - - @hybrid_property - def contact(self): - return self.contacts + contact = relationship("Contact", back_populates="clientlab", + secondary=clientlab_contact) #: contact involved with this org @classmethod @setup_lookup @@ -47,16 +42,16 @@ class Organization(BaseClass): id: int | None = None, name: str | None = None, limit: int = 0, - ) -> Organization | List[Organization]: + ) -> ClientLab | List[ClientLab]: """ - Lookup organizations in the database by a number of parameters. + Lookup clientlabs in the database by a number of parameters. Args: - name (str | None, optional): Name of the organization. Defaults to None. + name (str | None, optional): Name of the clientlab. Defaults to None. limit (int, optional): Maximum number of results to return (0 = all). Defaults to 0. Returns: - Organization|List[Organization]: + ClientLab|List[ClientLab]: """ query: Query = cls.__database_session__.query(cls) match id: @@ -89,7 +84,7 @@ class Organization(BaseClass): name = "NA" return OmniOrganization(instance_object=self, name=name, cost_centre=cost_centre, - contact=[item.to_omni() for item in self.contacts]) + contact=[item.to_omni() for item in self.contact]) class Contact(BaseClass): @@ -101,27 +96,27 @@ class Contact(BaseClass): name = Column(String(64)) #: contact name email = Column(String(64)) #: contact email phone = Column(String(32)) #: contact phone number - organization = relationship("Organization", back_populates="contacts", uselist=True, - secondary=orgs_contacts) #: relationship to joined organization - submissions = relationship("ClientSubmission", back_populates="contact") #: submissions this contact has submitted + clientlab = relationship("ClientLab", back_populates="contact", uselist=True, + secondary=clientlab_contact) #: relationship to joined clientlab + clientsubmission = relationship("ClientSubmission", back_populates="contact") #: procedure this contact has submitted @classproperty def searchables(cls): return [] - @classmethod - def query_or_create(cls, **kwargs) -> Tuple[Contact, bool]: - new = False - disallowed = [] - sanitized_kwargs = {k: v for k, v in kwargs.items() if k not in disallowed} - instance = cls.query(**sanitized_kwargs) - if not instance or isinstance(instance, list): - instance = cls() - new = True - for k, v in sanitized_kwargs.items(): - setattr(instance, k, v) - logger.info(f"Instance from contact query or create: {instance}") - return instance, new + # @classmethod + # def query_or_create(cls, **kwargs) -> Tuple[Contact, bool]: + # new = False + # disallowed = [] + # sanitized_kwargs = {k: v for k, v in kwargs.items() if k not in disallowed} + # instance = cls.query(**sanitized_kwargs) + # if not instance or isinstance(instance, list): + # instance = cls() + # new = True + # for k, v in sanitized_kwargs.items(): + # setattr(instance, k, v) + # logger.info(f"Instance from contact query or create: {instance}") + # return instance, new @classmethod @setup_lookup @@ -133,7 +128,7 @@ class Contact(BaseClass): limit: int = 0, ) -> Contact | List[Contact]: """ - Lookup contacts in the database by a number of parameters. + Lookup contact in the database by a number of parameters. Args: name (str | None, optional): Name of the contact. Defaults to None. diff --git a/src/submissions/backend/db/models/submissions.py b/src/submissions/backend/db/models/submissions.py index 71e2b4d..9e0fcdd 100644 --- a/src/submissions/backend/db/models/submissions.py +++ b/src/submissions/backend/db/models/submissions.py @@ -1,5 +1,5 @@ """ -Models for the main run and sample types. +Models for the main procedure and sample types. """ from __future__ import annotations @@ -15,7 +15,9 @@ from operator import itemgetter from pprint import pformat from pandas import DataFrame from sqlalchemy.ext.hybrid import hybrid_property -from . import Base, BaseClass, Reagent, SubmissionType, KitType, Organization, Contact, LogMixin + + +from . import Base, BaseClass, Reagent, SubmissionType, KitType, ClientLab, Contact, LogMixin from sqlalchemy import Column, String, TIMESTAMP, INTEGER, ForeignKey, JSON, FLOAT, case, func, Table from sqlalchemy.orm import relationship, validates, Query from sqlalchemy.orm.attributes import flag_modified @@ -34,84 +36,57 @@ from jinja2.exceptions import TemplateNotFound from jinja2 import Template from PIL import Image -from . import kittypes_runs +from . import kittype_procedure -logger = logging.getLogger(f"submissions.{__name__}") +logger = logging.getLogger(f"procedure.{__name__}") class ClientSubmission(BaseClass, LogMixin): """ - Object for the client run from which all run objects will be created. + Object for the client procedure from which all procedure objects will be created. """ id = Column(INTEGER, primary_key=True) #: primary key - submitter_plate_id = Column(String(127), unique=True) #: The number given to the run by the submitting lab - submitted_date = Column(TIMESTAMP) #: Date run received - submitting_lab = relationship("Organization", back_populates="submissions") #: client org - submitting_lab_id = Column(INTEGER, ForeignKey("_organization.id", ondelete="SET NULL", - name="fk_BS_sublab_id")) #: client lab id from _organizations - _submission_category = Column( - String(64)) #: ["Research", "Diagnostic", "Surveillance", "Validation"], else defaults to submission_type_name - sample_count = Column(INTEGER) #: Number of samples in the run + submitter_plate_id = Column(String(127), unique=True) #: The number given to the procedure by the submitting lab + submitted_date = Column(TIMESTAMP) #: Date procedure received + clientlab = relationship("ClientLab", back_populates="clientsubmission") #: client org + clientlab_id = Column(INTEGER, ForeignKey("_clientlab.id", ondelete="SET NULL", + name="fk_BS_sublab_id")) #: client lab id from _organizations + submission_category = Column(String(64)) + sample_count = Column(INTEGER) #: Number of sample in the procedure comment = Column(JSON) - runs = relationship("BasicRun", back_populates="client_submission") #: many-to-one relationship - # misc_info = Column(JSON) - contact = relationship("Contact", back_populates="submissions") #: client org + run = relationship("Run", back_populates="clientsubmission") #: many-to-one relationship + contact = relationship("Contact", back_populates="clientsubmission") #: client org contact_id = Column(INTEGER, ForeignKey("_contact.id", ondelete="SET NULL", name="fk_BS_contact_id")) #: client lab id from _organizations - submission_type_name = Column(String, ForeignKey("_submissiontype.name", ondelete="SET NULL", - name="fk_BS_subtype_name")) #: name of joined run type - submission_type = relationship("SubmissionType", back_populates="controls") #: archetype of this run - - + submissiontype_name = Column(String, ForeignKey("_submissiontype.name", ondelete="SET NULL", + name="fk_BS_subtype_name")) #: name of joined procedure type + submissiontype = relationship("SubmissionType", back_populates="clientsubmission") #: archetype of this procedure cost_centre = Column( String(64)) #: Permanent storage of used cost centre in case organization field changed in the future. - submission_sample_associations = relationship( - "SubmissionSampleAssociation", - back_populates="run", + clientsubmissionsampleassociation = relationship( + "ClientSubmissionSampleAssociation", + back_populates="clientsubmission", cascade="all, delete-orphan", - ) #: Relation to SubmissionSampleAssociation + ) #: Relation to ClientSubmissionSampleAssociation - samples = association_proxy("submission_sample_associations", - "sample", creator=lambda sample: SubmissionSampleAssociation( - sample=sample)) #: Association proxy to SubmissionSampleAssociation.samples + samples = association_proxy("clientsubmissionsampleassociation", + "sample") #, creator=lambda sample: ClientSubmissionSampleAssociation( + + # sample=sample)) #: Association proxy to ClientSubmissionSampleAssociation.sample @hybrid_property - def submission_category(self): - return self._submission_category - - @submission_category.setter - def submission_category(self, submission_category): - if submission_category in ["Research", "Diagnostic", "Surveillance", "Validation"]: - self._submission_category = submission_category - else: - try: - self._submission_category = self.submission_type_name - except AttributeError: - self._submission_category = "NA" - - def __init__(self): - super().__init__() - self.misc_info = {} - - def set_attribute(self, key, value): - if hasattr(self, key): - super().__setattr__(key, value) - else: - self.misc_info[key] = value - - @classmethod - def recruit_parser(cls): - pass + def name(self): + return self.submitter_plate_id @classmethod @setup_lookup def query(cls, submissiontype: str | SubmissionType | None = None, - submission_type_name: str | None = None, + submissiontype_name: str | None = None, id: int | str | None = None, - submitter_plate_num: str | None = None, + submitter_plate_id: str | None = None, start_date: date | datetime | str | int | None = None, end_date: date | datetime | str | int | None = None, chronologic: bool = False, @@ -119,9 +94,9 @@ class ClientSubmission(BaseClass, LogMixin): page: int = 1, page_size: None | int = 250, **kwargs - ) -> BasicRun | List[BasicRun]: + ) -> ClientSubmission | List[ClientSubmission]: """ - Lookup submissions based on a number of parameters. Overrides parent. + Lookup procedure based on a number of parameters. Overrides parent. Args: submission_type (str | models.SubmissionType | None, optional): Submission type of interest. Defaults to None. @@ -129,12 +104,12 @@ class ClientSubmission(BaseClass, LogMixin): rsl_plate_num (str | None, optional): Submission name in the database (limits results to 1). Defaults to None. start_date (date | str | int | None, optional): Beginning date to search by. Defaults to None. end_date (date | str | int | None, optional): Ending date to search by. Defaults to None. - reagent (models.Reagent | str | None, optional): A reagent used in the run. Defaults to None. + reagent (models.Reagent | str | None, optional): A reagent used in the procedure. Defaults to None. chronologic (bool, optional): Return results in chronologic order. Defaults to False. limit (int, optional): Maximum number of results to return. Defaults to 0. Returns: - models.BasicRun | List[models.BasicRun]: Submission(s) of interest + models.Run | List[models.Run]: Submission(s) of interest """ # from ... import RunReagentAssociation # NOTE: if you go back to using 'model' change the appropriate cls to model in the query filters @@ -145,22 +120,22 @@ class ClientSubmission(BaseClass, LogMixin): if end_date is not None and start_date is None: # NOTE: this query returns a tuple of (object, datetime), need to get only datetime. start_date = cls.__database_session__.query(cls, func.min(cls.submitted_date)).first()[1] - logger.warning(f"End date with no start date, using first run date: {start_date}") + logger.warning(f"End date with no start date, using first procedure date: {start_date}") if start_date is not None: start_date = cls.rectify_query_date(start_date) end_date = cls.rectify_query_date(end_date, eod=True) logger.debug(f"Start date: {start_date}, end date: {end_date}") query = query.filter(cls.submitted_date.between(start_date, end_date)) # NOTE: by rsl number (returns only a single value) - match submitter_plate_num: + match submitter_plate_id: case str(): - query = query.filter(cls.submitter_plate_num == submitter_plate_num) + query = query.filter(cls.submitter_plate_id == submitter_plate_id) limit = 1 case _: pass - match submission_type_name: + match submissiontype_name: case str(): - query = query.filter(cls.submission_type_name == submission_type_name) + query = query.filter(cls.submissiontype_name == submissiontype_name) case _: pass # NOTE: by id (returns only a single value) @@ -180,31 +155,31 @@ class ClientSubmission(BaseClass, LogMixin): page = page - 1 if page is not None: query = query.offset(page * page_size) - return cls.execute_query(query=query, model=cls, limit=limit, **kwargs) + return cls.execute_query(query=query, limit=limit, **kwargs) @classmethod - def submissions_to_df(cls, submission_type: str | None = None, limit: int = 0, + def submissions_to_df(cls, submissiontype: str | None = None, limit: int = 0, chronologic: bool = True, page: int = 1, page_size: int = 250) -> pd.DataFrame: """ - Convert all submissions to dataframe + Convert all procedure to dataframe Args: page_size (int, optional): Number of items to include in query result. Defaults to 250. - page (int, optional): Limits the number of submissions to a page size. Defaults to 1. - chronologic (bool, optional): Sort submissions in chronologic order. Defaults to True. - submission_type (str | None, optional): Filter by SubmissionType. Defaults to None. + page (int, optional): Limits the number of procedure to a page size. Defaults to 1. + chronologic (bool, optional): Sort procedure in chronologic order. Defaults to True. + submissiontype (str | None, optional): Filter by SubmissionType. Defaults to None. limit (int, optional): Maximum number of results to return. Defaults to 0. Returns: - pd.DataFrame: Pandas Dataframe of all relevant submissions + pd.DataFrame: Pandas Dataframe of all relevant procedure """ # NOTE: use lookup function to create list of dicts subs = [item.to_dict() for item in - cls.query(submissiontype=submission_type, limit=limit, chronologic=chronologic, page=page, + cls.query(submissiontype=submissiontype, limit=limit, chronologic=chronologic, page=page, page_size=page_size)] df = pd.DataFrame.from_records(subs) # NOTE: Exclude sub information - exclude = ['controls', 'extraction_info', 'pcr_info', 'comment', 'comments', 'samples', 'reagents', + exclude = ['control', 'extraction_info', 'pcr_info', 'comment', 'comments', 'sample', 'reagents', 'equipment', 'gel_info', 'gel_image', 'dna_core_submission_number', 'gel_controls', 'source_plates', 'pcr_technician', 'ext_technician', 'artic_technician', 'cost_centre', 'signed_by', 'artic_date', 'gel_barcode', 'gel_date', 'ngs_date', 'contact_phone', 'contact', @@ -222,7 +197,7 @@ class ClientSubmission(BaseClass, LogMixin): def to_dict(self, full_data: bool = False, backup: bool = False, report: bool = False) -> dict: """ - Constructs dictionary used in submissions summary + Constructs dictionary used in procedure summary Args: expand (bool, optional): indicates if generators to be expanded. Defaults to False. @@ -231,33 +206,34 @@ class ClientSubmission(BaseClass, LogMixin): backup (bool, optional): passed to adjust_to_dict_samples. Defaults to False. Returns: - dict: dictionary used in submissions summary and details + dict: dictionary used in procedure summary and details """ # NOTE: get lab from nested organization object try: - sub_lab = self.submitting_lab.name + sub_lab = self.clientlab.name except AttributeError: sub_lab = None try: sub_lab = sub_lab.replace("_", " ").title() except AttributeError: pass - # NOTE: get extraction kit name from nested kit object + + # NOTE: get extraction kittype name from nested kittype object output = { "id": self.id, - "submission_type": self.submission_type_name, - "submitter_plate_number": self.submitter_plate_id, + "submissiontype": self.submissiontype_name, + "submitter_plate_id": self.submitter_plate_id, "submitted_date": self.submitted_date.strftime("%Y-%m-%d"), - "submitting_lab": sub_lab, + "clientlab": sub_lab, "sample_count": self.sample_count, } if report: return output if full_data: - # dicto, _ = self.extraction_kit.construct_xl_map_for_use(self.submission_type) - # samples = self.generate_associations(name="submission_sample_associations") + # dicto, _ = self.kittype.construct_xl_map_for_use(self.proceduretype) + # sample = self.generate_associations(name="clientsubmissionsampleassociation") samples = None - runs = [item.to_dict() for item in self.runs] + runs = [item.to_dict() for item in self.run] # custom = self.custom else: samples = None @@ -272,66 +248,123 @@ class ClientSubmission(BaseClass, LogMixin): contact = self.contact.name except AttributeError as e: try: - contact = f"Defaulted to: {self.submitting_lab.contacts[0].name}" + contact = f"Defaulted to: {self.clientlab.contacts[0].name}" except (AttributeError, IndexError): contact = "NA" try: contact_phone = self.contact.phone except AttributeError: contact_phone = "NA" + output["abbreviation"] = self.submissiontype.defaults['abbreviation'] output["submission_category"] = self.submission_category - output["samples"] = samples + output["sample"] = samples output["comment"] = comments output["contact"] = contact output["contact_phone"] = contact_phone # output["custom"] = custom - output["runs"] = runs + output["run"] = runs return output + def add_sample(self, sample: Sample): + try: + assert isinstance(sample, Sample) + except AssertionError: + sample = sample.to_sql() + try: + row = sample._misc_info['row'] + except (KeyError, AttributeError): + row = 0 + try: + column = sample._misc_info['column'] + except KeyError: + column = 0 + assoc = ClientSubmissionSampleAssociation( + sample=sample, + submission=self, + submission_rank=sample._misc_info['submission_rank'], + row=row, + column=column + ) + return assoc -class BasicRun(BaseClass, LogMixin): + @property + def custom_context_events(self) -> dict: + """ + Creates dictionary of str:function to be passed to context menu + + Returns: + dict: dictionary of functions + """ + names = ["Add Run", "Edit", "Add Comment", "Show Details", "Delete"] + return {item: self.__getattribute__(item.lower().replace(" ", "_")) for item in names} + + def add_run(self, obj): + logger.debug("Add Run") + from frontend.widgets.sample_checker import SampleChecker + samples = [sample.to_pydantic() for sample in self.clientsubmissionsampleassociation] + checker = SampleChecker(parent=None, title="Create Run", samples=samples, clientsubmission=self) + if checker.exec(): + run = Run(clientsubmission=self, rsl_plate_num=checker.rsl_plate_num) + active_samples = [sample for sample in samples if sample.enabled] + logger.debug(active_samples) + for sample in active_samples: + sample = sample.to_sql() + logger.debug(f"Sample: {sample.id}") + assoc = run.add_sample(sample) + assoc.save() + else: + logger.warning("Run cancelled.") + + + def edit(self, obj): + logger.debug("Edit") + + def add_comment(self, obj): + logger.debug("Add Comment") + + def show_details(self, obj): + logger.debug("Show Details") + + +class Run(BaseClass, LogMixin): """ - Object for an entire run run. Links to client submissions, reagents, equipment, processes + Object for an entire procedure procedure. Links to client procedure, reagents, equipment, process """ - - id = Column(INTEGER, primary_key=True) #: primary key rsl_plate_num = Column(String(32), unique=True, nullable=False) #: RSL name (e.g. RSL-22-0012) - client_submission_id = Column(INTEGER, ForeignKey("_clientsubmission.id", ondelete="SET NULL", - name="fk_BS_clientsub_id")) #: client lab id from _organizations) - client_submission = relationship("ClientSubmission", back_populates="runs") - started_date = Column(TIMESTAMP) #: Date this run was started. - + clientsubmission_id = Column(INTEGER, ForeignKey("_clientsubmission.id", ondelete="SET NULL", + name="fk_BS_clientsub_id")) #: client lab id from _organizations) + clientsubmission = relationship("ClientSubmission", back_populates="run") + started_date = Column(TIMESTAMP) #: Date this procedure was started. run_cost = Column( - FLOAT(2)) #: total cost of running the plate. Set from constant and mutable kit costs at time of creation. - signed_by = Column(String(32)) #: user name of person who submitted the run to the database. + FLOAT(2)) #: total cost of running the plate. Set from constant and mutable kittype costs at time of creation. + signed_by = Column(String(32)) #: user name of person who submitted the procedure to the database. comment = Column(JSON) #: user notes custom = Column(JSON) completed_date = Column(TIMESTAMP) - procedures = relationship("Procedure", back_populates="run", uselist=True) + procedure = relationship("Procedure", back_populates="run", uselist=True) - run_sample_associations = relationship( + runsampleassociation = relationship( "RunSampleAssociation", back_populates="run", cascade="all, delete-orphan", - ) #: Relation to SubmissionSampleAssociation - - samples = association_proxy("run_sample_associations", - "sample", creator=lambda sample: RunSampleAssociation( - sample=sample)) #: Association proxy to SubmissionSampleAssociation.samples + ) #: Relation to ClientSubmissionSampleAssociation + sample = association_proxy("runsampleassociation", + "sample", creator=lambda sample: RunSampleAssociation( + sample=sample)) #: Association proxy to ClientSubmissionSampleAssociation.sample # NOTE: Allows for subclassing into ex. BacterialCulture, Wastewater, etc. # __mapper_args__ = { # "polymorphic_identity": "Basic Submission", # "polymorphic_on": case( # - # (submission_type_name == "Wastewater", "Wastewater"), - # (submission_type_name == "Wastewater Artic", "Wastewater Artic"), - # (submission_type_name == "Bacterial Culture", "Bacterial Culture"), + # (submissiontype_name == "Wastewater", "Wastewater"), + # (submissiontype_name == "Wastewater Artic", "Wastewater Artic"), + # (submissiontype_name == "Bacterial Culture", "Bacterial Culture"), # # else_="Basic Submission" # ), @@ -339,45 +372,37 @@ class BasicRun(BaseClass, LogMixin): # } def __repr__(self) -> str: - return f"" - - @hybrid_property - def kittype(self): - return self.extraction_kit - - @hybrid_property - def organization(self): - return self.submitting_lab + return f"" @hybrid_property def name(self): return self.rsl_plate_num @classmethod - def get_default_info(cls, *args, submission_type: SubmissionType | None = None) -> dict: + def get_default_info(cls, *args, submissiontype: SubmissionType | None = None) -> dict: """ - Gets default info from the database for a given run type. + Gets default info from the database for a given procedure type. Args: *args (): List of fields to get - submission_type (SubmissionType): the run type of interest. Necessary due to generic run types. + submissiontype (SubmissionType): the procedure type of interest. Necessary due to generic procedure types. Returns: dict: Default info """ - # NOTE: Create defaults for all submission_types + # NOTE: Create defaults for all proceduretype # NOTE: Singles tells the query which fields to set limit to 1 dicto = super().get_default_info() - recover = ['filepath', 'samples', 'csv', 'comment', 'equipment'] + recover = ['filepath', 'sample', 'csv', 'comment', 'equipment'] dicto.update(dict( - details_ignore=['excluded', 'reagents', 'samples', + details_ignore=['excluded', 'reagents', 'sample', 'extraction_info', 'comment', 'barcode', 'platemap', 'export_map', 'equipment', 'tips', 'custom'], # NOTE: Fields not placed in ui form form_ignore=['reagents', 'ctx', 'id', 'cost', 'extraction_info', 'signed_by', 'comment', 'namer', 'submission_object', "tips", 'contact_phone', 'custom', 'cost_centre', 'completed_date', - 'controls', "origin_plate"] + recover, + 'control', "origin_plate"] + recover, # NOTE: Fields not placed in ui form to be moved to pydantic form_recover=recover )) @@ -386,14 +411,15 @@ class BasicRun(BaseClass, LogMixin): output = {k: v for k, v in dicto.items() if k in args} else: output = {k: v for k, v in dicto.items()} - if isinstance(submission_type, SubmissionType): - st = submission_type + logger.debug(f"Submission type for get default info: {submissiontype}") + if isinstance(submissiontype, SubmissionType): + st = submissiontype else: - st = cls.get_submission_type(submission_type) + st = cls.get_submission_type(submissiontype) if st is None: - logger.error("No default info for BasicRun.") + logger.error("No default info for Run.") else: - output['submission_type'] = st.name + output['submissiontype'] = st.name for k, v in st.defaults.items(): if args and k not in args: continue @@ -414,36 +440,36 @@ class BasicRun(BaseClass, LogMixin): return output @classmethod - def get_submission_type(cls, sub_type: str | SubmissionType | None = None) -> SubmissionType: + def get_submission_type(cls, submissiontype: str | SubmissionType | None = None) -> SubmissionType: """ Gets the SubmissionType associated with this class Args: - sub_type (str | SubmissionType, Optional): Identity of the run type to retrieve. Defaults to None. + submissiontype (str | SubmissionType, Optional): Identity of the procedure type to retrieve. Defaults to None. Returns: SubmissionType: SubmissionType with name equal sub_type or this polymorphic identity if sub_type is None. """ - if isinstance(sub_type, dict): + if isinstance(submissiontype, dict): try: - sub_type = sub_type['value'] + submissiontype = submissiontype['value'] except KeyError as e: - logger.error(f"Couldn't extract value from {sub_type}") + logger.error(f"Couldn't extract value from {submissiontype}") raise e - match sub_type: + match submissiontype: case str(): - return SubmissionType.query(name=sub_type) + return SubmissionType.query(name=submissiontype) case SubmissionType(): - return sub_type + return submissiontype case _: # return SubmissionType.query(cls.__mapper_args__['polymorphic_identity']) return None @classmethod - def construct_info_map(cls, submission_type: SubmissionType | None = None, + def construct_info_map(cls, submissiontype: SubmissionType | None = None, mode: Literal["read", "write"] = "read") -> dict: """ - Method to call run type's construct info map. + Method to call procedure type's construct info map. Args: mode (Literal["read", "write"]): Which map to construct. @@ -451,17 +477,17 @@ class BasicRun(BaseClass, LogMixin): Returns: dict: Map of info locations. """ - return cls.get_submission_type(submission_type).construct_info_map(mode=mode) + return cls.get_submission_type(submissiontype).construct_info_map(mode=mode) @classmethod - def construct_sample_map(cls, submission_type: SubmissionType | None = None) -> dict: + def construct_sample_map(cls, submissiontype: SubmissionType | None = None) -> dict: """ - Method to call run type's construct_sample_map + Method to call procedure type's construct_sample_map Returns: dict: sample location map """ - return cls.get_submission_type(submission_type).sample_map + return cls.get_submission_type(submissiontype).sample_map def generate_associations(self, name: str, extra: str | None = None): try: @@ -476,7 +502,7 @@ class BasicRun(BaseClass, LogMixin): def to_dict(self, full_data: bool = False, backup: bool = False, report: bool = False) -> dict: """ - Constructs dictionary used in submissions summary + Constructs dictionary used in procedure summary Args: expand (bool, optional): indicates if generators to be expanded. Defaults to False. @@ -485,101 +511,65 @@ class BasicRun(BaseClass, LogMixin): backup (bool, optional): passed to adjust_to_dict_samples. Defaults to False. Returns: - dict: dictionary used in submissions summary and details + dict: dictionary used in procedure summary and details """ # NOTE: get lab from nested organization object try: - sub_lab = self.client_submission.submitting_lab.name + sub_lab = self.clientsubmission.clientlab.name except AttributeError: sub_lab = None try: sub_lab = sub_lab.replace("_", " ").title() except AttributeError: pass - # NOTE: get extraction kit name from nested kit object - # try: - # ext_kit = self.extraction_kit.name - # except AttributeError: - # ext_kit = None - # NOTE: load scraped extraction info - # try: - # ext_info = self.extraction_info - # except TypeError: - # ext_info = None output = { "id": self.id, - "plate_number": self.rsl_plate_num, - "submission_type": self.client_submission.submission_type_name, - "submitter_plate_number": self.client_submission.submitter_plate_id, - "started_date": self.client_submission.submitted_date.strftime("%Y-%m-%d"), - "submitting_lab": sub_lab, - "sample_count": self.client_submission.sample_count, - "extraction_kit": "Change submissions.py line 388", + "plate_number": self.name, + "submissiontype": self.clientsubmission.submissiontype_name, + "submitter_plate_id": self.clientsubmission.submitter_plate_id, + "started_date": self.clientsubmission.submitted_date.strftime("%Y-%m-%d"), + "clientlab": sub_lab, + "sample_count": self.clientsubmission.sample_count, + "kittype": "Change procedure.py line 388", "cost": self.run_cost } if report: return output if full_data: - try: - 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 = [] - # finally: - # dicto, _ = self.extraction_kit.construct_xl_map_for_use(self.submission_type) - # for k, v in dicto.items(): - # if k == 'info': - # continue - # if not any([item['role'] == k for item in reagents]): - # expiry = "NA" - # reagents.append( - # dict(role=k, name="Not Applicable", lot="NA", expiry=expiry, - # missing=True)) - samples = self.generate_associations(name="submission_sample_associations") + samples = self.generate_associations(name="clientsubmissionsampleassociation") equipment = self.generate_associations(name="submission_equipment_associations") tips = self.generate_associations(name="submission_tips_associations") - # cost_centre = self.cost_centre custom = self.custom - controls = [item.to_sub_dict() for item in self.controls] else: - reagents = None samples = None equipment = None tips = None - cost_centre = None custom = None - controls = None try: comments = self.comment except Exception as e: logger.error(f"Error setting comment: {self.comment}, {e}") comments = None try: - contact = self.contact.name + contact = self.clientsubmission.contact.name except AttributeError as e: try: - contact = f"Defaulted to: {self.submitting_lab.contacts[0].name}" + contact = f"Defaulted to: {self.clientsubmission.clientlab.contact[0].name}" except (AttributeError, IndexError): contact = "NA" try: - contact_phone = self.contact.phone + contact_phone = self.clientsubmission.contact.phone except AttributeError: contact_phone = "NA" - output["submission_category"] = self.client_submission.submission_category - output["technician"] = self.technician - output["reagents"] = reagents - output["samples"] = samples - # output["extraction_info"] = ext_info + output["submission_category"] = self.clientsubmission.submission_category + output["sample"] = samples output["comment"] = comments output["equipment"] = equipment output["tips"] = tips - # output["cost_centre"] = cost_centre output["signed_by"] = self.signed_by output["contact"] = contact output["contact_phone"] = contact_phone output["custom"] = custom - output["controls"] = controls try: output["completed_date"] = self.completed_date.strftime("%Y-%m-%d") except AttributeError: @@ -596,7 +586,7 @@ class BasicRun(BaseClass, LogMixin): query_out = [] for sub_type in submissiontype: subs = cls.query(page_size=0, start_date=start_date, end_date=end_date, submissiontype=sub_type) - # logger.debug(f"Sub results: {runs}") + # logger.debug(f"Sub results: {run}") query_out.append(subs) query_out = list(itertools.chain.from_iterable(query_out)) else: @@ -616,7 +606,7 @@ class BasicRun(BaseClass, LogMixin): @property def column_count(self) -> int: """ - Calculate the number of columns in this run + Calculate the number of columns in this procedure Returns: int: Number of unique columns. @@ -629,29 +619,30 @@ class BasicRun(BaseClass, LogMixin): Calculates cost of the plate """ # NOTE: Calculate number of columns based on largest column number - try: - cols_count_96 = self.column_count - except Exception as e: - logger.error(f"Column count error: {e}") - # NOTE: Get kit associated with this run - # logger.debug(f"Checking associations with run type: {self.submission_type_name}") - assoc = next((item for item in self.extraction_kit.kit_submissiontype_associations if - item.submission_type == self.submission_type), - None) - # logger.debug(f"Got association: {assoc}") - # NOTE: If every individual cost is 0 this is probably an old plate. - if all(item == 0.0 for item in [assoc.constant_cost, assoc.mutable_cost_column, assoc.mutable_cost_sample]): - try: - self.run_cost = self.extraction_kit.cost_per_run - except Exception as e: - logger.error(f"Calculation error: {e}") - else: - try: - self.run_cost = assoc.constant_cost + (assoc.mutable_cost_column * cols_count_96) + ( - assoc.mutable_cost_sample * int(self.sample_count)) - except Exception as e: - logger.error(f"Calculation error: {e}") - self.run_cost = round(self.run_cost, 2) + # try: + # cols_count_96 = self.column_count + # except Exception as e: + # logger.error(f"Column count error: {e}") + # # NOTE: Get kittype associated with this procedure + # # logger.debug(f"Checking associations with procedure type: {self.submissiontype_name}") + # assoc = next((item for item in self.kittype.kit_submissiontype_associations if + # item.proceduretype == self.submission_type), + # None) + # # logger.debug(f"Got association: {assoc}") + # # NOTE: If every individual cost is 0 this is probably an old plate. + # if all(item == 0.0 for item in [assoc.constant_cost, assoc.mutable_cost_column, assoc.mutable_cost_sample]): + # try: + # self.run_cost = self.kittype.cost_per_run + # except Exception as e: + # logger.error(f"Calculation error: {e}") + # else: + # try: + # self.run_cost = assoc.constant_cost + (assoc.mutable_cost_column * cols_count_96) + ( + # assoc.mutable_cost_sample * int(self.sample_count)) + # except Exception as e: + # logger.error(f"Calculation error: {e}") + # self.run_cost = round(self.run_cost, 2) + pass @property def hitpicked(self) -> list: @@ -661,16 +652,16 @@ class BasicRun(BaseClass, LogMixin): Returns: list: list of hitpick dictionaries for each sample """ - output_list = [assoc.hitpicked for assoc in self.submission_sample_associations] + output_list = [assoc.hitpicked for assoc in self.runsampleassociation] return output_list @classmethod def make_plate_map(cls, sample_list: list, plate_rows: int = 8, plate_columns=12) -> str: """ - Constructs an html based plate map for run details. + Constructs an html based plate map for procedure details. Args: - sample_list (list): List of run samples + sample_list (list): List of procedure sample plate_rows (int, optional): Number of rows in the plate. Defaults to 8. plate_columns (int, optional): Number of columns in the plate. Defaults to 12. @@ -694,28 +685,28 @@ class BasicRun(BaseClass, LogMixin): @property def used_equipment(self) -> Generator[str, None, None]: """ - Gets EquipmentRole names associated with this BasicRun + Gets EquipmentRole names associated with this Run Returns: List[str]: List of names """ - return (item.role for item in self.submission_equipment_associations) + return (item.equipmentrole for item in self.submission_equipment_associations) @classmethod def submissions_to_df(cls, submission_type: str | None = None, limit: int = 0, chronologic: bool = True, page: int = 1, page_size: int = 250) -> pd.DataFrame: """ - Convert all submissions to dataframe + Convert all procedure to dataframe Args: page_size (int, optional): Number of items to include in query result. Defaults to 250. - page (int, optional): Limits the number of submissions to a page size. Defaults to 1. - chronologic (bool, optional): Sort submissions in chronologic order. Defaults to True. + page (int, optional): Limits the number of procedure to a page size. Defaults to 1. + chronologic (bool, optional): Sort procedure in chronologic order. Defaults to True. submission_type (str | None, optional): Filter by SubmissionType. Defaults to None. limit (int, optional): Maximum number of results to return. Defaults to 0. Returns: - pd.DataFrame: Pandas Dataframe of all relevant submissions + pd.DataFrame: Pandas Dataframe of all relevant procedure """ # NOTE: use lookup function to create list of dicts subs = [item.to_dict() for item in @@ -723,7 +714,7 @@ class BasicRun(BaseClass, LogMixin): page_size=page_size)] df = pd.DataFrame.from_records(subs) # NOTE: Exclude sub information - exclude = ['controls', 'extraction_info', 'pcr_info', 'comment', 'comments', 'samples', 'reagents', + exclude = ['control', 'extraction_info', 'pcr_info', 'comment', 'comments', 'sample', 'reagents', 'equipment', 'gel_info', 'gel_image', 'dna_core_submission_number', 'gel_controls', 'source_plates', 'pcr_technician', 'ext_technician', 'artic_technician', 'cost_centre', 'signed_by', 'artic_date', 'gel_barcode', 'gel_date', 'ngs_date', 'contact_phone', 'contact', @@ -748,27 +739,27 @@ class BasicRun(BaseClass, LogMixin): value (_type_): value of attribute """ match key: - case "extraction_kit": + case "kittype": field_value = KitType.query(name=value) - case "submitting_lab": - field_value = Organization.query(name=value) + case "clientlab": + field_value = ClientLab.query(name=value) case "contact": field_value = Contact.query(name=value) - case "samples": + case "sample": for sample in value: - sample, _ = sample.to_sql(run=self) + sample, _ = sample.to_sql() return case "reagents": field_value = [reagent['value'].to_sql()[0] if isinstance(reagent, dict) else reagent.to_sql()[0] for reagent in value] - case "submission_type": + case "proceduretype": field_value = SubmissionType.query(name=value) case "sample_count": if value is None: - field_value = len(self.samples) + field_value = len(self.sample) else: field_value = value - case "ctx" | "csv" | "filepath" | "equipment" | "controls": + case "ctx" | "csv" | "filepath" | "equipment" | "control": return case item if item in self.jsons: match key: @@ -810,16 +801,17 @@ class BasicRun(BaseClass, LogMixin): except AttributeError as e: logger.error(f"Could not set {self} attribute {key} to {value} due to \n{e}") - def update_subsampassoc(self, assoc: SubmissionSampleAssociation, input_dict: dict) -> SubmissionSampleAssociation: + def update_subsampassoc(self, assoc: ClientSubmissionSampleAssociation, + input_dict: dict) -> ClientSubmissionSampleAssociation: """ - Update a joined run sample association. + Update a joined procedure sample association. Args: - assoc (SubmissionSampleAssociation): Sample association to be updated. + assoc (ClientSubmissionSampleAssociation): Sample association to be updated. input_dict (dict): updated values to insert. Returns: - SubmissionSampleAssociation: Updated association + ClientSubmissionSampleAssociation: Updated association """ # NOTE: No longer searches for association here, done in caller function for k, v in input_dict.items(): @@ -830,16 +822,16 @@ class BasicRun(BaseClass, LogMixin): pass return assoc - def update_reagentassoc(self, reagent: Reagent, role: str): - # NOTE: get the first reagent assoc that fills the given role. - try: - assoc = next(item for item in self.submission_reagent_associations if - item.reagent and role in [role.name for role in item.reagent.role]) - assoc.reagent = reagent - except StopIteration as e: - logger.error(f"Association for {role} not found, creating new association.") - assoc = RunReagentAssociation(submission=self, reagent=reagent) - self.submission_reagent_associations.append(assoc) + # def update_reagentassoc(self, reagent: Reagent, role: str): + # # NOTE: get the first reagent assoc that fills the given reagentrole. + # try: + # assoc = next(item for item in self.submission_reagent_associations if + # item.reagent and role in [role.name for role in item.reagent.equipmentrole]) + # assoc.reagent = reagent + # except StopIteration as e: + # logger.error(f"Association for {role} not found, creating new association.") + # assoc = ProcedureReagentAssociation(procedure=self, reagent=reagent) + # self.submission_reagent_associations.append(assoc) def to_pydantic(self, backup: bool = False) -> "PydSubmission": """ @@ -855,13 +847,13 @@ class BasicRun(BaseClass, LogMixin): missing = value in ['', 'None', None] match key: case "reagents": - field_value = [item.to_pydantic(extraction_kit=self.extraction_kit) for item in + field_value = [item.to_pydantic(kittype=self.extraction_kit) for item in self.submission_reagent_associations] - case "samples": + case "sample": field_value = [item.to_pydantic() for item in self.submission_sample_associations] case "equipment": field_value = [item.to_pydantic() for item in self.submission_equipment_associations] - case "controls": + case "control": try: field_value = [item.to_pydantic() for item in self.__getattribute__(key)] except TypeError as e: @@ -869,13 +861,13 @@ class BasicRun(BaseClass, LogMixin): continue case "tips": field_value = [item.to_pydantic() for item in self.submission_tips_associations] - case "submission_type": + case "proceduretype": field_value = dict(value=self.__getattribute__(key).name, missing=missing) case "plate_number": - key = 'rsl_plate_num' + key = 'name' field_value = dict(value=self.rsl_plate_num, missing=missing) case "submitter_plate_number": - key = "submitter_plate_num" + key = "submitter_plate_id" field_value = dict(value=self.submitter_plate_num, missing=missing) case "id": continue @@ -908,10 +900,10 @@ class BasicRun(BaseClass, LogMixin): @classmethod def get_regex(cls, submission_type: SubmissionType | str | None = None) -> re.Pattern: """ - Gets the regex string for identifying a certain class of run. + Gets the regex string for identifying a certain class of procedure. Args: - submission_type (SubmissionType | str | None, optional): run type of interest. Defaults to None. + submission_type (SubmissionType | str | None, optional): procedure type of interest. Defaults to None. Returns: str: String from which regex will be compiled. @@ -920,7 +912,7 @@ class BasicRun(BaseClass, LogMixin): try: regex = cls.get_submission_type(submission_type).defaults['regex'] except AttributeError as e: - logger.error(f"Couldn't get run type for {cls.__mapper_args__['polymorphic_identity']}") + logger.error(f"Couldn't get procedure type for {cls.__mapper_args__['polymorphic_identity']}") regex = None try: regex = re.compile(rf"{regex}", flags=re.IGNORECASE | re.VERBOSE) @@ -937,354 +929,13 @@ class BasicRun(BaseClass, LogMixin): Constructs catchall regex. Returns: - re.Pattern: Regular expression pattern to discriminate between run types. + re.Pattern: Regular expression pattern to discriminate between procedure types. """ res = [st.defaults['regex'] for st in SubmissionType.query() if st.defaults] rstring = rf'{"|".join(res)}' regex = re.compile(rstring, flags=re.IGNORECASE | re.VERBOSE) return regex - @classmethod - def find_polymorphic_subclass(cls, polymorphic_identity: str | SubmissionType | list | None = None, - attrs: dict | None = None) -> BasicRun | List[BasicRun]: - """ - Find subclass based on polymorphic identity or relevant attributes. - - Args: - polymorphic_identity (str | None, optional): String representing polymorphic identity. Defaults to None. - attrs (str | SubmissionType | None, optional): Attributes of the relevant class. Defaults to None. - - Returns: - _type_: Subclass of interest. - """ - if isinstance(polymorphic_identity, dict): - polymorphic_identity = polymorphic_identity['value'] - if isinstance(polymorphic_identity, SubmissionType): - polymorphic_identity = polymorphic_identity.name - model = cls - match polymorphic_identity: - case str(): - try: - model = cls.__mapper__.polymorphic_map[polymorphic_identity].class_ - except Exception as e: - logger.error( - f"Could not get polymorph {polymorphic_identity} of {cls} due to {e}, falling back to BasicRun") - case list(): - output = [] - for identity in polymorphic_identity: - if isinstance(identity, SubmissionType): - identity = polymorphic_identity.name - output.append(cls.__mapper__.polymorphic_map[identity].class_) - return output - case _: - pass - if attrs and any([not hasattr(cls, attr) for attr in attrs.keys()]): - # NOTE: looks for first model that has all included kwargs - try: - model = next(subclass for subclass in cls.__subclasses__() if - all([hasattr(subclass, attr) for attr in attrs.keys()])) - except StopIteration as e: - raise AttributeError( - f"Couldn't find existing class/subclass of {cls} with all attributes:\n{pformat(attrs.keys())}") - return model - - # NOTE: Child class custom functions - - @classmethod - def custom_info_parser(cls, input_dict: dict, xl: Workbook | None = None, custom_fields: dict = {}) -> dict: - """ - Update run dictionary with type specific information - - Args: - input_dict (dict): Input sample dictionary - xl (Workbook): original xl workbook, used for child classes mostly - custom_fields: Dictionary of locations, ranges, etc to be used by this function - - Returns: - dict: Updated sample dictionary - """ - input_dict['custom'] = {} - for k, v in custom_fields.items(): - logger.debug(f"Custom info parser getting type: {v['type']}") - match v['type']: - # NOTE: 'exempt' type not currently used - case "exempt": - continue - case "cell": - ws = xl[v['read']['sheet']] - input_dict['custom'][k] = ws.cell(row=v['read']['row'], column=v['read']['column']).value - case "range": - ws = xl[v['sheet']] - if v['start_row'] != v['end_row']: - v['end_row'] = v['end_row'] + 1 - rows = range(v['start_row'], v['end_row']) - if v['start_column'] != v['end_column']: - v['end_column'] = v['end_column'] + 1 - columns = range(v['start_column'], v['end_column']) - input_dict['custom'][k] = [dict(value=ws.cell(row=row, column=column).value, row=row, column=column) - for row in rows for column in columns] - return input_dict - - @classmethod - def parse_samples(cls, input_dict: dict) -> dict: - """ - Update sample dictionary with type specific information - - Args: - input_dict (dict): Input sample dictionary - - Returns: - dict: Updated sample dictionary - """ - return input_dict - - @classmethod - def custom_validation(cls, pyd: "PydSubmission") -> "PydSubmission": - """ - Performs any final parsing of the pydantic object that only needs to be done for this cls. - - Args: - input_dict (dict): Parser product up to this point. - xl (pd.ExcelFile | None, optional): Excel run form. Defaults to None. - info_map (dict | None, optional): Map of information locations from SubmissionType. Defaults to None. - plate_map (dict | None, optional): Constructed plate map of samples. Defaults to None. - - Returns: - dict: Updated parser product. - """ - return pyd - - @classmethod - def custom_info_writer(cls, input_excel: Workbook, info: dict | None = None, backup: bool = False, - custom_fields: dict = {}) -> Workbook: - """ - Adds custom autofill methods for run - - Args: - input_excel (Workbook): initial workbook. - info (dict | None, optional): dictionary of additional info. Defaults to None. - backup (bool, optional): Whether this is part of a backup operation. Defaults to False. - custom_fields: Dictionary of locations, ranges, etc to be used by this function - - Returns: - Workbook: Updated workbook - """ - for k, v in custom_fields.items(): - try: - assert v['type'] in ['exempt', 'range', 'cell'] - except (AssertionError, KeyError): - continue - match v['type']: - case "exempt": - continue - case "cell": - v['write'].append(v['read']) - for cell in v['write']: - ws = input_excel[cell['sheet']] - ws.cell(row=cell['row'], column=cell['column'], value=info['custom'][k]) - case "range": - ws = input_excel[v['sheet']] - if v['start_row'] != v['end_row']: - v['end_row'] = v['end_row'] + 1 - if v['start_column'] != v['end_column']: - v['end_column'] = v['end_column'] + 1 - for item in info['custom'][k]: - ws.cell(row=item['row'], column=item['column'], value=item['value']) - return input_excel - - @classmethod - def custom_sample_writer(self, sample: dict) -> dict: - """ - Performs any final alterations to sample writing unique to this run type. - Args: - sample (dict): Dictionary of sample values. - - Returns: - dict: Finalized dictionary. - """ - return sample - - @classmethod - def enforce_name(cls, instr: str, data: dict | None = {}) -> str: - """ - Custom naming method for this class. - - Args: - instr (str): Initial name. - data (dict | None, optional): Additional parameters for name. Defaults to None. - - Returns: - str: Updated name. - """ - from backend.validators import RSLNamer - if "submission_type" not in data.keys(): - data['submission_type'] = cls.__mapper_args__['polymorphic_identity'] - data['abbreviation'] = cls.get_default_info("abbreviation", submission_type=data['submission_type']) - if instr in [None, ""]: - outstr = RSLNamer.construct_new_plate_name(data=data) - else: - outstr = instr - if re.search(rf"{data['abbreviation']}", outstr, flags=re.IGNORECASE) is None: - # NOTE: replace RSL- with RSL-abbreviation- - outstr = re.sub(rf"RSL-?", rf"RSL-{data['abbreviation']}-", outstr, flags=re.IGNORECASE) - try: - # NOTE: remove dashes from date - outstr = re.sub(r"(\d{4})-(\d{2})-(\d{2})", r"\1\2\3", outstr) - # NOTE: insert dash between abbreviation and date - outstr = re.sub(rf"{data['abbreviation']}(\d{6})", rf"{data['abbreviation']}-\1", outstr, - flags=re.IGNORECASE).upper() - except (AttributeError, TypeError) as e: - logger.error(f"Error making outstr: {e}, sending to RSLNamer to make new plate name.") - outstr = RSLNamer.construct_new_plate_name(data=data) - try: - # NOTE: Grab plate number as number after a -|_ not followed by another number - plate_number = re.search(r"(?:(-|_)\d)(?!\d)", outstr).group().strip("_").strip("-") - except AttributeError as e: - plate_number = "1" - # NOTE: insert dash between date and plate number - outstr = re.sub(r"(\d{8})(-|_)?\d?(R\d?)?", rf"\1-{plate_number}\3", outstr) - try: - # NOTE: grab repeat number - repeat = re.search(r"-\dR(?P\d)?", outstr).groupdict()['repeat'] - if repeat is None: - repeat = "1" - except AttributeError as e: - repeat = "" - # NOTE: Insert repeat number? - outstr = re.sub(r"(-\dR)\d?", rf"\1 {repeat}", outstr).replace(" ", "") - # NOTE: This should already have been done. Do I dare remove it? - outstr = re.sub(rf"RSL{data['abbreviation']}", rf"RSL-{data['abbreviation']}", outstr) - return re.sub(rf"{data['abbreviation']}(\d)", rf"{data['abbreviation']}-\1", outstr) - - @classmethod - def parse_pcr(cls, xl: Workbook, rsl_plate_num: str) -> Generator[dict, None, None]: - """ - Perform parsing of pcr info. Since most of our PC outputs are the same format, this should work for most. - - Args: - xl (pd.DataFrame): pcr info form - rsl_plate_num (str): rsl plate num of interest - - Returns: - Generator[dict, None, None]: Updated samples - """ - pcr_sample_map = cls.get_submission_type().sample_map['pcr_samples'] - main_sheet = xl[pcr_sample_map['main_sheet']] - fields = {k: v for k, v in pcr_sample_map.items() if k not in ['main_sheet', 'start_row']} - logger.debug(f"Fields: {fields}") - for row in main_sheet.iter_rows(min_row=pcr_sample_map['start_row']): - idx = row[0].row - sample = {} - for k, v in fields.items(): - # logger.debug(f"Checking key: {k} with value {v}") - sheet = xl[v['sheet']] - sample[k] = sheet.cell(row=idx, column=v['column']).value - yield sample - - @classmethod - def parse_pcr_controls(cls, xl: Workbook, rsl_plate_num: str) -> Generator[dict, None, None]: - """ - Custom parsing of pcr controls from Design & Analysis Software export. - - Args: - xl (Workbook): D&A export file - rsl_plate_num (str): Plate number of the run to be joined. - - Yields: - Generator[dict, None, None]: Dictionaries of row values. - """ - location_map = cls.get_submission_type().sample_map['pcr_controls'] - # logger.debug(f"Location map: {location_map}") - submission = cls.query(rsl_plate_num=rsl_plate_num) - name_column = 1 - for item in location_map: - # logger.debug(f"Checking {item}") - worksheet = xl[item['sheet']] - for iii, row in enumerate(worksheet.iter_rows(max_row=len(worksheet['A']), max_col=name_column), start=1): - # logger.debug(f"Checking row {row}, {iii}") - for cell in row: - # logger.debug(f"Checking cell: {cell}, with value {cell.value} against {item['name']}") - if cell.value == item['name']: - subtype, _ = item['name'].split("-") - target = item['target'] - # logger.debug(f"Subtype: {subtype}, target: {target}") - ct = worksheet.cell(row=iii, column=item['ct_column']).value - # NOTE: Kind of a stop gap solution to find control reagents. - if subtype == "PC": - ctrl = next((assoc.reagent for assoc in submission.submission_reagent_associations - if - any(["positive control" in item.name.lower() for item in assoc.reagent.role])), - None) - elif subtype == "NC": - ctrl = next((assoc.reagent for assoc in submission.submission_reagent_associations - if any(["molecular grade water" in item.name.lower() for item in - assoc.reagent.role])), None) - else: - ctrl = None - # logger.debug(f"Control reagent: {ctrl.__dict__}") - try: - ct = float(ct) - except ValueError: - ct = 0.0 - if ctrl: - ctrl = ctrl.lot - else: - ctrl = None - output = dict( - name=f"{rsl_plate_num}<{item['name']}-{target}>", - ct=ct, - subtype=subtype, - target=target, - reagent_lot=ctrl - ) - # logger.debug(f"Control output: {pformat(output)}") - yield output - - @classmethod - def filename_template(cls) -> str: - """ - Constructs template for filename of this class. - Note: This is meant to be used with the dictionary constructed in self.to_dict(). Keys need to have spaces removed - - Returns: - str: filename template in jinja friendly format. - """ - return "{{ rsl_plate_num }}" - - @classmethod - def adjust_autofill_samples(cls, samples: List[Any]) -> List[Any]: - """ - Makes adjustments to samples before writing to excel. - - Args: - samples (List[Any]): List of Samples - - Returns: - List[Any]: Updated list of samples - """ - return samples - - @classmethod - def get_details_template(cls, base_dict: dict) -> Tuple[dict, Template]: - """ - Get the details jinja template for the correct class - - Args: - base_dict (dict): incoming dictionary of Submission fields - - Returns: - Tuple(dict, Template): (Updated dictionary, Template to be rendered) - """ - base_dict['excluded'] = cls.get_default_info('details_ignore') - base_dict['excluded'] += ['controls'] - env = jinja_template_loading() - temp_name = f"{cls.__name__.lower()}_details.html" - try: - template = env.get_template(temp_name) - except TemplateNotFound as e: - logger.error(f"Couldn't find template due to {e}") - template = env.get_template("basicrun_details.html") - return base_dict, template # NOTE: Query functions @@ -1292,51 +943,50 @@ class BasicRun(BaseClass, LogMixin): @setup_lookup def query(cls, submissiontype: str | SubmissionType | None = None, - submission_type_name: str | None = None, + submissiontype_name: str | None = None, id: int | str | None = None, - rsl_plate_num: str | None = None, + name: str | None = None, start_date: date | datetime | str | int | None = None, end_date: date | datetime | str | int | None = None, - reagent: Reagent | str | None = None, chronologic: bool = False, limit: int = 0, page: int = 1, page_size: None | int = 250, **kwargs - ) -> BasicRun | List[BasicRun]: + ) -> Run | List[Run]: """ - Lookup submissions based on a number of parameters. Overrides parent. + Lookup procedure based on a number of parameters. Overrides parent. Args: submission_type (str | models.SubmissionType | None, optional): Submission type of interest. Defaults to None. id (int | str | None, optional): Submission id in the database (limits results to 1). Defaults to None. - rsl_plate_num (str | None, optional): Submission name in the database (limits results to 1). Defaults to None. + name (str | None, optional): Submission name in the database (limits results to 1). Defaults to None. start_date (date | str | int | None, optional): Beginning date to search by. Defaults to None. end_date (date | str | int | None, optional): Ending date to search by. Defaults to None. - reagent (models.Reagent | str | None, optional): A reagent used in the run. Defaults to None. + reagent (models.Reagent | str | None, optional): A reagent used in the procedure. Defaults to None. chronologic (bool, optional): Return results in chronologic order. Defaults to False. limit (int, optional): Maximum number of results to return. Defaults to 0. Returns: - models.BasicRun | List[models.BasicRun]: Run(s) of interest + models.Run | List[models.Run]: Run(s) of interest """ # from ... import RunReagentAssociation # NOTE: if you go back to using 'model' change the appropriate cls to model in the query filters - if submissiontype is not None: - model = cls.find_polymorphic_subclass(polymorphic_identity=submissiontype) - elif len(kwargs) > 0: - # NOTE: find the subclass containing the relevant attributes - model = cls.find_polymorphic_subclass(attrs=kwargs) - else: - model = cls - query: Query = cls.__database_session__.query(model) + # if submissiontype is not None: + # model = cls.find_polymorphic_subclass(polymorphic_identity=submissiontype) + # elif len(kwargs) > 0: + # # NOTE: find the subclass containing the relevant attributes + # model = cls.find_polymorphic_subclass(attrs=kwargs) + # else: + # model = cls + query: Query = cls.__database_session__.query(cls) if start_date is not None and end_date is None: logger.warning(f"Start date with no end date, using today.") end_date = date.today() if end_date is not None and start_date is None: # NOTE: this query returns a tuple of (object, datetime), need to get only datetime. start_date = cls.__database_session__.query(cls, func.min(cls.submitted_date)).first()[1] - logger.warning(f"End date with no start date, using first run date: {start_date}") + logger.warning(f"End date with no start date, using first procedure date: {start_date}") if start_date is not None: # match start_date: # case date(): @@ -1371,37 +1021,27 @@ class BasicRun(BaseClass, LogMixin): end_date = cls.rectify_query_date(end_date, eod=True) logger.debug(f"Start date: {start_date}, end date: {end_date}") query = query.join(ClientSubmission).filter(ClientSubmission.submitted_date.between(start_date, end_date)) - # NOTE: by reagent (for some reason) - match reagent: - case str(): - query = query.join(RunReagentAssociation).join(Reagent).filter( - Reagent.lot == reagent) - case Reagent(): - query = query.join(RunReagentAssociation).filter( - RunReagentAssociation.reagent == reagent) - case _: - pass # NOTE: by rsl number (returns only a single value) - match rsl_plate_num: + match name: case str(): - query = query.filter(model.rsl_plate_num == rsl_plate_num) + query = query.filter(cls.name == name) limit = 1 case _: pass - match submission_type_name: + match submissiontype_name: case str(): if not start_date: query = query.join(ClientSubmission) - query = query.filter(ClientSubmission.submission_type_name == submission_type_name) + query = query.filter(ClientSubmission.submissiontype_name == submissiontype_name) case _: pass # NOTE: by id (returns only a single value) match id: case int(): - query = query.filter(model.id == id) + query = query.filter(cls.id == id) limit = 1 case str(): - query = query.filter(model.id == int(id)) + query = query.filter(cls.id == int(id)) limit = 1 case _: pass @@ -1412,62 +1052,63 @@ class BasicRun(BaseClass, LogMixin): page = page - 1 if page is not None: query = query.offset(page * page_size) - return cls.execute_query(query=query, model=model, limit=limit, **kwargs) + return cls.execute_query(query=query, limit=limit, **kwargs) - @classmethod - def query_or_create(cls, submission_type: str | SubmissionType | None = None, **kwargs) -> BasicRun: - """ - Returns object from db if exists, else, creates new. Due to need for user input, doesn't see much use ATM. - - Args: - submission_type (str | SubmissionType | None, optional): Submission type to be created. Defaults to None. - - Raises: - ValueError: Raised if no kwargs passed. - ValueError: Raised if disallowed key is passed. - - Returns: - cls: A BasicRun subclass instance. - """ - code = 0 - msg = "" - report = Report() - disallowed = ["id"] - if kwargs == {}: - raise ValueError("Need to narrow down query or the first available instance will be returned.") - sanitized_kwargs = {k: v for k, v in kwargs.items() if k not in disallowed} - instance = cls.query(submissiontype=submission_type, limit=1, **sanitized_kwargs) - if instance is None: - used_class = cls.find_polymorphic_subclass(attrs=kwargs, polymorphic_identity=submission_type) - instance = used_class(**sanitized_kwargs) - match submission_type: - case str(): - submission_type = SubmissionType.query(name=submission_type) - case _: - pass - instance.submission_type = submission_type - instance.submission_type_name = submission_type.name - if "submitted_date" not in kwargs.keys(): - instance.submitted_date = date.today() - else: - from frontend.widgets.pop_ups import QuestionAsker - logger.warning(f"Found existing instance: {instance}, asking to overwrite.") - # code = 1 - # msg = "This run already exists.\nWould you like to overwrite?" - # report.add_result(Result(msg=msg, code=code)) - dlg = QuestionAsker(title="Overwrite?", - message="This run already exists.\nWould you like to overwrite?") - if dlg.exec(): - pass - else: - code = 1 - msg = "This run already exists.\nWould you like to overwrite?" - report.add_result(Result(msg=msg, code=code)) - return None, report - return instance, report + # @classmethod + # def query_or_create(cls, submissiontype: str | SubmissionType | None = None, **kwargs) -> Run: + # """ + # Returns object from db if exists, else, creates new. Due to need for user input, doesn't see much use ATM. + # + # Args: + # submissiontype (str | SubmissionType | None, optional): Submission type to be created. Defaults to None. + # + # Raises: + # ValueError: Raised if no kwargs passed. + # ValueError: Raised if disallowed key is passed. + # + # Returns: + # cls: A Run subclass instance. + # """ + # code = 0 + # msg = "" + # report = Report() + # disallowed = ["id"] + # if kwargs == {}: + # raise ValueError("Need to narrow down query or the first available instance will be returned.") + # sanitized_kwargs = {k: v for k, v in kwargs.items() if k not in disallowed} + # instance = cls.query(submissiontype=submissiontype, limit=1, **sanitized_kwargs) + # if instance is None: + # used_class = cls.find_polymorphic_subclass(attrs=kwargs, polymorphic_identity=submissiontype) + # instance = used_class(**sanitized_kwargs) + # match submissiontype: + # case str(): + # submissiontype = SubmissionType.query(name=submissiontype) + # case _: + # pass + # instance.proceduretype = submissiontype + # instance.submissiontype_name = submissiontype.name + # if "submitted_date" not in kwargs.keys(): + # instance.submitted_date = date.today() + # else: + # from frontend.widgets.pop_ups import QuestionAsker + # logger.warning(f"Found existing instance: {instance}, asking to overwrite.") + # # code = 1 + # # msg = "This procedure already exists.\nWould you like to overwrite?" + # # report.add_result(Result(msg=msg, code=code)) + # dlg = QuestionAsker(title="Overwrite?", + # message="This procedure already exists.\nWould you like to overwrite?") + # if dlg.exec(): + # pass + # else: + # code = 1 + # msg = "This procedure already exists.\nWould you like to overwrite?" + # report.add_result(Result(msg=msg, code=code)) + # return None, report + # return instance, report # NOTE: Custom context events for the ui + @property def custom_context_events(self) -> dict: """ Creates dictionary of str:function to be passed to context menu @@ -1475,10 +1116,14 @@ class BasicRun(BaseClass, LogMixin): Returns: dict: dictionary of functions """ - names = ["Delete", "Details", "Edit", "Add Comment", "Add Equipment", "Export"] - funcs = [self.delete, self.show_details, self.edit, self.add_comment, self.add_equipment, self.backup] - dicto = {item[0]: item[1] for item in zip(names, funcs)} - return dicto + names = ["Add Procedure", "Edit", "Add Comment", "Show Details", "Delete"] + output = {item: self.__getattribute__(item.lower().replace(" ", "_")) for item in names} + logger.debug(output) + return output + + def add_procedure(self, obj, proceduretype_name: str): + procedure_type = next((proceduretype for proceduretype in self.allowed_procedures if proceduretype.name == proceduretype_name)) + logger.debug(f"Got ProcedureType: {procedure_type}") def delete(self, obj=None): """ @@ -1512,7 +1157,7 @@ class BasicRun(BaseClass, LogMixin): def show_details(self, obj): """ - Creates Widget for showing run details. + Creates Widget for showing procedure details. Args: obj (Widget): Parent widget @@ -1524,7 +1169,7 @@ class BasicRun(BaseClass, LogMixin): def edit(self, obj): """ - Return run to form widget for updating + Return procedure to form widget for updating Args: obj (Widget): Parent widget @@ -1533,16 +1178,17 @@ class BasicRun(BaseClass, LogMixin): for widget in obj.app.table_widget.formwidget.findChildren(SubmissionFormWidget): widget.setParent(None) pyd = self.to_pydantic(backup=True) - form = pyd.to_form(parent=obj, disable=['rsl_plate_num']) + form = pyd.to_form(parent=obj, disable=['name']) obj.app.table_widget.formwidget.layout().addWidget(form) def add_comment(self, obj): """ - Creates widget for adding comments to submissions + Creates widget for adding comments to procedure Args: obj (_type_): parent widget """ + logger.debug(obj) from frontend.widgets.submission_details import SubmissionComment dlg = SubmissionComment(parent=obj, submission=self) if dlg.exec(): @@ -1552,37 +1198,6 @@ class BasicRun(BaseClass, LogMixin): self.set_attribute(key='comment', value=comment) self.save(original=False) - def add_equipment(self, obj): - """ - Creates widget for adding equipment to this run - - Args: - obj (_type_): parent widget - """ - from frontend.widgets.equipment_usage import EquipmentUsage - dlg = EquipmentUsage(parent=obj, submission=self) - if dlg.exec(): - equipment = dlg.parse_form() - for equip in equipment: - logger.debug(f"Parsed equipment: {equip}") - _, assoc = equip.to_sql(submission=self) - logger.debug(f"Got equipment association: {assoc} for {equip}") - try: - assoc.save() - except AttributeError as e: - logger.error(f"Couldn't save association with {equip} due to {e}") - if equip.tips: - for tips in equip.tips: - # logger.debug(f"Attempting to add tips assoc: {tips} (pydantic)") - tassoc = tips.to_sql(submission=self) - # logger.debug(f"Attempting to add tips assoc: {tips.__dict__} (sql)") - if tassoc not in self.submission_tips_associations: - tassoc.save() - else: - logger.error(f"Tips already found in run, skipping.") - else: - pass - def backup(self, obj=None, fname: Path | None = None, full_backup: bool = False): """ Exports xlsx info files for this instance. @@ -1607,7 +1222,7 @@ class BasicRun(BaseClass, LogMixin): completed = self.completed_date.date() except AttributeError: completed = None - return self.calculate_turnaround(start_date=self.client_submission.submitted_date.date(), end_date=completed) + return self.calculate_turnaround(start_date=self.clientsubmission.submitted_date.date(), end_date=completed) @classmethod def calculate_turnaround(cls, start_date: date | None = None, end_date: date | None = None) -> int: @@ -1629,76 +1244,85 @@ class BasicRun(BaseClass, LogMixin): return None return delta + def add_sample(self, sample: Sample): + try: + assert isinstance(sample, Sample) + except AssertionError: + logger.warning(f"Sample {sample} is not an sql object.") + sample = sample.to_sql() + try: + row = sample._misc_info['row'] + except (KeyError, AttributeError): + row = 0 + try: + column = sample._misc_info['column'] + except KeyError: + column = 0 + assoc = RunSampleAssociation( + row=row, + column=column, + run=self, + sample=sample + ) + return assoc + + @property + def allowed_procedures(self): + return self.clientsubmission.submissiontype.proceduretype + + +class SampleType(BaseClass): + id = Column(INTEGER, primary_key=True) #: primary key + name = Column(String(64), nullable=False, unique=True) #: identification from submitter + + sample = relationship("Sample", back_populates="sampletype", uselist=True) # NOTE: Sample Classes -class BasicSample(BaseClass, LogMixin): +class Sample(BaseClass, LogMixin): """ Base of basic sample which polymorphs into BCSample and WWSample """ id = Column(INTEGER, primary_key=True) #: primary key - submitter_id = Column(String(64), nullable=False, unique=True) #: identification from submitter - sample_type = Column(String(32)) #: mode_sub_type of sample + sample_id = Column(String(64), nullable=False, unique=True) #: identification from submitter + sampletype_id = Column(INTEGER, ForeignKey("_sampletype.id", ondelete="SET NULL", + name="fk_SAMP_sampletype_id")) + sampletype = relationship("SampleType", back_populates="sample") # misc_info = Column(JSON) control = relationship("Control", back_populates="sample", uselist=False) - sample_submission_associations = relationship( - "SubmissionSampleAssociation", + sampleclientsubmissionassociation = relationship( + "ClientSubmissionSampleAssociation", back_populates="sample", cascade="all, delete-orphan", - ) #: associated submissions + ) #: associated procedure - submissions = association_proxy("sample_submission_associations", "run") #: proxy of associated submissions + clientsubmission = association_proxy("sampleclientsubmissionassociation", + "clientsubmission") #: proxy of associated procedure - sample_run_associations = relationship( + samplerunassociation = relationship( "RunSampleAssociation", back_populates="sample", cascade="all, delete-orphan", - ) #: associated submissions + ) #: associated procedure - submissions = association_proxy("sample_submission_associations", "run") #: proxy of associated submissions + run = association_proxy("samplerunassociation", "run") #: proxy of associated procedure - @validates('submitter_id') - def create_id(self, key: str, value: str) -> str: - """ - Creates a random string as a submitter id. - - Args: - key (str): name of attribute - value (str): submitter id - - Returns: - str: new (or unchanged) submitter id - """ - if value is None: - return uuid.uuid4().hex.upper() - else: - return value + @hybrid_property + def name(self): + return self.sample_id def __repr__(self) -> str: try: - return f"<{self.sample_type.replace('_', ' ').title().replace(' ', '')}({self.submitter_id})>" + return f"<{self.sampletype.name.replace('_', ' ').title().replace(' ', '')}({self.sample_id})>" except AttributeError: - return f"" @classproperty def searchables(cls): - return [dict(label="Submitter ID", field="submitter_id")] - - @classproperty - def timestamps(cls) -> List[str]: - """ - Constructs a list of all attributes stored as SQL Timestamps - - Returns: - List[str]: Attribute list - """ - output = [item.name for item in cls.__table__.columns if isinstance(item.type, TIMESTAMP)] - if issubclass(cls, BasicSample) and not cls.__name__ == "BasicSample": - output += BasicSample.timestamps - return output + return [dict(label="Submitter ID", field="sample_id")] def to_sub_dict(self, full_data: bool = False) -> dict: """ @@ -1708,15 +1332,19 @@ class BasicSample(BaseClass, LogMixin): full_data (bool): Whether to use full object or truncated. Defaults to False Returns: - dict: submitter id and sample type and linked submissions if full data + dict: submitter id and sample type and linked procedure if full data """ + try: + sample_type = self.sampletype.name + except AttributeError: + sample_type = "NA" sample = dict( - submitter_id=self.submitter_id, - sample_type=self.sample_type + sample_id=self.sample_id, + sampletype=sample_type ) if full_data: - sample['submissions'] = sorted([item.to_sub_dict() for item in self.sample_submission_associations], - key=itemgetter('submitted_date')) + sample['clientsubmission'] = sorted([item.to_sub_dict() for item in self.sampleclientsubmissionassociation], + key=itemgetter('submitted_date')) return sample def to_pydantic(self): @@ -1736,164 +1364,67 @@ class BasicSample(BaseClass, LogMixin): except AttributeError: logger.error(f"Attribute {name} not found") - @classmethod - def find_polymorphic_subclass(cls, polymorphic_identity: str | None = None, - attrs: dict | None = None) -> Type[BasicSample]: - """ - Retrieves subclasses of BasicSample based on type name. - - Args: - attrs (dict | None, optional): name: value of attributes in the wanted subclass - polymorphic_identity (str | None, optional): Name of subclass fed to polymorphic identity. Defaults to None. - - Returns: - BasicSample: Subclass of interest. - """ - if isinstance(polymorphic_identity, dict): - polymorphic_identity = polymorphic_identity['value'] - if polymorphic_identity is not None: - try: - model = cls.__mapper__.polymorphic_map[polymorphic_identity].class_ - except Exception as e: - logger.error(f"Could not get polymorph {polymorphic_identity} of {cls} due to {e}, using {cls}") - model = cls - return model - else: - model = cls - if attrs is None or len(attrs) == 0: - return model - if any([not hasattr(cls, attr) for attr in attrs.keys()]): - # NOTE: looks for first model that has all included kwargs - try: - model = next(subclass for subclass in cls.__subclasses__() if - all([hasattr(subclass, attr) for attr in attrs.keys()])) - except StopIteration as e: - raise AttributeError( - f"Couldn't find existing class/subclass of {cls} with all attributes:\n{pformat(attrs.keys())}") - return model - - @classmethod - def parse_sample(cls, input_dict: dict) -> dict: - """ - Custom sample parser - - Args: - input_dict (dict): Basic parser results. - - Returns: - dict: Updated parser results. - """ - return input_dict - - @classproperty - def details_template(cls) -> Template: - """ - Get the details jinja template for the correct class - - Args: - base_dict (dict): incoming dictionary of Submission fields - - Returns: - Tuple(dict, Template): (Updated dictionary, Template to be rendered) - """ - env = jinja_template_loading() - temp_name = f"{cls.__name__.lower()}_details.html" - try: - template = env.get_template(temp_name) - except TemplateNotFound as e: - logger.error(f"Couldn't find template {e}") - template = env.get_template("basicsample_details.html") - return template - @classmethod @setup_lookup def query(cls, - submitter_id: str | None = None, - sample_type: str | BasicSample | None = None, + sample_id: str | None = None, + sampletype: str | SampleType | None = None, limit: int = 0, **kwargs - ) -> BasicSample | List[BasicSample]: + ) -> Sample | List[Sample]: """ - Lookup samples in the database by a number of parameters. + Lookup sample in the database by a number of parameters. Args: - submitter_id (str | None, optional): Name of the sample (limits results to 1). Defaults to None. - sample_type (str | None, optional): Sample type. Defaults to None. + sample_id (str | None, optional): Name of the sample (limits results to 1). Defaults to None. + sampletype (str | None, optional): Sample type. Defaults to None. limit (int, optional): Maximum number of results to return (0 = all). Defaults to 0. Returns: - models.BasicSample|List[models.BasicSample]: Sample(s) of interest. + models.Sample|List[models.Sample]: Sample(s) of interest. """ - match sample_type: + query = cls.__database_session__.query(cls) + match sampletype: case str(): - model = cls.find_polymorphic_subclass(polymorphic_identity=sample_type) - case BasicSample(): - model = sample_type + query = query.join(SampleType).filter(SampleType.name == sampletype) + case SampleType(): + query = query.filter(cls.sampletype == sampletype) case _: - model = cls.find_polymorphic_subclass(attrs=kwargs) - query: Query = cls.__database_session__.query(model) - match submitter_id: + pass + match sample_id: case str(): - query = query.filter(model.submitter_id == submitter_id) + query = query.filter(cls.sample_id == sample_id) limit = 1 case _: pass - return cls.execute_query(query=query, model=model, limit=limit, **kwargs) - - @classmethod - def query_or_create(cls, sample_type: str | None = None, **kwargs) -> BasicSample: - """ - Queries for a sample, if none found creates a new one. - - Args: - sample_type (str): sample subclass name - - Raises: - ValueError: Raised if no kwargs are passed to narrow down controls - ValueError: Raised if unallowed key is given. - - Returns: - BasicSample: Instance of BasicSample - """ - disallowed = ["id"] - if kwargs == {}: - raise ValueError("Need to narrow down query or the first available instance will be returned.") - sanitized_kwargs = {k: v for k, v in kwargs.items() if k not in disallowed} - instance = cls.query(sample_type=sample_type, limit=1, **kwargs) - if instance is None: - used_class = cls.find_polymorphic_subclass(attrs=sanitized_kwargs, polymorphic_identity=sample_type) - instance = used_class(**sanitized_kwargs) - instance.sample_type = sample_type - return instance + return cls.execute_query(query=query, limit=limit, **kwargs) @classmethod def fuzzy_search(cls, - sample_type: str | BasicSample | None = None, + sampletype: str | Sample | None = None, **kwargs - ) -> List[BasicSample]: + ) -> List[Sample]: """ - Allows for fuzzy search of samples. + Allows for fuzzy search of sample. Args: - sample_type (str | BasicSample | None, optional): Type of sample. Defaults to None. + sampletype (str | BasicSample | None, optional): Type of sample. Defaults to None. Returns: - List[BasicSample]: List of samples that match kwarg search parameters. + List[Sample]: List of sample that match kwarg search parameters. """ - match sample_type: + query: Query = cls.__database_session__.query(cls) + match sampletype: case str(): - model = cls.find_polymorphic_subclass(polymorphic_identity=sample_type) - case BasicSample(): - model = sample_type - case None: - model = cls + query = query.join(SampleType).filter(SampleType.name == sampletype) + case SampleType(): + query = query.filter(cls.sampletype == sampletype) case _: - model = cls.find_polymorphic_subclass(attrs=kwargs) - query: Query = cls.__database_session__.query(model) + pass for k, v in kwargs.items(): search = f"%{v}%" try: - attr = getattr(model, k) + attr = getattr(cls, k) # NOTE: the secret sauce is in attr.like query = query.filter(attr.like(search)) except (ArgumentError, AttributeError) as e: @@ -1904,31 +1435,31 @@ class BasicSample(BaseClass, LogMixin): raise AttributeError(f"Delete not implemented for {self.__class__}") @classmethod - def samples_to_df(cls, sample_list: List[BasicSample], **kwargs) -> pd.DataFrame: + def samples_to_df(cls, sample_list: List[Sample], **kwargs) -> pd.DataFrame: """ Runs a fuzzy search and converts into a dataframe. Args: - sample_list (List[BasicSample]): List of samples to be parsed. Defaults to None. + sample_list (List[Sample]): List of sample to be parsed. Defaults to None. Returns: - pd.DataFrame: Dataframe all samples + pd.DataFrame: Dataframe all sample """ try: samples = [sample.to_sub_dict() for sample in sample_list] except TypeError as e: - logger.error(f"Couldn't find any samples with data: {kwargs}\nDue to {e}") + logger.error(f"Couldn't find any sample with data: {kwargs}\nDue to {e}") return None df = pd.DataFrame.from_records(samples) # NOTE: Exclude sub information - exclude = ['concentration', 'organism', 'colour', 'tooltip', 'comments', 'samples', 'reagents', + exclude = ['concentration', 'organism', 'colour', 'tooltip', 'comments', 'sample', 'reagents', 'equipment', 'gel_info', 'gel_image', 'dna_core_submission_number', 'gel_controls'] df = df.loc[:, ~df.columns.isin(exclude)] return df def show_details(self, obj): """ - Creates Widget for showing run details. + Creates Widget for showing procedure details. Args: obj (_type_): parent widget @@ -1955,38 +1486,38 @@ class BasicSample(BaseClass, LogMixin): # NOTE: Submission to Sample Associations -class SubmissionSampleAssociation(BaseClass): +class ClientSubmissionSampleAssociation(BaseClass): """ - table containing run/sample associations + table containing procedure/sample associations DOC: https://docs.sqlalchemy.org/en/14/orm/extensions/associationproxy.html """ - id = Column(INTEGER, unique=True, nullable=False) #: id to be used for inheriting purposes - sample_id = Column(INTEGER, ForeignKey("_basicsample.id"), nullable=False) #: id of associated sample - submission_id = Column(INTEGER, ForeignKey("_clientsubmission.id"), primary_key=True) #: id of associated run - row = Column(INTEGER, primary_key=True) #: row on the 96 well plate - column = Column(INTEGER, primary_key=True) #: column on the 96 well plate - submission_rank = Column(INTEGER, nullable=False, default=0) #: Location in sample list - # misc_info = Column(JSON) - + # id = Column(INTEGER, unique=True, nullable=False, autoincrement=True) #: id to be used for inheriting purposes + sample_id = Column(INTEGER, ForeignKey("_sample.id"), primary_key=True) #: id of associated sample + clientsubmission_id = Column(INTEGER, ForeignKey("_clientsubmission.id"), + primary_key=True) #: id of associated procedure + row = Column(INTEGER) + column = Column(INTEGER) + submission_rank = Column(INTEGER, primary_key=True, default=0) #: Location in sample list # NOTE: reference to the Submission object - submission = relationship(ClientSubmission, - back_populates="submission_sample_associations") #: associated run + clientsubmission = relationship("ClientSubmission", + back_populates="clientsubmissionsampleassociation") #: associated procedure # NOTE: reference to the Sample object - sample = relationship(BasicSample, back_populates="sample_submission_associations") #: associated sample + sample = relationship("Sample", back_populates="sampleclientsubmissionassociation") #: associated sample - def __init__(self, submission: ClientSubmission = None, sample: BasicSample = None, row: int = 1, column: int = 1, - id: int | None = None, submission_rank: int = 0, **kwargs): - self.submission = submission + def __init__(self, submission: ClientSubmission = None, sample: Sample = None, row: int = 0, column: int = 0, + submission_rank: int = 0, **kwargs): + super().__init__() + self.clientsubmission = submission self.sample = sample self.row = row self.column = column self.submission_rank = submission_rank - if id is not None: - self.id = id - else: - self.id = self.__class__.autoincrement_id() + # if id is not None: + # self.id = id + # else: + # self.id = self.__class__.autoincrement_id() for k, v in kwargs.items(): try: self.__setattr__(k, v) @@ -1995,7 +1526,7 @@ class SubmissionSampleAssociation(BaseClass): def __repr__(self) -> str: try: - return f"<{self.__class__.__name__}({self.submission.rsl_plate_num} & {self.sample.submitter_id})" + return f"<{self.__class__.__name__}({self.clientsubmission.submitter_plate_id} & {self.sample.sample_id})" except AttributeError as e: logger.error(f"Unable to construct __repr__ due to: {e}") return super().__repr__() @@ -2009,7 +1540,7 @@ class SubmissionSampleAssociation(BaseClass): """ # NOTE: Get associated sample info sample = self.sample.to_sub_dict() - sample['name'] = self.sample.submitter_id + sample['sample_id'] = self.sample.sample_id sample['row'] = self.row sample['column'] = self.column try: @@ -2017,9 +1548,9 @@ class SubmissionSampleAssociation(BaseClass): except KeyError as e: logger.error(f"Unable to find row {self.row} in row_map.") sample['Well'] = None - sample['plate_name'] = self.submission.rsl_plate_num + sample['plate_name'] = self.clientsubmission.submitter_plate_id sample['positive'] = False - sample['submitted_date'] = self.submission.submitted_date + sample['submitted_date'] = self.clientsubmission.submitted_date sample['submission_rank'] = self.submission_rank return sample @@ -2058,91 +1589,91 @@ class SubmissionSampleAssociation(BaseClass): tooltip_text += sample['tooltip'] except KeyError: pass - sample.update(dict(Name=self.sample.submitter_id[:10], tooltip=tooltip_text, background_color=background)) + sample.update(dict(Name=self.sample.sample_id[:10], tooltip=tooltip_text, background_color=background)) return sample - @classmethod - def autoincrement_id(cls) -> int: - """ - Increments the association id automatically + # @classmethod + # def autoincrement_id(cls) -> int: + # """ + # Increments the association id automatically + # + # Returns: + # int: incremented id + # """ + # if cls.__name__ == "ClientSubmissionSampleAssociation": + # model = cls + # else: + # model = next((base for base in cls.__bases__ if base.__name__ == "ClientSubmissionSampleAssociation"), + # ClientSubmissionSampleAssociation) + # try: + # return max([item.id for item in model.query()]) + 1 + # except ValueError as e: + # logger.error(f"Problem incrementing id: {e}") + # return 1 - Returns: - int: incremented id - """ - if cls.__name__ == "SubmissionSampleAssociation": - model = cls - else: - model = next((base for base in cls.__bases__ if base.__name__ == "SubmissionSampleAssociation"), - SubmissionSampleAssociation) - try: - return max([item.id for item in model.query()]) + 1 - except ValueError as e: - logger.error(f"Problem incrementing id: {e}") - return 1 - - @classmethod - def find_polymorphic_subclass(cls, polymorphic_identity: str | None = None) -> SubmissionSampleAssociation: - """ - Retrieves subclasses of SubmissionSampleAssociation based on type name. - - Args: - polymorphic_identity (str | None, optional): Name of subclass fed to polymorphic identity. Defaults to None. - - Returns: - SubmissionSampleAssociation: Subclass of interest. - """ - if isinstance(polymorphic_identity, dict): - polymorphic_identity = polymorphic_identity['value'] - if polymorphic_identity is None: - model = cls - else: - try: - model = cls.__mapper__.polymorphic_map[polymorphic_identity].class_ - except Exception as e: - logger.error(f"Could not get polymorph {polymorphic_identity} of {cls} due to {e}") - model = cls - return model + # @classmethod + # def find_polymorphic_subclass(cls, polymorphic_identity: str | None = None) -> ClientSubmissionSampleAssociation: + # """ + # Retrieves subclasses of ClientSubmissionSampleAssociation based on type name. + # + # Args: + # polymorphic_identity (str | None, optional): Name of subclass fed to polymorphic identity. Defaults to None. + # + # Returns: + # ClientSubmissionSampleAssociation: Subclass of interest. + # """ + # if isinstance(polymorphic_identity, dict): + # polymorphic_identity = polymorphic_identity['value'] + # if polymorphic_identity is None: + # model = cls + # else: + # try: + # model = cls.__mapper__.polymorphic_map[polymorphic_identity].class_ + # except Exception as e: + # logger.error(f"Could not get polymorph {polymorphic_identity} of {cls} due to {e}") + # model = cls + # return model @classmethod @setup_lookup def query(cls, - submission: ClientSubmission | str | None = None, + clientsubmission: ClientSubmission | str | None = None, exclude_submission_type: str | None = None, - sample: BasicSample | str | None = None, + sample: Sample | str | None = None, row: int = 0, column: int = 0, limit: int = 0, chronologic: bool = False, reverse: bool = False, **kwargs - ) -> SubmissionSampleAssociation | List[SubmissionSampleAssociation]: + ) -> ClientSubmissionSampleAssociation | List[ClientSubmissionSampleAssociation]: """ Lookup junction of Submission and Sample in the database Args: - run (models.BasicRun | str | None, optional): Submission of interest. Defaults to None. - sample (models.BasicSample | str | None, optional): Sample of interest. Defaults to None. - row (int, optional): Row of the sample location on run plate. Defaults to 0. - column (int, optional): Column of the sample location on the run plate. Defaults to 0. + run (models.Run | str | None, optional): Submission of interest. Defaults to None. + sample (models.Sample | str | None, optional): Sample of interest. Defaults to None. + row (int, optional): Row of the sample location on procedure plate. Defaults to 0. + column (int, optional): Column of the sample location on the procedure plate. Defaults to 0. limit (int, optional): Maximum number of results to return (0 = all). Defaults to 0. chronologic (bool, optional): Return results in chronologic order. Defaults to False. Returns: - models.SubmissionSampleAssociation|List[models.SubmissionSampleAssociation]: Junction(s) of interest + models.ClientSubmissionSampleAssociation|List[models.ClientSubmissionSampleAssociation]: Junction(s) of interest """ query: Query = cls.__database_session__.query(cls) - match submission: + match clientsubmission: case ClientSubmission(): - query = query.filter(cls.submission == submission) + query = query.filter(cls.clientsubmission == clientsubmission) case str(): - query = query.join(ClientSubmission).filter(ClientSubmission.rsl_plate_num == submission) + query = query.join(ClientSubmission).filter(ClientSubmission.submitter_plate_id == clientsubmission) case _: pass match sample: - case BasicSample(): + case Sample(): query = query.filter(cls.sample == sample) case str(): - query = query.join(BasicSample).filter(BasicSample.submitter_id == sample) + query = query.join(Sample).filter(Sample.sample_id == sample) case _: pass if row > 0: @@ -2151,12 +1682,12 @@ class SubmissionSampleAssociation(BaseClass): query = query.filter(cls.column == column) match exclude_submission_type: case str(): - query = query.join(BasicRun).filter( - BasicRun.submission_type_name != exclude_submission_type) + query = query.join(ClientSubmission).filter( + ClientSubmission.submissiontype_name != exclude_submission_type) case _: pass if reverse and not chronologic: - query = query.order_by(BasicRun.id.desc()) + query = query.order_by(ClientSubmission.id.desc()) if chronologic: if reverse: query = query.order_by(ClientSubmission.submitted_date.desc()) @@ -2167,34 +1698,34 @@ class SubmissionSampleAssociation(BaseClass): @classmethod def query_or_create(cls, association_type: str = "Basic Association", - submission: ClientSubmission | str | None = None, - sample: BasicSample | str | None = None, + clientsubmission: ClientSubmission | str | None = None, + sample: Sample | str | None = None, id: int | None = None, - **kwargs) -> SubmissionSampleAssociation: + **kwargs) -> ClientSubmissionSampleAssociation: """ Queries for an association, if none exists creates a new one. Args: association_type (str, optional): Subclass name. Defaults to "Basic Association". - submission (BasicRun | str | None, optional): associated run. Defaults to None. - sample (BasicSample | str | None, optional): associated sample. Defaults to None. + clientsubmission (Run | str | None, optional): associated procedure. Defaults to None. + sample (Sample | str | None, optional): associated sample. Defaults to None. id (int | None, optional): association id. Defaults to None. Returns: - SubmissionSampleAssociation: Queried or new association. + ClientSubmissionSampleAssociation: Queried or new association. """ - match submission: - case BasicRun(): + match clientsubmission: + case ClientSubmission(): pass case str(): - submission = ClientSubmission.query(rsl_plate_num=submission) + clientsubmission = ClientSubmission.query(rsl_plate_num=clientsubmission) case _: raise ValueError() match sample: - case BasicSample(): + case Sample(): pass case str(): - sample = BasicSample.query(submitter_id=sample) + sample = Sample.query(sample_id=sample) case _: raise ValueError() try: @@ -2206,12 +1737,11 @@ class SubmissionSampleAssociation(BaseClass): except KeyError: column = None try: - instance = cls.query(submission=submission, sample=sample, row=row, column=column, limit=1) + instance = cls.query(clientsubmission=clientsubmission, sample=sample, row=row, column=column, limit=1) except StatementError: instance = None if instance is None: - used_cls = cls.find_polymorphic_subclass(polymorphic_identity=association_type) - instance = used_cls(submission=submission, sample=sample, id=id, **kwargs) + instance = cls(submission=clientsubmission, sample=sample, id=id, **kwargs) return instance def delete(self): @@ -2219,37 +1749,31 @@ class SubmissionSampleAssociation(BaseClass): class RunSampleAssociation(BaseClass): - """ - table containing run/sample associations + table containing procedure/sample associations DOC: https://docs.sqlalchemy.org/en/14/orm/extensions/associationproxy.html """ - id = Column(INTEGER, unique=True, nullable=False) #: id to be used for inheriting purposes - sample_id = Column(INTEGER, ForeignKey("_basicsample.id"), nullable=False) #: id of associated sample - run_id = Column(INTEGER, ForeignKey("_basicrun.id"), primary_key=True) #: id of associated run + # id = Column(INTEGER, unique=True, nullable=False) #: id to be used for inheriting purposes + sample_id = Column(INTEGER, ForeignKey("_sample.id"), nullable=False) #: id of associated sample + run_id = Column(INTEGER, ForeignKey("_run.id"), primary_key=True) #: id of associated procedure row = Column(INTEGER, primary_key=True) #: row on the 96 well plate column = Column(INTEGER, primary_key=True) #: column on the 96 well plate # misc_info = Column(JSON) # NOTE: reference to the Submission object - run = relationship(BasicRun, - back_populates="run_sample_associations") #: associated run + run = relationship(Run, + back_populates="runsampleassociation") #: associated procedure # NOTE: reference to the Sample object - sample = relationship(BasicSample, back_populates="sample_run_associations") #: associated sample + sample = relationship(Sample, back_populates="samplerunassociation") #: associated sample - def __init__(self, run: BasicRun = None, sample: BasicSample = None, row: int = 1, column: int = 1, - id: int | None = None, **kwargs): + def __init__(self, run: Run = None, sample: Sample = None, row: int = 1, column: int = 1, **kwargs): self.run = run self.sample = sample self.row = row self.column = column - if id is not None: - self.id = id - else: - self.id = self.__class__.autoincrement_id() for k, v in kwargs.items(): try: self.__setattr__(k, v) @@ -2258,7 +1782,7 @@ class RunSampleAssociation(BaseClass): def __repr__(self) -> str: try: - return f"<{self.__class__.__name__}({self.submission.rsl_plate_num} & {self.sample.submitter_id})" + return f"<{self.__class__.__name__}({self.run.rsl_plate_num} & {self.sample.sample_id})" except AttributeError as e: logger.error(f"Unable to construct __repr__ due to: {e}") return super().__repr__() @@ -2272,7 +1796,7 @@ class RunSampleAssociation(BaseClass): """ # NOTE: Get associated sample info sample = self.sample.to_sub_dict() - sample['name'] = self.sample.submitter_id + sample['name'] = self.sample.sample_id sample['row'] = self.row sample['column'] = self.column try: @@ -2319,91 +1843,49 @@ class RunSampleAssociation(BaseClass): tooltip_text += sample['tooltip'] except KeyError: pass - sample.update(dict(Name=self.sample.submitter_id[:10], tooltip=tooltip_text, background_color=background)) + sample.update(dict(Name=self.sample.sample_id[:10], tooltip=tooltip_text, background_color=background)) return sample - @classmethod - def autoincrement_id(cls) -> int: - """ - Increments the association id automatically - - Returns: - int: incremented id - """ - if cls.__name__ == "SubmissionSampleAssociation": - model = cls - else: - model = next((base for base in cls.__bases__ if base.__name__ == "SubmissionSampleAssociation"), - SubmissionSampleAssociation) - try: - return max([item.id for item in model.query()]) + 1 - except ValueError as e: - logger.error(f"Problem incrementing id: {e}") - return 1 - - @classmethod - def find_polymorphic_subclass(cls, polymorphic_identity: str | None = None) -> SubmissionSampleAssociation: - """ - Retrieves subclasses of SubmissionSampleAssociation based on type name. - - Args: - polymorphic_identity (str | None, optional): Name of subclass fed to polymorphic identity. Defaults to None. - - Returns: - SubmissionSampleAssociation: Subclass of interest. - """ - if isinstance(polymorphic_identity, dict): - polymorphic_identity = polymorphic_identity['value'] - if polymorphic_identity is None: - model = cls - else: - try: - model = cls.__mapper__.polymorphic_map[polymorphic_identity].class_ - except Exception as e: - logger.error(f"Could not get polymorph {polymorphic_identity} of {cls} due to {e}") - model = cls - return model - @classmethod @setup_lookup def query(cls, - run: BasicRun | str | None = None, + run: Run | str | None = None, exclude_submission_type: str | None = None, - sample: BasicSample | str | None = None, + sample: Sample | str | None = None, row: int = 0, column: int = 0, limit: int = 0, chronologic: bool = False, reverse: bool = False, **kwargs - ) -> SubmissionSampleAssociation | List[SubmissionSampleAssociation]: + ) -> ClientSubmissionSampleAssociation | List[ClientSubmissionSampleAssociation]: """ Lookup junction of Submission and Sample in the database Args: - run (models.BasicRun | str | None, optional): Submission of interest. Defaults to None. - sample (models.BasicSample | str | None, optional): Sample of interest. Defaults to None. - row (int, optional): Row of the sample location on run plate. Defaults to 0. - column (int, optional): Column of the sample location on the run plate. Defaults to 0. + run (models.Run | str | None, optional): Submission of interest. Defaults to None. + sample (models.Sample | str | None, optional): Sample of interest. Defaults to None. + row (int, optional): Row of the sample location on procedure plate. Defaults to 0. + column (int, optional): Column of the sample location on the procedure plate. Defaults to 0. limit (int, optional): Maximum number of results to return (0 = all). Defaults to 0. chronologic (bool, optional): Return results in chronologic order. Defaults to False. Returns: - models.SubmissionSampleAssociation|List[models.SubmissionSampleAssociation]: Junction(s) of interest + models.ClientSubmissionSampleAssociation|List[models.ClientSubmissionSampleAssociation]: Junction(s) of interest """ query: Query = cls.__database_session__.query(cls) match run: - case BasicRun(): - query = query.filter(cls.submission == run) + case Run(): + query = query.filter(cls.run == run) case str(): - query = query.join(BasicRun).filter(BasicRun.rsl_plate_num == run) + query = query.join(Run).filter(Run.rsl_plate_num == run) case _: pass match sample: - case BasicSample(): + case Sample(): query = query.filter(cls.sample == sample) case str(): - query = query.join(BasicSample).filter(BasicSample.submitter_id == sample) + query = query.join(Sample).filter(Sample.sample_id == sample) case _: pass if row > 0: @@ -2412,50 +1894,50 @@ class RunSampleAssociation(BaseClass): query = query.filter(cls.column == column) match exclude_submission_type: case str(): - query = query.join(BasicRun).filter( - BasicRun.submission_type_name != exclude_submission_type) + query = query.join(Run).join(ClientSubmission).filter( + ClientSubmission.submissiontype_name != exclude_submission_type) case _: pass if reverse and not chronologic: - query = query.order_by(BasicRun.id.desc()) + query = query.order_by(Run.id.desc()) if chronologic: if reverse: - query = query.order_by(BasicRun.submitted_date.desc()) + query = query.order_by(Run.submitted_date.desc()) else: - query = query.order_by(BasicRun.submitted_date) + query = query.order_by(Run.submitted_date) return cls.execute_query(query=query, limit=limit, **kwargs) @classmethod def query_or_create(cls, association_type: str = "Basic Association", - run: BasicRun | str | None = None, - sample: BasicSample | str | None = None, + run: Run | str | None = None, + sample: Sample | str | None = None, id: int | None = None, - **kwargs) -> SubmissionSampleAssociation: + **kwargs) -> ClientSubmissionSampleAssociation: """ Queries for an association, if none exists creates a new one. Args: association_type (str, optional): Subclass name. Defaults to "Basic Association". - run (BasicRun | str | None, optional): associated run. Defaults to None. - sample (BasicSample | str | None, optional): associated sample. Defaults to None. + run (Run | str | None, optional): associated procedure. Defaults to None. + sample (Sample | str | None, optional): associated sample. Defaults to None. id (int | None, optional): association id. Defaults to None. Returns: - SubmissionSampleAssociation: Queried or new association. + ClientSubmissionSampleAssociation: Queried or new association. """ match run: - case BasicRun(): + case Run(): pass case str(): - run = BasicRun.query(rsl_plate_num=run) + run = Run.query(name=run) case _: raise ValueError() match sample: - case BasicSample(): + case Sample(): pass case str(): - sample = BasicSample.query(submitter_id=sample) + sample = Sample.query(sample_id=sample) case _: raise ValueError() try: @@ -2471,11 +1953,8 @@ class RunSampleAssociation(BaseClass): except StatementError: instance = None if instance is None: - used_cls = cls.find_polymorphic_subclass(polymorphic_identity=association_type) - instance = used_cls(run=run, sample=sample, id=id, **kwargs) + instance = cls(run=run, sample=sample, id=id, **kwargs) return instance def delete(self): raise AttributeError(f"Delete not implemented for {self.__class__}") - - diff --git a/src/submissions/backend/excel/__init__.py b/src/submissions/backend/excel/__init__.py index 22f5820..1dd3249 100644 --- a/src/submissions/backend/excel/__init__.py +++ b/src/submissions/backend/excel/__init__.py @@ -3,6 +3,6 @@ Contains pandas and openpyxl convenience functions for interacting with excel wo ''' from .parser import * -from .submission_parser import * +from backend.excel.parsers.submission_parser import * from .reports import * from .writer import * diff --git a/src/submissions/backend/excel/parser.py b/src/submissions/backend/excel/parser.py index 8f7df5f..3eb07a7 100644 --- a/src/submissions/backend/excel/parser.py +++ b/src/submissions/backend/excel/parser.py @@ -1,5 +1,5 @@ """ -contains parser objects for pulling values from client generated run sheets. +contains clientsubmissionparser objects for pulling values from client generated procedure sheets. """ import logging from copy import copy @@ -42,11 +42,11 @@ class SheetParser(object): raise FileNotFoundError(f"Couldn't parse file {self.filepath}") self.sub = OrderedDict() # NOTE: make decision about type of sample we have - self.sub['submission_type'] = dict(value=RSLNamer.retrieve_submission_type(filename=self.filepath), + self.sub['proceduretype'] = dict(value=RSLNamer.retrieve_submission_type(filename=self.filepath), missing=True) - self.submission_type = SubmissionType.query(name=self.sub['submission_type']) + self.submission_type = SubmissionType.query(name=self.sub['proceduretype']) self.sub_object = BasicRun.find_polymorphic_subclass(polymorphic_identity=self.submission_type) - # NOTE: grab the info map from the run type in database + # NOTE: grab the info map from the procedure type in database self.parse_info() self.import_kit_validation_check() self.parse_reagents() @@ -60,19 +60,19 @@ class SheetParser(object): """ parser = InfoParser(xl=self.xl, submission_type=self.submission_type, sub_object=self.sub_object) self.info_map = parser.info_map - # NOTE: in order to accommodate generic run types we have to check for the type in the excel sheet and rerun accordingly + # NOTE: in order to accommodate generic procedure types we have to check for the type in the excel sheet and rerun accordingly try: - check = parser.parsed_info['submission_type']['value'] not in [None, "None", "", " "] + check = parser.parsed_info['proceduretype']['value'] not in [None, "None", "", " "] except KeyError as e: - logger.error(f"Couldn't check run type due to KeyError: {e}") + logger.error(f"Couldn't check procedure type due to KeyError: {e}") return logger.info( - f"Checking for updated run type: {self.submission_type.name} against new: {parser.parsed_info['submission_type']['value']}") - if self.submission_type.name != parser.parsed_info['submission_type']['value']: + f"Checking for updated procedure type: {self.submission_type.name} against new: {parser.parsed_info['proceduretype']['value']}") + if self.submission_type.name != parser.parsed_info['proceduretype']['value']: if check: - # NOTE: If initial run type doesn't match parsed run type, defer to parsed run type. - self.submission_type = SubmissionType.query(name=parser.parsed_info['submission_type']['value']) - logger.info(f"Updated self.submission_type to {self.submission_type}. Rerunning parse.") + # NOTE: If initial procedure type doesn't match parsed procedure type, defer to parsed procedure type. + self.submission_type = SubmissionType.query(name=parser.parsed_info['proceduretype']['value']) + logger.info(f"Updated self.proceduretype to {self.submission_type}. Rerunning parse.") self.parse_info() else: self.submission_type = RSLNamer.retrieve_submission_type(filename=self.filepath) @@ -82,53 +82,53 @@ class SheetParser(object): def parse_reagents(self, extraction_kit: str | None = None): """ - Calls reagent parser class to pull info from the excel sheet + Calls reagent clientsubmissionparser class to pull info from the excel sheet Args: - extraction_kit (str | None, optional): Relevant extraction kit for reagent map. Defaults to None. + extraction_kit (str | None, optional): Relevant extraction kittype for reagent map. Defaults to None. """ if extraction_kit is None: - extraction_kit = self.sub['extraction_kit'] + extraction_kit = self.sub['kittype'] parser = ReagentParser(xl=self.xl, submission_type=self.submission_type, extraction_kit=extraction_kit) self.sub['reagents'] = parser.parsed_reagents def parse_samples(self): """ - Calls sample parser to pull info from the excel sheet + Calls sample clientsubmissionparser to pull info from the excel sheet """ parser = SampleParser(xl=self.xl, submission_type=self.submission_type) - self.sub['samples'] = parser.parsed_samples + self.sub['sample'] = parser.parsed_samples def parse_equipment(self): """ - Calls equipment parser to pull info from the excel sheet + Calls equipment clientsubmissionparser to pull info from the excel sheet """ parser = EquipmentParser(xl=self.xl, submission_type=self.submission_type) self.sub['equipment'] = parser.parsed_equipment def parse_tips(self): """ - Calls tips parser to pull info from the excel sheet + Calls tips clientsubmissionparser to pull info from the excel sheet """ parser = TipParser(xl=self.xl, submission_type=self.submission_type) self.sub['tips'] = parser.parsed_tips def import_kit_validation_check(self): """ - Enforce that the parser has an extraction kit + Enforce that the clientsubmissionparser has an extraction kittype """ - if 'extraction_kit' not in self.sub.keys() or not check_not_nan(self.sub['extraction_kit']['value']): + if 'kittype' not in self.sub.keys() or not check_not_nan(self.sub['kittype']['value']): from frontend.widgets.pop_ups import ObjectSelector - dlg = ObjectSelector(title="Kit Needed", message="At minimum a kit is needed. Please select one.", + dlg = ObjectSelector(title="Kit Needed", message="At minimum a kittype is needed. Please select one.", obj_type=KitType) if dlg.exec(): - self.sub['extraction_kit'] = dict(value=dlg.parse_form(), missing=True) + self.sub['kittype'] = dict(value=dlg.parse_form(), missing=True) else: - raise ValueError("Extraction kit needed.") + raise ValueError("Extraction kittype needed.") else: - if isinstance(self.sub['extraction_kit'], str): - self.sub['extraction_kit'] = dict(value=self.sub['extraction_kit'], missing=True) + if isinstance(self.sub['kittype'], str): + self.sub['kittype'] = dict(value=self.sub['kittype'], missing=True) def to_pydantic(self) -> PydSubmission: """ @@ -145,17 +145,17 @@ class InfoParser(object): Object to parse generic info from excel sheet. """ - def __init__(self, xl: Workbook, submission_type: str | SubmissionType, sub_object: BasicRun | None = None): + def __init__(self, xl: Workbook, submission_type: str | SubmissionType, sub_object: Run | None = None): """ Args: xl (Workbook): Openpyxl workbook from submitted excel file. - submission_type (str | SubmissionType): Type of run expected (Wastewater, Bacterial Culture, etc.) + submission_type (str | SubmissionType): Type of procedure expected (Wastewater, Bacterial Culture, etc.) sub_object (BasicRun | None, optional): Submission object holding methods. Defaults to None. """ if isinstance(submission_type, str): submission_type = SubmissionType.query(name=submission_type) if sub_object is None: - sub_object = BasicRun.find_polymorphic_subclass(polymorphic_identity=submission_type.name) + sub_object = Run.find_polymorphic_subclass(polymorphic_identity=submission_type.name) self.submission_type_obj = submission_type self.submission_type = dict(value=self.submission_type_obj.name, missing=True) self.sub_object = sub_object @@ -164,12 +164,12 @@ class InfoParser(object): @property def info_map(self) -> dict: """ - Gets location of basic info from the submission_type object in the database. + Gets location of basic info from the proceduretype object in the database. Returns: - dict: Location map of all info for this run type + dict: Location map of all info for this procedure type """ - # NOTE: Get the parse_info method from the run type specified + # NOTE: Get the parse_info method from the procedure type specified return self.sub_object.construct_info_map(submission_type=self.submission_type_obj, mode="read") @property @@ -186,7 +186,7 @@ class InfoParser(object): ws = self.xl[sheet] relevant = [] for k, v in self.info_map.items(): - # NOTE: If the value is hardcoded put it in the dictionary directly. Ex. Artic kit + # NOTE: If the value is hardcoded put it in the dictionary directly. Ex. Artic kittype if k == "custom": continue if isinstance(v, str): @@ -210,7 +210,7 @@ class InfoParser(object): # NOTE: Get cell contents at this location value = ws.cell(row=item['row'], column=item['column']).value match item['name']: - case "submission_type": + case "proceduretype": value, missing = is_missing(value) value = value.title() case "submitted_date": @@ -232,7 +232,7 @@ class InfoParser(object): dicto[item['name']] = dict(value=value, missing=missing) except (KeyError, IndexError): continue - # NOTE: Return after running the parser components held in run object. + # NOTE: Return after running the clientsubmissionparser components held in procedure object. return self.sub_object.custom_info_parser(input_dict=dicto, xl=self.xl, custom_fields=self.info_map['custom']) @@ -242,12 +242,12 @@ class ReagentParser(object): """ def __init__(self, xl: Workbook, submission_type: str | SubmissionType, extraction_kit: str, - run_object: BasicRun | None = None): + run_object: Run | None = None): """ Args: xl (Workbook): Openpyxl workbook from submitted excel file. - submission_type (str|SubmissionType): Type of run expected (Wastewater, Bacterial Culture, etc.) - extraction_kit (str): Extraction kit used. + submission_type (str|SubmissionType): Type of procedure expected (Wastewater, Bacterial Culture, etc.) + extraction_kit (str): Extraction kittype used. run_object (BasicRun | None, optional): Submission object holding methods. Defaults to None. """ if isinstance(submission_type, str): @@ -264,15 +264,16 @@ class ReagentParser(object): @property def kit_map(self) -> dict: """ - Gets location of kit reagents from database + Gets location of kittype reagents from database Args: - submission_type (str): Name of run type. + proceduretype (str): Name of procedure type. Returns: - dict: locations of reagent info for the kit. + dict: locations of reagent info for the kittype. """ - associations, self.kit_object = self.kit_object.construct_xl_map_for_use(submission_type=self.submission_type_obj) + associations, self.kit_object = self.kit_object.construct_xl_map_for_use( + proceduretype=self.submission_type_obj) reagent_map = {k: v for k, v in associations.items() if k != 'info'} try: del reagent_map['info'] @@ -323,16 +324,16 @@ class ReagentParser(object): class SampleParser(object): """ - Object to pull data for samples in excel sheet and construct individual sample objects + Object to pull data for sample in excel sheet and construct individual sample objects """ def __init__(self, xl: Workbook, submission_type: SubmissionType, sample_map: dict | None = None, - sub_object: BasicRun | None = None) -> None: + sub_object: Run | None = None) -> None: """ Args: xl (Workbook): Openpyxl workbook from submitted excel file. - submission_type (SubmissionType): Type of run expected (Wastewater, Bacterial Culture, etc.) - sample_map (dict | None, optional): Locations in database where samples are found. Defaults to None. + submission_type (SubmissionType): Type of procedure expected (Wastewater, Bacterial Culture, etc.) + sample_map (dict | None, optional): Locations in database where sample are found. Defaults to None. sub_object (BasicRun | None, optional): Submission object holding methods. Defaults to None. """ self.samples = [] @@ -343,19 +344,19 @@ class SampleParser(object): self.submission_type_obj = submission_type if sub_object is None: logger.warning( - f"Sample parser attempting to fetch run class with polymorphic identity: {self.submission_type}") - sub_object = BasicRun.find_polymorphic_subclass(polymorphic_identity=self.submission_type) + f"Sample clientsubmissionparser attempting to fetch procedure class with polymorphic identity: {self.submission_type}") + sub_object = Run.find_polymorphic_subclass(polymorphic_identity=self.submission_type) self.sub_object = sub_object - self.sample_type = self.sub_object.get_default_info("sample_type", submission_type=submission_type) - self.samp_object = BasicSample.find_polymorphic_subclass(polymorphic_identity=self.sample_type) + self.sample_type = self.sub_object.get_default_info("sampletype", submission_type=submission_type) + self.samp_object = Sample.find_polymorphic_subclass(polymorphic_identity=self.sample_type) @property def sample_map(self) -> dict: """ - Gets info locations in excel book for run type. + Gets info locations in excel book for procedure type. Args: - submission_type (str): run type + proceduretype (str): procedure type Returns: dict: Info locations. @@ -381,7 +382,7 @@ class SampleParser(object): if check_not_nan(id): if id not in invalids: sample_dict = dict(id=id, row=ii, column=jj) - sample_dict['sample_type'] = self.sample_type + sample_dict['sampletype'] = self.sample_type plate_map_samples.append(sample_dict) else: pass @@ -407,7 +408,7 @@ class SampleParser(object): row_dict[lmap['merge_on_id']] = str(row_dict[lmap['merge_on_id']]) except KeyError: pass - row_dict['sample_type'] = self.sample_type + row_dict['sampletype'] = self.sample_type row_dict['submission_rank'] = ii try: check = check_not_nan(row_dict[lmap['merge_on_id']]) @@ -423,14 +424,14 @@ class SampleParser(object): Merges sample info from lookup table and plate map. Returns: - List[dict]: Reconciled samples + List[dict]: Reconciled sample """ if not self.plate_map_samples or not self.lookup_samples: - logger.warning(f"No separate samples") + logger.warning(f"No separate sample") samples = self.lookup_samples or self.plate_map_samples for new in samples: - if not check_key_or_attr(key='submitter_id', interest=new, check_none=True): - new['submitter_id'] = new['id'] + if not check_key_or_attr(key='sample_id', interest=new, check_none=True): + new['sample_id'] = new['id'] new = self.sub_object.parse_samples(new) try: del new['id'] @@ -459,8 +460,8 @@ class SampleParser(object): if lsample[merge_on_id] == psample['id']), (-1, psample)) if jj >= 0: lookup_samples[jj] = {} - if not check_key_or_attr(key='submitter_id', interest=new, check_none=True): - new['submitter_id'] = psample['id'] + if not check_key_or_attr(key='sample_id', interest=new, check_none=True): + new['sample_id'] = psample['id'] new = self.sub_object.parse_samples(new) try: del new['id'] @@ -478,7 +479,7 @@ class EquipmentParser(object): """ Args: xl (Workbook): Openpyxl workbook from submitted excel file. - submission_type (str | SubmissionType): Type of run expected (Wastewater, Bacterial Culture, etc.) + submission_type (str | SubmissionType): Type of procedure expected (Wastewater, Bacterial Culture, etc.) """ if isinstance(submission_type, str): submission_type = SubmissionType.query(name=submission_type) @@ -488,7 +489,7 @@ class EquipmentParser(object): @property def equipment_map(self) -> dict: """ - Gets the map of equipment locations in the run type's spreadsheet + Gets the map of equipment locations in the procedure type's spreadsheet Returns: List[dict]: List of locations @@ -556,7 +557,7 @@ class TipParser(object): """ Args: xl (Workbook): Openpyxl workbook from submitted excel file. - submission_type (str | SubmissionType): Type of run expected (Wastewater, Bacterial Culture, etc.) + submission_type (str | SubmissionType): Type of procedure expected (Wastewater, Bacterial Culture, etc.) """ if isinstance(submission_type, str): submission_type = SubmissionType.query(name=submission_type) @@ -566,7 +567,7 @@ class TipParser(object): @property def tip_map(self) -> dict: """ - Gets the map of equipment locations in the run type's spreadsheet + Gets the map of equipment locations in the procedure type's spreadsheet Returns: List[dict]: List of locations @@ -609,7 +610,7 @@ class TipParser(object): class PCRParser(object): """Object to pull data from Design and Analysis PCR export file.""" - def __init__(self, filepath: Path | None = None, submission: BasicRun | None = None) -> None: + def __init__(self, filepath: Path | None = None, submission: Run | None = None) -> None: """ Args: filepath (Path | None, optional): file to parse. Defaults to None. @@ -659,7 +660,7 @@ class PCRParser(object): class ConcentrationParser(object): - def __init__(self, filepath: Path | None = None, run: BasicRun | None = None) -> None: + def __init__(self, filepath: Path | None = None, run: Run | None = None) -> None: if filepath is None: logger.error('No filepath given.') self.xl = None @@ -673,7 +674,7 @@ class ConcentrationParser(object): logger.error(f"Couldn't get permissions for {filepath.__str__()}. Operation might have been cancelled.") return None if run is None: - self.submission_obj = BasicRun() + self.submission_obj = Run() rsl_plate_num = None else: self.submission_obj = run diff --git a/src/submissions/backend/excel/reports.py b/src/submissions/backend/excel/reports.py index 47fe044..0134002 100644 --- a/src/submissions/backend/excel/reports.py +++ b/src/submissions/backend/excel/reports.py @@ -7,7 +7,7 @@ from pandas import DataFrame, ExcelWriter from pathlib import Path from datetime import date from typing import Tuple, List -from backend.db.models import BasicRun +from backend.db.models import Run from tools import jinja_template_loading, get_first_blank_df_row, row_map, flatten_list from PyQt6.QtWidgets import QWidget from openpyxl.worksheet.worksheet import Worksheet @@ -45,9 +45,9 @@ class ReportMaker(object): self.start_date = start_date self.end_date = end_date # NOTE: Set page size to zero to override limiting query size. - self.runs = BasicRun.query(start_date=start_date, end_date=end_date, page_size=0) + self.runs = Run.query(start_date=start_date, end_date=end_date, page_size=0) if organizations is not None: - self.runs = [run for run in self.runs if run.client_submission.submitting_lab.name in organizations] + self.runs = [run for run in self.runs if run.clientsubmission.clientlab.name in organizations] self.detailed_df, self.summary_df = self.make_report_xlsx() self.html = self.make_report_html(df=self.summary_df) @@ -61,14 +61,14 @@ class ReportMaker(object): if not self.runs: return DataFrame(), DataFrame() df = DataFrame.from_records([item.to_dict(report=True) for item in self.runs]) - # NOTE: put submissions with the same lab together - df = df.sort_values("submitting_lab") + # NOTE: put procedure with the same lab together + df = df.sort_values("clientlab") # NOTE: aggregate cost and sample count columns - df2 = df.groupby(["submitting_lab", "extraction_kit"]).agg( - {'extraction_kit': 'count', 'cost': 'sum', 'sample_count': 'sum'}) - df2 = df2.rename(columns={"extraction_kit": 'run_count'}) + df2 = df.groupby(["clientlab", "kittype"]).agg( + {'kittype': 'count', 'cost': 'sum', 'sample_count': 'sum'}) + df2 = df2.rename(columns={"kittype": 'run_count'}) df = df.drop('id', axis=1) - df = df.sort_values(['submitting_lab', "started_date"]) + df = df.sort_values(['clientlab', "started_date"]) return df, df2 def make_report_html(self, df: DataFrame) -> str: @@ -156,19 +156,19 @@ class TurnaroundMaker(ReportArchetype): self.start_date = start_date self.end_date = end_date # NOTE: Set page size to zero to override limiting query size. - self.subs = BasicRun.query(start_date=start_date, end_date=end_date, - submission_type_name=submission_type, page_size=0) + self.subs = Run.query(start_date=start_date, end_date=end_date, + submissiontype_name=submission_type, page_size=0) records = [self.build_record(sub) for sub in self.subs] self.df = DataFrame.from_records(records) self.sheet_name = "Turnaround" @classmethod - def build_record(cls, sub: BasicRun) -> dict: + def build_record(cls, sub: Run) -> dict: """ - Build a turnaround dictionary from a run + Build a turnaround dictionary from a procedure Args: - sub (BasicRun): The run to be processed. + sub (BasicRun): The procedure to be processed. Returns: @@ -203,9 +203,9 @@ class ConcentrationMaker(ReportArchetype): self.start_date = start_date self.end_date = end_date # NOTE: Set page size to zero to override limiting query size. - self.subs = BasicRun.query(start_date=start_date, end_date=end_date, - submission_type_name=submission_type, page_size=0) - # self.samples = flatten_list([sub.get_provisional_controls(controls_only=controls_only) for sub in self.runs]) + self.subs = Run.query(start_date=start_date, end_date=end_date, + submissiontype_name=submission_type, page_size=0) + # self.sample = flatten_list([sub.get_provisional_controls(controls_only=controls_only) for sub in self.run]) self.samples = flatten_list([sub.get_provisional_controls(include=include) for sub in self.subs]) self.records = [self.build_record(sample) for sample in self.samples] self.df = DataFrame.from_records(self.records) @@ -214,9 +214,9 @@ class ConcentrationMaker(ReportArchetype): @classmethod def build_record(cls, control) -> dict: regex = re.compile(r"^(ATCC)|(MCS)", flags=re.IGNORECASE) - if bool(regex.match(control.submitter_id)): + if bool(regex.match(control.sample_id)): positive = "positive" - elif control.submitter_id.lower().startswith("en"): + elif control.sample_id.lower().startswith("en"): positive = "negative" else: positive = "sample" @@ -224,8 +224,8 @@ class ConcentrationMaker(ReportArchetype): concentration = float(control.concentration) except (TypeError, ValueError): concentration = 0.0 - return dict(name=control.submitter_id, - submission=str(control.submission), concentration=concentration, + return dict(name=control.sample_id, + submission=str(control.clientsubmission), concentration=concentration, submitted_date=control.submitted_date, positive=positive) diff --git a/src/submissions/backend/excel/writer.py b/src/submissions/backend/excel/writer.py index e310960..0263761 100644 --- a/src/submissions/backend/excel/writer.py +++ b/src/submissions/backend/excel/writer.py @@ -1,5 +1,5 @@ """ -contains writer objects for pushing values to run sheet templates. +contains writer objects for pushing values to procedure sheet templates. """ import logging from copy import copy @@ -8,7 +8,7 @@ from operator import itemgetter from pprint import pformat from typing import List, Generator, Tuple from openpyxl import load_workbook, Workbook -from backend.db.models import SubmissionType, KitType, BasicRun +from backend.db.models import SubmissionType, KitType, Run from backend.validators.pydant import PydSubmission from io import BytesIO from collections import OrderedDict @@ -24,7 +24,7 @@ class SheetWriter(object): def __init__(self, submission: PydSubmission): """ Args: - submission (PydSubmission): Object containing run information. + submission (PydSubmission): Object containing procedure information. """ self.sub = OrderedDict(submission.improved_dict()) # NOTE: Set values from pydantic object. @@ -32,7 +32,7 @@ class SheetWriter(object): match k: case 'filepath': self.__setattr__(k, v) - case 'submission_type': + case 'proceduretype': self.sub[k] = v['value'] self.submission_type = SubmissionType.query(name=v['value']) self.run_object = BasicRun.find_polymorphic_subclass( @@ -58,7 +58,7 @@ class SheetWriter(object): """ Calls info writer """ - disallowed = ['filepath', 'reagents', 'samples', 'equipment', 'controls'] + disallowed = ['filepath', 'reagents', 'sample', 'equipment', 'control'] info_dict = {k: v for k, v in self.sub.items() if k not in disallowed} writer = InfoWriter(xl=self.xl, submission_type=self.submission_type, info_dict=info_dict) self.xl = writer.write_info() @@ -69,14 +69,14 @@ class SheetWriter(object): """ reagent_list = self.sub['reagents'] writer = ReagentWriter(xl=self.xl, submission_type=self.submission_type, - extraction_kit=self.sub['extraction_kit'], reagent_list=reagent_list) + extraction_kit=self.sub['kittype'], reagent_list=reagent_list) self.xl = writer.write_reagents() def write_samples(self): """ Calls sample writer """ - sample_list = self.sub['samples'] + sample_list = self.sub['sample'] writer = SampleWriter(xl=self.xl, submission_type=self.submission_type, sample_list=sample_list) self.xl = writer.write_samples() @@ -99,22 +99,22 @@ class SheetWriter(object): class InfoWriter(object): """ - object to write general run info into excel file + object to write general procedure info into excel file """ def __init__(self, xl: Workbook, submission_type: SubmissionType | str, info_dict: dict, - sub_object: BasicRun | None = None): + sub_object: Run | None = None): """ Args: xl (Workbook): Openpyxl workbook from submitted excel file. - submission_type (SubmissionType | str): Type of run expected (Wastewater, Bacterial Culture, etc.) + submission_type (SubmissionType | str): Type of procedure expected (Wastewater, Bacterial Culture, etc.) info_dict (dict): Dictionary of information to write. sub_object (BasicRun | None, optional): Submission object containing methods. Defaults to None. """ if isinstance(submission_type, str): submission_type = SubmissionType.query(name=submission_type) if sub_object is None: - sub_object = BasicRun.find_polymorphic_subclass(polymorphic_identity=submission_type.name) + sub_object = Run.find_polymorphic_subclass(polymorphic_identity=submission_type.name) self.submission_type = submission_type self.sub_object = sub_object self.xl = xl @@ -196,8 +196,8 @@ class ReagentWriter(object): """ Args: xl (Workbook): Openpyxl workbook from submitted excel file. - submission_type (SubmissionType | str): Type of run expected (Wastewater, Bacterial Culture, etc.) - extraction_kit (KitType | str): Extraction kit used. + submission_type (SubmissionType | str): Type of procedure expected (Wastewater, Bacterial Culture, etc.) + extraction_kit (KitType | str): Extraction kittype used. reagent_list (list): List of reagent dicts to be written to excel. """ self.xl = xl @@ -208,7 +208,7 @@ class ReagentWriter(object): extraction_kit = KitType.query(name=extraction_kit) self.kit_object = extraction_kit associations, self.kit_object = self.kit_object.construct_xl_map_for_use( - submission_type=self.submission_type_obj) + proceduretype=self.submission_type_obj) reagent_map = {k: v for k, v in associations.items()} self.reagents = self.reconcile_map(reagent_list=reagent_list, reagent_map=reagent_map) @@ -223,13 +223,13 @@ class ReagentWriter(object): Returns: List[dict]: merged dictionary """ - filled_roles = [item['role'] for item in reagent_list] + filled_roles = [item['reagentrole'] for item in reagent_list] for map_obj in reagent_map.keys(): if map_obj not in filled_roles: reagent_list.append(dict(name="Not Applicable", role=map_obj, lot="Not Applicable", expiry="Not Applicable")) for reagent in reagent_list: try: - mp_info = reagent_map[reagent['role']] + mp_info = reagent_map[reagent['reagentrole']] except KeyError: continue placeholder = copy(reagent) @@ -273,7 +273,7 @@ class SampleWriter(object): """ Args: xl (Workbook): Openpyxl workbook from submitted excel file. - submission_type (SubmissionType | str): Type of run expected (Wastewater, Bacterial Culture, etc.) + submission_type (SubmissionType | str): Type of procedure expected (Wastewater, Bacterial Culture, etc.) sample_list (list): List of sample dictionaries to be written to excel file. """ if isinstance(submission_type, str): @@ -281,7 +281,7 @@ class SampleWriter(object): self.submission_type = submission_type self.xl = xl self.sample_map = submission_type.sample_map['lookup_table'] - # NOTE: exclude any samples without a run rank. + # NOTE: exclude any sample without a procedure rank. samples = [item for item in self.reconcile_map(sample_list) if item['submission_rank'] > 0] self.samples = sorted(samples, key=itemgetter('submission_rank')) self.blank_lookup_table() @@ -322,7 +322,7 @@ class SampleWriter(object): Performs writing operations. Returns: - Workbook: Workbook with samples written + Workbook: Workbook with sample written """ sheet = self.xl[self.sample_map['sheet']] columns = self.sample_map['sample_columns'] @@ -351,7 +351,7 @@ class EquipmentWriter(object): """ Args: xl (Workbook): Openpyxl workbook from submitted excel file. - submission_type (SubmissionType | str): Type of run expected (Wastewater, Bacterial Culture, etc.) + submission_type (SubmissionType | str): Type of procedure expected (Wastewater, Bacterial Culture, etc.) equipment_list (list): List of equipment dictionaries to write to excel file. """ if isinstance(submission_type, str): @@ -376,9 +376,9 @@ class EquipmentWriter(object): return for ii, equipment in enumerate(equipment_list, start=1): try: - mp_info = equipment_map[equipment['role']] + mp_info = equipment_map[equipment['reagentrole']] except KeyError: - logger.error(f"No {equipment['role']} in {pformat(equipment_map)}") + logger.error(f"No {equipment['reagentrole']} in {pformat(equipment_map)}") mp_info = None placeholder = copy(equipment) if not mp_info: @@ -433,7 +433,7 @@ class TipWriter(object): """ Args: xl (Workbook): Openpyxl workbook from submitted excel file. - submission_type (SubmissionType | str): Type of run expected (Wastewater, Bacterial Culture, etc.) + submission_type (SubmissionType | str): Type of procedure expected (Wastewater, Bacterial Culture, etc.) tips_list (list): List of tip dictionaries to write to the excel file. """ if isinstance(submission_type, str): diff --git a/src/submissions/backend/validators/__init__.py b/src/submissions/backend/validators/__init__.py index 6f0c8e4..45673b0 100644 --- a/src/submissions/backend/validators/__init__.py +++ b/src/submissions/backend/validators/__init__.py @@ -5,7 +5,7 @@ import logging, re import sys from pathlib import Path from openpyxl import load_workbook -from backend.db.models import BasicRun, SubmissionType +from backend.db.models import Run, SubmissionType from tools import jinja_template_loading from jinja2 import Template from dateutil.parser import parse @@ -25,22 +25,22 @@ class RSLNamer(object): self.submission_type = submission_type if not self.submission_type: self.submission_type = self.retrieve_submission_type(filename=filename) - logger.info(f"got run type: {self.submission_type}") + logger.info(f"got procedure type: {self.submission_type}") if self.submission_type: self.sub_object = BasicRun.find_polymorphic_subclass(polymorphic_identity=self.submission_type) self.parsed_name = self.retrieve_rsl_number(filename=filename, regex=self.sub_object.get_regex( submission_type=submission_type)) if not data: data = dict(submission_type=self.submission_type) - if "submission_type" not in data.keys(): - data['submission_type'] = self.submission_type + if "proceduretype" not in data.keys(): + data['proceduretype'] = self.submission_type self.parsed_name = self.sub_object.enforce_name(instr=self.parsed_name, data=data) logger.info(f"Parsed name: {self.parsed_name}") @classmethod def retrieve_submission_type(cls, filename: str | Path) -> str: """ - Gets run type from excel file properties or sheet names or regex pattern match or user input + Gets procedure type from excel file properties or sheet names or regex pattern match or user input Args: filename (str | Path): filename @@ -49,7 +49,7 @@ class RSLNamer(object): TypeError: Raised if unsupported variable type for filename given. Returns: - str: parsed run type + str: parsed procedure type """ def st_from_path(filepath: Path) -> str: @@ -89,7 +89,7 @@ class RSLNamer(object): sub_type = m.lastgroup except AttributeError as e: sub_type = None - logger.critical(f"No run type found or run type found!: {e}") + logger.critical(f"No procedure type found or procedure type found!: {e}") return sub_type match filename: @@ -107,8 +107,8 @@ class RSLNamer(object): if "pytest" in sys.modules: raise ValueError("Submission Type came back as None.") from frontend.widgets import ObjectSelector - dlg = ObjectSelector(title="Couldn't parse run type.", - message="Please select run type from list below.", + dlg = ObjectSelector(title="Couldn't parse procedure type.", + message="Please select procedure type from list below.", obj_type=SubmissionType) if dlg.exec(): submission_type = dlg.parse_form() @@ -118,7 +118,7 @@ class RSLNamer(object): @classmethod def retrieve_rsl_number(cls, filename: str | Path, regex: re.Pattern | None = None): """ - Uses regex to retrieve the plate number and run type from an input string + Uses regex to retrieve the plate number and procedure type from an input string Args: regex (str): string to construct pattern @@ -145,14 +145,15 @@ class RSLNamer(object): @classmethod def construct_new_plate_name(cls, data: dict) -> str: """ - Make a brand-new plate name from run data. + Make a brand-new plate name from procedure data. Args: - data (dict): incoming run data + data (dict): incoming procedure data Returns: str: Output filename """ + logger.debug(data) if "submitted_date" in data.keys(): if isinstance(data['submitted_date'], dict): if data['submitted_date']['value'] is not None: @@ -163,14 +164,16 @@ class RSLNamer(object): today = data['submitted_date'] else: try: - today = re.search(r"\d{4}(_|-)?\d{2}(_|-)?\d{2}", data['rsl_plate_num']) + today = re.search(r"\d{4}(_|-)?\d{2}(_|-)?\d{2}", data['name']) today = parse(today.group()) except (AttributeError, KeyError): today = datetime.now() - if "rsl_plate_num" in data.keys(): - plate_number = data['rsl_plate_num'].split("-")[-1][0] + if isinstance(today, str): + today = datetime.strptime(today, "%Y-%m-%d") + if "name" in data.keys(): + plate_number = data['name'].split("-")[-1][0] else: - previous = BasicRun.query(start_date=today, end_date=today, submissiontype=data['submission_type']) + previous = Run.query(start_date=today, end_date=today, submissiontype=data['submissiontype']) plate_number = len(previous) + 1 return f"RSL-{data['abbreviation']}-{today.year}{str(today.month).zfill(2)}{str(today.day).zfill(2)}-{plate_number}" @@ -205,4 +208,4 @@ class RSLNamer(object): from .pydant import PydSubmission, PydKitType, PydContact, PydOrganization, PydSample, PydReagent, PydReagentRole, \ - PydEquipment, PydEquipmentRole, PydTips, PydPCRControl, PydIridaControl, PydProcess, PydElastic, PydClientSubmission + PydEquipment, PydEquipmentRole, PydTips, PydProcess, PydElastic, PydClientSubmission diff --git a/src/submissions/backend/validators/omni_gui_objects.py b/src/submissions/backend/validators/omni_gui_objects.py index e0c5b68..e14687a 100644 --- a/src/submissions/backend/validators/omni_gui_objects.py +++ b/src/submissions/backend/validators/omni_gui_objects.py @@ -650,22 +650,22 @@ class OmniProcess(BaseOmni): new_assoc = st.to_sql() except AttributeError: new_assoc = SubmissionType.query(name=st) - if new_assoc not in instance.submission_types: - instance.submission_types.append(new_assoc) + if new_assoc not in instance.proceduretype: + instance.proceduretype.append(new_assoc) for er in self.equipment_roles: try: new_assoc = er.to_sql() except AttributeError: new_assoc = EquipmentRole.query(name=er) - if new_assoc not in instance.equipment_roles: - instance.equipment_roles.append(new_assoc) + if new_assoc not in instance.equipmentrole: + instance.equipmentrole.append(new_assoc) for tr in self.tip_roles: try: new_assoc = tr.to_sql() except AttributeError: new_assoc = TipRole.query(name=tr) - if new_assoc not in instance.tip_roles: - instance.tip_roles.append(new_assoc) + if new_assoc not in instance.tiprole: + instance.tiprole.append(new_assoc) return instance @property diff --git a/src/submissions/backend/validators/pydant.py b/src/submissions/backend/validators/pydant.py index 823eed0..2b890fb 100644 --- a/src/submissions/backend/validators/pydant.py +++ b/src/submissions/backend/validators/pydant.py @@ -2,8 +2,8 @@ Contains pydantic models and accompanying validators """ from __future__ import annotations -import uuid, re, logging, csv, sys -from pydantic import BaseModel, field_validator, Field, model_validator +import uuid, re, logging, csv, sys, string +from pydantic import BaseModel, field_validator, Field, model_validator, PrivateAttr from datetime import date, datetime, timedelta from dateutil.parser import parse from dateutil.parser import ParserError @@ -12,19 +12,107 @@ from types import GeneratorType from . import RSLNamer from pathlib import Path from tools import check_not_nan, convert_nans_to_nones, Report, Result, timezone +from backend.db import models from backend.db.models import * from sqlalchemy.exc import StatementError, IntegrityError from sqlalchemy.orm.properties import ColumnProperty from sqlalchemy.orm.relationships import _RelationshipDeclared +from sqlalchemy.orm.attributes import InstrumentedAttribute from PyQt6.QtWidgets import QWidget -logger = logging.getLogger(f"submissions.{__name__}") +logger = logging.getLogger(f"procedure.{__name__}") + + +class PydBaseClass(BaseModel, extra='allow', validate_assignment=True): + + _sql_object: ClassVar = None + + @model_validator(mode="before") + @classmethod + def prevalidate(cls, data): + sql_fields = [k for k, v in cls._sql_object.__dict__.items() if isinstance(v, InstrumentedAttribute)] + output = {} + for key, value in data.items(): + new_key = key.replace("_", "") + if new_key in sql_fields: + output[new_key] = value + else: + output[key] = value + return output + + @model_validator(mode='after') + @classmethod + def validate_model(cls, data): + # _sql_object = getattr(models, cls.__name__.replace("Pyd", "")) + + # total_dict = data.model_fields.update(data.model_extra) + for key, value in data.model_extra.items(): + if key in cls._sql_object.timestamps: + if isinstance(value, str): + data.__setattr__(key, datetime.strptime(value, "%Y-%m-%d")) + if key == "row" and isinstance(value, str): + if value.lower() in string.ascii_lowercase[0:8]: + try: + value = row_keys[value] + except KeyError: + value = value + data.__setattr__(key, value) + return data + + def __init__(self, **data): + # NOTE: Grab the sql model for validation purposes. + self.__class__._sql_object = getattr(models, self.__class__.__name__.replace("Pyd", "")) + super().__init__(**data) + + def filter_field(self, key: str) -> Any: + """ + Attempts to get value from field dictionary + + Args: + key (str): name of the field of interest + + Returns: + Any (): Value found. + """ + item = getattr(self, key) + match item: + case dict(): + try: + item = item['value'] + except KeyError: + logger.error(f"Couldn't get dict value: {item}") + case _: + pass + return item + + def improved_dict(self, dictionaries: bool = True) -> dict: + """ + Adds model_extra to fields. + + Args: + dictionaries (bool, optional): Are dictionaries expected as input? i.e. Should key['value'] be retrieved. Defaults to True. + + Returns: + dict: This instance as a dictionary + """ + fields = list(self.model_fields.keys()) + list(self.model_extra.keys()) + if dictionaries: + output = {k: getattr(self, k) for k in fields} + else: + output = {k: self.filter_field(k) for k in fields} + return output + + def to_sql(self): + dicto = self.improved_dict(dictionaries=False) + logger.debug(f"Dicto: {dicto}") + sql, _ = self._sql_object().query_or_create(**dicto) + + return sql class PydReagent(BaseModel): - lot: str | None - role: str | None + reagentrole: str | None expiry: date | datetime | Literal['NA'] | None = Field(default=None, validate_default=True) name: str | None = Field(default=None, validate_default=True) missing: bool = Field(default=True) @@ -37,7 +125,7 @@ class PydReagent(BaseModel): return "" return value - @field_validator("role", mode='before') + @field_validator("reagentrole", mode='before') @classmethod def remove_undesired_types(cls, value): match value: @@ -46,7 +134,7 @@ class PydReagent(BaseModel): case _: return value - @field_validator("role") + @field_validator("reagentrole") @classmethod def rescue_type_with_lookup(cls, value, values): if value is None and values.data['lot'] is not None: @@ -104,7 +192,7 @@ class PydReagent(BaseModel): if value is not None: return convert_nans_to_nones(str(value).strip()) else: - return values.data['role'].strip() + return values.data['reagentrole'].strip() def improved_dict(self) -> dict: """ @@ -121,9 +209,9 @@ class PydReagent(BaseModel): return {k: getattr(self, k) for k in fields} @report_result - def to_sql(self, submission: BasicRun | str = None) -> Tuple[Reagent, Report]: + def to_sql(self, procedure: Procedure | str = None) -> Tuple[Reagent, Report]: """ - Converts this instance into a backend.db.models.kit.Reagent instance + Converts this instance into a backend.db.models.kittype.Reagent instance Returns: Tuple[Reagent, Report]: Reagent instance and result of function @@ -140,8 +228,8 @@ class PydReagent(BaseModel): value = value['value'] # NOTE: reagent method sets fields based on keys in dictionary reagent.set_attribute(key, value) - if submission is not None and reagent not in submission.reagents: - assoc = RunReagentAssociation(reagent=reagent, submission=submission) + if procedure is not None and reagent not in procedure.reagents: + assoc = ProcedureReagentAssociation(reagent=reagent, procedure=procedure) assoc.comments = self.comment else: assoc = None @@ -151,41 +239,19 @@ class PydReagent(BaseModel): return reagent, report -class PydSample(BaseModel, extra='allow'): +class PydSample(PydBaseClass): - submitter_id: str - sample_type: str - row: int | List[int] | None - column: int | List[int] | None - assoc_id: int | List[int | None] | None = Field(default=None, validate_default=True) + sample_id: str + sampletype: str | None = Field(default=None) submission_rank: int | List[int] | None = Field(default=0, validate_default=True) + enabled: bool = Field(default=True) - @model_validator(mode='after') - @classmethod - def validate_model(cls, data): - model = BasicSample.find_polymorphic_subclass(polymorphic_identity=data.sample_type) - for k, v in data.model_extra.items(): - if k in model.timestamps: - if isinstance(v, str): - v = datetime.strptime(v, "%Y-%m-%d") - data.__setattr__(k, v) - return data - - @field_validator("row", "column", "assoc_id", "submission_rank") - @classmethod - def row_int_to_list(cls, value): - match value: - case int() | None: - return [value] - case _: - return value - - @field_validator("submitter_id", mode="before") + @field_validator("sample_id", mode="before") @classmethod def int_to_str(cls, value): return str(value) - @field_validator("submitter_id") + @field_validator("sample_id") @classmethod def strip_sub_id(cls, value): match value: @@ -197,69 +263,13 @@ class PydSample(BaseModel, extra='allow'): pass return value - @report_result - def to_sql(self, run: BasicRun | str = None) -> Tuple[ - BasicSample, List[SubmissionSampleAssociation], Result | None]: - """ - Converts this instance into a backend.db.models.submissions.Sample object - - Args: - run (BasicRun | str, optional): Submission joined to this sample. Defaults to None. - - Returns: - Tuple[BasicSample, Result]: Sample object and result object. - """ - report = None - self.__dict__.update(self.model_extra) - instance = BasicSample.query_or_create(sample_type=self.sample_type, submitter_id=self.submitter_id) - for key, value in self.__dict__.items(): - match key: - # NOTE: row, column go in the association - case "row" | "column": - continue - case _: - instance.__setattr__(key, value) - out_associations = [] - if run is not None: - if isinstance(run, str): - run = BasicRun.query(rsl_plate_num=run) - assoc_type = run.submission_type_name - for row, column, aid, submission_rank in zip(self.row, self.column, self.assoc_id, self.submission_rank): - association = SubmissionSampleAssociation.query_or_create(association_type=f"{assoc_type} Association", - submission=run, - sample=instance, - row=row, column=column, id=aid, - submission_rank=submission_rank, - **self.model_extra) - try: - out_associations.append(association) - except IntegrityError as e: - logger.error(f"Could not attach run sample association due to: {e}") - instance.metadata.session.rollback() - return instance, out_associations, report - - def improved_dict(self) -> dict: - """ - Constructs a dictionary consisting of model.fields and model.extras - - Returns: - dict: Information dictionary - """ - try: - extras = list(self.model_extra.keys()) - except AttributeError: - extras = [] - fields = list(self.model_fields.keys()) + extras - return {k: getattr(self, k) for k in fields} - class PydTips(BaseModel): - name: str lot: str | None = Field(default=None) - role: str + tiprole: str - @field_validator('role', mode='before') + @field_validator('tiprole', mode='before') @classmethod def get_role_name(cls, value): if isinstance(value, TipRole): @@ -267,40 +277,39 @@ class PydTips(BaseModel): return value @report_result - def to_sql(self, submission: BasicRun) -> SubmissionTipsAssociation: + def to_sql(self, procedure: Run) -> ProcedureTipsAssociation: """ Convert this object to the SQL version for database storage. Args: - submission (BasicRun): A run object to associate tips represented here. + procedure (BasicRun): A procedure object to associate tips represented here. Returns: - SubmissionTipsAssociation: Association between queried tips and run + SubmissionTipsAssociation: Association between queried tips and procedure """ report = Report() tips = Tips.query(name=self.name, limit=1) # logger.debug(f"Tips query has yielded: {tips}") - assoc = SubmissionTipsAssociation.query_or_create(tips=tips, run=submission, role=self.role, limit=1) + assoc = ProcedureTipsAssociation.query_or_create(tips=tips, procedure=procedure, role=self.role, limit=1) return assoc, report class PydEquipment(BaseModel, extra='ignore'): - asset_number: str name: str nickname: str | None - processes: List[str] | None - role: str | None + process: List[str] | None + equipmentrole: str | None tips: List[PydTips] | None = Field(default=None) - @field_validator('role', mode='before') + @field_validator('equipmentrole', mode='before') @classmethod def get_role_name(cls, value): if isinstance(value, EquipmentRole): value = value.name return value - @field_validator('processes', mode='before') + @field_validator('process', mode='before') @classmethod def make_empty_list(cls, value): if isinstance(value, GeneratorType): @@ -315,52 +324,54 @@ class PydEquipment(BaseModel, extra='ignore'): return value @report_result - def to_sql(self, submission: BasicRun | str = None, extraction_kit: KitType | str = None) -> Tuple[Equipment, RunEquipmentAssociation]: + def to_sql(self, procedure: Procedure | str = None, kittype: KitType | str = None) -> Tuple[ + Equipment, ProcedureEquipmentAssociation]: """ Creates Equipment and SubmssionEquipmentAssociations for this PydEquipment Args: - submission ( BasicRun | str ): BasicRun of interest + procedure ( BasicRun | str ): BasicRun of interest Returns: Tuple[Equipment, RunEquipmentAssociation]: SQL objects """ report = Report() - if isinstance(submission, str): - submission = BasicRun.query(rsl_plate_num=submission) - if isinstance(extraction_kit, str): - extraction_kit = KitType.query(name=extraction_kit) + if isinstance(procedure, str): + procedure = Procedure.query(name=procedure) + if isinstance(kittype, str): + kittype = KitType.query(name=kittype) equipment = Equipment.query(asset_number=self.asset_number) if equipment is None: logger.error("No equipment found. Returning None.") return - if submission is not None: - # NOTE: Need to make sure the same association is not added to the run + if procedure is not None: + # NOTE: Need to make sure the same association is not added to the procedure try: - assoc = RunEquipmentAssociation.query(equipment_id=equipment.id, submission_id=submission.id, - role=self.role, limit=1) + assoc = ProcedureEquipmentAssociation.query(equipment_id=equipment.id, submission_id=procedure.id, + equipmentrole=self.equipmentrole, limit=1) except TypeError as e: logger.error(f"Couldn't get association due to {e}, returning...") assoc = None if assoc is None: - assoc = RunEquipmentAssociation(submission=submission, equipment=equipment) + assoc = ProcedureEquipmentAssociation(submission=procedure, equipment=equipment) # TODO: This seems precarious. What if there is more than one process? - # NOTE: It looks like the way fetching the processes is done in the SQL model, this shouldn't be a problem, but I'll include a failsafe. - # NOTE: I need to find a way to filter this by the kit involved. + # NOTE: It looks like the way fetching the process is done in the SQL model, this shouldn't be a problem, but I'll include a failsafe. + # NOTE: I need to find a way to filter this by the kittype involved. if len(self.processes) > 1: - process = Process.query(submissiontype=submission.get_submission_type(), kittype=extraction_kit, equipmentrole=self.role) + process = Process.query(proceduretype=procedure.get_submission_type(), kittype=kittype, + equipmentrole=self.role) else: process = Process.query(name=self.processes[0]) if process is None: logger.error(f"Found unknown process: {process}.") # logger.debug(f"Using process: {process}") assoc.process = process - assoc.role = self.role + assoc.equipmentrole = self.role else: logger.warning(f"Found already existing association: {assoc}") assoc = None else: - logger.warning(f"No run found") + logger.warning(f"No procedure found") assoc = None return equipment, assoc, report @@ -380,20 +391,19 @@ class PydEquipment(BaseModel, extra='ignore'): class PydSubmission(BaseModel, extra='allow'): - filepath: Path - submission_type: dict | None - submitter_plate_num: dict | None = Field(default=dict(value=None, missing=True), validate_default=True) + submissiontype: dict | None + submitter_plate_id: dict | None = Field(default=dict(value=None, missing=True), validate_default=True) rsl_plate_num: dict | None = Field(default=dict(value=None, missing=True), validate_default=True) submitted_date: dict | None = Field(default=dict(value=date.today(), missing=True), validate_default=True) - submitting_lab: dict | None + clientlab: dict | None sample_count: dict | None - extraction_kit: dict | None + kittype: dict | None technician: dict | None submission_category: dict | None = Field(default=dict(value=None, missing=True), validate_default=True) comment: dict | None = Field(default=dict(value="", missing=True), validate_default=True) - reagents: List[dict] | List[PydReagent] = [] - samples: List[PydSample] | Generator + reagent: List[dict] | List[PydReagent] = [] + sample: List[PydSample] | Generator equipment: List[PydEquipment] | None = [] cost_centre: dict | None = Field(default=dict(value=None, missing=True), validate_default=True) contact: dict | None = Field(default=dict(value=None, missing=True), validate_default=True) @@ -428,7 +438,7 @@ class PydSubmission(BaseModel, extra='allow'): return "" return value - @field_validator("submitter_plate_num") + @field_validator("submitter_plate_id") @classmethod def enforce_with_uuid(cls, value): if value['value'] in [None, "None"]: @@ -474,19 +484,19 @@ class PydSubmission(BaseModel, extra='allow'): value['value'] = output.replace(tzinfo=timezone) return value - @field_validator("submitting_lab", mode="before") + @field_validator("clientlab", mode="before") @classmethod def rescue_submitting_lab(cls, value): if value is None: return dict(value=None, missing=True) return value - @field_validator("submitting_lab") + @field_validator("clientlab") @classmethod def lookup_submitting_lab(cls, value): if isinstance(value['value'], str): try: - value['value'] = Organization.query(name=value['value']).name + value['value'] = ClientLab.query(name=value['value']).name except AttributeError: value['value'] = None if value['value'] is None: @@ -497,7 +507,7 @@ class PydSubmission(BaseModel, extra='allow'): from frontend.widgets.pop_ups import ObjectSelector dlg = ObjectSelector(title="Missing Submitting Lab", message="We need a submitting lab. Please select from the list.", - obj_type=Organization) + obj_type=ClientLab) if dlg.exec(): value['value'] = dlg.parse_form() else: @@ -514,7 +524,7 @@ class PydSubmission(BaseModel, extra='allow'): @field_validator("rsl_plate_num") @classmethod def rsl_from_file(cls, value, values): - sub_type = values.data['submission_type']['value'] + sub_type = values.data['proceduretype']['value'] if check_not_nan(value['value']): value['value'] = value['value'].strip() return value @@ -549,7 +559,7 @@ class PydSubmission(BaseModel, extra='allow'): return dict(value=None, missing=True) return value - @field_validator("extraction_kit", mode='before') + @field_validator("kittype", mode='before') @classmethod def rescue_kit(cls, value): if check_not_nan(value): @@ -558,13 +568,13 @@ class PydSubmission(BaseModel, extra='allow'): elif isinstance(value, dict): return value else: - raise ValueError(f"No extraction kit found.") + raise ValueError(f"No extraction kittype found.") if value is None: - # NOTE: Kit selection is done in the parser, so should not be necessary here. + # NOTE: Kit selection is done in the clientsubmissionparser, so should not be necessary here. return dict(value=None, missing=True) return value - @field_validator("submission_type", mode='before') + @field_validator("submissiontype", mode='before') @classmethod def make_submission_type(cls, value, values): if not isinstance(value, dict): @@ -588,27 +598,27 @@ class PydSubmission(BaseModel, extra='allow'): if isinstance(value['value'], str): value['value'] = value['value'].title() if value['value'] not in ["Research", "Diagnostic", "Surveillance", "Validation"]: - value['value'] = values.data['submission_type']['value'] + value['value'] = values.data['proceduretype']['value'] return value - @field_validator("reagents", mode="before") + @field_validator("reagent", mode="before") @classmethod def expand_reagents(cls, value): if isinstance(value, Generator): return [PydReagent(**reagent) for reagent in value] return value - @field_validator("samples", mode="before") + @field_validator("sample", mode="before") @classmethod def expand_samples(cls, value): if isinstance(value, Generator): return [PydSample(**sample) for sample in value] return value - @field_validator("samples") + @field_validator("sample") @classmethod def assign_ids(cls, value): - starting_id = SubmissionSampleAssociation.autoincrement_id() + starting_id = ClientSubmissionSampleAssociation.autoincrement_id() for iii, sample in enumerate(value, start=starting_id): # NOTE: Why is this a list? Answer: to zip with the lists of rows and columns in case of multiple of the same sample. sample.assoc_id = [iii] @@ -629,7 +639,7 @@ class PydSubmission(BaseModel, extra='allow'): match value['value']: case None: from backend.db.models import Organization - org = Organization.query(name=values.data['submitting_lab']['value']) + org = Organization.query(name=values.data['clientlab']['value']) try: return dict(value=org.cost_centre, missing=True) except AttributeError: @@ -653,12 +663,12 @@ class PydSubmission(BaseModel, extra='allow'): check = Contact.query(name=value['value']) # logger.debug(f"Check came back with {check}") if not isinstance(check, Contact): - org = values.data['submitting_lab']['value'] + org = values.data['clientlab']['value'] # logger.debug(f"Checking organization: {org}") if isinstance(org, str): - org = Organization.query(name=values.data['submitting_lab']['value'], limit=1) - if isinstance(org, Organization): - contact = org.contacts[0].name + org = ClientLab.query(name=values.data['clientlab']['value'], limit=1) + if isinstance(org, ClientLab): + contact = org.contact[0].name else: logger.warning(f"All attempts at defaulting Contact failed, returning: {value}") return value @@ -674,7 +684,7 @@ class PydSubmission(BaseModel, extra='allow'): def __init__(self, run_custom: bool = False, **data): super().__init__(**data) # NOTE: this could also be done with default_factory - self.submission_object = BasicRun.find_polymorphic_subclass( + self.submission_object = Run.find_polymorphic_subclass( polymorphic_identity=self.submission_type['value']) self.namer = RSLNamer(self.rsl_plate_num['value'], submission_type=self.submission_type['value']) if run_custom: @@ -692,13 +702,13 @@ class PydSubmission(BaseModel, extra='allow'): def handle_duplicate_samples(self): """ - Collapses multiple samples with same submitter id into one with lists for rows, columns. - Necessary to prevent trying to create duplicate samples in SQL creation. + Collapses multiple sample with same submitter id into one with lists for rows, columns. + Necessary to prevent trying to create duplicate sample in SQL creation. """ - submitter_ids = list(set([sample.submitter_id for sample in self.samples])) + submitter_ids = list(set([sample.sample_id for sample in self.samples])) output = [] for id in submitter_ids: - relevants = [item for item in self.samples if item.submitter_id == id] + relevants = [item for item in self.samples if item.sample_id == id] if len(relevants) <= 1: output += relevants else: @@ -728,7 +738,7 @@ class PydSubmission(BaseModel, extra='allow'): if dictionaries: output = {k: getattr(self, k) for k in fields} output['reagents'] = [item.improved_dict() for item in self.reagents] - output['samples'] = [item.improved_dict() for item in self.samples] + output['sample'] = [item.improved_dict() for item in self.samples] try: output['equipment'] = [item.improved_dict() for item in self.equipment] except TypeError: @@ -773,17 +783,17 @@ class PydSubmission(BaseModel, extra='allow'): @report_result def to_sql(self) -> Tuple[BasicRun | None, Report]: """ - Converts this instance into a backend.db.models.submissions.BasicRun instance + Converts this instance into a backend.db.models.procedure.BasicRun instance Returns: Tuple[BasicRun, Result]: BasicRun instance, result object """ report = Report() dicto = self.improved_dict() - # logger.debug(f"Pydantic run type: {self.submission_type['value']}") + # logger.debug(f"Pydantic procedure type: {self.proceduretype['value']}") # logger.debug(f"Pydantic improved_dict: {pformat(dicto)}") - instance, result = BasicRun.query_or_create(submission_type=self.submission_type['value'], - rsl_plate_num=self.rsl_plate_num['value']) + instance, result = BasicRun.query_or_create(submissiontype=self.submission_type['value'], + rsl_plate_num=self.rsl_plate_num['value']) # logger.debug(f"Created or queried instance: {instance}") if instance is None: report.add_result(Result(msg="Overwrite Cancelled.")) @@ -806,20 +816,20 @@ class PydSubmission(BaseModel, extra='allow'): case "reagents": for reagent in self.reagents: reagent = reagent.to_sql(submission=instance) - case "samples": + case "sample": for sample in self.samples: sample, associations, _ = sample.to_sql(run=instance) for assoc in associations: if assoc is not None: - if assoc not in instance.submission_sample_associations: - instance.submission_sample_associations.append(assoc) + if assoc not in instance.clientsubmissionsampleassociation: + instance.clientsubmissionsampleassociation.append(assoc) else: logger.warning(f"Sample association {assoc} is already present in {instance}") case "equipment": for equip in self.equipment: if equip is None: continue - equip, association = equip.to_sql(submission=instance, extraction_kit=self.extraction_kit) + equip, association = equip.to_sql(procedure=instance, kittype=self.extraction_kit) if association is not None: instance.submission_equipment_associations.append(association) case "tips": @@ -827,7 +837,7 @@ class PydSubmission(BaseModel, extra='allow'): if tips is None: continue try: - association = tips.to_sql(submission=instance) + association = tips.to_sql(procedure=instance) except AttributeError: continue if association is not None: @@ -877,15 +887,15 @@ class PydSubmission(BaseModel, extra='allow'): try: instance.calculate_base_cost() except (TypeError, AttributeError) as e: - logger.error(f"Looks like that kit doesn't have cost breakdown yet due to: {e}, using 0.") + logger.error(f"Looks like that kittype doesn't have cost breakdown yet due to: {e}, using 0.") try: instance.run_cost = instance.extraction_kit.cost_per_run except AttributeError: instance.run_cost = 0 - # NOTE: Apply any discounts that are applicable for client and kit. + # NOTE: Apply any discounts that are applicable for client and kittype. try: discounts = [item.amount for item in - Discount.query(kittype=instance.extraction_kit, organization=instance.submitting_lab)] + Discount.query(kittype=instance.extraction_kit, organization=instance.clientlab)] if len(discounts) > 0: instance.run_cost = instance.run_cost - sum(discounts) except Exception as e: @@ -908,7 +918,7 @@ class PydSubmission(BaseModel, extra='allow'): logger.debug(f"PCR info: {self.pcr_info}") except AttributeError: pass - return SubmissionFormWidget(parent=parent, submission=self, disable=disable) + return SubmissionFormWidget(parent=parent, pyd=self, disable=disable) def to_writer(self) -> "SheetWriter": """ @@ -935,29 +945,30 @@ class PydSubmission(BaseModel, extra='allow'): def check_kit_integrity(self, extraction_kit: str | dict | None = None, exempt: List[PydReagent] = []) -> Tuple[ List[PydReagent], Report, List[PydReagent]]: """ - Ensures all reagents expected in kit are listed in Submission + Ensures all reagents expected in kittype are listed in Submission Args: - extraction_kit (str | dict | None, optional): kit to be checked. Defaults to None. + extraction_kit (str | dict | None, optional): kittype to be checked. Defaults to None. exempt (List[PydReagent], optional): List of reagents that don't need to be checked. Defaults to [] Returns: Tuple[List[PydReagent], Report]: List of reagents and Result object containing a message and any missing components. """ report = Report() - # logger.debug(f"The following reagents are exempt from the kit integrity check:\n{exempt}") + # logger.debug(f"The following reagents are exempt from the kittype integrity check:\n{exempt}") if isinstance(extraction_kit, str): extraction_kit = dict(value=extraction_kit) if extraction_kit is not None and extraction_kit != self.extraction_kit['value']: self.extraction_kit['value'] = extraction_kit['value'] ext_kit = KitType.query(name=self.extraction_kit['value']) ext_kit_rtypes = [item.to_pydantic() for item in - ext_kit.get_reagents(required_only=True, submission_type=self.submission_type['value'])] - # NOTE: Exclude any reagenttype found in this pyd not expected in kit. - expected_check = [item.role for item in ext_kit_rtypes] + ext_kit.get_reagents(required_only=True, proceduretype=self.submission_type['value'])] + # NOTE: Exclude any reagenttype found in this pydclientsubmission not expected in kittype. + expected_check = [item.equipmentrole for item in ext_kit_rtypes] output_reagents = [rt for rt in self.reagents if rt.role in expected_check] missing_check = [item.role for item in output_reagents] - missing_reagents = [rt for rt in ext_kit_rtypes if rt.role not in missing_check and rt.role not in exempt] + missing_reagents = [rt for rt in ext_kit_rtypes if + rt.equipmentrole not in missing_check and rt.equipmentrole not in exempt] # logger.debug(f"Missing reagents: {missing_reagents}") missing_reagents += [rt for rt in output_reagents if rt.missing] output_reagents += [rt for rt in missing_reagents if rt not in output_reagents] @@ -966,7 +977,7 @@ class PydSubmission(BaseModel, extra='allow'): result = None else: result = Result( - msg=f"The excel sheet you are importing is missing some reagents expected by the kit.\n\nIt looks like you are missing: {[item.role.upper() for item in missing_reagents]}\n\nAlternatively, you may have set the wrong extraction kit.\n\nThe program will populate lists using existing reagents.\n\nPlease make sure you check the lots carefully!", + msg=f"The excel sheet you are importing is missing some reagents expected by the kittype.\n\nIt looks like you are missing: {[item.equipmentrole.upper() for item in missing_reagents]}\n\nAlternatively, you may have set the wrong extraction kittype.\n\nThe program will populate lists using existing reagents.\n\nPlease make sure you check the lots carefully!", status="Warning") report.add_result(result) return output_reagents, report, missing_reagents @@ -1018,7 +1029,6 @@ class PydSubmission(BaseModel, extra='allow'): class PydContact(BaseModel): - name: str phone: str | None email: str | None @@ -1038,7 +1048,7 @@ class PydContact(BaseModel): def to_sql(self) -> Tuple[Contact, Report]: """ Converts this instance into a backend.db.models.organization. Contact instance. - Does not query for existing contacts. + Does not query for existing contact. Returns: Contact: Contact instance @@ -1066,12 +1076,11 @@ class PydContact(BaseModel): class PydOrganization(BaseModel): - name: str cost_centre: str - contacts: List[PydContact] | None + contact: List[PydContact] | None - @field_validator("contacts", mode="before") + @field_validator("contact", mode="before") @classmethod def string_to_list(cls, value): if isinstance(value, str): @@ -1083,7 +1092,7 @@ class PydOrganization(BaseModel): return value @report_result - def to_sql(self) -> Organization: + def to_sql(self) -> ClientLab: """ Converts this instance into a backend.db.models.organization.Organization instance. @@ -1091,10 +1100,10 @@ class PydOrganization(BaseModel): Organization: Organization instance """ report = Report() - instance = Organization() + instance = ClientLab() for field in self.model_fields: match field: - case "contacts": + case "contact": value = getattr(self, field) if value: value = [item.to_sql() for item in value if item] @@ -1107,7 +1116,6 @@ class PydOrganization(BaseModel): class PydReagentRole(BaseModel): - name: str eol_ext: timedelta | int | None uses: dict | None @@ -1140,13 +1148,12 @@ class PydReagentRole(BaseModel): except StatementError: assoc = None if assoc is None: - assoc = KitTypeReagentRoleAssociation(kit_type=kit, reagent_role=instance, uses=self.uses, + assoc = KitTypeReagentRoleAssociation(kittype=kit, reagentrole=instance, uses=self.uses, required=self.required) return instance, report class PydKitType(BaseModel): - name: str reagent_roles: List[PydReagent] = [] @@ -1168,12 +1175,11 @@ class PydKitType(BaseModel): class PydEquipmentRole(BaseModel): - name: str equipment: List[PydEquipment] - processes: List[str] | None + process: List[str] | None - @field_validator("processes", mode="before") + @field_validator("process", mode="before") @classmethod def expand_processes(cls, value): if isinstance(value, GeneratorType): @@ -1186,7 +1192,7 @@ class PydEquipmentRole(BaseModel): Args: parent (_type_): parent widget - used (list): list of equipment already added to run + used (list): list of equipment already added to procedure Returns: RoleComboBox: widget @@ -1195,76 +1201,75 @@ class PydEquipmentRole(BaseModel): return RoleComboBox(parent=parent, role=self, used=used) -class PydPCRControl(BaseModel): - - name: str - subtype: str - target: str - ct: float - reagent_lot: str - submitted_date: datetime #: Date submitted to Robotics - run_id: int - controltype_name: str - - @report_result - def to_sql(self): - report = Report - instance = PCRControl.query(name=self.name) - if not instance: - instance = PCRControl() - for key in self.model_fields: - field_value = self.__getattribute__(key) - if instance.__getattribute__(key) != field_value: - instance.__setattr__(key, field_value) - return instance, report - - -class PydIridaControl(BaseModel, extra='ignore'): - - name: str - contains: list | dict #: unstructured hashes in contains.tsv for each organism - matches: list | dict #: unstructured hashes in matches.tsv for each organism - kraken: list | dict #: unstructured output from kraken_report - subtype: Literal["ATCC49226", "ATCC49619", "EN-NOS", "EN-SSTI", "MCS-NOS", "MCS-SSTI", "SN-NOS", "SN-SSTI"] - refseq_version: str #: version of refseq used in fastq parsing - kraken2_version: str - kraken2_db_version: str - sample_id: int - submitted_date: datetime #: Date submitted to Robotics - run_id: int - controltype_name: str - - @field_validator("refseq_version", "kraken2_version", "kraken2_db_version", mode='before') - @classmethod - def enforce_string(cls, value): - if not value: - value = "" - return value - - @report_result - def to_sql(self): - report = Report() - instance = IridaControl.query(name=self.name) - if not instance: - instance = IridaControl() - for key in self.model_fields: - field_value = self.__getattribute__(key) - if instance.__getattribute__(key) != field_value: - instance.__setattr__(key, field_value) - return instance, report +# class PydPCRControl(BaseModel): +# +# name: str +# subtype: str +# target: str +# ct: float +# reagent_lot: str +# submitted_date: datetime #: Date submitted to Robotics +# procedure_id: int +# controltype_name: str +# +# @report_result +# def to_sql(self): +# report = Report +# instance = PCRControl.query(name=self.name) +# if not instance: +# instance = PCRControl() +# for key in self.model_fields: +# field_value = self.__getattribute__(key) +# if instance.__getattribute__(key) != field_value: +# instance.__setattr__(key, field_value) +# return instance, report +# +# +# class PydIridaControl(BaseModel, extra='ignore'): +# +# name: str +# contains: list | dict #: unstructured hashes in contains.tsv for each organism +# matches: list | dict #: unstructured hashes in matches.tsv for each organism +# kraken: list | dict #: unstructured output from kraken_report +# subtype: Literal["ATCC49226", "ATCC49619", "EN-NOS", "EN-SSTI", "MCS-NOS", "MCS-SSTI", "SN-NOS", "SN-SSTI"] +# refseq_version: str #: version of refseq used in fastq parsing +# kraken2_version: str +# kraken2_db_version: str +# sample_id: int +# submitted_date: datetime #: Date submitted to Robotics +# procedure_id: int +# controltype_name: str +# +# @field_validator("refseq_version", "kraken2_version", "kraken2_db_version", mode='before') +# @classmethod +# def enforce_string(cls, value): +# if not value: +# value = "" +# return value +# +# @report_result +# def to_sql(self): +# report = Report() +# instance = IridaControl.query(name=self.name) +# if not instance: +# instance = IridaControl() +# for key in self.model_fields: +# field_value = self.__getattribute__(key) +# if instance.__getattribute__(key) != field_value: +# instance.__setattr__(key, field_value) +# return instance, report class PydProcess(BaseModel, extra="allow"): - name: str version: str = Field(default="1") - submission_types: List[str] + submissiontype: List[str] equipment: List[str] - equipment_roles: List[str] - kit_types: List[str] - tip_roles: List[str] + equipmentrole: List[str] + kittype: List[str] + tiprole: List[str] - @field_validator("submission_types", "equipment", "equipment_roles", "kit_types", "tip_roles", mode="before") + @field_validator("submissiontype", "equipment", "equipmentrole", "kittype", "tiprole", mode="before") @classmethod def enforce_list(cls, value): if not isinstance(value, list): @@ -1328,16 +1333,17 @@ class PydElastic(BaseModel, extra="allow", arbitrary_types_allowed=True): self.instance.__setattr__(field, field_value) return self.instance + # NOTE: Generified objects below: -class PydClientSubmission(BaseModel, extra="allow", validate_assignment=True): +class PydClientSubmission(PydBaseClass): - sql_object: ClassVar = ClientSubmission + # sql_object: ClassVar = ClientSubmission filepath: Path - submission_type: dict | None + submissiontype: dict | None submitted_date: dict | None = Field(default=dict(value=date.today(), missing=True), validate_default=True) - submitting_lab: dict | None + clientlab: dict | None sample_count: dict | None submission_category: dict | None = Field(default=dict(value=None, missing=True), validate_default=True) comment: dict | None = Field(default=dict(value="", missing=True), validate_default=True) @@ -1358,7 +1364,7 @@ class PydClientSubmission(BaseModel, extra="allow", validate_assignment=True): @classmethod def create_submitter_plate_num(cls, value, values): if value['value'] in [None, "None"]: - val = f"{values.data['submission_type']['value']}-{values.data['submission_category']['value']}-{values.data['submitted_date']['value']}" + val = f"{values.data['submissiontype']['value']}-{values.data['submission_category']['value']}-{values.data['submitted_date']['value']}" return dict(value=val, missing=True) else: value['value'] = value['value'].strip() @@ -1382,54 +1388,22 @@ class PydClientSubmission(BaseModel, extra="allow", validate_assignment=True): pass return value - def filter_field(self, key: str) -> Any: - """ - Attempts to get value from field dictionary + @field_validator("submission_category") + @classmethod + def enforce_typing(cls, value, values): + if not value['value'] in ["Research", "Diagnostic", "Surveillance", "Validation"]: + try: + value['value'] = values.data['submissiontype']['value'] + except AttributeError: + value['value'] = "NA" + return value - Args: - key (str): name of the field of interest - - Returns: - Any (): Value found. - """ - item = getattr(self, key) - match item: - case dict(): - try: - item = item['value'] - except KeyError: - logger.error(f"Couldn't get dict value: {item}") - case _: - pass - return item - - def improved_dict(self, dictionaries: bool = True) -> dict: - """ - Adds model_extra to fields. - - Args: - dictionaries (bool, optional): Are dictionaries expected as input? i.e. Should key['value'] be retrieved. Defaults to True. - - Returns: - dict: This instance as a dictionary - """ - fields = list(self.model_fields.keys()) + list(self.model_extra.keys()) - if dictionaries: - output = {k: getattr(self, k) for k in fields} - else: - output = {k: self.filter_field(k) for k in fields} - try: - del output['filepath'] - except KeyError: - pass - logger.debug(f"Output; {pformat(output)}") - return output - - def to_form(self, parent: QWidget, disable: list | None = None): + def to_form(self, parent: QWidget, samples: List = [], disable: list | None = None): """ Converts this instance into a frontend.widgets.submission_widget.SubmissionFormWidget Args: + samples (): disable (list, optional): a list of widgets to be disabled in the form. Defaults to None. parent (QWidget): parent widget of the constructed object @@ -1437,12 +1411,6 @@ class PydClientSubmission(BaseModel, extra="allow", validate_assignment=True): SubmissionFormWidget: Submission form widget """ from frontend.widgets.submission_widget import ClientSubmissionFormWidget - return ClientSubmissionFormWidget(parent=parent, submission=self, disable=disable) + return ClientSubmissionFormWidget(parent=parent, clientsubmission=self, samples=samples, disable=disable) + - def to_sql(self): - sql = self.sql_object() - for key, value in self.improved_dict().items(): - if isinstance(value, dict): - value = value['value'] - sql.set_attribute(key, value) - return sql diff --git a/src/submissions/frontend/visualizations/__init__.py b/src/submissions/frontend/visualizations/__init__.py index 9f66acd..16f2a3a 100644 --- a/src/submissions/frontend/visualizations/__init__.py +++ b/src/submissions/frontend/visualizations/__init__.py @@ -92,7 +92,7 @@ class CustomFigure(Figure): Creates list of buttons with one for each mode to be used in showing/hiding mode traces. Args: - modes (list): list of modes used by main parser. + modes (list): list of modes used by main clientsubmissionparser. fig_len (int): number of traces in the figure Returns: diff --git a/src/submissions/frontend/visualizations/concentrations_chart.py b/src/submissions/frontend/visualizations/concentrations_chart.py index 3034fca..bd26db1 100644 --- a/src/submissions/frontend/visualizations/concentrations_chart.py +++ b/src/submissions/frontend/visualizations/concentrations_chart.py @@ -28,12 +28,12 @@ class ConcentrationsChart(CustomFigure): self.df = df try: self.df = self.df[self.df.concentration.notnull()] - self.df = self.df.sort_values(['submitted_date', 'run'], ascending=[True, True]).reset_index( + self.df = self.df.sort_values(['submitted_date', 'procedure'], ascending=[True, True]).reset_index( drop=True) self.df = self.df.reset_index().rename(columns={"index": "idx"}) # logger.debug(f"DF after changes:\n{self.df}") - scatter = px.scatter(data_frame=self.df, x='run', y="concentration", - hover_data=["name", "run", "submitted_date", "concentration"], + scatter = px.scatter(data_frame=self.df, x='procedure', y="concentration", + hover_data=["name", "procedure", "submitted_date", "concentration"], color="positive", color_discrete_map={"positive": "red", "negative": "green", "sample":"orange"} ) except (ValueError, AttributeError) as e: @@ -44,11 +44,11 @@ class ConcentrationsChart(CustomFigure): for trace in traces: self.add_trace(trace) try: - tickvals = self.df['run'].tolist() + tickvals = self.df['procedure'].tolist() except KeyError: tickvals = [] try: - ticklabels = self.df['run'].tolist() + ticklabels = self.df['procedure'].tolist() except KeyError: ticklabels = [] self.update_layout( diff --git a/src/submissions/frontend/visualizations/irida_charts.py b/src/submissions/frontend/visualizations/irida_charts.py index c27bc7b..eb6336d 100644 --- a/src/submissions/frontend/visualizations/irida_charts.py +++ b/src/submissions/frontend/visualizations/irida_charts.py @@ -1,5 +1,5 @@ """ -Functions for constructing irida controls graphs using plotly. +Functions for constructing irida control graphs using plotly. """ from datetime import date from pprint import pformat @@ -23,12 +23,12 @@ class IridaFigure(CustomFigure): def construct_chart(self, df: pd.DataFrame, modes: list, start_date: date, end_date:date): """ - Creates a plotly chart for controls from a pandas dataframe + Creates a plotly chart for control from a pandas dataframe Args: end_date (): start_date (): - df (pd.DataFrame): input dataframe of controls + df (pd.DataFrame): input dataframe of control modes (list): analysis modes to construct charts for ytitle (str | None, optional): title on the y-axis. Defaults to None. diff --git a/src/submissions/frontend/visualizations/pcr_charts.py b/src/submissions/frontend/visualizations/pcr_charts.py index 27db500..0d4fa1d 100644 --- a/src/submissions/frontend/visualizations/pcr_charts.py +++ b/src/submissions/frontend/visualizations/pcr_charts.py @@ -1,5 +1,5 @@ """ -Functions for constructing irida controls graphs using plotly. +Functions for constructing irida control graphs using plotly. """ from pprint import pformat from . import CustomFigure diff --git a/src/submissions/frontend/widgets/app.py b/src/submissions/frontend/widgets/app.py index f4267d4..56bee9a 100644 --- a/src/submissions/frontend/widgets/app.py +++ b/src/submissions/frontend/widgets/app.py @@ -13,7 +13,7 @@ from PyQt6.QtGui import QAction from pathlib import Path from markdown import markdown from pandas import ExcelWriter -from backend import Reagent, BasicSample, Organization, KitType, BasicRun +from backend import Reagent, Sample, ClientSubmission, KitType, Run from tools import ( check_if_app, Settings, Report, jinja_template_loading, check_authorization, page_size, is_power_user, under_development @@ -22,7 +22,7 @@ from .date_type_picker import DateTypePicker from .functions import select_save_file from .pop_ups import HTMLPop from .misc import Pagifier -from .submission_table import SubmissionsSheet, SubmissionsTree, ClientRunModel +from .submission_table import SubmissionsSheet, SubmissionsTree, ClientSubmissionRunModel from .submission_widget import SubmissionFormContainer from .controls_chart import ControlsViewer from .summary import Summary @@ -30,7 +30,7 @@ from .turnaround import TurnaroundTime from .concentrations import Concentrations from .omni_search import SearchBox -logger = logging.getLogger(f'submissions.{__name__}') +logger = logging.getLogger(f'procedure.{__name__}') class App(QMainWindow): @@ -57,7 +57,7 @@ class App(QMainWindow): # NOTE: insert tabs into main app self.table_widget = AddSubForm(self) self.setCentralWidget(self.table_widget) - # NOTE: run initial setups + # NOTE: procedure initial setups self._createActions() self._createMenuBar() self._createToolBar() @@ -173,14 +173,14 @@ class App(QMainWindow): def runSampleSearch(self): """ - Create a search for samples. + Create a search for sample. """ - dlg = SearchBox(self, object_type=BasicSample, extras=[]) + dlg = SearchBox(self, object_type=Sample, extras=[]) dlg.exec() @check_authorization def edit_reagent(self, *args, **kwargs): - dlg = SearchBox(parent=self, object_type=Reagent, extras=[dict(name='Role', field="role")]) + dlg = SearchBox(parent=self, object_type=Reagent, extras=[dict(name='Role', field="reagentrole")]) dlg.exec() def update_data(self): @@ -239,7 +239,7 @@ class AddSubForm(QWidget): self.tabs.addTab(self.tab3, "PCR Controls") self.tabs.addTab(self.tab4, "Cost Report") self.tabs.addTab(self.tab5, "Turnaround Times") - # NOTE: Create run adder form + # NOTE: Create procedure adder form self.formwidget = SubmissionFormContainer(self) self.formlayout = QVBoxLayout(self) self.formwidget.setLayout(self.formlayout) @@ -249,12 +249,12 @@ class AddSubForm(QWidget): self.interior.setWidgetResizable(True) self.interior.setFixedWidth(325) self.interior.setWidget(self.formwidget) - # NOTE: Create sheet to hold existing submissions + # NOTE: Create sheet to hold existing procedure self.sheetwidget = QWidget(self) self.sheetlayout = QVBoxLayout(self) self.sheetwidget.setLayout(self.sheetlayout) # self.sub_wid = SubmissionsSheet(parent=parent) - self.sub_wid = SubmissionsTree(parent=parent, model=ClientRunModel(self)) + self.sub_wid = SubmissionsTree(parent=parent, model=ClientSubmissionRunModel(self)) self.pager = Pagifier(page_max=self.sub_wid.total_count / page_size) self.sheetlayout.addWidget(self.sub_wid) self.sheetlayout.addWidget(self.pager) @@ -264,11 +264,13 @@ class AddSubForm(QWidget): self.tab1.layout.addWidget(self.interior) self.tab1.layout.addWidget(self.sheetwidget) self.tab2.layout = QVBoxLayout(self) - self.irida_viewer = ControlsViewer(self, archetype="Irida Control") + # self.irida_viewer = ControlsViewer(self, archetype="Irida Control") + self.irida_viewer = None self.tab2.layout.addWidget(self.irida_viewer) self.tab2.setLayout(self.tab2.layout) self.tab3.layout = QVBoxLayout(self) - self.pcr_viewer = ControlsViewer(self, archetype="PCR Control") + # self.pcr_viewer = ControlsViewer(self, archetype="PCR Control") + self.pcr_viewer = None self.tab3.layout.addWidget(self.pcr_viewer) self.tab3.setLayout(self.tab3.layout) summary_report = Summary(self) diff --git a/src/submissions/frontend/widgets/controls_chart.py b/src/submissions/frontend/widgets/controls_chart.py index 3c843a1..c3663e7 100644 --- a/src/submissions/frontend/widgets/controls_chart.py +++ b/src/submissions/frontend/widgets/controls_chart.py @@ -7,7 +7,7 @@ from PyQt6.QtWidgets import ( ) from PyQt6.QtCore import QSignalBlocker from backend import ChartReportMaker -from backend.db import ControlType, IridaControl +from backend.db import ControlType import logging from tools import Report, report_result from frontend.visualizations import CustomFigure @@ -25,7 +25,7 @@ class ControlsViewer(InfoPane): return # NOTE: set tab2 layout self.control_sub_typer = QComboBox() - # NOTE: fetch types of controls + # NOTE: fetch types of control con_sub_types = [item for item in self.archetype.targets.keys()] self.control_sub_typer.addItems(con_sub_types) # NOTE: create custom widget to get types of analysis -- disabled by PCR control @@ -52,7 +52,7 @@ class ControlsViewer(InfoPane): @report_result def update_data(self, *args, **kwargs): """ - Get controls based on start/end dates + Get control based on start/end dates """ super().update_data() # NOTE: mode_sub_type defaults to disabled @@ -70,7 +70,7 @@ class ControlsViewer(InfoPane): sub_types = [] # NOTE: added in allowed to have subtypes in case additions made in future. if sub_types and self.mode.lower() in self.archetype.instance_class.subtyping_allowed: - # NOTE: block signal that will rerun controls getter and update mode_sub_typer + # NOTE: block signal that will rerun control getter and update mode_sub_typer with QSignalBlocker(self.mode_sub_typer) as blocker: self.mode_sub_typer.addItems(sub_types) self.mode_sub_typer.setEnabled(True) @@ -83,7 +83,7 @@ class ControlsViewer(InfoPane): @report_result def chart_maker_function(self, *args, **kwargs): """ - Create html chart for controls reporting + Create html chart for control reporting Args: obj (QMainWindow): original app window @@ -98,7 +98,7 @@ class ControlsViewer(InfoPane): else: self.mode_sub_type = self.mode_sub_typer.currentText() months = self.diff_month(self.start_date, self.end_date) - # NOTE: query all controls using the type/start and end dates from the gui + # NOTE: query all control using the type/start and end dates from the gui chart_settings = dict( sub_type=self.con_sub_type, start_date=self.start_date, diff --git a/src/submissions/frontend/widgets/equipment_usage.py b/src/submissions/frontend/widgets/equipment_usage.py index ba1df9d..f0da363 100644 --- a/src/submissions/frontend/widgets/equipment_usage.py +++ b/src/submissions/frontend/widgets/equipment_usage.py @@ -6,7 +6,7 @@ from PyQt6.QtCore import Qt, QSignalBlocker from PyQt6.QtWidgets import ( QDialog, QComboBox, QCheckBox, QLabel, QWidget, QVBoxLayout, QDialogButtonBox, QGridLayout ) -from backend.db.models import Equipment, BasicRun, Process +from backend.db.models import Equipment, Run, Process, Procedure from backend.validators.pydant import PydEquipment, PydEquipmentRole, PydTips import logging from typing import Generator @@ -16,13 +16,13 @@ logger = logging.getLogger(f"submissions.{__name__}") class EquipmentUsage(QDialog): - def __init__(self, parent, submission: BasicRun): + def __init__(self, parent, procedure: Procedure): super().__init__(parent) - self.submission = submission - self.setWindowTitle(f"Equipment Checklist - {submission.rsl_plate_num}") - self.used_equipment = self.submission.used_equipment - self.kit = self.submission.extraction_kit - self.opt_equipment = submission.submission_type.get_equipment() + self.procedure = procedure + self.setWindowTitle(f"Equipment Checklist - {procedure.rsl_plate_num}") + self.used_equipment = self.procedure.equipment + self.kit = self.procedure.kittype + self.opt_equipment = procedure.proceduretype.get_equipment() self.layout = QVBoxLayout() self.setLayout(self.layout) self.populate_form() @@ -120,7 +120,7 @@ class RoleComboBox(QWidget): def update_processes(self): """ - Changes processes when equipment is changed + Changes process when equipment is changed """ equip = self.box.currentText() equip2 = next((item for item in self.role.equipment if item.name == equip), self.role.equipment[0]) @@ -134,10 +134,10 @@ class RoleComboBox(QWidget): """ process = self.process.currentText().strip() process = Process.query(name=process) - if process.tip_roles: - for iii, tip_role in enumerate(process.tip_roles): + if process.tiprole: + for iii, tip_role in enumerate(process.tiprole): widget = QComboBox() - tip_choices = [item.name for item in tip_role.controls] + tip_choices = [item.name for item in tip_role.control] widget.setEditable(False) widget.addItems(tip_choices) widget.setObjectName(f"tips_{tip_role.name}") diff --git a/src/submissions/frontend/widgets/gel_checker.py b/src/submissions/frontend/widgets/gel_checker.py index f443cca..a3c23a1 100644 --- a/src/submissions/frontend/widgets/gel_checker.py +++ b/src/submissions/frontend/widgets/gel_checker.py @@ -12,7 +12,7 @@ import logging, numpy as np from pprint import pformat from typing import Tuple, List from pathlib import Path -from backend.db.models import BasicRun +from backend.db.models import Run logger = logging.getLogger(f"submissions.{__name__}") @@ -20,7 +20,7 @@ logger = logging.getLogger(f"submissions.{__name__}") # Main window class class GelBox(QDialog): - def __init__(self, parent, img_path: str | Path, submission: BasicRun): + def __init__(self, parent, img_path: str | Path, submission: Run): super().__init__(parent) # NOTE: setting title self.setWindowTitle(f"Gel - {img_path}") @@ -135,7 +135,7 @@ class ControlsForm(QWidget): def parse_form(self) -> Tuple[List[dict], str]: """ - Pulls the controls statuses from the form. + Pulls the control statuses from the form. Returns: List[dict]: output of values diff --git a/src/submissions/frontend/widgets/info_tab.py b/src/submissions/frontend/widgets/info_tab.py index 5e70aad..69e80ee 100644 --- a/src/submissions/frontend/widgets/info_tab.py +++ b/src/submissions/frontend/widgets/info_tab.py @@ -39,7 +39,7 @@ class InfoPane(QWidget): lastmonth = self.datepicker.end_date.date().addDays(-31) msg = f"Start date after end date is not allowed! Setting to {lastmonth.toString()}." logger.warning(msg) - # NOTE: block signal that will rerun controls getter and set start date without triggering this function again + # NOTE: block signal that will rerun control getter and set start date without triggering this function again with QSignalBlocker(self.datepicker.start_date) as blocker: self.datepicker.start_date.setDate(lastmonth) self.update_data() diff --git a/src/submissions/frontend/widgets/misc.py b/src/submissions/frontend/widgets/misc.py index 1eb8729..71eb0e0 100644 --- a/src/submissions/frontend/widgets/misc.py +++ b/src/submissions/frontend/widgets/misc.py @@ -19,7 +19,7 @@ env = jinja_template_loading() class StartEndDatePicker(QWidget): """ - custom widget to pick start and end dates for controls graphs + custom widget to pick start and end dates for control graphs """ def __init__(self, default_start: int) -> None: diff --git a/src/submissions/frontend/widgets/omni_add_edit.py b/src/submissions/frontend/widgets/omni_add_edit.py index c02d23a..bbe1b4c 100644 --- a/src/submissions/frontend/widgets/omni_add_edit.py +++ b/src/submissions/frontend/widgets/omni_add_edit.py @@ -71,7 +71,7 @@ class AddEdit(QDialog): # logger.debug(f"We have an elastic model.") parsed['instance'] = self.instance # NOTE: Hand-off to pydantic model for validation. - # NOTE: Also, why am I not just using the toSQL method here. I could write one for contacts. + # NOTE: Also, why am I not just using the toSQL method here. I could write one for contact. model = model(**parsed) return model, report diff --git a/src/submissions/frontend/widgets/omni_manager_pydant.py b/src/submissions/frontend/widgets/omni_manager_pydant.py index b83e304..033fba0 100644 --- a/src/submissions/frontend/widgets/omni_manager_pydant.py +++ b/src/submissions/frontend/widgets/omni_manager_pydant.py @@ -144,7 +144,7 @@ class ManagerWindow(QDialog): def update_data(self) -> None: """ - Performs updating of widgets on first run and after options change. + Performs updating of widgets on first procedure and after options change. Returns: None diff --git a/src/submissions/frontend/widgets/omni_search.py b/src/submissions/frontend/widgets/omni_search.py index 4f1d590..3a62b24 100644 --- a/src/submissions/frontend/widgets/omni_search.py +++ b/src/submissions/frontend/widgets/omni_search.py @@ -100,7 +100,7 @@ class SearchBox(QDialog): def update_data(self): """ - Shows dataframe of relevant samples. + Shows dataframe of relevant sample. """ fields = self.parse_form() sample_list_creator = self.object_type.fuzzy_search(**fields) diff --git a/src/submissions/frontend/widgets/sample_checker.py b/src/submissions/frontend/widgets/sample_checker.py index 90296ca..4ea0dee 100644 --- a/src/submissions/frontend/widgets/sample_checker.py +++ b/src/submissions/frontend/widgets/sample_checker.py @@ -1,4 +1,3 @@ - import logging from pathlib import Path from typing import List @@ -6,9 +5,12 @@ from PyQt6.QtCore import Qt, pyqtSlot from PyQt6.QtWebChannel import QWebChannel from PyQt6.QtWebEngineWidgets import QWebEngineView from PyQt6.QtWidgets import QDialog, QDialogButtonBox, QGridLayout -from backend.validators import PydSubmission + +from backend.db.models import ClientSubmission +from backend.validators import PydSample, RSLNamer from tools import get_application_from_parent, jinja_template_loading + env = jinja_template_loading() logger = logging.getLogger(f"submissions.{__name__}") @@ -16,9 +18,13 @@ logger = logging.getLogger(f"submissions.{__name__}") class SampleChecker(QDialog): - def __init__(self, parent, title:str, pyd: PydSubmission): + def __init__(self, parent, title: str, samples: List[PydSample], clientsubmission: ClientSubmission|None=None): super().__init__(parent) - self.pyd = pyd + if clientsubmission: + self.rsl_plate_num = RSLNamer.construct_new_plate_name(clientsubmission.to_dict()) + else: + self.rsl_plate_num = clientsubmission + self.samples = samples self.setWindowTitle(title) self.app = get_application_from_parent(parent) self.webview = QWebEngineView(parent=self) @@ -36,9 +42,10 @@ class SampleChecker(QDialog): css = f.read() try: samples = self.formatted_list - except AttributeError: + except AttributeError as e: + logger.error(f"Problem getting sample list: {e}") samples = [] - html = template.render(samples=samples, css=css) + html = template.render(samples=samples, css=css, rsl_plate_num=self.rsl_plate_num) self.webview.setHtml(html) QBtn = QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel self.buttonBox = QDialogButtonBox(QBtn) @@ -51,25 +58,37 @@ class SampleChecker(QDialog): @pyqtSlot(str, str, str) def text_changed(self, submission_rank: str, key: str, new_value: str): logger.debug(f"Name: {submission_rank}, Key: {key}, Value: {new_value}") - match key: - case "row" | "column": - value = [new_value] - case _: - value = new_value try: - item = next((sample for sample in self.pyd.samples if int(submission_rank) in sample.submission_rank)) + item = next((sample for sample in self.samples if int(submission_rank) == sample.submission_rank)) except StopIteration: logger.error(f"Unable to find sample {submission_rank}") return - item.__setattr__(key, value) + item.__setattr__(key, new_value) + + @pyqtSlot(str, bool) + def enable_sample(self, submission_rank: str, enabled: bool): + logger.debug(f"Name: {submission_rank}, Enabled: {enabled}") + try: + item = next((sample for sample in self.samples if int(submission_rank) == sample.submission_rank)) + except StopIteration: + logger.error(f"Unable to find sample {submission_rank}") + return + item.__setattr__("enabled", enabled) + + @pyqtSlot(str) + def set_rsl_plate_num(self, rsl_plate_num: str): + logger.debug(f"RSL plate num: {rsl_plate_num}") + self.rsl_plate_num = rsl_plate_num @property def formatted_list(self) -> List[dict]: output = [] - for sample in self.pyd.sample_list: - if sample['submitter_id'] in [item['submitter_id'] for item in output]: - sample['color'] = "red" + for sample in self.samples: + logger.debug(sample) + s = sample.improved_dict(dictionaries=False) + if s['sample_id'] in [item['sample_id'] for item in output]: + s['color'] = "red" else: - sample['color'] = "black" - output.append(sample) + s['color'] = "black" + output.append(s) return output diff --git a/src/submissions/frontend/widgets/submission_details.py b/src/submissions/frontend/widgets/submission_details.py index 3dc3364..a6c7e76 100644 --- a/src/submissions/frontend/widgets/submission_details.py +++ b/src/submissions/frontend/widgets/submission_details.py @@ -1,5 +1,5 @@ """ -Webview to show run and sample details. +Webview to show procedure and sample details. """ from PyQt6.QtWidgets import (QDialog, QPushButton, QVBoxLayout, QDialogButtonBox, QTextEdit, QGridLayout) @@ -7,7 +7,7 @@ from PyQt6.QtWebEngineWidgets import QWebEngineView from PyQt6.QtWebChannel import QWebChannel from PyQt6.QtCore import Qt, pyqtSlot from jinja2 import TemplateNotFound -from backend.db.models import BasicRun, BasicSample, Reagent, KitType, Equipment, Process, Tips +from backend.db.models import Run, Sample, Reagent, KitType, Equipment, Process, Tips from tools import is_power_user, jinja_template_loading, timezone, get_application_from_parent from .functions import select_save_file, save_pdf from pathlib import Path @@ -18,15 +18,15 @@ from pprint import pformat from typing import List -logger = logging.getLogger(f"submissions.{__name__}") +logger = logging.getLogger(f"procedure.{__name__}") class SubmissionDetails(QDialog): """ - a window showing text details of run + a window showing text details of procedure """ - def __init__(self, parent, sub: BasicRun | BasicSample | Reagent) -> None: + def __init__(self, parent, sub: Run | Sample | Reagent) -> None: super().__init__(parent) self.app = get_application_from_parent(parent) @@ -51,10 +51,10 @@ class SubmissionDetails(QDialog): self.channel = QWebChannel() self.channel.registerObject('backend', self) match sub: - case BasicRun(): + case Run(): self.run_details(run=sub) self.rsl_plate_num = sub.rsl_plate_num - case BasicSample(): + case Sample(): self.sample_details(sample=sub) case Reagent(): self.reagent_details(reagent=sub) @@ -127,7 +127,7 @@ class SubmissionDetails(QDialog): self.setWindowTitle(f"Process Details - {tips.name}") @pyqtSlot(str) - def sample_details(self, sample: str | BasicSample): + def sample_details(self, sample: str | Sample): """ Changes details view to summary of Sample @@ -136,19 +136,19 @@ class SubmissionDetails(QDialog): """ logger.debug(f"Sample details.") if isinstance(sample, str): - sample = BasicSample.query(submitter_id=sample) + sample = Sample.query(sample_id=sample) base_dict = sample.to_sub_dict(full_data=True) - exclude = ['submissions', 'excluded', 'colour', 'tooltip'] + exclude = ['procedure', 'excluded', 'colour', 'tooltip'] base_dict['excluded'] = exclude template = sample.details_template template_path = Path(template.environment.loader.__getattribute__("searchpath")[0]) with open(template_path.joinpath("css", "styles.css"), "r") as f: css = f.read() html = template.render(sample=base_dict, css=css) - # with open(f"{sample.submitter_id}.html", 'w') as f: + # with open(f"{sample.sample_id}.html", 'w') as f: # f.write(html) self.webview.setHtml(html) - self.setWindowTitle(f"Sample Details - {sample.submitter_id}") + self.setWindowTitle(f"Sample Details - {sample.sample_id}") @pyqtSlot(str, str) def reagent_details(self, reagent: str | Reagent, kit: str | KitType): @@ -156,7 +156,7 @@ class SubmissionDetails(QDialog): Changes details view to summary of Reagent Args: - kit (str | KitType): Name of kit. + kit (str | KitType): Name of kittype. reagent (str | Reagent): Lot number of the reagent """ logger.debug(f"Reagent details.") @@ -164,7 +164,7 @@ class SubmissionDetails(QDialog): reagent = Reagent.query(lot=reagent) if isinstance(kit, str): self.kit = KitType.query(name=kit) - base_dict = reagent.to_sub_dict(extraction_kit=self.kit, full_data=True) + base_dict = reagent.to_sub_dict(kittype=self.kit, full_data=True) env = jinja_template_loading() temp_name = "reagent_details.html" try: @@ -203,7 +203,7 @@ class SubmissionDetails(QDialog): logger.error(f"Reagent with lot {old_lot} not found.") @pyqtSlot(str) - def run_details(self, run: str | BasicRun): + def run_details(self, run: str | Run): """ Sets details view to summary of Submission. @@ -212,24 +212,24 @@ class SubmissionDetails(QDialog): """ logger.debug(f"Submission details.") if isinstance(run, str): - run = BasicRun.query(rsl_plate_num=run) + run = Run.query(name=run) self.rsl_plate_num = run.rsl_plate_num self.base_dict = run.to_dict(full_data=True) # NOTE: don't want id self.base_dict['platemap'] = run.make_plate_map(sample_list=run.hitpicked) self.base_dict['excluded'] = run.get_default_info("details_ignore") - self.base_dict, self.template = run.get_details_template(base_dict=self.base_dict) + self.template = run.details_template template_path = Path(self.template.environment.loader.__getattribute__("searchpath")[0]) with open(template_path.joinpath("css", "styles.css"), "r") as f: css = f.read() - # logger.debug(f"Base dictionary of run {self.rsl_plate_num}: {pformat(self.base_dict)}") + # logger.debug(f"Base dictionary of procedure {self.name}: {pformat(self.base_dict)}") self.html = self.template.render(sub=self.base_dict, permission=is_power_user(), css=css) self.webview.setHtml(self.html) @pyqtSlot(str) - def sign_off(self, run: str | BasicRun) -> None: + def sign_off(self, run: str | Run) -> None: """ - Allows power user to signify a run is complete. + Allows power user to signify a procedure is complete. Args: run (str | BasicRun): Submission to be completed @@ -239,7 +239,7 @@ class SubmissionDetails(QDialog): """ logger.info(f"Signing off on {run} - ({getuser()})") if isinstance(run, str): - run = BasicRun.query(rsl_plate_num=run) + run = Run.query(name=run) run.signed_by = getuser() run.completed_date = datetime.now() run.completed_date.replace(tzinfo=timezone) @@ -248,7 +248,7 @@ class SubmissionDetails(QDialog): def save_pdf(self): """ - Renders run to html, then creates and saves .pdf file to user selected file. + Renders procedure to html, then creates and saves .pdf file to user selected file. """ fname = select_save_file(obj=self, default_name=self.export_plate, extension="pdf") save_pdf(obj=self.webview, filename=fname) @@ -256,11 +256,11 @@ class SubmissionDetails(QDialog): class SubmissionComment(QDialog): """ - a window for adding comment text to a run + a window for adding comment text to a procedure """ - def __init__(self, parent, submission: BasicRun) -> None: - + def __init__(self, parent, submission: Run) -> None: + logger.debug(parent) super().__init__(parent) self.app = get_application_from_parent(parent) self.submission = submission @@ -282,7 +282,7 @@ class SubmissionComment(QDialog): def parse_form(self) -> List[dict]: """ - Adds comment to run object. + Adds comment to procedure object. """ commenter = getuser() comment = self.txt_editor.toPlainText() diff --git a/src/submissions/frontend/widgets/submission_table.py b/src/submissions/frontend/widgets/submission_table.py index 9e4cb99..27d6882 100644 --- a/src/submissions/frontend/widgets/submission_table.py +++ b/src/submissions/frontend/widgets/submission_table.py @@ -1,18 +1,20 @@ """ -Contains widgets specific to the run summary and run details. +Contains widgets specific to the procedure summary and procedure details. """ -import logging -import sys + +import sys, logging, re from pprint import pformat + from PyQt6.QtWidgets import QTableView, QMenu, QTreeView, QStyledItemDelegate, QStyle, QStyleOptionViewItem, \ QHeaderView, QAbstractItemView, QWidget, QTreeWidgetItemIterator from PyQt6.QtCore import Qt, QAbstractTableModel, QSortFilterProxyModel, pyqtSlot, QModelIndex -from PyQt6.QtGui import QAction, QCursor, QStandardItemModel, QStandardItem, QIcon, QColor -from backend.db.models import BasicRun, ClientSubmission +from PyQt6.QtGui import QAction, QCursor, QStandardItemModel, QStandardItem, QIcon, QColor, QContextMenuEvent + +from backend.db.models import Run, ClientSubmission from tools import Report, Result, report_result from .functions import select_open_file -logger = logging.getLogger(f"submissions.{__name__}") +logger = logging.getLogger(f"procedure.{__name__}") class pandasModel(QAbstractTableModel): @@ -63,7 +65,7 @@ class pandasModel(QAbstractTableModel): class SubmissionsSheet(QTableView): """ - presents run summary to user in tab1 + presents procedure summary to user in tab1 """ def __init__(self, parent) -> None: @@ -78,16 +80,16 @@ class SubmissionsSheet(QTableView): self.resizeColumnsToContents() self.resizeRowsToContents() self.setSortingEnabled(True) - self.doubleClicked.connect(lambda x: BasicRun.query(id=x.sibling(x.row(), 0).data()).show_details(self)) - # NOTE: Have to run native query here because mine just returns results? - self.total_count = BasicRun.__database_session__.query(BasicRun).count() + self.doubleClicked.connect(lambda x: Run.query(id=x.sibling(x.row(), 0).data()).show_details(self)) + # NOTE: Have to procedure native query here because mine just returns results? + self.total_count = Run.__database_session__.query(Run).count() def set_data(self, page: int = 1, page_size: int = 250) -> None: """ sets data in model """ # self.data = ClientSubmission.submissions_to_df(page=page, page_size=page_size) - self.data = BasicRun.submissions_to_df(page=page, page_size=page_size) + self.data = Run.submissions_to_df(page=page, page_size=page_size) try: self.data['Id'] = self.data['Id'].apply(str) self.data['Id'] = self.data['Id'].str.zfill(4) @@ -108,7 +110,7 @@ class SubmissionsSheet(QTableView): id = self.selectionModel().currentIndex() # NOTE: Convert to data in id column (i.e. column 0) id = id.sibling(id.row(), 0).data() - submission = BasicRun.query(id=id) + submission = Run.query(id=id) self.menu = QMenu(self) self.con_actions = submission.custom_context_events() for k in self.con_actions.keys(): @@ -140,7 +142,7 @@ class SubmissionsSheet(QTableView): def link_extractions_function(self): """ - Link extractions from runlogs to imported submissions + Link extractions from runlogs to imported procedure Args: obj (QMainWindow): original app window @@ -166,9 +168,9 @@ class SubmissionsSheet(QTableView): # NOTE: elution columns are item 6 in the comma split list to the end for ii in range(6, len(run)): new_run[f"column{str(ii - 5)}_vol"] = run[ii] - # NOTE: Lookup imported submissions - sub = BasicRun.query(rsl_plate_num=new_run['rsl_plate_num']) - # NOTE: If no such run exists, move onto the next run + # NOTE: Lookup imported procedure + sub = Run.query(name=new_run['name']) + # NOTE: If no such procedure exists, move onto the next procedure if sub is None: continue try: @@ -192,7 +194,7 @@ class SubmissionsSheet(QTableView): def link_pcr_function(self): """ - Link PCR data from run logs to an imported run + Link PCR data from procedure logs to an imported procedure Args: obj (QMainWindow): original app window @@ -215,9 +217,9 @@ class SubmissionsSheet(QTableView): experiment_name=run[4].strip(), end_time=run[5].strip() ) - # NOTE: lookup imported run - sub = BasicRun.query(rsl_number=new_run['rsl_plate_num']) - # NOTE: if imported run doesn't exist move on to next run + # NOTE: lookup imported procedure + sub = Run.query(rsl_number=new_run['name']) + # NOTE: if imported procedure doesn't exist move on to next procedure if sub is None: continue sub.set_attribute('pcr_info', new_run) @@ -227,9 +229,10 @@ class SubmissionsSheet(QTableView): return report -class RunDelegate(QStyledItemDelegate): +class ClientSubmissionDelegate(QStyledItemDelegate): + def __init__(self, parent=None): - super(RunDelegate, self).__init__(parent) + super(ClientSubmissionDelegate, self).__init__(parent) pixmapi = QStyle.StandardPixmap.SP_ToolBarHorizontalExtensionButton icon1 = QWidget().style().standardIcon(pixmapi) pixmapi = QStyle.StandardPixmap.SP_ToolBarVerticalExtensionButton @@ -238,23 +241,29 @@ class RunDelegate(QStyledItemDelegate): self._minus_icon = icon2 def initStyleOption(self, option, index): - super(RunDelegate, self).initStyleOption(option, index) + super(ClientSubmissionDelegate, self).initStyleOption(option, index) if not index.parent().isValid(): is_open = bool(option.state & QStyle.StateFlag.State_Open) option.features |= QStyleOptionViewItem.ViewItemFeature.HasDecoration option.icon = self._minus_icon if is_open else self._plus_icon + +class RunDelegate(ClientSubmissionDelegate): + pass + + class SubmissionsTree(QTreeView): """ https://stackoverflow.com/questions/54385437/how-can-i-make-a-table-that-can-collapse-its-rows-into-categories-in-qt """ + def __init__(self, model, parent=None): super(SubmissionsTree, self).__init__(parent) self.total_count = ClientSubmission.__database_session__.query(ClientSubmission).count() self.setIndentation(0) self.setExpandsOnDoubleClick(False) self.clicked.connect(self.on_clicked) - delegate = RunDelegate(self) + delegate = ClientSubmissionDelegate(self) self.setItemDelegateForColumn(0, delegate) self.model = model self.setModel(self.model) @@ -263,32 +272,69 @@ class SubmissionsTree(QTreeView): # self.setStyleSheet("background-color: #0D1225;") self.set_data() self.doubleClicked.connect(self.show_details) + # self.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu) + # self.customContextMenuRequested.connect(self.open_menu) for ii in range(2): self.resizeColumnToContents(ii) - @pyqtSlot(QModelIndex) def on_clicked(self, index): if not index.parent().isValid() and index.column() == 0: self.setExpanded(index, not self.isExpanded(index)) + def contextMenuEvent(self, event: QContextMenuEvent): + """ + Creates actions for right click menu events. + + Args: + event (_type_): the item of interest + """ + indexes = self.selectedIndexes() + + dicto = next((item.data(1) for item in indexes if item.data(1))) + query_obj = dicto['item_type'].query(name=dicto['query_str'], limit=1) + logger.debug(query_obj) + + # NOTE: Convert to data in id column (i.e. column 0) + # id = id.sibling(id.row(), 0).data() + + # logger.debug(id.model().query_group_object(id.row())) + # clientsubmission = id.model().query_group_object(id.row()) + self.menu = QMenu(self) + self.con_actions = query_obj.custom_context_events + for key in self.con_actions.keys(): + if key.lower() == "add procedure": + action = QMenu(self.menu) + action.setTitle("Add Procedure") + for procedure in query_obj.allowed_procedures: + proc_name = procedure.name + proc = QAction(proc_name, action) + proc.triggered.connect(lambda _, procedure_name=proc_name: self.con_actions['Add Procedure'](obj=self, proceduretype_name=procedure_name)) + action.addAction(proc) + self.menu.addMenu(action) + else: + action = QAction(key, self) + action.triggered.connect(lambda _, action_name=key: self.con_actions[action_name](obj=self)) + self.menu.addAction(action) + # # NOTE: add other required actions + self.menu.popup(QCursor.pos()) + def set_data(self, page: int = 1, page_size: int = 250) -> None: """ sets data in model """ self.clear() - # self.data = ClientSubmission.submissions_to_df(page=page, page_size=page_size) - self.data = [item.to_dict(full_data=True) for item in ClientSubmission.query(chronologic=True, page=page, page_size=page_size)] - logger.debug(pformat(self.data)) + self.data = [item.to_dict(full_data=True) for item in + ClientSubmission.query(chronologic=True, page=page, page_size=page_size)] + logger.debug(f"setting data:\n {pformat(self.data)}") # sys.exit() for submission in self.data: - group_str = f"{submission['submission_type']}-{submission['submitter_plate_number']}-{submission['submitted_date']}" - group_item = self.model.add_group(group_str) - for run in submission['runs']: + group_str = f"{submission['submissiontype']}-{submission['submitter_plate_id']}-{submission['submitted_date']}" + group_item = self.model.add_group(group_str, query_str=submission['submitter_plate_id']) + for run in submission['run']: self.model.append_element_to_group(group_item=group_item, element=run) - def clear(self): if self.model != None: # self.model.clear() # works @@ -302,8 +348,7 @@ class SubmissionsTree(QTreeView): id = int(id.data()) except ValueError: return - BasicRun.query(id=id).show_details(self) - + Run.query(id=id).show_details(self) def link_extractions(self): pass @@ -312,62 +357,64 @@ class SubmissionsTree(QTreeView): pass -class ClientRunModel(QStandardItemModel): +class ClientSubmissionRunModel(QStandardItemModel): + def __init__(self, parent=None): - super(ClientRunModel, self).__init__(parent) - headers = ["", "id", "Plate Number", "Started Date", "Completed Date", "Technician", "Signed By"] + super(ClientSubmissionRunModel, self).__init__(parent) + headers = ["", "id", "Plate Number", "Started Date", "Completed Date", "Signed By"] self.setColumnCount(len(headers)) self.setHorizontalHeaderLabels(headers) - for i in range(self.columnCount()): - it = self.horizontalHeaderItem(i) - try: - logger.debug(it.text()) - except AttributeError: - pass - # it.setForeground(QColor("#F2F2F2")) - - def add_group(self, group_name): + def add_group(self, item_name, query_str: str): item_root = QStandardItem() item_root.setEditable(False) - item = QStandardItem(group_name) + item = QStandardItem(item_name) item.setEditable(False) ii = self.invisibleRootItem() i = ii.rowCount() for j, it in enumerate((item_root, item)): + # NOTE: Adding item to invisible root row i, column j (wherever j comes from) ii.setChild(i, j, it) ii.setEditable(False) for j in range(self.columnCount()): it = ii.child(i, j) if it is None: + # NOTE: Set invisible root child to empty if it is None. it = QStandardItem() ii.setChild(i, j, it) - # it.setBackground(QColor("#002842")) - # it.setForeground(QColor("#F2F2F2")) + item_root.setData(dict(item_type=ClientSubmission, query_str=query_str), 1) return item_root - def append_element_to_group(self, group_item, element:dict): - logger.debug(f"Element: {pformat(element)}") + def append_element_to_group(self, group_item, element: dict): + # logger.debug(f"Element: {pformat(element)}") j = group_item.rowCount() item_icon = QStandardItem() item_icon.setEditable(False) - # item_icon.setBackground(QColor("#0D1225")) + # item_icon.setData(dict(item_type="Run", query_str=element['plate_number']), 1) # group_item.setChild(j, 0, item_icon) for i in range(self.columnCount()): it = self.horizontalHeaderItem(i) try: key = it.text().lower().replace(" ", "_") except AttributeError: - continue + key = None if not key: continue value = str(element[key]) item = QStandardItem(value) item.setBackground(QColor("#CFE2F3")) item.setEditable(False) + # item_icon.setChild(j, i, item) + item.setData(dict(item_type=Run, query_str=element['plate_number']),1) group_item.setChild(j, i, item) # group_item.setChild(j, 1, QStandardItem("B")) + def get_value(self, idx: int, column: int = 1): + return self.item(idx, column) + def query_group_object(self, idx: int): + row_obj = self.get_value(idx) + logger.debug(row_obj.query_str) + return self.sql_object.query(name=row_obj.query_str, limit=1) diff --git a/src/submissions/frontend/widgets/submission_widget.py b/src/submissions/frontend/widgets/submission_widget.py index 2662dd8..2e59fbe 100644 --- a/src/submissions/frontend/widgets/submission_widget.py +++ b/src/submissions/frontend/widgets/submission_widget.py @@ -1,5 +1,5 @@ """ -Contains all run related frontend functions +Contains all procedure related frontend functions """ from PyQt6.QtWidgets import ( QWidget, QPushButton, QVBoxLayout, @@ -10,11 +10,11 @@ from .functions import select_open_file, select_save_file import logging from pathlib import Path from tools import Report, Result, check_not_nan, main_form_style, report_result, get_application_from_parent -from backend.excel import SheetParser, InfoParser -from backend.validators import PydSubmission, PydReagent +from backend.excel import ClientSubmissionParser, SampleParser +from backend.validators import PydSubmission, PydReagent, PydClientSubmission, PydSample from backend.db import ( - Organization, SubmissionType, Reagent, - ReagentRole, KitTypeReagentRoleAssociation, BasicRun + ClientLab, SubmissionType, Reagent, + ReagentRole, KitTypeReagentRoleAssociation, Run ) from pprint import pformat from .pop_ups import QuestionAsker, AlertPop @@ -93,7 +93,7 @@ class SubmissionFormContainer(QWidget): @report_result def import_submission_function(self, fname: Path | None = None) -> Report: """ - Import a new run to the app window + Import a new procedure to the app window Args: obj (QMainWindow): original app window @@ -110,7 +110,7 @@ class SubmissionFormContainer(QWidget): self.form.setParent(None) except AttributeError: pass - # NOTE: initialize samples + # NOTE: initialize sample self.samples = [] self.missing_info = [] # NOTE: set file dialog @@ -121,19 +121,28 @@ class SubmissionFormContainer(QWidget): return report # NOTE: create sheetparser using excel sheet and context from gui try: - # self.prsr = SheetParser(filepath=fname) - self.parser = InfoParser(filepath=fname) + self.clientsubmissionparser = ClientSubmissionParser(filepath=fname) except PermissionError: logger.error(f"Couldn't get permission to access file: {fname}") return except AttributeError: - self.parser = InfoParser(filepath=fname) - self.pyd = self.parser.to_pydantic() - # logger.debug(f"Samples: {pformat(self.pyd.samples)}") - checker = SampleChecker(self, "Sample Checker", self.pyd) + self.clientsubmissionparser = ClientSubmissionParser(filepath=fname) + try: + # self.prsr = SheetParser(filepath=fname) + self.sampleparser = SampleParser(filepath=fname) + except PermissionError: + logger.error(f"Couldn't get permission to access file: {fname}") + return + except AttributeError: + self.sampleparser = SampleParser(filepath=fname) + self.pydclientsubmission = self.clientsubmissionparser.to_pydantic() + self.pydsamples = self.sampleparser.to_pydantic() + # logger.debug(f"Samples: {pformat(self.pydclientsubmission.sample)}") + checker = SampleChecker(self, "Sample Checker", self.pydsamples) if checker.exec(): - # logger.debug(pformat(self.pyd.samples)) - self.form = self.pyd.to_form(parent=self) + # logger.debug(pformat(self.pydclientsubmission.sample)) + self.form = self.pydclientsubmission.to_form(parent=self) + self.form.samples = self.pydsamples self.layout().addWidget(self.form) else: message = "Submission cancelled." @@ -150,7 +159,7 @@ class SubmissionFormContainer(QWidget): instance (Reagent | None): Blank reagent instance to be edited and then added. Returns: - models.Reagent: the constructed reagent object to add to run + models.Reagent: the constructed reagent object to add to procedure """ report = Report() if not instance: @@ -167,23 +176,23 @@ class SubmissionFormContainer(QWidget): class SubmissionFormWidget(QWidget): - update_reagent_fields = ['extraction_kit'] + update_reagent_fields = ['kittype'] - def __init__(self, parent: QWidget, submission: PydSubmission, disable: list | None = None) -> None: + def __init__(self, parent: QWidget, pyd: PydSubmission, disable: list | None = None) -> None: super().__init__(parent) if disable is None: disable = [] self.app = get_application_from_parent(parent) - self.pyd = submission + self.pyd = pyd self.missing_info = [] - self.submission_type = SubmissionType.query(name=self.pyd.submission_type['value']) - basic_submission_class = self.submission_type.submission_class - logger.debug(f"Basic run class: {basic_submission_class}") - defaults = basic_submission_class.get_default_info("form_recover", "form_ignore", submission_type=self.pyd.submission_type['value']) + self.submissiontype = SubmissionType.query(name=self.pyd.submissiontype['value']) + # basic_submission_class = self.submission_type.submission_class + # logger.debug(f"Basic procedure class: {basic_submission_class}") + defaults = Run.get_default_info("form_recover", "form_ignore", submissiontype=self.pyd.submissiontype['value']) self.recover = defaults['form_recover'] self.ignore = defaults['form_ignore'] self.layout = QVBoxLayout() - for k in list(self.pyd.model_fields.keys()) + list(self.pyd.model_extra.keys()): + for k in list(self.pyd.model_fields.keys()):# + list(self.pyd.model_extra.keys()): logger.debug(f"Pydantic field: {k}") if k in self.ignore: logger.warning(f"{k} in form_ignore {self.ignore}, not creating widget") @@ -201,8 +210,8 @@ class SubmissionFormWidget(QWidget): except KeyError: value = dict(value=None, missing=True) logger.debug(f"Pydantic value: {value}") - add_widget = self.create_widget(key=k, value=value, submission_type=self.submission_type, - run_object=basic_submission_class, disable=check) + add_widget = self.create_widget(key=k, value=value, submission_type=self.submissiontype, + run_object=Run(), disable=check) if add_widget is not None: self.layout.addWidget(add_widget) if k in self.__class__.update_reagent_fields: @@ -212,7 +221,7 @@ class SubmissionFormWidget(QWidget): self.layout.addWidget(self.disabler) self.disabler.checkbox.checkStateChanged.connect(self.disable_reagents) self.setStyleSheet(main_form_style) - # self.scrape_reagents(self.extraction_kit) + # self.scrape_reagents(self.kittype) self.setLayout(self.layout) def disable_reagents(self): @@ -223,7 +232,7 @@ class SubmissionFormWidget(QWidget): reagent.flip_check(self.disabler.checkbox.isChecked()) def create_widget(self, key: str, value: dict | PydReagent, submission_type: str | SubmissionType | None = None, - extraction_kit: str | None = None, run_object: BasicRun | None = None, + extraction_kit: str | None = None, run_object: Run | None = None, disable: bool = False) -> "self.InfoItem": """ Make an InfoItem widget to hold a field @@ -256,14 +265,14 @@ class SubmissionFormWidget(QWidget): return None @report_result - def scrape_reagents(self, *args, **kwargs): #extraction_kit:str, caller:str|None=None): + def scrape_reagents(self, *args, **kwargs): #kittype:str, caller:str|None=None): """ - Extracted scrape reagents function that will run when - form 'extraction_kit' widget is updated. + Extracted scrape reagents function that will procedure when + form 'kittype' widget is updated. Args: obj (QMainWindow): updated main application - extraction_kit (str): name of extraction kit (in 'extraction_kit' widget) + extraction_kit (str): name of extraction kittype (in 'kittype' widget) Returns: Tuple[QMainWindow, dict]: Updated application and result @@ -373,7 +382,7 @@ class SubmissionFormWidget(QWidget): return report case _: pass - # NOTE: add reagents to run object + # NOTE: add reagents to procedure object if base_submission is None: return for reagent in base_submission.reagents: @@ -393,7 +402,7 @@ class SubmissionFormWidget(QWidget): def export_csv_function(self, fname: Path | None = None): """ - Save the run's csv file. + Save the procedure's csv file. Args: fname (Path | None, optional): Input filename. Defaults to None. @@ -405,7 +414,7 @@ class SubmissionFormWidget(QWidget): except PermissionError: logger.warning(f"Could not get permissions to {fname}. Possibly the request was cancelled.") except AttributeError: - logger.error(f"No csv file found in the run at this point.") + logger.error(f"No csv file found in the procedure at this point.") def parse_form(self) -> Report: """ @@ -442,11 +451,10 @@ class SubmissionFormWidget(QWidget): report.add_result(report) return report - class InfoItem(QWidget): def __init__(self, parent: QWidget, key: str, value: dict, submission_type: str | SubmissionType | None = None, - run_object: BasicRun | None = None) -> None: + run_object: Run | None = None) -> None: super().__init__(parent) if isinstance(submission_type, str): submission_type = SubmissionType.query(name=submission_type) @@ -492,7 +500,7 @@ class SubmissionFormWidget(QWidget): def set_widget(self, parent: QWidget, key: str, value: dict, submission_type: str | SubmissionType | None = None, - sub_obj: BasicRun | None = None) -> QWidget: + sub_obj: Run | None = None) -> QWidget: """ Creates form widget @@ -515,16 +523,16 @@ class SubmissionFormWidget(QWidget): pass obj = parent.parent().parent() match key: - case 'submitting_lab': + case 'clientlab': add_widget = MyQComboBox(scrollWidget=parent) - # NOTE: lookup organizations suitable for submitting_lab (ctx: self.InfoItem.SubmissionFormWidget.SubmissionFormContainer.AddSubForm ) - labs = [item.name for item in Organization.query()] + # NOTE: lookup organizations suitable for clientlab (ctx: self.InfoItem.SubmissionFormWidget.SubmissionFormContainer.AddSubForm ) + labs = [item.name for item in ClientLab.query()] if isinstance(value, dict): value = value['value'] - if isinstance(value, Organization): + if isinstance(value, ClientLab): value = value.name try: - looked_up_lab = Organization.query(name=value, limit=1) + looked_up_lab = ClientLab.query(name=value, limit=1) except AttributeError: looked_up_lab = None if looked_up_lab: @@ -536,28 +544,28 @@ class SubmissionFormWidget(QWidget): # NOTE: set combobox values to lookedup values add_widget.addItems(labs) add_widget.setToolTip("Select submitting lab.") - case 'extraction_kit': - # NOTE: if extraction kit not available, all other values fail + case 'kittype': + # NOTE: if extraction kittype not available, all other values fail if not check_not_nan(value): - msg = AlertPop(message="Make sure to check your extraction kit in the excel sheet!", + msg = AlertPop(message="Make sure to check your extraction kittype in the excel sheet!", status="warning") msg.exec() # NOTE: create combobox to hold looked up kits add_widget = MyQComboBox(scrollWidget=parent) - # NOTE: lookup existing kits by 'submission_type' decided on by sheetparser + # NOTE: lookup existing kits by 'proceduretype' decided on by sheetparser uses = [item.name for item in submission_type.kit_types] obj.uses = uses if check_not_nan(value): try: uses.insert(0, uses.pop(uses.index(value))) except ValueError: - logger.warning(f"Couldn't find kit in list, skipping move to top of list.") + logger.warning(f"Couldn't find kittype in list, skipping move to top of list.") obj.ext_kit = value else: - logger.error(f"Couldn't find {obj.prsr.sub['extraction_kit']}") + logger.error(f"Couldn't find {obj.prsr.sub['kittype']}") obj.ext_kit = uses[0] add_widget.addItems(uses) - add_widget.setToolTip("Select extraction kit.") + add_widget.setToolTip("Select extraction kittype.") parent.extraction_kit = add_widget.currentText() case 'submission_category': add_widget = MyQComboBox(scrollWidget=parent) @@ -568,7 +576,7 @@ class SubmissionFormWidget(QWidget): except ValueError: categories.insert(0, categories.pop(categories.index(submission_type))) add_widget.addItems(categories) - add_widget.setToolTip("Enter run category or select from list.") + add_widget.setToolTip("Enter procedure category or select from list.") case _: if key in sub_obj.timestamps: add_widget = MyQDateEdit(calendarPopup=True, scrollWidget=parent) @@ -692,10 +700,10 @@ class SubmissionFormWidget(QWidget): wanted_reagent = self.parent.parent().add_reagent(instance=wanted_reagent) return wanted_reagent, report else: - # NOTE: In this case we will have an empty reagent and the run will fail kit integrity check + # NOTE: In this case we will have an empty reagent and the procedure will fail kittype integrity check return None, report else: - # NOTE: Since this now gets passed in directly from the parser -> pyd -> form and the parser gets the name from the db, it should no longer be necessary to query the db with reagent/kit, but with rt name directly. + # NOTE: Since this now gets passed in directly from the clientsubmissionparser -> pydclientsubmission -> form and the clientsubmissionparser gets the name from the db, it should no longer be necessary to query the db with reagent/kittype, but with rt name directly. rt = ReagentRole.query(name=self.reagent.role) if rt is None: rt = ReagentRole.query(kittype=self.extraction_kit, reagent=wanted_reagent) @@ -738,7 +746,7 @@ class SubmissionFormWidget(QWidget): def __init__(self, scrollWidget, reagent, extraction_kit: str) -> None: super().__init__(scrollWidget=scrollWidget) self.setEditable(True) - looked_up_rt = KitTypeReagentRoleAssociation.query(reagentrole=reagent.role, + looked_up_rt = KitTypeReagentRoleAssociation.query(reagentrole=reagent.equipmentrole, kittype=extraction_kit) relevant_reagents = [str(item.lot) for item in looked_up_rt.get_all_relevant_reagents()] # NOTE: if reagent in sheet is not found insert it into the front of relevant reagents so it shows @@ -754,7 +762,8 @@ class SubmissionFormWidget(QWidget): looked_up_reg = None if looked_up_reg: try: - relevant_reagents.insert(0, relevant_reagents.pop(relevant_reagents.index(looked_up_reg.lot))) + relevant_reagents.insert(0, relevant_reagents.pop( + relevant_reagents.index(looked_up_reg.lot))) except ValueError as e: logger.error(f"Error reordering relevant reagents: {e}") else: @@ -764,9 +773,9 @@ class SubmissionFormWidget(QWidget): relevant_reagents.insert(0, moved_reag) else: pass - self.setObjectName(f"lot_{reagent.role}") + self.setObjectName(f"lot_{reagent.equipmentrole}") self.addItems(relevant_reagents) - self.setToolTip(f"Enter lot number for the reagent used for {reagent.role}") + self.setToolTip(f"Enter lot number for the reagent used for {reagent.equipmentrole}") class DisableReagents(QWidget): @@ -783,16 +792,22 @@ class SubmissionFormWidget(QWidget): class ClientSubmissionFormWidget(SubmissionFormWidget): - def __init__(self, parent: QWidget, submission: PydSubmission, disable: list | None = None) -> None: - super().__init__(parent, submission=submission, disable=disable) - self.disabler.setHidden(True) + def __init__(self, parent: QWidget, clientsubmission: PydClientSubmission, samples: List = [], + disable: list | None = None) -> None: + super().__init__(parent, pyd=clientsubmission, disable=disable) + try: + self.disabler.setHidden(True) + except AttributeError: + pass # save_btn = QPushButton("Save") + self.samples = samples + logger.debug(f"Samples: {self.samples}") start_run_btn = QPushButton("Save") # self.layout.addWidget(save_btn) self.layout.addWidget(start_run_btn) start_run_btn.clicked.connect(self.create_new_submission) - + @report_result def parse_form(self) -> Report: """ Transforms form info into PydSubmission @@ -801,7 +816,7 @@ class ClientSubmissionFormWidget(SubmissionFormWidget): Report: Report on status of parse. """ report = Report() - logger.info(f"Hello from client run form parser!") + logger.info(f"Hello from client procedure form parser!") info = {} reagents = [] for widget in self.findChildren(QWidget): @@ -827,18 +842,20 @@ class ClientSubmissionFormWidget(SubmissionFormWidget): report.add_result(report) return report - @report_result + # @report_result def to_pydantic(self, *args): self.parse_form() return self.pyd + @report_result def create_new_submission(self, *args) -> Report: - self.parse_form() - sql = self.pyd.to_sql() + pyd = self.to_pydantic() + sql = pyd.to_sql() + for sample in self.samples: + if isinstance(sample, PydSample): + sample = sample.to_sql() + sql.add_sample(sample=sample) logger.debug(sql.__dict__) sql.save() self.app.table_widget.sub_wid.set_data() self.setParent(None) - - - diff --git a/src/submissions/frontend/widgets/summary.py b/src/submissions/frontend/widgets/summary.py index 3c3fa55..adaec8b 100644 --- a/src/submissions/frontend/widgets/summary.py +++ b/src/submissions/frontend/widgets/summary.py @@ -3,7 +3,7 @@ Pane to hold information e.g. cost summary. """ from .info_tab import InfoPane from PyQt6.QtWidgets import QWidget, QLabel, QPushButton -from backend.db import Organization +from backend.db import ClientLab from backend.excel import ReportMaker from .misc import CheckableComboBox import logging @@ -24,7 +24,7 @@ class Summary(InfoPane): self.org_select = CheckableComboBox() self.org_select.setEditable(False) self.org_select.addItem("Select", header=True) - for org in [org.name for org in Organization.query()]: + for org in [org.name for org in ClientLab.query()]: self.org_select.addItem(org) self.org_select.model().itemChanged.connect(self.update_data) self.layout.addWidget(QLabel("Client"), 1, 0, 1, 1) diff --git a/src/submissions/templates/basicrun_details.html b/src/submissions/templates/run_details.html similarity index 100% rename from src/submissions/templates/basicrun_details.html rename to src/submissions/templates/run_details.html diff --git a/src/submissions/templates/sample_checker.html b/src/submissions/templates/sample_checker.html index 12e887e..821f52d 100644 --- a/src/submissions/templates/sample_checker.html +++ b/src/submissions/templates/sample_checker.html @@ -9,15 +9,22 @@ {% block body %}

Sample Checker


+ {% if rsl_plate_num %} +
+ + + {% endif %} +

Take a moment to verify sample names.


-   Submitter ID              Row           Column
+   Submitter ID
{% for sample in samples %} + {% if rsl_plate_num %}{% endif %} {{ '%02d' % sample['submission_rank'] }} - > - - + + +
{% endfor %}
@@ -30,15 +37,23 @@ document.getElementById("{{ sample['submission_rank'] }}_id").addEventListener("input", function(){ backend.text_changed("{{ sample['submission_rank'] }}", this.name, this.value); }); - document.getElementById("{{ sample['submission_rank'] }}_row").addEventListener("input", function(){ - backend.text_changed("{{ sample['submission_rank'] }}", this.name, this.value); - }); - document.getElementById("{{ sample['submission_rank'] }}_column").addEventListener("input", function(){ - backend.text_changed("{{ sample['submission_rank'] }}", this.name, this.value); + {% if rsl_plate_num %} + document.getElementById("{{ sample['submission_rank'] }}_enabled").addEventListener("input", function(){ + backend.enable_sample("{{ sample['submission_rank'] }}", this.checked); }); + {% endif %} + + + + + + {% endfor %} document.addEventListener('DOMContentLoaded', function() { backend.activate_export(false); }, false); + document.getElementById("rsl_plate_num").addEventListener("input", function(){ + backend.set_rsl_plate_num(this.value); + }); {% endblock %} \ No newline at end of file diff --git a/src/submissions/templates/basicsample_details.html b/src/submissions/templates/sample_details.html similarity index 100% rename from src/submissions/templates/basicsample_details.html rename to src/submissions/templates/sample_details.html diff --git a/src/submissions/tools/__init__.py b/src/submissions/tools/__init__.py index e566ad3..2f0535c 100644 --- a/src/submissions/tools/__init__.py +++ b/src/submissions/tools/__init__.py @@ -30,7 +30,7 @@ from functools import wraps timezone = tz("America/Winnipeg") -logger = logging.getLogger(f"submissions.{__name__}") +logger = logging.getLogger(f"procedure.{__name__}") logger.info(f"Package dir: {project_path}") @@ -41,7 +41,7 @@ else: os_config_dir = ".config" logger.info(f"Got platform {platform.system()}, config_dir: {os_config_dir}") -main_aux_dir = Path.home().joinpath(f"{os_config_dir}/submissions") +main_aux_dir = Path.home().joinpath(f"{os_config_dir}/procedure") CONFIGDIR = main_aux_dir.joinpath("config") LOGDIR = main_aux_dir.joinpath("logs") @@ -343,7 +343,7 @@ class StreamToLogger(object): class CustomLogger(Logger): - def __init__(self, name: str = "submissions", level=logging.DEBUG): + def __init__(self, name: str = "procedure", level=logging.DEBUG): super().__init__(name, level) self.extra_info = None ch = logging.StreamHandler(stream=sys.stdout) @@ -394,7 +394,7 @@ def setup_logger(verbosity: int = 3): return logger.critical("Uncaught exception", exc_info=(exc_type, exc_value, exc_traceback)) - logger = logging.getLogger("submissions") + logger = logging.getLogger("procedure") logger.setLevel(logging.DEBUG) # NOTE: create file handler which logs even debug messages try: @@ -937,7 +937,7 @@ class Settings(BaseSettings, extra="allow"): else: os_config_dir = ".config" # logger.info(f"Got platform {platform.system()}, config_dir: {os_config_dir}") - return Path.home().joinpath(f"{os_config_dir}/submissions") + return Path.home().joinpath(f"{os_config_dir}/procedure") @classproperty def configdir(cls): @@ -955,12 +955,12 @@ class Settings(BaseSettings, extra="allow"): else: settings_path = None if settings_path is None: - # NOTE: Check user .config/submissions directory + # NOTE: Check user .config/procedure directory if cls.configdir.joinpath("config.yml").exists(): settings_path = cls.configdir.joinpath("config.yml") - # NOTE: Check user .submissions directory - elif Path.home().joinpath(".submissions", "config.yml").exists(): - settings_path = Path.home().joinpath(".submissions", "config.yml") + # NOTE: Check user .procedure directory + elif Path.home().joinpath(".procedure", "config.yml").exists(): + settings_path = Path.home().joinpath(".procedure", "config.yml") # NOTE: finally look in the local config else: if check_if_app(): @@ -1275,7 +1275,7 @@ class Settings(BaseSettings, extra="allow"): logger.warning(f"Logging directory {self.configdir} already exists.") dicto = {} for k, v in self.__dict__.items(): - if k in ['package', 'database_session', 'submission_types']: + if k in ['package', 'database_session', 'proceduretype']: continue match v: case Path():