diff --git a/src/submissions/backend/db/__init__.py b/src/submissions/backend/db/__init__.py index 582ee86..8a73e24 100644 --- a/src/submissions/backend/db/__init__.py +++ b/src/submissions/backend/db/__init__.py @@ -24,8 +24,8 @@ def set_sqlite_pragma(dbapi_connection, connection_record): if ctx.database_schema == "sqlite": execution_phrase = "PRAGMA foreign_keys=ON" # cursor.execute(execution_phrase) - elif ctx.database_schema == "mssql+pyodbc": - execution_phrase = "SET IDENTITY_INSERT dbo._wastewater ON;" + # elif ctx.database_schema == "mssql+pyodbc": + # execution_phrase = "SET IDENTITY_INSERT dbo._wastewater ON;" else: print("Nothing to execute, returning") cursor.close() diff --git a/src/submissions/backend/db/models/kits.py b/src/submissions/backend/db/models/kits.py index 5539b39..d0daf54 100644 --- a/src/submissions/backend/db/models/kits.py +++ b/src/submissions/backend/db/models/kits.py @@ -261,9 +261,9 @@ class KitType(BaseClass): Returns: dict: Dictionary containing relevant info for SubmissionType construction """ - base_dict = dict(name=self.name) - base_dict['reagent roles'] = [] - base_dict['equipment roles'] = [] + base_dict = dict(name=self.name, reagent_roles=[], equipment_roles=[]) + # base_dict['reagent roles'] = [] + # base_dict['equipment roles'] = [] for k, v in self.construct_xl_map_for_use(submission_type=submission_type): # logger.debug(f"Value: {v}") try: @@ -272,17 +272,17 @@ class KitType(BaseClass): continue for kk, vv in assoc.to_export_dict().items(): v[kk] = vv - base_dict['reagent roles'].append(v) + base_dict['reagent_roles'].append(v) for k, v in submission_type.construct_field_map("equipment"): try: assoc = next(item for item in submission_type.submissiontype_equipmentrole_associations if item.equipment_role.name == k) except StopIteration: continue - for kk, vv in assoc.to_export_dict(kit_type=self).items(): + for kk, vv in assoc.to_export_dict(extraction_kit=self).items(): # logger.debug(f"{kk}:{vv}") v[kk] = vv - base_dict['equipment roles'].append(v) + base_dict['equipment_roles'].append(v) # logger.debug(f"KT returning {base_dict}") return base_dict @@ -360,12 +360,12 @@ class ReagentRole(BaseClass): assert reagent.role # logger.debug(f"Looking up reagent type for {type(kit_type)} {kit_type} and {type(reagent)} {reagent}") # logger.debug(f"Kit reagent types: {kit_type.reagent_types}") - result = list(set(kit_type.reagent_roles).intersection(reagent.role)) + result = set(kit_type.reagent_roles).intersection(reagent.role) # logger.debug(f"Result: {result}") - try: - return result[0] - except IndexError: - return None + # try: + return next((item for item in result), None) + # except IndexError: + # return None match name: case str(): # logger.debug(f"Looking up reagent type by name str: {name}") @@ -445,12 +445,8 @@ class Reagent(BaseClass, LogMixin): if extraction_kit 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]) - # try: - # reagent_role = list(set(self.role).intersection(extraction_kit.reagent_roles))[0] - # # NOTE: Most will be able to fall back to first ReagentType in itself because most will only have 1. - # except: - # reagent_role = self.role[0] + reagent_role = next((item for item in set(self.role).intersection(extraction_kit.reagent_roles)), + self.role[0]) else: try: reagent_role = self.role[0] @@ -580,11 +576,14 @@ class Reagent(BaseClass, LogMixin): for key, value in vars.items(): match key: case "expiry": - if not isinstance(value, date): - field_value = datetime.strptime(value, "%Y-%m-%d").date - field_value.replace(tzinfo=timezone) + if isinstance(value, str): + field_value = datetime.strptime(value, "%Y-%m-%d") + # field_value.replace(tzinfo=timezone) + elif isinstance(value, date): + field_value = datetime.combine(value, datetime.min.time()) else: field_value = value + field_value.replace(tzinfo=timezone) case "role": continue case _: @@ -792,6 +791,15 @@ class SubmissionType(BaseClass): return self.sample_map 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"submissiontype_{field}role_associations"): fmap = item.uses if fmap is None: @@ -799,6 +807,12 @@ class SubmissionType(BaseClass): yield getattr(item, f"{field}_role").name, fmap def get_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: @@ -1379,7 +1393,7 @@ class Equipment(BaseClass): else: return {k: v for k, v in self.__dict__.items()} - def get_processes(self, submission_type: SubmissionType, extraction_kit: str | KitType | None = None) -> List[str]: + def get_processes(self, submission_type: str | SubmissionType | None = None, extraction_kit: str | KitType | None = None) -> List[str]: """ Get all processes associated with this Equipment for a given SubmissionType @@ -1390,23 +1404,35 @@ class Equipment(BaseClass): Returns: List[Process]: List of process names """ - processes = [process for process in self.processes if submission_type in process.submission_types] - match extraction_kit: - case str(): - # logger.debug(f"Filtering processes by extraction_kit str {extraction_kit}") - processes = [process for process in processes if - extraction_kit in [kit.name for kit in process.kit_types]] - case KitType(): - # logger.debug(f"Filtering processes by extraction_kit KitType {extraction_kit}") - processes = [process for process in processes if extraction_kit in process.kit_types] - case _: - pass - # NOTE: Convert to strings - processes = [process.name for process in processes] - assert all([isinstance(process, str) for process in processes]) - if len(processes) == 0: - processes = [''] - return processes + if isinstance(submission_type, str): + submission_type = SubmissionType.query(name=submission_type) + if isinstance(extraction_kit, str): + extraction_kit = KitType.query(name=extraction_kit) + for process in self.processes: + if submission_type not in process.submission_types: + continue + if extraction_kit and extraction_kit not in process.kit_types: + continue + yield process + # processes = (process for process in self.processes if submission_type in process.submission_types) + # match extraction_kit: + # case str(): + # # logger.debug(f"Filtering processes by extraction_kit str {extraction_kit}") + # processes = (process for process in processes if + # extraction_kit in [kit.name for kit in process.kit_types]) + # case KitType(): + # # logger.debug(f"Filtering processes by extraction_kit KitType {extraction_kit}") + # processes = (process for process in processes if extraction_kit in process.kit_types) + # case _: + # pass + # # NOTE: Convert to strings + # # processes = [process.name for process in processes] + # # assert all([isinstance(process, str) for process in processes]) + # # if len(processes) == 0: + # # processes = [''] + # # return processes + # for process in processes: + # yield process.name @classmethod @setup_lookup @@ -1452,8 +1478,7 @@ class Equipment(BaseClass): pass return cls.execute_query(query=query, limit=limit) - def to_pydantic(self, submission_type: SubmissionType, - extraction_kit: str | KitType | None = None, + def to_pydantic(self, submission_type: SubmissionType, extraction_kit: str | KitType | None = None, role: str = None) -> "PydEquipment": """ Creates PydEquipment of this Equipment @@ -1466,8 +1491,8 @@ class Equipment(BaseClass): PydEquipment: pydantic equipment object """ from backend.validators.pydant import PydEquipment - return PydEquipment( - processes=self.get_processes(submission_type=submission_type, extraction_kit=extraction_kit), role=role, + processes = self.get_processes(submission_type=submission_type, extraction_kit=extraction_kit) + return PydEquipment(processes=processes, role=role, **self.to_dict(processes=False)) @classmethod @@ -1603,7 +1628,7 @@ class EquipmentRole(BaseClass): return cls.execute_query(query=query, limit=limit) def get_processes(self, submission_type: str | SubmissionType | None, - extraction_kit: str | KitType | None = None) -> List[Process]: + extraction_kit: str | KitType | None = None) -> Generator[Process, None, None]: """ Get processes used by this EquipmentRole @@ -1612,30 +1637,38 @@ class EquipmentRole(BaseClass): extraction_kit (str | KitType | None, optional): KitType of interest. Defaults to None. Returns: - List[Process]: _description_ + List[Process]: List of processes """ if isinstance(submission_type, str): # logger.debug(f"Checking if str {submission_type} exists") submission_type = SubmissionType.query(name=submission_type) - if submission_type is not None: - # logger.debug("Getting all processes for this EquipmentRole") - processes = [process for process in self.processes if submission_type in process.submission_types] - else: - processes = self.processes - match extraction_kit: - case str(): - # logger.debug(f"Filtering processes by extraction_kit str {extraction_kit}") - processes = [item for item in processes if extraction_kit in [kit.name for kit in item.kit_types]] - case KitType(): - # logger.debug(f"Filtering processes by extraction_kit KitType {extraction_kit}") - processes = [item for item in processes if extraction_kit in [kit for kit in item.kit_types]] - case _: - pass - output = [item.name for item in processes] - if len(output) == 0: - return [''] - else: - return output + if isinstance(extraction_kit, str): + extraction_kit = KitType.query(name=extraction_kit) + for process in self.processes: + if submission_type and submission_type not in process.submission_types: + continue + if extraction_kit and extraction_kit not in process.kit_types: + continue + yield process.name + # if submission_type is not None: + # # logger.debug("Getting all processes for this EquipmentRole") + # processes = [process for process in self.processes if submission_type in process.submission_types] + # else: + # processes = self.processes + # match extraction_kit: + # case str(): + # # logger.debug(f"Filtering processes by extraction_kit str {extraction_kit}") + # processes = [item for item in processes if extraction_kit in [kit.name for kit in item.kit_types]] + # case KitType(): + # # logger.debug(f"Filtering processes by extraction_kit KitType {extraction_kit}") + # processes = [item for item in processes if extraction_kit in [kit for kit in item.kit_types]] + # case _: + # pass + # output = [item.name for item in processes] + # if len(output) == 0: + # return [''] + # else: + # return output def to_export_dict(self, submission_type: SubmissionType, kit_type: KitType): """ @@ -1644,8 +1677,8 @@ class EquipmentRole(BaseClass): Returns: dict: dictionary of Association and related reagent role """ - return dict(role=self.name, - processes=self.get_processes(submission_type=submission_type, extraction_kit=kit_type)) + processes = self.get_processes(submission_type=submission_type, extraction_kit=kit_type) + return dict(role=self.name, processes=[item for item in processes]) class SubmissionEquipmentAssociation(BaseClass): diff --git a/src/submissions/backend/db/models/submissions.py b/src/submissions/backend/db/models/submissions.py index 13f2904..e18121b 100644 --- a/src/submissions/backend/db/models/submissions.py +++ b/src/submissions/backend/db/models/submissions.py @@ -1074,6 +1074,7 @@ class BasicSubmission(BaseClass, LogMixin): @setup_lookup def query(cls, submission_type: str | SubmissionType | None = None, + submission_type_name: str|None = None, id: int | str | None = None, rsl_plate_num: str | None = None, start_date: date | str | int | None = None, @@ -1173,6 +1174,11 @@ class BasicSubmission(BaseClass, LogMixin): limit = 1 case _: pass + match submission_type_name: + case str(): + query = query.filter(model.submission_type_name == submission_type_name) + case _: + pass # NOTE: by id (returns only a single value) match id: case int(): @@ -1389,13 +1395,23 @@ class BasicSubmission(BaseClass, LogMixin): @classmethod def calculate_turnaround(cls, start_date:date|None=None, end_date:date|None=None) -> Tuple[int|None, bool|None]: + if 'pytest' not in sys.modules: + from tools import ctx + else: + from test_settings import ctx if not end_date: return None, None try: delta = np.busday_count(start_date, end_date, holidays=create_holidays_for_year(start_date.year)) + 1 except ValueError: return None, None - return delta, delta <= ctx.TaT_threshold + try: + tat = cls.get_default_info("turnaround_time") + except (AttributeError, KeyError): + tat = None + if not tat: + tat = ctx.TaT_threshold + return delta, delta <= tat # Below are the custom submission types diff --git a/src/submissions/backend/excel/reports.py b/src/submissions/backend/excel/reports.py index 70ae2b2..44d88b4 100644 --- a/src/submissions/backend/excel/reports.py +++ b/src/submissions/backend/excel/reports.py @@ -142,18 +142,18 @@ class ReportMaker(object): class TurnaroundMaker(object): - def __init__(self, start_date: date, end_date: date): + def __init__(self, start_date: date, end_date: date, submission_type:str): self.start_date = start_date self.end_date = end_date # NOTE: Set page size to zero to override limiting query size. - self.subs = BasicSubmission.query(start_date=start_date, end_date=end_date, page_size=0) + self.subs = BasicSubmission.query(start_date=start_date, end_date=end_date, submission_type_name=submission_type, page_size=0) records = [self.build_record(sub) for sub in self.subs] self.df = DataFrame.from_records(records) @classmethod def build_record(cls, sub): days, tat_ok = sub.get_turnaround_time() - return dict(name=sub.rsl_plate_num, days=days, submitted_date=sub.submitted_date, + return dict(name=str(sub.rsl_plate_num), days=days, submitted_date=sub.submitted_date, completed_date=sub.completed_date, acceptable=tat_ok) def write_report(self, filename: Path | str, obj: QWidget | None = None): diff --git a/src/submissions/backend/validators/pydant.py b/src/submissions/backend/validators/pydant.py index daf51a9..82f3f24 100644 --- a/src/submissions/backend/validators/pydant.py +++ b/src/submissions/backend/validators/pydant.py @@ -9,6 +9,7 @@ from datetime import date, datetime, timedelta from dateutil.parser import parse from dateutil.parser import ParserError from typing import List, Tuple, Literal +from types import GeneratorType from . import RSLNamer from pathlib import Path from tools import check_not_nan, convert_nans_to_nones, Report, Result, timezone @@ -343,6 +344,8 @@ class PydEquipment(BaseModel, extra='ignore'): @classmethod def make_empty_list(cls, value): # logger.debug(f"Pydantic value: {value}") + if isinstance(value, GeneratorType): + value = [item.name for item in value] value = convert_nans_to_nones(value) if not value: value = [''] @@ -380,6 +383,7 @@ class PydEquipment(BaseModel, extra='ignore'): assoc = None if assoc is None: assoc = SubmissionEquipmentAssociation(submission=submission, equipment=equipment) + # TODO: This seems precarious. What if there is more than one process? process = Process.query(name=self.processes[0]) if process is None: logger.error(f"Found unknown process: {process}.") @@ -1122,6 +1126,13 @@ class PydEquipmentRole(BaseModel): equipment: List[PydEquipment] processes: List[str] | None + @field_validator("processes", mode="before") + @classmethod + def expand_processes(cls, value): + if isinstance(value, GeneratorType): + value = [item for item in value] + return value + def to_form(self, parent, used: list) -> "RoleComboBox": """ Creates a widget for user input into this class. diff --git a/src/submissions/frontend/visualizations/turnaround_chart.py b/src/submissions/frontend/visualizations/turnaround_chart.py index 24bdb06..e1b8d21 100644 --- a/src/submissions/frontend/visualizations/turnaround_chart.py +++ b/src/submissions/frontend/visualizations/turnaround_chart.py @@ -11,25 +11,33 @@ logger = logging.getLogger(f"submissions.{__name__}") class TurnaroundChart(CustomFigure): - def __init__(self, df: pd.DataFrame, modes: list, settings: dict, ytitle: str | None = None, + def __init__(self, df: pd.DataFrame, modes: list, settings: dict, threshold: float | None = None, + ytitle: str | None = None, parent: QWidget | None = None, months: int = 6): super().__init__(df=df, modes=modes, settings=settings) + self.df = df try: months = int(settings['months']) except KeyError: months = 6 # logger.debug(f"DF: {self.df}") - self.construct_chart(df=df) - self.add_hline(y=3.5) + self.construct_chart() + if threshold: + self.add_hline(y=threshold) # self.update_xaxes() self.update_layout(showlegend=False) - def construct_chart(self, df: pd.DataFrame): + def construct_chart(self, df: pd.DataFrame | None = None): + if df: + self.df = df # logger.debug(f"PCR df:\n {df}") - df = df.sort_values(by=['submitted_date', 'name']) + self.df = self.df[self.df.days.notnull()] + self.df = self.df.sort_values(['submitted_date', 'name'], ascending=[True, True]).reset_index(drop=True) + self.df = self.df.reset_index().rename(columns={"index": "idx"}) + # logger.debug(f"DF: {self.df}") try: - scatter = px.scatter(data_frame=df, x='name', y="days", + scatter = px.scatter(data_frame=self.df, x='idx', y="days", hover_data=["name", "submitted_date", "completed_date", "days"], color="acceptable", color_discrete_map={True: "green", False: "red"} ) @@ -37,3 +45,12 @@ class TurnaroundChart(CustomFigure): scatter = px.scatter() self.add_traces(scatter.data) self.update_traces(marker={'size': 15}) + tickvals = self.df['idx'].tolist() + ticklabels = self.df['name'].tolist() + self.update_layout( + xaxis=dict( + tickmode='array', + tickvals=tickvals, + ticktext=ticklabels, + ) + ) diff --git a/src/submissions/frontend/widgets/submission_details.py b/src/submissions/frontend/widgets/submission_details.py index b98d4fe..8693363 100644 --- a/src/submissions/frontend/widgets/submission_details.py +++ b/src/submissions/frontend/widgets/submission_details.py @@ -8,7 +8,7 @@ from PyQt6.QtWebChannel import QWebChannel from PyQt6.QtCore import Qt, pyqtSlot from jinja2 import TemplateNotFound from backend.db.models import BasicSubmission, BasicSample, Reagent, KitType -from tools import is_power_user, jinja_template_loading +from tools import is_power_user, jinja_template_loading, timezone from .functions import select_save_file from .misc import save_pdf from pathlib import Path @@ -176,7 +176,8 @@ class SubmissionDetails(QDialog): if isinstance(submission, str): submission = BasicSubmission.query(rsl_plate_num=submission) submission.signed_by = getuser() - submission.completed_date = datetime.now().date() + submission.completed_date = datetime.now() + submission.completed_date.replace(tzinfo=timezone) submission.save() self.submission_details(submission=self.rsl_plate_num) diff --git a/src/submissions/frontend/widgets/turnaround.py b/src/submissions/frontend/widgets/turnaround.py index 9768651..cd59628 100644 --- a/src/submissions/frontend/widgets/turnaround.py +++ b/src/submissions/frontend/widgets/turnaround.py @@ -1,10 +1,10 @@ from PyQt6.QtCore import QSignalBlocker from PyQt6.QtWebEngineWidgets import QWebEngineView -from PyQt6.QtWidgets import QWidget, QGridLayout, QPushButton, QLabel +from PyQt6.QtWidgets import QWidget, QGridLayout, QPushButton, QLabel, QComboBox from .info_tab import InfoPane from backend.excel.reports import TurnaroundMaker from pandas import DataFrame -from backend.db import BasicSubmission +from backend.db import BasicSubmission, SubmissionType from frontend.visualizations.turnaround_chart import TurnaroundChart import logging @@ -17,6 +17,11 @@ class TurnaroundTime(InfoPane): super().__init__(parent) self.chart = None self.report_object = None + self.submission_typer = QComboBox(self) + subs = ["Any"] + [item.name for item in SubmissionType.query()] + self.submission_typer.addItems(subs) + self.layout.addWidget(self.submission_typer, 1, 1, 1, 3) + self.submission_typer.currentTextChanged.connect(self.date_changed) self.date_changed() def date_changed(self): @@ -31,6 +36,16 @@ class TurnaroundTime(InfoPane): return super().date_changed() chart_settings = dict(start_date=self.start_date, end_date=self.end_date) - self.report_obj = TurnaroundMaker(start_date=self.start_date, end_date=self.end_date) - self.chart = TurnaroundChart(df=self.report_obj.df, settings=chart_settings, modes=[]) + if self.submission_typer.currentText() == "Any": + submission_type = None + subtype_obj = None + else: + submission_type = self.submission_typer.currentText() + subtype_obj = SubmissionType.query(name = submission_type) + self.report_obj = TurnaroundMaker(start_date=self.start_date, end_date=self.end_date, submission_type=submission_type) + if subtype_obj: + threshold = subtype_obj.defaults['turnaround_time'] + 0.5 + else: + threshold = None + self.chart = TurnaroundChart(df=self.report_obj.df, settings=chart_settings, modes=[], threshold=threshold) self.webview.setHtml(self.chart.to_html())