diff --git a/src/submissions/backend/db/models/__init__.py b/src/submissions/backend/db/models/__init__.py index 1a5304e..1bc043b 100644 --- a/src/submissions/backend/db/models/__init__.py +++ b/src/submissions/backend/db/models/__init__.py @@ -18,7 +18,10 @@ from sqlalchemy.exc import ArgumentError from typing import Any, List, ClassVar from pathlib import Path from sqlalchemy.orm.relationships import _RelationshipDeclared + +from frontend import select_save_file from tools import report_result, list_sort_dict +from backend.excel import writers # NOTE: Load testing environment if 'pytest' in sys.modules: @@ -241,6 +244,7 @@ class BaseClass(Base): allowed = [k for k, v in cls.__dict__.items() if isinstance(v, InstrumentedAttribute) or isinstance(v, hybrid_property)] # and not isinstance(v.property, _RelationshipDeclared)] sanitized_kwargs = {k: v for k, v in kwargs.items() if k in allowed} + outside_kwargs = {k: v for k, v in kwargs.items() if k not in allowed} logger.debug(f"Sanitized kwargs: {sanitized_kwargs}") instance = cls.query(**sanitized_kwargs) if not instance or isinstance(instance, list): @@ -258,6 +262,7 @@ class BaseClass(Base): setattr(instance, k, v.to_sql()) else: logger.error(f"Could not set {k} due to {e}") + instance._misc_info.update(outside_kwargs) logger.info(f"Instance from query or create: {instance}, new: {new}") return instance, new @@ -309,10 +314,16 @@ class BaseClass(Base): check = False if check: logger.debug("Got uselist") - query = query.filter(attr.contains(v)) + try: + query = query.filter(attr.contains(v)) + except ArgumentError: + continue else: logger.debug("Single item.") - query = query.filter(attr == v) + try: + query = query.filter(attr == v) + except ArgumentError: + continue if k in singles: logger.warning(f"{k} is in singles. Returning only one value.") limit = 1 @@ -496,7 +507,10 @@ class BaseClass(Base): value = json.dumps(value) except TypeError: value = str(value) - self._misc_info.update({key: value}) + try: + self._misc_info.update({key: value}) + except AttributeError: + self._misc_info = {key: value} return try: field_type = getattr(self.__class__, key) @@ -623,6 +637,17 @@ class BaseClass(Base): if dlg.exec(): pass + def export(self, obj, output_filepath: str|Path|None=None): + if not hasattr(self, "template_file"): + logger.error(f"Export not implemented for {self.__class__.__name__}") + return + pyd = self.to_pydantic() + if not output_filepath: + output_filepath = select_save_file(obj=obj, default_name=pyd.construct_filename(), extension="xlsx") + Writer = getattr(writers, f"{self.__class__.__name__}Writer") + writer = Writer(output_filepath=output_filepath, pydant_obj=pyd, range_dict=self.range_dict) + workbook = writer + class LogMixin(Base): tracking_exclusion: ClassVar = ['artic_technician', 'clientsubmissionsampleassociation', diff --git a/src/submissions/backend/db/models/submissions.py b/src/submissions/backend/db/models/submissions.py index f93dc88..10696c7 100644 --- a/src/submissions/backend/db/models/submissions.py +++ b/src/submissions/backend/db/models/submissions.py @@ -15,6 +15,8 @@ from operator import itemgetter from pprint import pformat from pandas import DataFrame from sqlalchemy.ext.hybrid import hybrid_property + +from frontend import select_save_file from . import Base, BaseClass, Reagent, SubmissionType, KitType, ClientLab, Contact, LogMixin, Procedure, kittype_procedure from sqlalchemy import Column, String, TIMESTAMP, INTEGER, ForeignKey, JSON, FLOAT, case, func, Table, Sequence from sqlalchemy.orm import relationship, validates, Query @@ -158,6 +160,14 @@ class ClientSubmission(BaseClass, LogMixin): offset = None return cls.execute_query(query=query, limit=limit, offset=offset, **kwargs) + @property + def template_file(self): + return self.submissiontype.template_file + + @property + def range_dict(self): + return self.submissiontype.info_map + @classmethod def submissions_to_df(cls, submissiontype: str | None = None, limit: int = 0, chronologic: bool = True, page: int = 1, page_size: int = 250) -> pd.DataFrame: @@ -318,12 +328,12 @@ class ClientSubmission(BaseClass, LogMixin): logger.debug(f"Sample: {sample.id}") if sample not in run.sample: assoc = run.add_sample(sample) - assoc.save() + # assoc.save() + run.save() else: logger.warning("Run cancelled.") obj.set_data() - def edit(self, obj): logger.debug("Edit") @@ -352,6 +362,9 @@ class ClientSubmission(BaseClass, LogMixin): output['expanded'] = ["clientlab", "contact", "submissiontype"] return output + def to_pydantic(self, filepath: Path|str|None=None, **kwargs): + output = super().to_pydantic(filepath=filepath, **kwargs) + return output class Run(BaseClass, LogMixin): """ @@ -1266,6 +1279,15 @@ class Run(BaseClass, LogMixin): self.set_attribute(key='comment', value=comment) self.save(original=False) + def export(self, obj, output_filepath: str|Path|None=None): + + clientsubmission_pyd = self.clientsubmission.to_pydantic() + if not output_filepath: + output_filepath = select_save_file(obj=obj, default_name=clientsubmission_pyd.construct_filename(), extension="xlsx") + Writer = getattr(writers, "ClientSubmissionWriter") + writer = Writer(output_filepath=output_filepath, pydant_obj=pyd, range_dict=self.range_dict) + workbook = writer. + def backup(self, obj=None, fname: Path | None = None, full_backup: bool = False): """ Exports xlsx info files for this instance. @@ -1890,8 +1912,8 @@ class RunSampleAssociation(BaseClass): # id = Column(INTEGER, unique=True, nullable=False) #: id to be used for inheriting purposes sample_id = Column(INTEGER, ForeignKey("_sample.id"), primary_key=True) #: id of associated sample run_id = Column(INTEGER, ForeignKey("_run.id"), primary_key=True) #: id of associated procedure - row = Column(INTEGER) #: row on the 96 well plate - column = Column(INTEGER) #: column on the 96 well plate + # row = Column(INTEGER) #: row on the 96 well plate + # column = Column(INTEGER) #: column on the 96 well plate # misc_info = Column(JSON) # NOTE: reference to the Submission object diff --git a/src/submissions/backend/excel/parsers/__init__.py b/src/submissions/backend/excel/parsers/__init__.py index 7e5db59..463020b 100644 --- a/src/submissions/backend/excel/parsers/__init__.py +++ b/src/submissions/backend/excel/parsers/__init__.py @@ -5,6 +5,8 @@ from __future__ import annotations import logging, re from pathlib import Path from typing import Generator, Tuple, TYPE_CHECKING + +from openpyxl.reader.excel import load_workbook from pandas import DataFrame from backend.validators import pydant if TYPE_CHECKING: @@ -78,8 +80,6 @@ class DefaultKEYVALUEParser(DefaultParser): sheet="Sample List" )] - - @property def parsed_info(self) -> Generator[Tuple, None, None]: for item in self.range_dict: @@ -91,7 +91,10 @@ class DefaultKEYVALUEParser(DefaultParser): key = re.sub(r"\(.*\)", "", key) key = key.lower().replace(":", "").strip().replace(" ", "_") value = item['worksheet'].cell(row, item['value_column']).value - value = dict(value=value, missing=False if value else True) + missing = False if value else True + location_map = dict(row=row, key_column=item['key_column'], value_column=item['value_column'], sheet=item['sheet']) + value = dict(value=value, location=location_map, missing=missing) + logger.debug(f"Yieldings {value} for {key}") yield key, value @@ -126,5 +129,5 @@ class DefaultTABLEParser(DefaultParser): def to_pydantic(self, **kwargs): return [self._pyd_object(**output) for output in self.parsed_info] -from .clientsubmission_parser import * +from .clientsubmission_parser import ClientSubmissionSampleParser, ClientSubmissionInfoParser from backend.excel.parsers.results_parsers.pcr_results_parser import PCRInfoParser, PCRSampleParser diff --git a/src/submissions/backend/excel/parsers/clientsubmission_parser.py b/src/submissions/backend/excel/parsers/clientsubmission_parser.py index ac96e9e..2a02e40 100644 --- a/src/submissions/backend/excel/parsers/clientsubmission_parser.py +++ b/src/submissions/backend/excel/parsers/clientsubmission_parser.py @@ -96,8 +96,6 @@ class ClientSubmissionInfoParser(DefaultKEYVALUEParser, SubmissionTyperMixin): # TODO: check if run with name already exists add_run = QuestionAsker(title="Add Run?", message="We've detected a sheet corresponding to an associated procedure type.\nWould you like to add a new run?") if add_run.accepted: - - # NOTE: recruit parser. try: manager = getattr(procedure_managers, name) diff --git a/src/submissions/backend/validators/pydant.py b/src/submissions/backend/validators/pydant.py index 36bbdcf..f84cb10 100644 --- a/src/submissions/backend/validators/pydant.py +++ b/src/submissions/backend/validators/pydant.py @@ -27,6 +27,7 @@ logger = logging.getLogger(f"procedure.{__name__}") class PydBaseClass(BaseModel, extra='allow', validate_assignment=True): _sql_object: ClassVar = None + # _misc_info: dict|None = None @model_validator(mode="before") @classmethod @@ -67,6 +68,10 @@ class PydBaseClass(BaseModel, extra='allow', validate_assignment=True): def __init__(self, **data): # NOTE: Grab the sql model for validation purposes. self.__class__._sql_object = getattr(models, self.__class__.__name__.replace("Pyd", "")) + # try: + # self.template_file = self.__class__._sql_object.template_file + # except AttributeError: + # pass super().__init__(**data) def filter_field(self, key: str) -> Any: @@ -105,6 +110,12 @@ class PydBaseClass(BaseModel, extra='allow', validate_assignment=True): output = {k: getattr(self, k) for k in fields} else: output = {k: self.filter_field(k) for k in fields} + if hasattr(self, "misc_info") and "info_placement" in self.misc_info: + for k, v in output.items(): + try: + output[k]['location'] = [item['location'] for item in self.misc_info['info_placement'] if item['name'] == k] + except (TypeError, KeyError): + continue return output def to_sql(self): @@ -301,6 +312,7 @@ class PydSample(PydBaseClass): sql._misc_info["submission_rank"] = self.submission_rank return sql + class PydTips(BaseModel): name: str lot: str | None = Field(default=None) @@ -1662,7 +1674,7 @@ class PydProcedure(PydBaseClass, arbitrary_types_allowed=True): class PydClientSubmission(PydBaseClass): # sql_object: ClassVar = ClientSubmission - filepath: Path + filepath: Path | None = Field(default=None) submissiontype: dict | None submitted_date: dict | None = Field(default=dict(value=date.today(), missing=True), validate_default=True) clientlab: dict | None @@ -1673,6 +1685,39 @@ class PydClientSubmission(PydBaseClass): contact: dict | None = Field(default=dict(value=None, missing=True), validate_default=True) submitter_plate_id: dict | None = Field(default=dict(value=None, missing=True), validate_default=True) + @field_validator("submitted_date", mode="before") + @classmethod + def enforce_submitted_date(cls, value): + match value: + case str(): + value = dict(value=datetime.strptime(value, "%Y-%m-%d %H:%M:%S"), missing=False) + case date() | datetime(): + value = dict(value=value, missing=False) + case _: + pass + return value + + @field_validator("submitter_plate_id", mode="before") + @classmethod + def enforce_submitter_plate_id(cls, value): + if isinstance(value, str): + value = dict(value=value, missing=False) + return value + + @field_validator("submission_category", mode="before") + @classmethod + def enforce_submission_category_id(cls, value): + if isinstance(value, str): + value = dict(value=value, missing=False) + return value + + @field_validator("sample_count", mode="before") + @classmethod + def enforce_sample_count(cls, value): + if isinstance(value, str) or isinstance(value, int): + value = dict(value=value, missing=False) + return value + @field_validator("sample_count") @classmethod def enforce_integer(cls, value): @@ -1700,7 +1745,7 @@ class PydClientSubmission(PydBaseClass): except TypeError: check = True if check: - return dict(value=date.today(), missing=True) + value.update(dict(value=date.today(), missing=True)) else: match value['value']: case str(): @@ -1735,6 +1780,29 @@ class PydClientSubmission(PydBaseClass): from frontend.widgets.submission_widget import ClientSubmissionFormWidget return ClientSubmissionFormWidget(parent=parent, clientsubmission=self, samples=samples, disable=disable) + def to_sql(self): + sql = super().to_sql() + if "info_placement" not in sql._misc_info: + sql._misc_info['info_placement'] = [] + info_placement = [] + for k in list(self.model_fields.keys()) + list(self.model_extra.keys()): + attribute = getattr(self, k) + match k: + case "filepath": + sql._misc_info[k] = attribute.__str__() + continue + case _: + pass + logger.debug(f"Setting {k} to {attribute}") + if isinstance(attribute, dict): + if "location" in attribute: + info_placement.append(dict(name=k, location=attribute['location'])) + else: + info_placement.append(dict(name=k, location=None)) + max_row = max([value['location']['row'] for value in info_placement if value]) + sql._misc_info['info_placement'] = info_placement + return sql + class PydResults(PydBaseClass, arbitrary_types_allowed=True): results: dict = Field(default={}) diff --git a/src/submissions/frontend/widgets/sample_checker.py b/src/submissions/frontend/widgets/sample_checker.py index 62e7669..0dfc948 100644 --- a/src/submissions/frontend/widgets/sample_checker.py +++ b/src/submissions/frontend/widgets/sample_checker.py @@ -35,8 +35,8 @@ class SampleChecker(QDialog): self.channel = QWebChannel() self.channel.registerObject('backend', self) # NOTE: Used to maintain javascript functions. - template = env.get_template("sample_checker.html") - template_path = Path(template.environment.loader.__getattribute__("searchpath")[0]) + # template = env.get_template("sample_checker.html") + # template_path = Path(template.environment.loader.__getattribute__("searchpath")[0]) # with open(template_path.joinpath("css", "styles.css"), "r") as f: # css = [f.read()] try: diff --git a/src/submissions/frontend/widgets/submission_widget.py b/src/submissions/frontend/widgets/submission_widget.py index 2a4163f..49282d8 100644 --- a/src/submissions/frontend/widgets/submission_widget.py +++ b/src/submissions/frontend/widgets/submission_widget.py @@ -472,6 +472,10 @@ class SubmissionFormWidget(QWidget): self.missing: bool = value['missing'] except (TypeError, KeyError): self.missing: bool = True + try: + self.location: dict|None = value['location'] + except (TypeError, KeyError): + self.location: dict|None = None if self.input is not None: layout.addWidget(self.label) layout.addWidget(self.input) @@ -501,7 +505,7 @@ class SubmissionFormWidget(QWidget): value = self.input.date().toPyDate() case _: return None, None - return self.input.objectName(), dict(value=value, missing=self.missing) + return self.input.objectName(), dict(value=value, missing=self.missing, location=self.location) def set_widget(self, parent: QWidget, key: str, value: dict, submission_type: str | SubmissionType | None = None, @@ -518,6 +522,7 @@ class SubmissionFormWidget(QWidget): Returns: QWidget: Form object """ + if isinstance(submission_type, str): submission_type = SubmissionType.query(name=submission_type) if sub_obj is None: @@ -843,6 +848,7 @@ class ClientSubmissionFormWidget(SubmissionFormWidget): value = getattr(self, item) info[item] = value for k, v in info.items(): + logger.debug(f"Setting pyd {k} to {v}") self.pyd.__setattr__(k, v) report.add_result(report) return report diff --git a/src/submissions/templates/bacterialculture_details.html b/src/submissions/templates/bacterialculture_details.html deleted file mode 100644 index 193b606..0000000 --- a/src/submissions/templates/bacterialculture_details.html +++ /dev/null @@ -1,28 +0,0 @@ -{% extends "basicsubmission_details.html" %} - - - {% block head %} - {{ super() }} - {% endblock %} - - - - {% block body %} - {{ super() }} - {% if sub['controls'] %} -

Attached Controls:

- {% for item in sub['controls'] %} -

   {{ item['name'] }}: {{ item['type'] }} (Targets: {{ item['targets'] }})

- {% if item['kraken'] %} -

   {{ item['name'] }} Top 10 Kraken Results:

-

{% for genera in item['kraken'] %} -         {{ genera['name'] }}: {{ genera['kraken_count'] }} ({{ genera['kraken_percent'] }})
- {% endfor %}

- {% endif %} - {% endfor %} - {% endif %} - {% endblock %} - {% block signing_button %} - {{ super() }} - {% endblock %} - \ No newline at end of file diff --git a/src/submissions/templates/equipment_details.html b/src/submissions/templates/equipment_details.html index 34906c3..59bdbe1 100644 --- a/src/submissions/templates/equipment_details.html +++ b/src/submissions/templates/equipment_details.html @@ -10,12 +10,8 @@

Equipment Details for {{ equipment['name'] }}

{{ super() }}

{% for key, value in equipment.items() if key not in equipment['excluded'] %} -     {{ key | replace("_", " ") | title }}: {{ value }}
{% endfor %}

- - - {% if equipment['submissions'] %}

Submissions:

{% for submission in equipment['submissions'] %}

{{ submission['plate'] }}: {{ submission['process'] }}

diff --git a/src/submissions/templates/support/tooltip.html b/src/submissions/templates/support/tooltip.html index 285d2c4..c19de75 100644 --- a/src/submissions/templates/support/tooltip.html +++ b/src/submissions/templates/support/tooltip.html @@ -1,4 +1,4 @@ Sample name: {{ fields['submitter_id'] }}
{% if fields['organism'] %}Organism: {{ fields['organism'] }}
{% endif %} {% if fields['concentration'] %}Concentration: {{ fields['concentration'] }}
{% endif %} -Well: {{ fields['well'] }} \ No newline at end of file +Well: {{ fields['well'] }} \ No newline at end of file diff --git a/src/submissions/templates/wastewater_details.html b/src/submissions/templates/wastewater_details.html deleted file mode 100644 index 5f0350f..0000000 --- a/src/submissions/templates/wastewater_details.html +++ /dev/null @@ -1,37 +0,0 @@ -{% extends "basicsubmission_details.html" %} - - - {% block head %} - {{ super() }} - {% endblock %} - - - - {% block body %} - {{ super() }} - {% if sub['pcr_info'] %} - {% for entry in sub['pcr_info'] %} - {% if 'comment' not in entry.keys() %} -

qPCR Momentum Status:

- {% else %} -

qPCR Status:

- {% endif %} -

{% for key, value in entry.items() if key != 'imported_by'%} - {% if "column" in key %} -     {{ key|replace('_', ' ')|title() }}: {{ value }}uL
- {% else %} -     {{ key|replace('_', ' ')|title() }}: {{ value }}
- {% endif %} - {% endfor %}

- {% endfor %} - {% endif %} - {% if sub['origin_plate'] %} -
-

24 Well Plate:

- {{ sub['origin_plate'] }} - {% endif %} - {% endblock %} - {% block signing_button %} - {{ super() }} - {% endblock %} - diff --git a/src/submissions/templates/wastewaterartic_details.html b/src/submissions/templates/wastewaterartic_details.html deleted file mode 100644 index 52e5c2f..0000000 --- a/src/submissions/templates/wastewaterartic_details.html +++ /dev/null @@ -1,41 +0,0 @@ -{% extends "basicsubmission_details.html" %} - - - {% block head %} - {{ super() }} - {% endblock %} - - - - {% block body %} - {{ super() }} - {% if sub['gel_info'] %} -
-

Gel Box:

- {% if sub['gel_image_actual'] %} -
- - {% endif %} -
- - - {% for header in sub['headers'] %} - - {% endfor %} - - {% for field in sub['gel_info'] %} - - - {% for item in field['values'] %} - - {% endfor %} - - {% endfor %} -
{{ header }}
{{ field['name'] }}{{ item['value'] }}
-
- {% endif %} - {% endblock %} - {% block signing_button %} - {{ super() }} - {% endblock %} -