Compare commits

...

20 Commits

Author SHA1 Message Date
ccee4b3afe Addition of new reagent lots updated. 2025-10-03 10:19:17 -05:00
1445d2b93b Qubit sample results now written to export. 2025-10-01 15:04:02 -05:00
8fee07b0c3 Qubit sample results now written to export. 2025-09-29 12:22:50 -05:00
lwark
7f40e091fa Qubit sample results now written to export. 2025-09-23 15:06:18 -05:00
lwark
e9ff0a2774 Fixed Tips not being written during export. 2025-09-23 10:52:46 -05:00
lwark
4522f5909e Qubit results parsing complete. 2025-09-23 09:00:25 -05:00
lwark
4d70d751ca Qubit results parsing complete. 2025-09-23 08:59:04 -05:00
lwark
39d20bbc22 Qubit results parsing complete. 2025-09-23 08:57:40 -05:00
lwark
6f1202d3ba Sortable headers in treeview 2025-09-17 13:12:23 -05:00
lwark
656164d124 Sortable headers in treeview 2025-09-17 13:11:38 -05:00
lwark
3862604dfa Plate map in procedure details. 2025-09-15 09:21:52 -05:00
lwark
11abaafcfc Attempt to cleanup imports. 2025-09-12 10:14:53 -05:00
lwark
ba4912cab7 ReagentLot add/edit updated. 2025-09-10 12:39:02 -05:00
lwark
c9396d6c41 Frontend code cleanup finished. 2025-09-05 15:23:56 -05:00
lwark
610859d84f Code cleanup for validators complete. 2025-09-04 15:21:50 -05:00
lwark
c8b4762747 Code cleanup for managers complete. 2025-09-04 15:01:02 -05:00
lwark
1a90543639 Code cleanup for excel complete. 2025-09-04 14:49:21 -05:00
lwark
0c20ade65a Code cleanup for excel.writers complete. 2025-09-04 14:46:25 -05:00
lwark
b6e1c0dee2 Code cleanup for excel.parsers complete. 2025-09-04 14:35:44 -05:00
lwark
fcda0d873c Code cleanup for db.models complete. 2025-09-03 14:04:26 -05:00
69 changed files with 4112 additions and 6092 deletions

View File

@@ -1,10 +1,27 @@
# 202510.01
- Update for Python 3.13
# 202509.04
- Qubit results parsing complete.
# 202509.03
- Sortable headers in treeview.
- Added gitea remote.
# 202509.02
- First usable updated version.
# 202504.04
- Added html links for equipment/processes/tips.
# 202504.03
- Split Concentration controls on the chart so they are individually selectable.
- Split Concentration controls on the chart, so they are individually selectable.
# 202504.02
@@ -307,7 +324,7 @@
## 202307.03
- Auto-filling of some empty cells in Excel file.
- Autofilling of some empty cells in Excel file.
- Better pydantic validations of missing data.
## 202307.02

View File

@@ -1,5 +1,7 @@
- [ ] Add in database objects for rsl_run (submission -> run), procedure (run -> procedure), many more things will likely be associated with procedure.
- [ ] Add in database object for client submission.
- [ ] Do results writing.
- [ ] Allow use of multiple tips per process.
- [x] Add in database objects for rsl_run (submission -> run), procedure (run -> procedure), many more things will likely be associated with procedure.
- [x] Add in database object for client submission.
- [ ] Add arbitrary pipette addition to equipment UI.
- [ ] transfer details template rendering fully into sql objects
- [x] Add in connecting links for tips.

View File

@@ -1,6 +1,32 @@
'''
"""
Contains database, validators and excel operations.
'''
from .db import *
from .excel import *
from .validators import *
"""
from .db import (
set_sqlite_pragma,
LogMixin, ConfigItem,
AuditLog,
ControlType, Control,
ClientLab, Contact,
ReagentRole, Reagent, ReagentLot, Discount, SubmissionType, ProcedureType, Procedure, ProcedureTypeReagentRoleAssociation,
ProcedureReagentLotAssociation, EquipmentRole, Equipment, EquipmentRoleEquipmentAssociation, Process, ProcessVersion,
Tips, TipsLot, ProcedureEquipmentAssociation,
ProcedureTypeEquipmentRoleAssociation, Results,
ClientSubmission, Run, Sample, ClientSubmissionSampleAssociation, RunSampleAssociation, ProcedureSampleAssociation,
update_log
)
from .excel import (
DefaultParser, DefaultKEYVALUEParser, DefaultTABLEParser, ProcedureInfoParser, ProcedureSampleParser,
ProcedureReagentParser, ProcedureEquipmentParser, DefaultResultsInfoParser, DefaultResultsSampleParser,
PCRSampleParser, PCRInfoParser, ClientSubmissionSampleParser, ClientSubmissionInfoParser, PCRInfoParser,
PCRSampleParser,
DefaultWriter, DefaultKEYVALUEWriter, DefaultTABLEWriter,
ProcedureInfoWriter, ProcedureSampleWriter, ProcedureReagentWriter, ProcedureEquipmentWriter,
PCRInfoWriter, PCRSampleWriter,
ClientSubmissionInfoWriter, ClientSubmissionSampleWriter,
ReportArchetype, ReportMaker, TurnaroundMaker, ConcentrationMaker, ChartReportMaker
)
from .validators import (
DefaultNamer, ClientSubmissionNamer, RSLNamer,
PydRun, PydContact, PydClientLab, PydSample, PydReagent, PydReagentRole, PydEquipment, PydEquipmentRole, PydTips,
PydProcess, PydElastic, PydClientSubmission, PydProcedure, PydResults, PydReagentLot
)

View File

@@ -1,9 +1,14 @@
"""
All database related operations.
"""
from datetime import datetime
from getpass import getuser
from sqlalchemy import event, inspect
from sqlalchemy.engine import Engine
from tools import ctx
import logging
logger = logging.getLogger(f"submissions.{__name__}")
@event.listens_for(Engine, "connect")
@@ -22,14 +27,23 @@ def set_sqlite_pragma(dbapi_connection, connection_record):
execution_phrase = "PRAGMA foreign_keys=ON"
print(f"Executing '{execution_phrase}' in sql.")
else:
# print("Nothing to execute, returning")
cursor.close()
return
cursor.execute(execution_phrase)
cursor.close()
from .models import *
from .models import (
LogMixin, ConfigItem,
AuditLog,
ReagentRole, Reagent, ReagentLot, Discount, SubmissionType, ProcedureType, Procedure, ProcedureTypeReagentRoleAssociation,
ProcedureReagentLotAssociation, EquipmentRole, Equipment, EquipmentRoleEquipmentAssociation, Process, ProcessVersion,
Tips, TipsLot, ProcedureEquipmentAssociation,
ProcedureTypeEquipmentRoleAssociation, Results,
ClientSubmission, Run, Sample, ClientSubmissionSampleAssociation, RunSampleAssociation, ProcedureSampleAssociation,
ControlType, Control,
ClientLab, Contact
)
def update_log(mapper, connection, target):
@@ -55,9 +69,6 @@ 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', '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:
continue
deleted = [str(item) for item in hist.deleted]

View File

@@ -2,11 +2,14 @@
Contains all models for sqlalchemy
"""
from __future__ import annotations
import sys, logging, json
import sys, logging, json, inspect
from datetime import datetime, date
from pprint import pformat
from dateutil.parser import parse
from jinja2 import TemplateNotFound, Template
from pandas import DataFrame
from pydantic import BaseModel
from sqlalchemy import Column, INTEGER, String, JSON
from sqlalchemy import Column, INTEGER, String, JSON, TIMESTAMP
from sqlalchemy.ext.associationproxy import AssociationProxy
from sqlalchemy.orm import DeclarativeMeta, declarative_base, Query, Session, InstrumentedAttribute, ColumnProperty
from sqlalchemy.ext.declarative import declared_attr
@@ -15,7 +18,7 @@ from sqlalchemy.exc import ArgumentError
from typing import Any, List, ClassVar
from pathlib import Path
from sqlalchemy.orm.relationships import _RelationshipDeclared
from tools import report_result, list_sort_dict
from tools import report_result, list_sort_dict, jinja_template_loading, Report, Alert, ctx
# NOTE: Load testing environment
if 'pytest' in sys.modules:
@@ -36,10 +39,6 @@ class BaseClass(Base):
__table_args__ = {'extend_existing': True} #: NOTE Will only add new columns
singles = ['id']
# omni_removes = ["id", 'run', "omnigui_class_dict", "omnigui_instance_dict"]
# omni_sort = ["name"]
# omni_inheritable = []
searchables = []
_misc_info = Column(JSON)
@@ -49,8 +48,10 @@ class BaseClass(Base):
except AttributeError:
return f"<{self.__class__.__name__}(Name Unavailable)>"
@classproperty
def aliases(cls) -> List[str]:
@declared_attr
@classmethod
def aliases(cls):
"""
List of other names this class might be known by.
@@ -59,8 +60,9 @@ class BaseClass(Base):
"""
return [cls.query_alias]
@classproperty
def query_alias(cls) -> str:
@declared_attr
@classmethod
def query_alias(cls):
"""
What to query this class as.
@@ -69,8 +71,8 @@ class BaseClass(Base):
"""
return cls.__name__.lower()
@classmethod
@declared_attr
@classmethod
def __tablename__(cls) -> str:
"""
Sets table name to lower case class name.
@@ -80,8 +82,8 @@ class BaseClass(Base):
"""
return f"_{cls.__name__.lower()}"
@classmethod
@declared_attr
@classmethod
def __database_session__(cls) -> Session:
"""
Pull db session from ctx to be used in operations
@@ -89,14 +91,10 @@ class BaseClass(Base):
Returns:
Session: DB session from ctx settings.
"""
if 'pytest' not in sys.modules:
from tools import ctx
else:
from test_settings import ctx
return ctx.database_session
@classmethod
@declared_attr
@classmethod
def __directory_path__(cls) -> Path:
"""
Pull directory path from ctx to be used in operations.
@@ -104,14 +102,10 @@ class BaseClass(Base):
Returns:
Path: Location of the Submissions directory in Settings object
"""
if 'pytest' not in sys.modules:
from tools import ctx
else:
from test_settings import ctx
return ctx.directory_path
@classmethod
@declared_attr
@classmethod
def __backup_path__(cls) -> Path:
"""
Pull backup directory path from ctx to be used in operations.
@@ -119,18 +113,15 @@ class BaseClass(Base):
Returns:
Path: Location of the Submissions backup directory in Settings object
"""
if 'pytest' not in sys.modules:
from tools import ctx
else:
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]:
@declared_attr
@classmethod
def jsons(cls):
"""
Get list of JSON db columns
@@ -142,8 +133,9 @@ class BaseClass(Base):
except AttributeError:
return []
@classproperty
def timestamps(cls) -> List[str]:
@declared_attr
@classmethod
def timestamps(cls):
"""
Get list of TIMESTAMP columns
@@ -155,6 +147,30 @@ class BaseClass(Base):
except AttributeError:
return []
@classmethod
def get_omni_sort(cls):
output = [item[0] for item in inspect.getmembers(cls, lambda a: not (inspect.isroutine(a)))
if isinstance(item[1], InstrumentedAttribute)] # and not isinstance(item[1].property, _RelationshipDeclared)]
output = [item for item in output if item not in ['_misc_info']]
return output
@classmethod
def get_searchables(cls):
output = []
for item in inspect.getmembers(cls, lambda a: not (inspect.isroutine(a))):
if item[0] in ["_misc_info"]:
continue
if not isinstance(item[1], InstrumentedAttribute):
continue
if not isinstance(item[1].property, ColumnProperty):
continue
if len(item[1].foreign_keys) > 0:
continue
if item[1].type.__class__.__name__ not in ["String"]:
continue
output.append(item[0])
return output
@classmethod
def get_default_info(cls, *args) -> dict | list | str:
"""
@@ -164,8 +180,7 @@ class BaseClass(Base):
dict | list | str: Output of key:value dict or single (list, str) desired variable
"""
# NOTE: singles is a list of fields that need to be limited to 1 result.
singles = list(set(cls.singles + BaseClass.singles))
return dict(singles=singles)
return dict(singles=list(set(cls.singles + BaseClass.singles)))
@classmethod
def find_regular_subclass(cls, name: str | None = None) -> Any:
@@ -222,14 +237,15 @@ class BaseClass(Base):
"""
if not objects:
try:
records = [obj.details_dict(**kwargs) for obj in cls.query()]
q = cls.query()
except AttributeError:
records = [obj.details_dict(**kwargs) for obj in cls.query(page_size=0)]
q = cls.query(page_size=0)
else:
try:
records = [obj.to_sub_dict(**kwargs) for obj in objects]
except AttributeError:
records = [{k: v['instance_attr'] for k, v in obj.omnigui_instance_dict.items()} for obj in objects]
q = objects
records = []
for obj in q:
dicto = obj.details_dict(**kwargs)
records.append({key: value for key, value in dicto.items() if key not in dicto['excluded']})
return DataFrame.from_records(records)
@classmethod
@@ -237,10 +253,8 @@ class BaseClass(Base):
new = False
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(limit=1, **sanitized_kwargs)
if not instance or isinstance(instance, list):
instance = cls()
@@ -254,8 +268,6 @@ class BaseClass(Base):
from backend.validators.pydant import PydBaseClass
if issubclass(v.__class__, PydBaseClass):
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
@@ -286,17 +298,10 @@ class BaseClass(Base):
Returns:
Any | List[Any]: Single result if limit = 1 or List if other.
"""
# logger.debug(f"Kwargs: {kwargs}")
# if model is None:
# model = cls
# logger.debug(f"Model: {model}")
if query is None:
query: Query = cls.__database_session__.query(cls)
# else:
# logger.debug(f"Incoming query: {query}")
singles = cls.get_default_info('singles')
for k, v in kwargs.items():
# logger.info(f"Using key: {k} with value: {v} against {cls}")
try:
attr = getattr(cls, k)
except (ArgumentError, AttributeError) as e:
@@ -308,13 +313,11 @@ class BaseClass(Base):
except AttributeError:
check = False
if check:
logger.debug("Got uselist")
try:
query = query.filter(attr.contains(v))
except ArgumentError:
continue
else:
# logger.debug("Single item.")
try:
query = query.filter(attr == v)
except ArgumentError:
@@ -354,15 +357,12 @@ class BaseClass(Base):
try:
self.__database_session__.add(self)
self.__database_session__.commit()
# except sqlalchemy.exc.IntegrityError as i:
# logger.error(f"Integrity error saving {self} due to: {i}")
# logger.error(pformat(self.__dict__))
except Exception as e:
logger.critical(f"Problem saving {self} due to: {e}")
logger.error(f"Error message: {type(e)}")
logger.error(pformat(self.__dict__))
self.__database_session__.rollback()
report.add_result(Result(msg=e, status="Critical"))
report.add_result(Alert(msg=e, status="Critical"))
return report
@property
@@ -374,16 +374,14 @@ class BaseClass(Base):
dict: Dictionary of object minus _sa_instance_state with id at the front.
"""
dicto = {key: dict(class_attr=getattr(self.__class__, key), instance_attr=getattr(self, key))
for key in dir(self.__class__) if
isinstance(getattr(self.__class__, key), InstrumentedAttribute) and key not in self.omni_removes
}
for key in self.get_omni_sort()}
for k, v in dicto.items():
try:
v['instance_attr'] = v['instance_attr'].name
except AttributeError:
continue
try:
dicto = list_sort_dict(input_dict=dicto, sort_list=self.__class__.omni_sort)
dicto = list_sort_dict(input_dict=dicto, sort_list=self.__class__.get_omni_sort())
except TypeError as e:
logger.error(f"Could not sort {self.__class__.__name__} by list due to :{e}")
try:
@@ -392,8 +390,9 @@ class BaseClass(Base):
pass
return dicto
@classproperty
def pydantic_model(cls) -> BaseModel:
@declared_attr
@classmethod
def pydantic_model(cls):
"""
Gets the pydantic model corresponding to this object.
@@ -405,11 +404,16 @@ class BaseClass(Base):
model = getattr(pydant, f"Pyd{cls.__name__}")
except AttributeError:
logger.warning(f"Couldn't get {cls.__name__} pydantic model.")
try:
model = getattr(pydant, f"Pyd{cls.pyd_model_name}")
except AttributeError:
return pydant.PydElastic
return model
@classproperty
def add_edit_tooltips(cls) -> dict:
# @classproperty
@declared_attr
@classmethod
def add_edit_tooltips(cls):
"""
Gets tooltips for Omni-add-edit
@@ -418,8 +422,9 @@ class BaseClass(Base):
"""
return dict()
@classproperty
def details_template(cls) -> Template:
@declared_attr
@classmethod
def details_template(cls):
"""
Get the details jinja template for the correct class
@@ -448,14 +453,11 @@ class BaseClass(Base):
Returns:
bool: If a single unequivocal value is found will be false, else true.
"""
# logger.debug(f"Incoming attributes: {attributes}")
for key, value in attributes.items():
if value.lower() == "none":
value = None
# logger.debug(f"Attempting to grab attribute: {key}")
self_value = getattr(self, key)
class_attr = getattr(self.__class__, key)
# logger.debug(f"Self value: {self_value}, class attr: {class_attr} of type: {type(class_attr)}")
if isinstance(class_attr, property):
filter = "property"
else:
@@ -475,7 +477,6 @@ class BaseClass(Base):
case "property":
pass
case _RelationshipDeclared():
# logger.debug(f"Checking {self_value}")
try:
self_value = self_value.name
except AttributeError:
@@ -483,18 +484,14 @@ class BaseClass(Base):
if class_attr.property.uselist:
self_value = self_value.__str__()
try:
# logger.debug(f"Check if {self_value.__class__} is subclass of {self.__class__}")
check = issubclass(self_value.__class__, self.__class__)
except TypeError as e:
logger.error(f"Couldn't check if {self_value.__class__} is subclass of {self.__class__} due to {e}")
check = False
if check:
# logger.debug(f"Checking for subclass name.")
self_value = self_value.name
# logger.debug(f"Checking self_value {self_value} of type {type(self_value)} against attribute {value} of type {type(value)}")
if self_value != value:
output = False
# logger.debug(f"Value {key} is False, returning.")
return output
return True
@@ -502,17 +499,14 @@ 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:
value = json.dumps(value)
except TypeError:
except TypeError as e:
logger.error(f"Error json dumping value: {e}")
value = str(value)
try:
self._misc_info.update({key: value})
@@ -524,27 +518,21 @@ class BaseClass(Base):
except AttributeError:
return super().__setattr__(key, value)
if isinstance(field_type, InstrumentedAttribute):
# logger.debug(f"{key} is an InstrumentedAttribute.")
match field_type.property:
case ColumnProperty():
# 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}")
if field_type.property.uselist:
# logger.debug(f"Setting with uselist")
existing = self.__getattribute__(key)
# NOTE: This is causing problems with removal of items from lists. Have to overhaul it.
if existing is not None:
logger.debug(f"{key} Existing: {existing}, incoming: {value}")
if isinstance(value, list):
# value = existing + value
value = value
else:
value = existing + [value]
else:
if isinstance(value, list):
# value = value
pass
else:
value = [value]
@@ -552,7 +540,6 @@ class BaseClass(Base):
value = list(set(value))
except TypeError:
pass
# logger.debug(f"Final value for {key}: {value}")
return super().__setattr__(key, value)
else:
if isinstance(value, list):
@@ -573,7 +560,10 @@ class BaseClass(Base):
case _:
return super().__setattr__(key, value)
else:
try:
return super().__setattr__(key, value)
except AttributeError:
raise AttributeError(f"Can't set {key} to {value}")
def delete(self, **kwargs):
logger.error(f"Delete has not been implemented for {self.__class__.__name__}")
@@ -608,7 +598,6 @@ class BaseClass(Base):
relevant = {k: v for k, v in self.__class__.__dict__.items() if
isinstance(v, InstrumentedAttribute) or isinstance(v, AssociationProxy)}
# output = OrderedDict()
output = dict(excluded=["excluded", "misc_info", "_misc_info", "id"])
for k, v in relevant.items():
try:
@@ -621,15 +610,14 @@ class BaseClass(Base):
value = getattr(self, k)
except AttributeError:
continue
# try:
# logger.debug(f"Setting {k} to {value} for details dict.")
# except AttributeError as e:
# logger.error(f"Can't log {k} value due to {type(e)}")
# continue
match value:
case str():
value = value.strip('\"')
case _:
pass
output[k.strip("_")] = value
if self._misc_info:
for key, value in self._misc_info.items():
# logger.debug(f"Misc info key {key}")
output[key] = value
return output
@@ -664,33 +652,21 @@ class BaseClass(Base):
from backend.validators import pydant
if not pyd_model_name:
pyd_model_name = f"Pyd{self.__class__.__name__}"
logger.debug(f"Looking for pydant model {pyd_model_name}")
# logger.info(f"Looking for pydant model {pyd_model_name}")
try:
pyd = getattr(pydant, pyd_model_name)
except AttributeError:
raise AttributeError(f"Could not get pydantic class {pyd_model_name}")
# logger.debug(f"Kwargs: {kwargs}")
# logger.debug(f"Dict: {pformat(self.details_dict())}")
pyd.model_rebuild()
return pyd(**self.details_dict(**kwargs))
def show_details(self, obj):
logger.debug("Show Details")
from frontend.widgets.submission_details import SubmissionDetails
dlg = SubmissionDetails(parent=obj, sub=self)
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:
# from frontend import select_save_file
# 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
from backend import managers
Manager = getattr(managers, f"Default{self.__class__.__name__}")
manager = Manager(parent=obj, input_object=self)
@@ -721,6 +697,7 @@ class ConfigItem(BaseClass):
"""
Key:JSON objects to store config settings in database.
"""
id = Column(INTEGER, primary_key=True)
key = Column(String(32)) #: Name of the configuration item.
value = Column(JSON) #: Value associated with the config item.
@@ -749,12 +726,20 @@ class ConfigItem(BaseClass):
return config_items
from .controls import *
# NOTE: import order must go: orgs, kittype, run due to circular import issues
from .organizations import *
from .procedures import *
from .submissions import *
from .audit import AuditLog
from .organizations import (
ClientLab, Contact, BaseClass # NOTE: For some reason I need to import BaseClass at this point for queries to work.
)
from .procedures import (
ReagentRole, Reagent, ReagentLot, Discount, SubmissionType, ProcedureType, Procedure, ProcedureTypeReagentRoleAssociation,
ProcedureReagentLotAssociation, EquipmentRole, Equipment, EquipmentRoleEquipmentAssociation, Process, ProcessVersion,
Tips, TipsLot, ProcedureEquipmentAssociation, ProcedureTypeEquipmentRoleAssociation, Results
)
from .submissions import (
ClientSubmission, Run, Sample, ClientSubmissionSampleAssociation, RunSampleAssociation, ProcedureSampleAssociation
)
from .controls import ControlType, Control
# 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

View File

@@ -11,6 +11,7 @@ import logging
logger = logging.getLogger(f"submissions.{__name__}")
# NOTE: Need a seperate base for this.
Base: DeclarativeMeta = declarative_base()
class AuditLog(Base):
@@ -18,10 +19,10 @@ class AuditLog(Base):
__tablename__ = "_auditlog"
id = Column(INTEGER, primary_key=True, autoincrement=True) #: primary key
user = Column(String(64))
time = Column(TIMESTAMP)
object = Column(String(64))
changes = Column(JSON)
user = Column(String(64)) #: The user who made the change
time = Column(TIMESTAMP) #: When the change was made
object = Column(String(64)) #: What was changed
changes = Column(JSON) #: List of changes that were made
def __repr__(self):
return f"<{self.object}: {self.user} @ {self.time}>"

View File

@@ -2,17 +2,13 @@
All control related models.
"""
from __future__ import annotations
import itertools
from pprint import pformat
from PyQt6.QtWidgets import QWidget, QCheckBox, QLabel
from pandas import DataFrame
from sqlalchemy import Column, String, TIMESTAMP, JSON, INTEGER, ForeignKey, case, FLOAT
from sqlalchemy.orm import relationship, Query, validates
from PyQt6.QtWidgets import QWidget
from sqlalchemy import Column, String, TIMESTAMP, JSON, INTEGER, ForeignKey, case
from sqlalchemy.orm import relationship, Query
import logging, re
from operator import itemgetter
from . import BaseClass
from tools import setup_lookup, report_result, Result, Report, Settings, get_unique_values_in_df_column, super_splitter, \
flatten_list, timer
from tools import setup_lookup, Report, Settings, super_splitter
from datetime import date, datetime, timedelta
from typing import List, Literal, Tuple, Generator
from re import Pattern
@@ -131,16 +127,6 @@ class Control(BaseClass):
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": "*",
# }
def __repr__(self) -> str:
return f"<{self.controltype_name}({self.name})>"
@@ -282,450 +268,3 @@ class Control(BaseClass):
def delete(self):
self.__database_session__.delete(self)
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()
# 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")

View File

@@ -3,21 +3,19 @@ All client organization related models.
'''
from __future__ import annotations
import logging
from pathlib import Path
from pprint import pformat
from sqlalchemy import Column, String, INTEGER, ForeignKey, Table
from sqlalchemy.ext.hybrid import hybrid_property
from sqlalchemy.orm import relationship, Query
from . import Base, BaseClass
from sqlalchemy.orm import relationship, Query, declared_attr
from . import BaseClass
from tools import check_authorization, setup_lookup
from typing import List, Tuple
from typing import List
logger = logging.getLogger(f"submissions.{__name__}")
# table containing clientlab/contact relationship
clientlab_contact = Table(
"_clientlab_contact",
Base.metadata,
# Base.metadata,
BaseClass.__base__.metadata,
Column("clientlab_id", INTEGER, ForeignKey("_clientlab.id")),
Column("contact_id", INTEGER, ForeignKey("_contact.id")),
extend_existing=True
@@ -31,7 +29,7 @@ class ClientLab(BaseClass):
id = Column(INTEGER, primary_key=True) #: primary key
name = Column(String(64)) #: clientlab name
clientsubmission = relationship("ClientSubmission", back_populates="clientlab") #: procedure this clientlab has submitted
clientsubmission = relationship("ClientSubmission", back_populates="clientlab") #: submission this clientlab has submitted
cost_centre = Column(String()) #: cost centre used by org for payment
contact = relationship("Contact", back_populates="clientlab",
secondary=clientlab_contact) #: contact involved with this org
@@ -47,6 +45,7 @@ class ClientLab(BaseClass):
Lookup clientlabs in the database by a number of parameters.
Args:
id (int | None, optional): id integer of the clientlab. 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.
@@ -100,24 +99,12 @@ class Contact(BaseClass):
secondary=clientlab_contact) #: relationship to joined clientlab
clientsubmission = relationship("ClientSubmission", back_populates="contact") #: procedure this contact has submitted
@classproperty
# @classproperty
@classmethod
@declared_attr
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
@setup_lookup
def query(cls,
@@ -131,6 +118,7 @@ class Contact(BaseClass):
Lookup contact in the database by a number of parameters.
Args:
id (int | None, optional): id integer of the contact. Defaults to None.
name (str | None, optional): Name of the contact. Defaults to None.
email (str | None, optional): Email of the contact. Defaults to None.
phone (str | None, optional): Phone number of the contact. Defaults to None.

File diff suppressed because it is too large Load Diff

View File

@@ -2,43 +2,27 @@
Models for the main procedure and sample types.
"""
from __future__ import annotations
import itertools
import pickle
from copy import deepcopy
from getpass import getuser
import logging, uuid, tempfile, re, base64, numpy as np, pandas as pd, types, sys
import logging, tempfile, re, numpy as np, pandas as pd, types, sys, itertools
from inspect import isclass
from io import BytesIO
from zipfile import ZipFile, BadZipfile
from tempfile import TemporaryDirectory, TemporaryFile
from zipfile import BadZipfile
from operator import itemgetter
from pprint import pformat
import openpyxl
from pandas import DataFrame
from sqlalchemy.ext.hybrid import hybrid_property
from frontend.widgets.functions import select_save_file
from . import Base, BaseClass, Reagent, SubmissionType, ClientLab, Contact, LogMixin, Procedure
from sqlalchemy import Column, String, TIMESTAMP, INTEGER, ForeignKey, JSON, FLOAT, case, func, Table, Sequence
from sqlalchemy.orm import relationship, validates, Query
from . import BaseClass, SubmissionType, ClientLab, Contact, LogMixin, Procedure
from sqlalchemy import Column, String, TIMESTAMP, INTEGER, ForeignKey, JSON, FLOAT, case, func
from sqlalchemy.orm import relationship, Query, declared_attr
from sqlalchemy.orm.attributes import flag_modified
from sqlalchemy.ext.associationproxy import association_proxy
from sqlalchemy.exc import OperationalError as AlcOperationalError, IntegrityError as AlcIntegrityError, StatementError, \
ArgumentError
from sqlalchemy.exc import OperationalError as AlcOperationalError, IntegrityError as AlcIntegrityError, StatementError
from sqlite3 import OperationalError as SQLOperationalError, IntegrityError as SQLIntegrityError
from openpyxl import Workbook
from openpyxl.drawing.image import Image as OpenpyxlImage
from tools import row_map, setup_lookup, jinja_template_loading, rreplace, row_keys, check_key_or_attr, Result, Report, \
report_result, create_holidays_for_year, check_dictionary_inclusion_equality, is_power_user
from tools import (setup_lookup, jinja_template_loading, create_holidays_for_year,
check_dictionary_inclusion_equality, is_power_user, row_map)
from datetime import datetime, date
from typing import List, Any, Tuple, Literal, Generator, Type, TYPE_CHECKING
from typing import List, Literal, Generator, TYPE_CHECKING
from pathlib import Path
from jinja2.exceptions import TemplateNotFound
from jinja2 import Template
from PIL import Image
if TYPE_CHECKING:
from backend.db.models.procedures import ProcedureType, Procedure
@@ -51,21 +35,21 @@ class ClientSubmission(BaseClass, LogMixin):
"""
id = Column(INTEGER, primary_key=True) #: primary key
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
submitter_plate_id = Column(String(127), unique=True) #: The number given to the submission by the submitting lab
submitted_date = Column(TIMESTAMP) #: Date submission 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))
submission_category = Column(String(64)) #: i.e. Surveillance
sample_count = Column(INTEGER) #: Number of sample in the procedure
full_batch_size = Column(INTEGER) #: Number of wells in provided plate. 0 if no plate.
comment = Column(JSON)
comments = Column(JSON) #: comment objects from users.
run = relationship("Run", back_populates="clientsubmission") #: many-to-one relationship
contact = relationship("Contact", back_populates="clientsubmission") #: client org
contact = relationship("Contact", back_populates="clientsubmission") #: contact representing submitting lab.
contact_id = Column(INTEGER, ForeignKey("_contact.id", ondelete="SET NULL",
name="fk_BS_contact_id")) #: client lab id from _organizations
name="fk_BS_contact_id")) #: contact id from _organizations
submissiontype_name = Column(String, ForeignKey("_submissiontype.name", ondelete="SET NULL",
name="fk_BS_subtype_name")) #: name of joined procedure type
name="fk_BS_subtype_name")) #: name of joined submission 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.
@@ -93,7 +77,7 @@ class ClientSubmission(BaseClass, LogMixin):
@setup_lookup
def query(cls,
submissiontype: str | SubmissionType | None = None,
submissiontype_name: str | None = None,
# submissiontype_name: str | None = None,
id: int | str | None = None,
submitter_plate_id: str | None = None,
start_date: date | datetime | str | int | None = None,
@@ -108,7 +92,7 @@ class ClientSubmission(BaseClass, LogMixin):
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.
submissiontype (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_number (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.
@@ -133,7 +117,6 @@ class ClientSubmission(BaseClass, LogMixin):
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_id:
@@ -142,9 +125,11 @@ class ClientSubmission(BaseClass, LogMixin):
limit = 1
case _:
pass
match submissiontype_name:
match submissiontype:
case SubmissionType():
query = query.filter(cls.submissiontype == submissiontype)
case str():
query = query.filter(cls.submissiontype_name == submissiontype_name)
query = query.filter(cls.submissiontype_name == submissiontype)
case _:
pass
# NOTE: by id (returns only a single value)
@@ -157,7 +142,6 @@ class ClientSubmission(BaseClass, LogMixin):
limit = 1
case _:
pass
# query = query.order_by(cls.submitted_date.desc())
# NOTE: Split query results into pages of size {page_size}
if page_size > 0 and limit == 0:
limit = page_size
@@ -249,19 +233,16 @@ class ClientSubmission(BaseClass, LogMixin):
if report:
return output
if full_data:
# dicto, _ = self.kittype.construct_xl_map_for_use(self.proceduretype)
# sample = self.generate_associations(name="clientsubmissionsampleassociation")
samples = None
runs = [item.to_dict(full_data=True) for item in self.run]
# custom = self.custom
else:
samples = None
custom = None
runs = None
try:
comments = self.comment
comments = self.comments
except Exception as e:
logger.error(f"Error setting comment: {self.comment}, {e}")
logger.error(f"Error setting comment: {self.comments}, {e}")
comments = None
try:
contact = self.contact.name
@@ -280,7 +261,6 @@ class ClientSubmission(BaseClass, LogMixin):
output["comment"] = comments
output["contact"] = contact
output["contact_phone"] = contact_phone
# output["custom"] = custom
output["run"] = runs
output['name'] = self.name
return output
@@ -291,7 +271,6 @@ class ClientSubmission(BaseClass, LogMixin):
except AssertionError:
logger.warning(f"Converting {sample} to sql.")
sample = sample.to_sql()
# logger.debug(sample.__dict__)
try:
row = sample._misc_info['row']
except (KeyError, AttributeError):
@@ -300,7 +279,6 @@ class ClientSubmission(BaseClass, LogMixin):
column = sample._misc_info['column']
except KeyError:
column = 0
# logger.debug(f"Sample: {sample}")
submission_rank = sample._misc_info['submission_rank']
if sample in self.sample:
return
@@ -311,7 +289,6 @@ class ClientSubmission(BaseClass, LogMixin):
row=row,
column=column
)
# assoc.save()
return assoc
@property
@@ -326,20 +303,16 @@ class ClientSubmission(BaseClass, LogMixin):
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_number=checker.rsl_plate_number)
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}")
if sample not in run.sample:
assoc = run.add_sample(sample)
# assoc.save()
run.save()
else:
logger.warning("Run cancelled.")
@@ -351,13 +324,6 @@ class ClientSubmission(BaseClass, LogMixin):
def add_comment(self, obj):
logger.debug("Add Comment")
# def show_details(self, obj):
# logger.debug("Show Details")
# from frontend.widgets.submission_details import SubmissionDetails
# dlg = SubmissionDetails(parent=obj, sub=self)
# if dlg.exec():
# pass
def details_dict(self, **kwargs):
output = super().details_dict(**kwargs)
output['clientlab'] = output['clientlab'].details_dict()
@@ -377,7 +343,6 @@ class ClientSubmission(BaseClass, LogMixin):
def to_pydantic(self, filepath: Path | str | None = None, **kwargs):
output = super().to_pydantic(filepath=filepath, **kwargs)
# output.template_file = self.template_file
return output
@@ -389,18 +354,16 @@ class Run(BaseClass, LogMixin):
id = Column(INTEGER, primary_key=True) #: primary key
rsl_plate_number = Column(String(32), unique=True, nullable=False) #: RSL name (e.g. RSL-22-0012)
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")
name="fk_BS_clientsub_id")) #: id of parent clientsubmission
clientsubmission = relationship("ClientSubmission", back_populates="run") #: parent clientsubmission
_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 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)
procedure = relationship("Procedure", back_populates="run", uselist=True)
custom = Column(JSON) #: unknown
_completed_date = Column(TIMESTAMP) #: Date this procedure was finished.
procedure = relationship("Procedure", back_populates="run", uselist=True) #: children procedures
runsampleassociation = relationship(
"RunSampleAssociation",
@@ -412,20 +375,6 @@ class Run(BaseClass, LogMixin):
"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(
#
# (submissiontype_name == "Wastewater", "Wastewater"),
# (submissiontype_name == "Wastewater Artic", "Wastewater Artic"),
# (submissiontype_name == "Bacterial Culture", "Bacterial Culture"),
#
# else_="Basic Submission"
# ),
# "with_polymorphic": "*",
# }
def __repr__(self) -> str:
return f"<Submission({self.name})>"
@@ -505,13 +454,14 @@ class Run(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()}
logger.debug(f"Submission type for get default info: {submissiontype}")
# logger.debug(f"Submission type for get default info: {submissiontype}")
if isinstance(submissiontype, SubmissionType):
st = submissiontype
else:
st = cls.get_submission_type(submissiontype)
if st is None:
logger.error("No default info for Run.")
# logger.error("No default info for Run.")
pass
else:
output['submissiontype'] = st.name
for k, v in st.defaults.items():
@@ -556,7 +506,6 @@ class Run(BaseClass, LogMixin):
case SubmissionType():
return submissiontype
case _:
# return SubmissionType.query(cls.__mapper_args__['polymorphic_identity'])
return None
@classmethod
@@ -697,7 +646,6 @@ class Run(BaseClass, LogMixin):
'permission', "clientsubmission"]
output['sample_count'] = self.sample_count
output['clientsubmission'] = self.clientsubmission.name
output['clientlab'] = self.clientsubmission.clientlab
output['started_date'] = self.started_date
output['completed_date'] = self.completed_date
return output
@@ -712,14 +660,12 @@ class Run(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: {run}")
query_out.append(subs)
query_out = list(itertools.chain.from_iterable(query_out))
else:
query_out = cls.query(page_size=0, start_date=start_date, end_date=end_date)
records = []
for sub in query_out:
# output = sub.to_dict(full_data=True)
output = sub.details_dict()
for k, v in output.items():
if isinstance(v, types.GeneratorType):
@@ -746,29 +692,6 @@ class Run(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 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
@@ -802,7 +725,6 @@ class Run(BaseClass, LogMixin):
"""
rows = range(1, plate_rows + 1)
columns = range(1, plate_columns + 1)
# logger.debug(f"sample list for plate map: {pformat(sample_list)}")
# NOTE: An overly complicated list comprehension create a list of sample locations
# NOTE: next will return a blank cell if no value found for row/column
output_samples = [next((item for item in sample_list if item['row'] == row and item['column'] == column),
@@ -841,7 +763,6 @@ class Run(BaseClass, LogMixin):
pd.DataFrame: Pandas Dataframe of all relevant procedure
"""
# NOTE: use lookup function to create list of dicts
# subs = [item.to_dict() for item in
subs = [item.details_dict() for item in
cls.query(submissiontype=submission_type, limit=limit, chronologic=chronologic, page=page,
page_size=page_size)]
@@ -872,8 +793,6 @@ class Run(BaseClass, LogMixin):
value (_type_): value of attribute
"""
match key:
# case "kittype":
# field_value = KitType.query(name=value)
case "clientlab":
field_value = ClientLab.query(name=value)
case "contact":
@@ -900,13 +819,11 @@ class Run(BaseClass, LogMixin):
existing = value
case _:
existing = self.__getattribute__(key)
logger.debug(f"Existing value is {pformat(existing)}")
if value in ['', 'null', None]:
logger.error(f"No value given, not setting.")
return
if existing is None:
existing = []
# if value in existing:
if check_dictionary_inclusion_equality(existing, value):
logger.warning("Value already exists. Preventing duplicate addition.")
return
@@ -955,17 +872,6 @@ class Run(BaseClass, LogMixin):
pass
return 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":
"""
Converts this instance into a PydSubmission
@@ -973,7 +879,7 @@ class Run(BaseClass, LogMixin):
Returns:
PydSubmission: converted object.
"""
from backend.validators import PydRun
from backend.validators import PydClientSubmission, PydRun
dicto = self.details_dict(full_data=True, backup=backup)
new_dict = {}
for key, value in dicto.items():
@@ -1028,7 +934,6 @@ class Run(BaseClass, LogMixin):
Returns:
str: String from which regex will be compiled.
"""
# logger.debug(f"Class for regex: {cls}")
try:
regex = cls.get_submission_type(submission_type).defaults['regex']
except AttributeError as e:
@@ -1038,12 +943,12 @@ class Run(BaseClass, LogMixin):
regex = re.compile(rf"{regex}", flags=re.IGNORECASE | re.VERBOSE)
except re.error as e:
regex = cls.construct_regex()
# logger.debug(f"Returning regex: {regex}")
return regex
# NOTE: Polymorphic functions
@classproperty
@classmethod
@declared_attr
def regex(cls) -> re.Pattern:
"""
Constructs catchall regex.
@@ -1089,15 +994,6 @@ class Run(BaseClass, LogMixin):
Returns:
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(cls)
if start_date is not None and end_date is None:
logger.warning(f"Start date with no end date, using today.")
@@ -1107,38 +1003,8 @@ class Run(BaseClass, LogMixin):
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 procedure date: {start_date}")
if start_date is not None:
# match start_date:
# case date():
# pass
# case datetime():
# start_date = start_date.date()
# case int():
# start_date = datetime.fromordinal(
# datetime(1900, 1, 1).toordinal() + start_date - 2).date()
# case _:
# start_date = parse(start_date).date()
# # start_date = start_date.strftime("%Y-%m-%d")
# match end_date:
# case date():
# pass
# case datetime():
# end_date = end_date # + timedelta(days=1)
# # pass
# case int():
# end_date = datetime.fromordinal(datetime(1900, 1, 1).toordinal() + end_date - 2).date() # \
# # + timedelta(days=1)
# case _:
# end_date = parse(end_date).date() # + timedelta(days=1)
# # end_date = end_date.strftime("%Y-%m-%d")
# start_date = datetime.combine(start_date, datetime.min.time()).strftime("%Y-%m-%d %H:%M:%S.%f")
# end_date = datetime.combine(end_date, datetime.max.time()).strftime("%Y-%m-%d %H:%M:%S.%f")
# # if start_date == end_date:
# # start_date = start_date.strftime("%Y-%m-%d %H:%M:%S.%f")
# # query = query.filter(model.submitted_date == start_date)
# # else:
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.join(ClientSubmission).filter(ClientSubmission.submitted_date.between(start_date, end_date))
# NOTE: by rsl number (returns only a single value)
match name:
@@ -1164,7 +1030,6 @@ class Run(BaseClass, LogMixin):
limit = 1
case _:
pass
# query = query.order_by(cls.submitted_date.desc())
# NOTE: Split query results into pages of size {page_size}
if page_size > 0:
query = query.limit(page_size)
@@ -1173,58 +1038,6 @@ class Run(BaseClass, LogMixin):
query = query.offset(page * page_size)
return cls.execute_query(query=query, limit=limit, **kwargs)
# @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
@@ -1237,18 +1050,15 @@ class Run(BaseClass, LogMixin):
"""
names = ["Add Procedure", "Edit", "Export", "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):
from frontend.widgets.procedure_creation import ProcedureCreation
procedure_type: ProcedureType = next(
(proceduretype for proceduretype in self.allowed_procedures if proceduretype.name == proceduretype_name))
logger.debug(f"Got ProcedureType: {procedure_type}")
dlg = ProcedureCreation(parent=obj, procedure=procedure_type.construct_dummy_procedure(run=self))
if dlg.exec():
sql, _ = dlg.return_sql(new=True)
# sys.exit(pformat(sql.__dict__))
sql.save()
obj.set_data()
@@ -1282,18 +1092,6 @@ class Run(BaseClass, LogMixin):
except AttributeError:
logger.error("App will not refresh data at this time.")
# def show_details(self, obj):
# """
# Creates Widget for showing procedure details.
#
# Args:
# obj (Widget): Parent widget
# """
# from frontend.widgets.submission_details import SubmissionDetails
# dlg = SubmissionDetails(parent=obj, sub=self)
# if dlg.exec():
# pass
def edit(self, obj):
"""
Return procedure to form widget for updating
@@ -1315,7 +1113,6 @@ class Run(BaseClass, LogMixin):
Args:
obj (_type_): parent widget
"""
logger.debug(obj)
from frontend.widgets.submission_details import SubmissionComment
dlg = SubmissionComment(parent=obj, submission=self)
if dlg.exec():
@@ -1437,8 +1234,6 @@ class Run(BaseClass, LogMixin):
unranked_samples.append(sample)
possible_ranks = (item for item in list(plate_dict.keys()) if
item not in [sample['submission_rank'] for sample in ranked_samples])
# logger.debug(possible_ranks)
# possible_ranks = (plate_dict[idx] for idx in possible_ranks)
for sample in unranked_samples:
try:
submission_rank = next(possible_ranks)
@@ -1457,17 +1252,9 @@ class Run(BaseClass, LogMixin):
background_color="#ffffff", enabled=False)
)
padded_list.append(sample)
# logger.debug(f"Final padded list:\n{pformat(list(sorted(padded_list, key=itemgetter('submission_rank'))))}")
return list(sorted(padded_list, key=itemgetter('submission_rank')))
# 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 Sample(BaseClass, LogMixin):
@@ -1477,11 +1264,7 @@ class Sample(BaseClass, LogMixin):
id = Column(INTEGER, primary_key=True) #: primary key
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)
control = relationship("Control", back_populates="sample", uselist=False) #: Control function this sample fills.
sampleclientsubmissionassociation = relationship(
"ClientSubmissionSampleAssociation",
@@ -1515,7 +1298,8 @@ class Sample(BaseClass, LogMixin):
def __repr__(self) -> str:
return f"<Sample({self.sample_id})>"
@classproperty
@classmethod
@declared_attr
def searchables(cls):
return [dict(label="Submitter ID", field="sample_id")]
@@ -1529,13 +1313,8 @@ class Sample(BaseClass, LogMixin):
Returns:
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(
sample_id=self.sample_id
# sampletype=sample_type
)
if full_data:
sample['clientsubmission'] = sorted([item.to_sub_dict() for item in self.sampleclientsubmissionassociation],
@@ -1563,7 +1342,6 @@ class Sample(BaseClass, LogMixin):
@setup_lookup
def query(cls,
sample_id: str | None = None,
# sampletype: str | SampleType | None = None,
limit: int = 0,
**kwargs
) -> Sample | List[Sample]:
@@ -1578,13 +1356,6 @@ class Sample(BaseClass, LogMixin):
models.Sample|List[models.Sample]: Sample(s) of interest.
"""
query = cls.__database_session__.query(cls)
# match sampletype:
# case str():
# query = query.join(SampleType).filter(SampleType.name == sampletype)
# case SampleType():
# query = query.filter(cls.sampletype == sampletype)
# case _:
# pass
match sample_id:
case str():
query = query.filter(cls.sample_id == sample_id)
@@ -1593,38 +1364,6 @@ class Sample(BaseClass, LogMixin):
pass
return cls.execute_query(query=query, limit=limit, **kwargs)
# @classmethod
# def fuzzy_search(cls,
# sampletype: str | Sample | None = None,
# **kwargs
# ) -> List[Sample]:
# """
# Allows for fuzzy search of sample.
#
# Args:
# sampletype (str | BasicSample | None, optional): Type of sample. Defaults to None.
#
# Returns:
# List[Sample]: List of sample that match kwarg search parameters.
# """
# query: Query = cls.__database_session__.query(cls)
# match sampletype:
# case str():
# query = query.join(SampleType).filter(SampleType.name == sampletype)
# case SampleType():
# query = query.filter(cls.sampletype == sampletype)
# case _:
# pass
# for k, v in kwargs.items():
# search = f"%{v}%"
# try:
# attr = getattr(cls, k)
# # NOTE: the secret sauce is in attr.like
# query = query.filter(attr.like(search))
# except (ArgumentError, AttributeError) as e:
# logger.error(f"Attribute {k} unavailable due to:\n\t{e}\nSkipping.")
# return query.limit(50).all()
def delete(self):
raise AttributeError(f"Delete not implemented for {self.__class__}")
@@ -1686,12 +1425,9 @@ class ClientSubmissionSampleAssociation(BaseClass):
DOC: https://docs.sqlalchemy.org/en/14/orm/extensions/associationproxy.html
"""
# 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)
primary_key=True) #: id of associated client submission
submission_rank = Column(INTEGER, primary_key=True, default=0) #: Location in sample list
# NOTE: reference to the Submission object
clientsubmission = relationship("ClientSubmission",
@@ -1708,10 +1444,6 @@ class ClientSubmissionSampleAssociation(BaseClass):
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()
for k, v in kwargs.items():
try:
self.__setattr__(k, v)
@@ -1735,13 +1467,6 @@ class ClientSubmissionSampleAssociation(BaseClass):
# NOTE: Get associated sample info
sample = self.sample.to_sub_dict()
sample['sample_id'] = self.sample.sample_id
# sample['row'] = self.row
# sample['column'] = self.column
# try:
# sample['well'] = f"{row_map[self.row]}{self.column}"
# except (KeyError, AttributeError) as e:
# logger.error(f"Unable to find row {self.row} in row_map.")
# sample['Well'] = None
sample['plate_name'] = self.clientsubmission.submitter_plate_id
sample['positive'] = False
sample['submitted_date'] = self.clientsubmission.submitted_date
@@ -1752,10 +1477,8 @@ class ClientSubmissionSampleAssociation(BaseClass):
output = super().details_dict()
# NOTE: Figure out how to merge the misc_info if doing .update instead.
relevant = {k: v for k, v in output.items() if k not in ['sample']}
# logger.debug(f"Relevant info from assoc output: {pformat(relevant)}")
output = output['sample'].details_dict()
misc = output['misc_info']
# # logger.debug(f"Output from sample: {pformat(output)}")
output.update(relevant)
output['misc_info'] = misc
return output
@@ -1798,48 +1521,6 @@ class ClientSubmissionSampleAssociation(BaseClass):
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__ == "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
# @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,
@@ -1857,12 +1538,14 @@ class ClientSubmissionSampleAssociation(BaseClass):
Lookup junction of Submission and Sample in the database
Args:
run (models.Run | str | None, optional): Submission of interest. Defaults to None.
clientsubmission (models.ClientSubmission | str | None, optional): Submission of interest. Defaults to None.
exclude_submission_type ( str | None, optional): Name of submissiontype to exclude. 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.
reverse (bool, optional): Whether or not to reverse order of list. Defaults to False.
Returns:
models.ClientSubmissionSampleAssociation|List[models.ClientSubmissionSampleAssociation]: Junction(s) of interest
@@ -1960,12 +1643,8 @@ class RunSampleAssociation(BaseClass):
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("_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
# misc_info = Column(JSON)
# NOTE: reference to the Submission object
@@ -2003,13 +1682,6 @@ class RunSampleAssociation(BaseClass):
# NOTE: Get associated sample info
sample = self.sample.to_sub_dict()
sample['name'] = self.sample.sample_id
# sample['row'] = self.row
# sample['column'] = self.column
# try:
# sample['well'] = f"{row_map[self.row]}{self.column}"
# except KeyError as e:
# logger.error(f"Unable to find row {self.row} in row_map.")
# sample['Well'] = None
sample['plate_name'] = self.run.rsl_plate_number
sample['positive'] = False
return sample
@@ -2070,11 +1742,13 @@ class RunSampleAssociation(BaseClass):
Args:
run (models.Run | str | None, optional): Submission of interest. Defaults to None.
exclude_submission_type ( str | None, optional): Name of submissiontype to exclude. 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.
reverse (bool, optional): Whether or not to reverse order of list. Defaults to False.
Returns:
models.ClientSubmissionSampleAssociation|List[models.ClientSubmissionSampleAssociation]: Junction(s) of interest
@@ -2169,17 +1843,17 @@ class RunSampleAssociation(BaseClass):
output = super().details_dict()
# NOTE: Figure out how to merge the misc_info if doing .update instead.
relevant = {k: v for k, v in output.items() if k not in ['sample']}
# logger.debug(f"Relevant info from assoc output: {pformat(relevant)}")
output = output['sample'].details_dict()
misc = output['misc_info']
# logger.debug(f"Output from sample: {pformat(output)}")
output.update(relevant)
output['misc_info'] = misc
return output
class ProcedureSampleAssociation(BaseClass):
pyd_model_name = "PydSample"
id = Column(INTEGER, unique=True, nullable=False)
procedure_id = Column(INTEGER, ForeignKey("_procedure.id"), primary_key=True) #: id of associated procedure
sample_id = Column(INTEGER, ForeignKey("_sample.id"), primary_key=True) #: id of associated equipment
@@ -2192,7 +1866,17 @@ class ProcedureSampleAssociation(BaseClass):
sample = relationship(Sample, back_populates="sampleprocedureassociation") #: associated equipment
results = relationship("Results", back_populates="sampleprocedureassociation")
results = relationship("Results", back_populates="sampleprocedureassociation") #: associated results
@property
def well(self):
if self.row > 0:
if self.column > 0:
return f"{row_map[self.row]}{self.column}"
else:
return self.row
else:
return None
@classmethod
def query(cls, sample: Sample | str | None = None, procedure: Procedure | str | None = None, limit: int = 0,
@@ -2242,17 +1926,19 @@ class ProcedureSampleAssociation(BaseClass):
# NOTE: Figure out how to merge the misc_info if doing .update instead.
relevant = {k: v for k, v in output.items() if k not in ['sample']}
output = output['sample'].details_dict()
logger.debug(f"Output: {pformat(output)}")
logger.debug(f"Relevant: {pformat(relevant)}")
# relevant['submission_rank'] = output['misc_info']['submission_rank']
# logger.debug(output)
misc = output['misc_info']
output.update(relevant)
output['misc_info'] = misc
output['results'] = [result.details_dict() for result in output['results']]
output['row'] = self.row
output['column'] = self.column
output['results'] = [item.details_dict() for item in self.results]
return output
def to_pydantic(self, **kwargs):
output = super().to_pydantic(pyd_model_name="PydSample")
# from backend.validators.pydant import PydSample
# output = PydSample(**self.details_dict(**kwargs))
try:
output.submission_rank = output.misc_info['submission_rank']
except KeyError:

View File

@@ -1,8 +1,18 @@
'''
"""
Contains pandas and openpyxl convenience functions for interacting with excel workbooks
'''
"""
# from .parser import *
from backend.excel.parsers.clientsubmission_parser import ClientSubmissionInfoParser, ClientSubmissionSampleParser
# from .reports import *
# from .writer import *
from .parsers import (
DefaultParser, DefaultKEYVALUEParser, DefaultTABLEParser,
ProcedureInfoParser, ProcedureSampleParser, ProcedureReagentParser, ProcedureEquipmentParser,
DefaultResultsInfoParser, DefaultResultsSampleParser, PCRSampleParser, PCRInfoParser,
ClientSubmissionSampleParser, ClientSubmissionInfoParser,
PCRInfoParser, PCRSampleParser
)
from .writers import (
DefaultWriter, DefaultKEYVALUEWriter, DefaultTABLEWriter,
ProcedureInfoWriter, ProcedureSampleWriter, ProcedureReagentWriter, ProcedureEquipmentWriter,
PCRInfoWriter, PCRSampleWriter,
ClientSubmissionInfoWriter, ClientSubmissionSampleWriter
)
from .reports import ReportArchetype, ReportMaker, TurnaroundMaker, ConcentrationMaker, ChartReportMaker

View File

@@ -1,12 +1,14 @@
"""
Default Parser archetypes.
"""
from __future__ import annotations
import logging, re
import logging, re, csv
from pathlib import Path
from pprint import pformat
from typing import Generator, TYPE_CHECKING
from openpyxl.cell import MergedCell
from openpyxl.reader.excel import load_workbook
from openpyxl.workbook import Workbook
from pandas import DataFrame
from backend.validators import pydant
if TYPE_CHECKING:
@@ -43,7 +45,10 @@ class DefaultParser(object):
*args ():
**kwargs ():
"""
logger.debug(f"\n\nHello from {self.__class__.__name__}\n\n")
logger.info(f"\n\nHello from {self.__class__.__name__}\n\n")
if isinstance(filepath, str):
filepath = Path(filepath)
self.filepath = filepath
self.proceduretype = proceduretype
try:
self._pyd_object = getattr(pydant,
@@ -57,15 +62,27 @@ class DefaultParser(object):
self.sheet = sheet
if not start_row:
start_row = self.__class__.start_row
if self.filepath.suffix == ".xlsx":
self.workbook = load_workbook(self.filepath, data_only=True)
self.worksheet = self.workbook[self.sheet]
elif self.filepath.suffix == ".csv":
self.workbook, self.worksheet = self.csv2xlsx(self.filepath)
self.start_row = self.delineate_start_row(start_row=start_row)
self.end_row = self.delineate_end_row(start_row=self.start_row)
logger.debug(f"Start row: {self.start_row}, End row: {self.end_row}")
@classmethod
def csv2xlsx(cls, filepath):
wb = Workbook()
ws = wb.active
with open(filepath, "r") as f:
reader = csv.reader(f, delimiter=",")
for row in reader:
ws.append(row)
return wb, ws
def to_pydantic(self):
# data = {key: value['value'] for key, value in self.parsed_info.items()}
data = self.parsed_info
logger.debug(f"Data for {self.__class__.__name__}: {pformat(data)}")
data['filepath'] = self.filepath
return self._pyd_object(**data)
@@ -86,7 +103,7 @@ class DefaultParser(object):
for iii, row in enumerate(self.worksheet.iter_rows(min_row=start_row), start=start_row):
if all([item.value is None for item in row]):
return iii
return self.worksheet.max_row
return self.worksheet.max_row + 1
class DefaultKEYVALUEParser(DefaultParser):
@@ -100,7 +117,6 @@ class DefaultKEYVALUEParser(DefaultParser):
rows = range(self.start_row, self.end_row)
for row in rows:
check_row = [item for item in self.worksheet.rows][row-1]
logger.debug(f"Checking row {row-1}, {check_row} for merged cells.")
if any([isinstance(cell, MergedCell) for cell in check_row]):
continue
key = self.worksheet.cell(row, 1).value
@@ -110,9 +126,7 @@ class DefaultKEYVALUEParser(DefaultParser):
key = key.lower().replace(":", "").strip().replace(" ", "_")
value = self.worksheet.cell(row, 2).value
missing = False if value else True
# location_map = dict(row=row, key_column=1, value_column=2, sheet=self.worksheet.title)
value = dict(value=value, missing=missing)#, location=location_map)
logger.debug(f"Yielding {value} for {key}")
yield key, value
@@ -123,7 +137,6 @@ class DefaultTABLEParser(DefaultParser):
@property
def parsed_info(self) -> Generator[dict, None, None]:
logger.debug(f"creating dataframe from {self.start_row} to {self.end_row}")
df = DataFrame(
[item for item in self.worksheet.values][self.start_row - 1:self.end_row - 1])
df.columns = df.iloc[0]
@@ -131,12 +144,10 @@ class DefaultTABLEParser(DefaultParser):
df = df.dropna(axis=1, how='all')
for ii, row in enumerate(df.iterrows()):
output = {}
# for key, value in row[1].to_dict().items():
for key, value in row[1].details_dict().items():
for key, value in row[1].to_dict().items():
if isinstance(key, str):
key = key.lower().replace(" ", "_")
key = re.sub(r"_(\(.*\)|#)", "", key)
# logger.debug(f"Row {ii} values: {key}: {value}")
output[key] = value
yield output
@@ -144,5 +155,10 @@ class DefaultTABLEParser(DefaultParser):
return [self._pyd_object(**output) for output in self.parsed_info]
from .procedure_parsers import ProcedureInfoParser, ProcedureSampleParser, ProcedureReagentParser, ProcedureEquipmentParser
from .results_parsers import (
DefaultResultsInfoParser, DefaultResultsSampleParser,
PCRSampleParser, PCRInfoParser
)
from .clientsubmission_parser import ClientSubmissionSampleParser, ClientSubmissionInfoParser
from backend.excel.parsers.results_parsers.pcr_results_parser import PCRInfoParser, PCRSampleParser
from .results_parsers.pcr_results_parser import PCRInfoParser, PCRSampleParser

View File

@@ -1,11 +1,13 @@
"""
Module for clientsubmission parsing
"""
from __future__ import annotations
import logging
import sys
from datetime import datetime
from pathlib import Path
from string import ascii_lowercase
from typing import Generator, TYPE_CHECKING, Literal
from typing import Generator, TYPE_CHECKING
from openpyxl.reader.excel import load_workbook
from tools import row_keys
from . import DefaultKEYVALUEParser, DefaultTABLEParser
@@ -122,20 +124,6 @@ class ClientSubmissionInfoParser(DefaultKEYVALUEParser, SubmissionTyperMixin):
else:
self.submissiontype = submissiontype
super().__init__(filepath=filepath, sheet="Client Info", start_row=1, **kwargs)
# NOTE: move to the manager class.
# allowed_procedure_types = [item.name for item in self.submissiontype.proceduretype]
# for name in allowed_procedure_types:
# if name in self.workbook.sheetnames:
# # 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)
# except AttributeError:
# manager = procedure_managers.DefaultManager
# self.manager = manager(proceduretype=name)
# pass
@property
def parsed_info(self):
@@ -144,13 +132,14 @@ class ClientSubmissionInfoParser(DefaultKEYVALUEParser, SubmissionTyperMixin):
output['clientlab'] = output['client_lab']
except KeyError:
pass
# output['submissiontype'] = dict(value=self.submissiontype.name.title())
try:
output['submissiontype'] = output['submission_type']
output['submissiontype']['value'] = self.submissiontype.name.title()
except KeyError:
pass
logger.debug(f"Data: {output}")
if isinstance(output['submitted_date']['value'], datetime):
output['submitted_date']['value'] = output['submitted_date']['value'].date()
return output
@@ -173,8 +162,6 @@ class ClientSubmissionSampleParser(DefaultTABLEParser, SubmissionTyperMixin):
def parsed_info(self) -> Generator[dict, None, None]:
output = super().parsed_info
for ii, sample in enumerate(output, start=1):
# logger.debug(f"Parsed info sample: {sample}")
if isinstance(sample["row"], str) and sample["row"].lower() in ascii_lowercase[0:8]:
try:
sample["row"] = row_keys[sample["row"]]
@@ -184,5 +171,4 @@ class ClientSubmissionSampleParser(DefaultTABLEParser, SubmissionTyperMixin):
yield sample
def to_pydantic(self):
logger.debug(f"Attempting to pydantify: {self._pyd_object}")
return [self._pyd_object(**sample) for sample in self.parsed_info if sample['sample_id']]

View File

@@ -8,6 +8,13 @@ if TYPE_CHECKING:
logger = logging.getLogger(f"submissions.{__name__}")
"""
TODO
- range dicts should hopefully not be necessary in this type of parser. Hopefully all procedure parsers are the same.
"""
class ProcedureInfoParser(DefaultKEYVALUEParser):
default_range_dict = [dict(

View File

@@ -12,12 +12,22 @@ logger = logging.getLogger(f"submissions.{__name__}")
class DefaultResultsInfoParser(DefaultKEYVALUEParser):
pyd_name = "PydResults"
def __init__(self, filepath: Path | str, proceduretype: "ProcedureType" | None = None,
results_type: str | None = "PCR", *args, **kwargs):
def __init__(self, filepath: Path | str, results_type: str, proceduretype: "ProcedureType" | None = None,
*args, **kwargs):
if results_type:
self.results_type = results_type
try:
sheet = proceduretype.allowed_result_methods[results_type]['info']['sheet']
except KeyError:
sheet = 1
if "start_row" not in kwargs:
try:
start_row = proceduretype.allowed_result_methods[results_type]['info']['start_row']
except KeyError:
start_row = 1
else:
start_row = kwargs.pop('start_row')
# start_row = proceduretype.allowed_result_methods[results_type]['info']['start_row']
super().__init__(filepath=filepath, proceduretype=proceduretype, sheet=sheet, start_row=start_row, *args,
**kwargs)
@@ -25,14 +35,24 @@ class DefaultResultsInfoParser(DefaultKEYVALUEParser):
class DefaultResultsSampleParser(DefaultTABLEParser):
pyd_name = "PydResults"
def __init__(self, filepath: Path | str, proceduretype: "ProcedureType" | None = None,
results_type: str | None = "PCR", *args, **kwargs):
def __init__(self, filepath: Path | str, results_type: str, proceduretype: "ProcedureType" | None = None,
*args, **kwargs):
if results_type:
self.results_type = results_type
try:
sheet = proceduretype.allowed_result_methods[results_type]['sample']['sheet']
except KeyError:
sheet = 1
if "start_row" not in kwargs:
try:
start_row = proceduretype.allowed_result_methods[results_type]['sample']['start_row']
except KeyError:
start_row = 1
else:
start_row = kwargs.pop('start_row')
super().__init__(filepath=filepath, proceduretype=proceduretype, sheet=sheet, start_row=start_row, *args,
**kwargs)
from .pcr_results_parser import PCRInfoParser, PCRSampleParser
from .qubit_results_parser import QubitInfoParser, QubitSampleParser

View File

@@ -1,5 +1,5 @@
"""
Parser for pcr results from Design and Analysis Studio
"""
from __future__ import annotations
import logging
@@ -15,7 +15,7 @@ logger = logging.getLogger(f"submissions.{__name__}")
class PCRInfoParser(DefaultResultsInfoParser):
def __init__(self, filepath: Path | str, sheet: str | None = None, start_row: int = 1, procedure=None, **kwargs):
def __init__(self, filepath: Path | str, procedure=None, **kwargs):
self.results_type = "PCR"
self.procedure = procedure
super().__init__(filepath=filepath, proceduretype=self.procedure.proceduretype)

View File

@@ -0,0 +1,58 @@
"""
"""
from __future__ import annotations
import logging
from csv import reader
from typing import Generator, TYPE_CHECKING
from frontend.widgets.results_sample_matcher import ResultsSampleMatcher
from backend import Procedure
from backend.db.models import ProcedureSampleAssociation
from backend.excel.parsers.results_parsers import DefaultResultsInfoParser, DefaultResultsSampleParser
from pathlib import Path
if TYPE_CHECKING:
from backend.validators.pydant import PydSample
logger = logging.getLogger(f"submissions.{__name__}")
class QubitInfoParser(DefaultResultsInfoParser):
def __init__(self, filepath: Path | str, procedure=None, **kwargs):
self.results_type = "Qubit"
self.procedure = procedure
super().__init__(filepath=filepath, proceduretype=self.procedure.proceduretype, results_type="Qubit")
def to_pydantic(self):
"""
Since there is no overview generated, return blank PydResults object.
Returns:
PydResults
"""
from backend.validators.pydant import PydResults
return None
class QubitSampleParser(DefaultResultsSampleParser):
"""Object to pull data from Design and Analysis PCR export file."""
def __init__(self, filepath: Path | str, sheet: str | None = None, start_row: int = 1, procedure=None, **kwargs):
self.results_type = "Qubit"
self.procedure = procedure
super().__init__(filepath=filepath, proceduretype=self.procedure.proceduretype, results_type="Qubit")
self.sample_matcher()
def sample_matcher(self):
# samples = [item for item in self.procedure.proceduresampleassociation]
dlg = ResultsSampleMatcher(
parent=None,
results_var_name="original_sample_conc.",
results=self.parsed_info,
samples=self.procedure.proceduresampleassociation,
procedure=self.procedure,
results_type="Qubit"
)
if dlg.exec():
for result in dlg.output:
result.save()

View File

@@ -7,10 +7,8 @@ from pandas import DataFrame, ExcelWriter
from pathlib import Path
from datetime import date
from typing import Tuple, List
# from backend import Procedure
from backend.db.models import Procedure, Run
from tools import jinja_template_loading, get_first_blank_df_row, row_map, flatten_list
from tools import jinja_template_loading, get_first_blank_df_row, row_map, flatten_list, ctx
from PyQt6.QtWidgets import QWidget
from openpyxl.worksheet.worksheet import Worksheet
@@ -47,7 +45,6 @@ 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 = Run.query(start_date=start_date, end_date=end_date, page_size=0)
self.procedures = Procedure.query(start_date=start_date, end_date=end_date, page_size=0)
if organizations is not None:
self.procedures = [procedure for procedure in self.procedures if procedure.run.clientsubmission.clientlab.name in organizations]
@@ -63,9 +60,7 @@ class ReportMaker(object):
"""
if not self.procedures:
return DataFrame(), DataFrame()
# df = DataFrame.from_records([item.to_dict(report=True) for item in self.runs])
df = DataFrame.from_records([item.details_dict() for item in self.procedures])
logger.debug(df.columns)
# NOTE: put procedure with the same lab together
df = df.sort_values("clientlab")
# NOTE: aggregate cost and sample count columns
@@ -178,10 +173,6 @@ class TurnaroundMaker(ReportArchetype):
Returns:
"""
if 'pytest' not in sys.modules:
from tools import ctx
else:
from test_settings import ctx
days = sub.turnaround_time
try:
tat = sub.get_default_info("turnaround_time")
@@ -203,14 +194,12 @@ class TurnaroundMaker(ReportArchetype):
class ConcentrationMaker(ReportArchetype):
def __init__(self, start_date: date, end_date: date, submission_type: str = "Bacterial Culture",
# controls_only: bool = True):
include: List[str] = []):
self.start_date = start_date
self.end_date = end_date
# NOTE: Set page size to zero to override limiting query size.
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])
try:
self.samples = flatten_list([sub.get_provisional_controls(include=include) for sub in self.subs])
except AttributeError:

View File

@@ -1,11 +1,12 @@
"""
Module for default excel writers
"""
from __future__ import annotations
import logging, sys
from datetime import datetime, date
from pprint import pformat
from types import NoneType
from typing import Any, Literal
from openpyxl.styles import Alignment, PatternFill
from openpyxl.utils import get_column_letter
from openpyxl.workbook.workbook import Workbook
from openpyxl.worksheet.worksheet import Worksheet
from pandas import DataFrame
@@ -44,10 +45,8 @@ class DefaultWriter(object):
value = None
case datetime() | date():
value = value.strftime("%Y-%m-%d")
case _:
value = str(value)
# logger.debug(f"Returning value: {value}")
return value
@classmethod
@@ -60,7 +59,6 @@ class DefaultWriter(object):
def write_to_workbook(self, workbook: Workbook, sheet: str | None = None,
start_row: int | None = None, *args, **kwargs):
logger.debug(f"Writing to workbook with {self.__class__.__name__}")
if not start_row:
try:
start_row = self.__class__.start_row
@@ -81,14 +79,19 @@ class DefaultWriter(object):
self.worksheet = self.prewrite(self.worksheet, start_row=start_row)
self.start_row = self.delineate_start_row(start_row=start_row)
self.end_row = self.delineate_end_row(start_row=start_row)
logger.debug(f"{self.__class__.__name__} Start row: {self.start_row}, end row: {self.end_row}")
return workbook
def delineate_start_row(self, start_row: int = 1):
logger.debug(f"Attempting to find start row from {start_row}")
def delineate_start_row(self, start_row: int = 1) -> int:
"""
Gets the first black row.
Args:
start_row (int): row to start looking at.
Returns:
int
"""
for iii, row in enumerate(self.worksheet.iter_rows(min_row=start_row), start=start_row):
if all([item.value is None for item in row]):
logger.debug(f"Returning {iii} for start row.")
return iii
if self.worksheet.max_row == 1:
return self.worksheet.max_row + 1
@@ -109,7 +112,7 @@ class DefaultWriter(object):
if len(str(cell.value)) > setlen:
setlen = len(str(cell.value))
set_col_width = setlen + 5
# Setting the column width
# Note: Setting the column width
worksheet.column_dimensions[column].width = set_col_width
return worksheet
@@ -130,7 +133,6 @@ class DefaultKEYVALUEWriter(DefaultWriter):
@classmethod
def check_location(cls, locations: list, sheet: str):
logger.debug(f"Checking for location against {sheet}")
return any([item['sheet'] == sheet for item in locations])
def write_to_workbook(self, workbook: Workbook, sheet: str | None = None,
@@ -164,7 +166,8 @@ class DefaultTABLEWriter(DefaultWriter):
return row_count
def delineate_end_row(self, start_row: int = 1) -> int:
return start_row + len(self.pydant_obj) + 1
end_row = start_row + len(self.pydant_obj) + 1
return end_row
def pad_samples_to_length(self, row_count,
mode: Literal["submission", "procedure"] = "submission"): #, column_names):
@@ -193,7 +196,6 @@ class DefaultTABLEWriter(DefaultWriter):
start_row: int | None = None, *args, **kwargs) -> Workbook:
workbook = super().write_to_workbook(workbook=workbook, sheet=sheet, start_row=start_row, *args, **kwargs)
self.header_list = self.sort_header_row(list(set(flatten_list([item.fields for item in self.pydant_obj]))))
logger.debug(f"Header row: {self.header_list}")
self.worksheet = self.write_header_row(worksheet=self.worksheet)
for iii, object in enumerate(self.pydant_obj, start=1):
write_row = self.start_row + iii
@@ -219,7 +221,6 @@ class DefaultTABLEWriter(DefaultWriter):
@classmethod
def sort_header_row(cls, header_list: list) -> list:
output = []
logger.debug(cls.exclude)
for item in cls.header_order:
if item in [header for header in header_list if header not in cls.exclude]:
output.append(header_list.pop(header_list.index(item)))
@@ -237,4 +238,9 @@ class DefaultTABLEWriter(DefaultWriter):
return worksheet
from .procedure_writers import ProcedureInfoWriter, ProcedureSampleWriter, ProcedureReagentWriter, ProcedureEquipmentWriter
from .results_writers import (
PCRInfoWriter, PCRSampleWriter,
QubitInfoWriter, QubitSampleWriter
)
from .clientsubmission_writer import ClientSubmissionInfoWriter, ClientSubmissionSampleWriter

View File

@@ -1,3 +1,6 @@
"""
Module for ClientSubmission writing
"""
from __future__ import annotations
import logging
from pprint import pformat
@@ -17,10 +20,8 @@ class ClientSubmissionInfoWriter(DefaultKEYVALUEWriter):
def __init__(self, pydant_obj, *args, **kwargs):
super().__init__(pydant_obj=pydant_obj, *args, **kwargs)
logger.debug(f"{self.__class__.__name__} recruited!")
def prewrite(self, worksheet: Worksheet, start_row: int) -> Worksheet:
# worksheet.merge_cells(start_row=start_row, start_column=1, end_row=start_row, end_column=4)
worksheet.cell(row=start_row, column=1, value="Submitter Info")
worksheet.cell(row=start_row, column=1).alignment = Alignment(horizontal="center")
return worksheet

View File

@@ -1,14 +1,11 @@
"""
Default writers for procedures.
"""
from __future__ import annotations
import logging
import sys
import logging, sys
from pprint import pformat
from openpyxl.workbook import Workbook
from backend.excel.writers import DefaultKEYVALUEWriter, DefaultTABLEWriter
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from backend.db.models import ProcedureType
logger = logging.getLogger(f"submissions.{__name__}")
@@ -18,55 +15,47 @@ class ProcedureInfoWriter(DefaultKEYVALUEWriter):
header_order = []
exclude = ['control', 'equipment', 'excluded', 'id', 'misc_info', 'plate_map', 'possible_kits',
'procedureequipmentassociation', 'procedurereagentassociation', 'proceduresampleassociation', 'proceduretipsassociation', 'reagent',
'reagentrole', 'results', 'sample', 'tips']
'reagentrole', 'results', 'sample', 'tips', 'reagentlot', 'platemap']
def __init__(self, pydant_obj, *args, **kwargs):
super().__init__(pydant_obj=pydant_obj, *args, **kwargs)
self.fill_dictionary = {k: v for k, v in self.fill_dictionary.items() if k not in self.__class__.exclude}
# logger.debug(pformat(self.fill_dictionary))
# for rng in self.range_dict:
# if "sheet" not in rng or rng['sheet'] == "":
# rng['sheet'] = f"{pydant_obj.proceduretype.name} Quality"
def write_to_workbook(self, workbook: Workbook, sheet: str | None = None,
start_row: int = 1, *args, **kwargs) -> Workbook:
workbook = super().write_to_workbook(workbook=workbook, sheet=f"{self.pydant_obj.proceduretype.name} Quality")
workbook = super().write_to_workbook(workbook=workbook, sheet=f"{self.pydant_obj.proceduretype.name[:20]} Quality")
return workbook
class ProcedureReagentWriter(DefaultTABLEWriter):
exclude = ["id", "comments", "missing"]
header_order = ["reagentrole", "name", "lot", "expiry"]
exclude = ["id", "comments", "missing", "active", "name"]
header_order = ["reagentrole", "reagent_name", "lot", "expiry"]
def __init__(self, pydant_obj, *args, **kwargs):
super().__init__(pydant_obj=pydant_obj, *args, **kwargs)
self.sheet = f"{self.pydant_obj.proceduretype.name} Quality"
self.sheet = f"{self.pydant_obj.proceduretype.name[:20]} Quality"
self.pydant_obj = self.pydant_obj.reagent
def write_to_workbook(self, workbook: Workbook, sheet: str | None = None,
start_row: int = 1, *args, **kwargs) -> Workbook:
logger.debug(self.pydant_obj)
workbook = super().write_to_workbook(workbook=workbook, sheet=self.sheet)
workbook = super().write_to_workbook(workbook=workbook, sheet=self.sheet, start_row=start_row)
return workbook
class ProcedureEquipmentWriter(DefaultTABLEWriter):
exclude = ['id']
exclude = ['id', "equipment_role"]
header_order = ['equipmentrole', 'name', 'asset_number', 'process', 'tips']
def __init__(self, pydant_obj, range_dict: dict | None = None, *args, **kwargs):
super().__init__(pydant_obj=pydant_obj, range_dict=range_dict, *args, **kwargs)
self.sheet = f"{self.pydant_obj.proceduretype.name} Quality"
self.sheet = f"{self.pydant_obj.proceduretype.name[:20]} Quality"
self.pydant_obj = self.pydant_obj.equipment
def write_to_workbook(self, workbook: Workbook, sheet: str | None = None,
start_row: int = 1, *args, **kwargs) -> Workbook:
logger.debug(self.pydant_obj)
workbook = super().write_to_workbook(workbook=workbook, sheet=self.sheet)
workbook = super().write_to_workbook(workbook=workbook, sheet=self.sheet, start_row=start_row)
return workbook
@@ -77,12 +66,10 @@ class ProcedureSampleWriter(DefaultTABLEWriter):
def __init__(self, pydant_obj, range_dict: dict | None = None, *args, **kwargs):
super().__init__(pydant_obj=pydant_obj, range_dict=range_dict, *args, **kwargs)
self.sheet = f"{self.pydant_obj.proceduretype.name} Quality"
# self.pydant_obj = self.pydant_obj.sample
self.sheet = f"{self.pydant_obj.proceduretype.name[:20]} Quality"
self.pydant_obj = self.pad_samples_to_length(row_count=pydant_obj.max_sample_rank, mode="procedure")
def write_to_workbook(self, workbook: Workbook, sheet: str | None = None,
start_row: int = 1, *args, **kwargs) -> Workbook:
logger.debug(self.pydant_obj)
workbook = super().write_to_workbook(workbook=workbook, sheet=self.sheet)
workbook = super().write_to_workbook(workbook=workbook, sheet=self.sheet, start_row=start_row)
return workbook

View File

@@ -1 +1,32 @@
"""
"""
from openpyxl import Workbook
from backend.excel.writers import DefaultKEYVALUEWriter, DefaultTABLEWriter
from backend.db.models import ProcedureType
from tools import flatten_list
class DefaultResultsInfoWriter(DefaultKEYVALUEWriter):
pass
class DefaultResultsSampleWriter(DefaultTABLEWriter):
def __init__(self, pydant_obj, proceduretype: ProcedureType | None = None, *args, **kwargs):
super().__init__(pydant_obj=pydant_obj, proceduretype=proceduretype, *args, **kwargs)
self.pydant_obj = flatten_list([sample.results for sample in pydant_obj.sample])
def write_to_workbook(self, workbook: Workbook, sheet: str | None = None,
start_row: int | None = None, *args, **kwargs) -> Workbook:
try:
self.worksheet = workbook[f"{self.proceduretype.name[:15]} Results"]
except KeyError:
self.worksheet = workbook.create_sheet(f"{self.proceduretype.name[:15]} Results")
# worksheet = workbook[f"{self.proceduretype.name[:15]} Results"]
return workbook
from .qubit_results_writer import QubitInfoWriter, QubitSampleWriter
from .pcr_results_writer import PCRInfoWriter, PCRSampleWriter

View File

@@ -1,50 +1,35 @@
"""
Writers for PCR results from Design and Analysis Software
"""
from __future__ import annotations
import logging
from pathlib import Path
from pprint import pformat
from typing import Generator, TYPE_CHECKING
from openpyxl import Workbook
from openpyxl.styles import Alignment
from backend.excel.writers import DefaultKEYVALUEWriter, DefaultTABLEWriter
# from backend.excel.writers import DefaultKEYVALUEWriter, DefaultTABLEWriter
from . import DefaultResultsInfoWriter, DefaultResultsSampleWriter
from tools import flatten_list
if TYPE_CHECKING:
from backend.db.models import ProcedureType
logger = logging.getLogger(f"submissions.{__name__}")
class PCRInfoWriter(DefaultKEYVALUEWriter):
class PCRInfoWriter(DefaultResultsInfoWriter):
start_row = 1
def __init__(self, pydant_obj, proceduretype: "ProcedureType" | None = None, *args, **kwargs):
super().__init__(pydant_obj=pydant_obj, proceduretype=proceduretype, *args, **kwargs)
self.fill_dictionary = self.pydant_obj.improved_dict()['result']
logger.debug(pformat(self.fill_dictionary))
def write_to_workbook(self, workbook: Workbook, sheet: str | None = None,
start_row: int | None = None, *args, **kwargs) -> Workbook:
workbook = super().write_to_workbook(workbook=workbook, sheet=f"{self.proceduretype.name} Results")
# if not start_row:
# try:
# start_row = self.__class__.start_row
# except AttributeError as e:
# logger.error(f"Couldn't get start row due to {e}")
# start_row = 1
# # worksheet = workbook[f"{self.proceduretype.name} Results"]
# self.worksheet = workbook.create_sheet(f"{self.proceduretype.name} Results")
# self.worksheet = self.prewrite(self.worksheet, start_row=start_row)
# # self.start_row = self.delineate_start_row(start_row=start_row)
# # self.end_row = self.delineate_end_row(start_row=start_row)
# # for key, value in self.fill_dictionary['result'].items():
# # # logger.debug(f"Filling in {key} with {value}")
# # self.worksheet.cell(value['location']['row'], value['location']['key_column'], value=key.replace("_", " ").title())
# # self.worksheet.cell(value['location']['row'], value['location']['value_column'], value=value['value'])
return workbook
class PCRSampleWriter(DefaultTABLEWriter):
class PCRSampleWriter(DefaultResultsSampleWriter):
def write_to_workbook(self, workbook: Workbook) -> Workbook:
worksheet = workbook[f"{self.proceduretype.name} Results"]
@@ -56,7 +41,6 @@ class PCRSampleWriter(DefaultTABLEWriter):
columns.append((iii, header))
columns = sorted(columns, key=lambda x: x[0])
columns = proto_columns + columns
# logger.debug(columns)
all_results = flatten_list([[item for item in self.rearrange_results(result)] for result in self.pydant_obj])
if len(all_results) > 0 :
worksheet.cell(row=header_row, column=1, value="Sample")
@@ -83,21 +67,10 @@ class PCRSampleWriter(DefaultTABLEWriter):
def column_headers(self):
output = []
for item in self.pydant_obj:
# logger.debug(item)
dicto: dict = item.result
for value in dicto.values():
if not isinstance(value, dict):
# logger.debug(f"Will not include {value} in column headers.")
continue
for key in value.keys():
output.append(key)
return sorted(list(set(output)))

View File

@@ -0,0 +1,51 @@
"""
Writers for PCR results from Qubit device
"""
from __future__ import annotations
import logging
from pprint import pformat
from openpyxl import Workbook
from openpyxl.styles import Alignment
from . import DefaultResultsInfoWriter, DefaultResultsSampleWriter
logger = logging.getLogger(f"submissions.{__name__}")
class QubitInfoWriter(DefaultResultsInfoWriter):
def write_to_workbook(self, workbook: Workbook, sheet: str | None = None,
start_row: int = 1, *args, **kwargs) -> Workbook:
return workbook
class QubitSampleWriter(DefaultResultsSampleWriter):
def write_to_workbook(self, workbook: Workbook, *args, **kwargs) -> Workbook:
workbook = super().write_to_workbook(workbook=workbook, *args, **kwargs)
header_row = self.proceduretype.allowed_result_methods['Qubit']['sample']['start_row']
for iii, header in enumerate(self.column_headers, start=1):
# logger.debug(f"Row: {header_row}, column: {iii}")
self.worksheet.cell(row=header_row, column=iii, value=header.replace("_", " ").title())
# logger.debug(f"Column headers: {self.column_headers}")
for iii, result in enumerate(self.pydant_obj, start = 1):
row = header_row + iii
for k, v in result.result.items():
try:
column = next((col[0].column for col in self.worksheet.iter_cols() if col[0].value == k.replace("_", " ").title()))
except StopIteration:
print(f"fail for {k.replace('_', ' ').title()}")
continue
# logger.debug(f"Writing to row: {row}, column {column}")
cell = self.worksheet.cell(row=row, column=column)
cell.value = v
cell.alignment = Alignment(horizontal='left')
self.worksheet = self.postwrite(self.worksheet)
return workbook
@property
def column_headers(self):
output = []
for result in self.pydant_obj:
for k, value in result.result.items():
output.append(k)
return sorted(list(set(output)))

View File

@@ -1,8 +1,9 @@
"""
Module for manager defaults.
"""
import logging
from pprint import pformat
from pathlib import Path
from typing import Literal
from backend.db.models import ProcedureType
from frontend.widgets.functions import select_open_file
from tools import get_application_from_parent
from backend.validators.pydant import PydBaseClass
@@ -13,8 +14,8 @@ logger = logging.getLogger(f"submissions.{__name__}")
class DefaultManager(object):
def __init__(self, parent, input_object: Path | str | None = None):
logger.debug(f"FName before correction: {type(input_object)}")
self.parent = parent
match input_object:
case str():
self.input_object = Path(input_object)
@@ -23,16 +24,12 @@ class DefaultManager(object):
self.input_object = input_object
self.pyd = self.to_pydantic()
case x if issubclass(input_object.__class__, PydBaseClass):
# logger.debug("Subclass of PydBaseClass")
self.pyd = input_object
case x if issubclass(input_object.__class__, BaseClass):
# logger.debug("Subclass of BaseClass")
self.pyd = input_object.to_pydantic()
case _:
self.input_object = select_open_file(file_extension="xlsx", obj=get_application_from_parent(parent))
self.pyd = self.to_pydantic()
# logger.debug(f"FName after correction: {input_object}")
from .clientsubmissions import DefaultClientSubmissionManager

View File

@@ -1,9 +1,10 @@
"""
Module for manager of ClientSubmission object
"""
from __future__ import annotations
import logging
import sys
import logging, sys
from typing import TYPE_CHECKING
from pathlib import Path
from openpyxl.reader.excel import load_workbook
from openpyxl.workbook import Workbook
from backend.validators import RSLNamer
from backend.managers import DefaultManager
@@ -42,23 +43,15 @@ class DefaultClientSubmissionManager(DefaultManager):
self.sample_parser = ClientSubmissionSampleParser(filepath=self.input_object,
submissiontype=self.submissiontype,
start_row=self.info_parser.end_row)
logger.debug(self.sample_parser.__dict__)
self.clientsubmission = self.info_parser.to_pydantic()
self.clientsubmission.full_batch_size = self.sample_parser.end_row - self.sample_parser.start_row
self.clientsubmission.sample = self.sample_parser.to_pydantic()
return self.clientsubmission
# def to_pydantic(self):
# self.clientsubmission = self.info_parser.to_pydantic()
# self.clientsubmission.sample = self.sample_parser.to_pydantic()
def write(self, workbook: Workbook) -> Workbook:
# workbook: Workbook = load_workbook(BytesIO(self.submissiontype.template_file))
self.info_writer = ClientSubmissionInfoWriter(pydant_obj=self.pyd)
assert isinstance(self.info_writer, ClientSubmissionInfoWriter)
logger.debug("Attempting write.")
workbook = self.info_writer.write_to_workbook(workbook)
self.sample_writer = ClientSubmissionSampleWriter(pydant_obj=self.pyd)
workbook = self.sample_writer.write_to_workbook(workbook, start_row=self.info_writer.worksheet.max_row + 1)

View File

@@ -1,12 +1,10 @@
"""
Module for manager of Procedure object.
"""
from __future__ import annotations
import logging
from io import BytesIO
from pprint import pformat
from openpyxl.reader.excel import load_workbook
import logging, sys
from openpyxl.workbook import Workbook
from backend.managers import DefaultManager, results
from backend.managers import DefaultManager
from typing import TYPE_CHECKING
from pathlib import Path
from backend.excel.parsers import procedure_parsers
@@ -24,6 +22,7 @@ class DefaultProcedureManager(DefaultManager):
if isinstance(proceduretype, str):
proceduretype = ProcedureType.query(name=proceduretype)
self.proceduretype = proceduretype
self.procedure = input_object
super().__init__(parent=parent, input_object=input_object)
@@ -57,7 +56,6 @@ class DefaultProcedureManager(DefaultManager):
self.equipment = self.equipment_parser.to_pydantic()
def write(self, workbook: Workbook) -> Workbook:
# workbook = load_workbook(BytesIO(self.proceduretype.template_file))
try:
info_writer = getattr(procedure_writers, f"{self.proceduretype.name.replace(' ', '')}InfoWriter")
except AttributeError:
@@ -69,29 +67,26 @@ class DefaultProcedureManager(DefaultManager):
except AttributeError:
reagent_writer = procedure_writers.ProcedureReagentWriter
self.reagent_writer = reagent_writer(pydant_obj=self.pyd)
workbook = self.reagent_writer.write_to_workbook(workbook)
workbook = self.reagent_writer.write_to_workbook(workbook, start_row=self.info_writer.end_row)
try:
equipment_writer = getattr(procedure_writers, f"{self.proceduretype.name.replace(' ', '')}EquipmentWriter")
except AttributeError:
equipment_writer = procedure_writers.ProcedureEquipmentWriter
self.equipment_writer = equipment_writer(pydant_obj=self.pyd)
workbook = self.equipment_writer.write_to_workbook(workbook)
workbook = self.equipment_writer.write_to_workbook(workbook, start_row=self.reagent_writer.end_row)
try:
sample_writer = getattr(procedure_writers, f"{self.proceduretype.name.replace(' ', '')}SampleWriter")
except AttributeError:
sample_writer = procedure_writers.ProcedureSampleWriter
self.sample_writer = sample_writer(pydant_obj=self.pyd)
workbook = self.sample_writer.write_to_workbook(workbook)
# # logger.debug(self.pyd.result)
workbook = self.sample_writer.write_to_workbook(workbook, start_row=self.equipment_writer.end_row)
# # TODO: Find way to group results by result_type.
for result in self.pyd.result:
logger.debug(f"Writing {result.result_type}")
Writer = getattr(results_writers, f"{result.result_type}InfoWriter")
res_info_writer = Writer(pydant_obj=result, proceduretype=self.proceduretype)
workbook = res_info_writer.write_to_workbook(workbook=workbook)
# # sample_results = [sample.result for sample in self.pyd.sample]
# # logger.debug(pformat(self.pyd.sample_results))
# Writer = getattr(results_writers, "PCRSampleWriter")
# res_sample_writer = Writer(pydant_obj=self.pyd.sample_results, proceduretype=self.proceduretype)
# workbook = res_sample_writer.write_to_workbook(workbook=workbook)
for result in self.pyd.sample_results:
Writer = getattr(results_writers, f"{result.result_type}SampleWriter")
res_sample_writer = Writer(pydant_obj=self.procedure, proceduretype=self.proceduretype)
workbook = res_sample_writer.write_to_workbook(workbook=workbook)
return workbook

View File

@@ -1,4 +1,6 @@
"""
Module for default results manager
"""
from __future__ import annotations
import logging
from .. import DefaultManager
@@ -7,7 +9,6 @@ from pathlib import Path
from frontend.widgets.functions import select_open_file
from tools import get_application_from_parent
from typing import TYPE_CHECKING, List
if TYPE_CHECKING:
from backend.validators.pydant import PydResults
@@ -16,17 +17,19 @@ logger = logging.getLogger(f"submission.{__name__}")
class DefaultResultsManager(DefaultManager):
def __init__(self, procedure: Procedure, parent, fname: Path | str | None = None):
logger.debug(f"FName before correction: {fname}")
def __init__(self, procedure: Procedure, parent, fname: Path | str | None = None, extension: str|None="xlsx"):
self.procedure = procedure
if not fname:
self.fname = select_open_file(file_extension="xlsx", obj=get_application_from_parent(parent))
fname = select_open_file(file_extension=extension, obj=get_application_from_parent(parent))
elif isinstance(fname, str):
self.fname = Path(fname)
logger.debug(f"FName after correction: {fname}")
fname = Path(fname)
self.fname = fname
def procedure_to_pydantic(self) -> PydResults:
logger.debug(f"Info parser: {self.info_parser}")
info = self.info_parser.to_pydantic()
if info:
info.parent = self.procedure
return info
@@ -35,3 +38,4 @@ class DefaultResultsManager(DefaultManager):
return sample
from .pcr_results_manager import PCRManager
from .qubit_results_manager import QubitManager

View File

@@ -1,14 +1,12 @@
"""
Module for pcr results from Design and Analysis Studio
"""
from __future__ import annotations
import logging
from io import BytesIO
from pathlib import Path
from typing import Tuple, List, TYPE_CHECKING
from typing import TYPE_CHECKING
from openpyxl.reader.excel import load_workbook
from backend.db.models import Procedure
from backend.excel.parsers.results_parsers.pcr_results_parser import PCRSampleParser, PCRInfoParser
from backend.excel.writers.results_writers.pcr_results_writer import PCRInfoWriter, PCRSampleWriter
@@ -33,9 +31,3 @@ class PCRManager(DefaultResultsManager):
self.info_writer = PCRInfoWriter(pydant_obj=self.procedure.to_pydantic(), proceduretype=self.procedure.proceduretype)
workbook = self.info_writer.write_to_workbook(workbook)
self.sample_writer = PCRSampleWriter(pydant_obj=self.procedure.to_pydantic(), proceduretype=self.procedure.proceduretype)

View File

@@ -0,0 +1,33 @@
"""
"""
from __future__ import annotations
import logging
from io import BytesIO
from pathlib import Path
from typing import TYPE_CHECKING
from openpyxl.reader.excel import load_workbook
from backend.db.models import Procedure
from backend.excel.parsers.results_parsers.qubit_results_parser import QubitSampleParser, QubitInfoParser
from backend.excel.writers.results_writers.qubit_results_writer import QubitInfoWriter, QubitSampleWriter
from . import DefaultResultsManager
if TYPE_CHECKING:
from backend.validators.pydant import PydResults
logger = logging.getLogger(f"submissions.{__name__}")
class QubitManager(DefaultResultsManager):
def __init__(self, procedure: Procedure, parent, fname: Path | str | None = None):
super().__init__(procedure=procedure, parent=parent, fname=fname, extension="csv")
self.parse()
def parse(self):
self.info_parser = QubitInfoParser(filepath=self.fname, procedure=self.procedure)
self.sample_parser = QubitSampleParser(filepath=self.fname, procedure=self.procedure, start_row=self.info_parser.end_row)
def write(self):
workbook = load_workbook(BytesIO(self.procedure.proceduretype.template_file))
self.sample_writer = QubitSampleWriter(pydant_obj=self.procedure.to_pydantic(), proceduretype=self.procedure.proceduretype)
workbook = self.sample_writer.write_to_workbook(workbook)
return workbook

View File

@@ -1,13 +1,10 @@
"""
Module for managing Runs object
"""
from __future__ import annotations
import logging
from pathlib import Path
import logging, sys
from pprint import pformat
from openpyxl import load_workbook
from openpyxl.workbook.workbook import Workbook
from tools import copy_xl_sheet
from backend.managers import DefaultManager
logger = logging.getLogger(f"submissions.{__name__}")
@@ -16,16 +13,13 @@ class DefaultRunManager(DefaultManager):
def write(self) -> Workbook:
from backend.managers import DefaultClientSubmissionManager, DefaultProcedureManager
logger.debug(f"Initializing write")
clientsubmission = DefaultClientSubmissionManager(parent=self.parent, input_object=self.pyd.clientsubmission, submissiontype=self.pyd.clientsubmission.submissiontype)
logger.info(f"Initializing write")
self.clientsubmission = DefaultClientSubmissionManager(parent=self.parent, input_object=self.pyd.clientsubmission, submissiontype=self.pyd.clientsubmission.submissiontype)
workbook = Workbook()
workbook = clientsubmission.write(workbook=workbook)
workbook = self.clientsubmission.write(workbook=workbook)
self.procedures = []
for procedure in self.pyd.procedure:
# # logger.debug(f"Running procedure: {pformat(procedure.__dict__)}")
procedure = DefaultProcedureManager(proceduretype=procedure.proceduretype, parent=self.parent, input_object=procedure)
workbook: Workbook = procedure.write(workbook=workbook)
# for sheetname in wb.sheetnames:
# source_sheet = wb[sheetname]
# ws = workbook.create_sheet(sheetname)
# copy_xl_sheet(source_sheet, ws)
self.procedures.append(procedure)
return workbook

View File

@@ -1,15 +1,18 @@
"""
Contains all validators
"""
from __future__ import annotations
import logging, re
import sys
from pathlib import Path
from openpyxl import load_workbook
from backend.db.models import Run, SubmissionType
from tools import jinja_template_loading
from jinja2 import Template
from dateutil.parser import parse
from datetime import datetime
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from backend.db.models import SubmissionType
logger = logging.getLogger(f"submissions.{__name__}")
@@ -27,15 +30,17 @@ class DefaultNamer(object):
class ClientSubmissionNamer(DefaultNamer):
def __init__(self, filepath: str | Path, submissiontype: str|SubmissionType|None=None,
def __init__(self, filepath: str | Path, submissiontype: str|"SubmissionType"|None=None,
data: dict | None = None, **kwargs):
from backend.db.models import SubmissionType
super().__init__(filepath=filepath)
if not submissiontype:
submissiontype = self.retrieve_submissiontype(filepath=self.filepath)
self.submissiontype = self.retrieve_submissiontype(filepath=self.filepath)
if isinstance(submissiontype, str):
submissiontype = SubmissionType.query(name=submissiontype)
self.submissiontype = SubmissionType.query(name=submissiontype)
def retrieve_submissiontype(self, filepath: str | Path):
def retrieve_submissiontype(self):
from backend.db.models import SubmissionType
# NOTE: Attempt 1, get from form properties:
sub_type = self.get_subtype_from_properties()
if not sub_type:
@@ -48,11 +53,10 @@ class ClientSubmissionNamer(DefaultNamer):
if not sub_type:
logger.warning(f"Getting submissiontype from regex failed, using default submissiontype.")
sub_type = SubmissionType.query(name="Default")
logger.debug(f"Submission Type: {sub_type}")
sys.exit()
return sub_type
def get_subtype_from_regex(self) -> SubmissionType:
from backend.db.models import SubmissionType
regex = SubmissionType.regex
m = regex.search(self.filepath.__str__())
try:
@@ -66,6 +70,7 @@ class ClientSubmissionNamer(DefaultNamer):
def get_subtype_from_preparse(self) -> SubmissionType:
from backend.excel.parsers.clientsubmission_parser import ClientSubmissionInfoParser
from backend.db.models import SubmissionType
parser = ClientSubmissionInfoParser(self.filepath)
sub_type = next((value for k, value in parser.parsed_info.items() if k == "submissiontype"), None)
sub_type = SubmissionType.query(name=sub_type)
@@ -74,6 +79,7 @@ class ClientSubmissionNamer(DefaultNamer):
return sub_type
def get_subtype_from_properties(self) -> SubmissionType:
from backend.db.models import SubmissionType
wb = load_workbook(self.filepath)
# NOTE: Gets first category in the metadata.
categories = wb.properties.category.split(";")
@@ -84,31 +90,22 @@ class ClientSubmissionNamer(DefaultNamer):
return sub_type
class RSLNamer(object):
"""
Object that will enforce proper formatting on RSL plate names.
"""
def __init__(self, filename: str, submission_type: str | None = None, data: dict | None = None):
from backend.db.models import SubmissionType
# NOTE: Preferred method is path retrieval, but might also need validation for just string.
filename = Path(filename) if Path(filename).exists() else filename
self.submission_type = submission_type
if not self.submission_type:
self.submission_type = self.retrieve_submission_type(filename=filename)
# logger.info(f"got submission type: {self.submission_type}")
if self.submission_type:
# self.sub_object = BasicRun.find_polymorphic_subclass(polymorphic_identity=self.submission_type)
self.sub_object = SubmissionType.query(name=self.submission_type['name'], limit=1)
self.parsed_name = self.retrieve_rsl_number(filename=filename, regex=self.sub_object.get_regex(
submission_type=self.submission_type))
# if not data:
# data = dict(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
@@ -125,7 +122,7 @@ class RSLNamer(object):
Returns:
str: parsed procedure type
"""
from backend.db.models import SubmissionType
def st_from_path(filepath: Path) -> str:
"""
Sub def to get proceduretype from a file path
@@ -198,8 +195,9 @@ class RSLNamer(object):
regex (str): string to construct pattern
filename (str): string to be parsed
"""
from backend.db.models import Run
if regex is None:
regex = BasicRun.regex
regex = Run.regex
match filename:
case Path():
m = regex.search(filename.stem)
@@ -227,7 +225,7 @@ class RSLNamer(object):
Returns:
str: Output filename
"""
logger.debug(data)
from backend.db.models import Run
if "submitted_date" in data.keys():
if isinstance(data['submitted_date'], dict):
if data['submitted_date']['value'] is not None:
@@ -244,13 +242,8 @@ class RSLNamer(object):
today = datetime.now()
if isinstance(today, str):
today = datetime.strptime(today, "%Y-%m-%d")
# if "name" in data.keys():
# logger.debug(f"Found name: {data['name']}")
# plate_number = data['name'].split("-")[-1][0]
# else:
previous = Run.query(start_date=today, end_date=today, submissiontype=data['submissiontype'])
plate_number = len(previous) + 1
logger.debug(f"Using plate number: {plate_number}")
return f"RSL-{data['abbreviation']}-{today.year}{str(today.month).zfill(2)}{str(today.day).zfill(2)}-{plate_number}"
@classmethod
@@ -283,5 +276,7 @@ class RSLNamer(object):
return ""
from .pydant import PydRun, PydKitType, PydContact, PydOrganization, PydSample, PydReagent, PydReagentRole, \
PydEquipment, PydEquipmentRole, PydTips, PydProcess, PydElastic, PydClientSubmission, PydProcedure, PydResults
from .pydant import (
PydRun, PydContact, PydClientLab, PydSample, PydReagent, PydReagentRole, PydEquipment, PydEquipmentRole, PydTips,
PydProcess, PydElastic, PydClientSubmission, PydProcedure, PydResults, PydReagentLot
)

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,32 @@
'''
"""
Constructs main application.
'''
from .widgets import *
from .visualizations import *
"""
from .widgets import (
pandasModel,
App,
Concentrations,
ControlsViewer,
DateTypePicker,
EquipmentUsage, RoleComboBox,
select_open_file, select_save_file, save_pdf,
GelBox, ControlsForm,
InfoPane,
StartEndDatePicker, CheckableComboBox, Pagifier,
AddEdit, EditProperty,
SearchBox, SearchResults, FieldSearch,
QuestionAsker, AlertPop, HTMLPop, ObjectSelector,
ProcedureCreation,
SampleChecker,
SubmissionDetails, SubmissionComment,
SubmissionsTree, ClientSubmissionRunModel,
MyQComboBox, MyQDateEdit, SubmissionFormContainer, SubmissionFormWidget, ClientSubmissionFormWidget,
Summary,
TurnaroundMaker
)
from .visualizations import (
CustomFigure,
IridaFigure,
PCRFigure,
ConcentrationsChart,
TurnaroundChart
)

View File

@@ -1,10 +1,9 @@
'''
"""
Contains all operations for creating charts, graphs and visual effects.
'''
"""
from datetime import timedelta, date
from pathlib import Path
from typing import Generator
import plotly
from PyQt6.QtWidgets import QWidget
import pandas as pd, logging
@@ -128,13 +127,10 @@ class CustomFigure(Figure):
html = f'<html><body>'
if self is not None:
# NOTE: Just cannot get this load from string to freaking work.
# html += self.to_html(include_plotlyjs='cdn', full_html=False)
html += plotly.offline.plot(self, output_type='div', include_plotlyjs="cdn")
else:
html += "<h1>No data was retrieved for the given parameters.</h1>"
html += '</body></html>'
# with open("test.html", "w", encoding="utf-8") as f:
# f.write(html)
return html

View File

@@ -3,10 +3,9 @@ Construct BC control concentration charts
"""
from pprint import pformat
from . import CustomFigure
import plotly.express as px
import logging, sys, plotly.express as px
import pandas as pd
from PyQt6.QtWidgets import QWidget
import logging
from operator import itemgetter
logger = logging.getLogger(f"submissions.{__name__}")
@@ -31,13 +30,12 @@ class ConcentrationsChart(CustomFigure):
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='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:
logger.error(f"Error constructing chart: {e}")
# logger.error(f"Error constructing chart: {e}")
scatter = px.scatter()
# NOTE: For some reason if data is allowed to sort itself it leads to wrong ordering of x axis.
traces = sorted(scatter.data, key=itemgetter("name"))

View File

@@ -3,11 +3,9 @@ Functions for constructing irida control graphs using plotly.
"""
from datetime import date
from pprint import pformat
import plotly.express as px
import pandas as pd
import logging, plotly.express as px, pandas as pd
from PyQt6.QtWidgets import QWidget
from . import CustomFigure
import logging
from tools import get_unique_values_in_df_column
logger = logging.getLogger(f"submissions.{__name__}")

View File

@@ -1,20 +1,71 @@
"""
Contains all custom generated PyQT6 derivative widgets.
"""
from PyQt6.QtCore import QAbstractTableModel, Qt
class pandasModel(QAbstractTableModel):
"""
pandas model for inserting summary sheet into gui
NOTE: Copied from Stack Overflow. I have no idea how it actually works.
"""
def __init__(self, data) -> None:
QAbstractTableModel.__init__(self)
self._data = data
def rowCount(self, parent=None) -> int:
"""
does what it says
Args:
parent (_type_, optional): _description_. Defaults to None.
Returns:
int: number of rows in data
"""
return self._data.shape[0]
def columnCount(self, parent=None) -> int:
"""
does what it says
Args:
parent (_type_, optional): _description_. Defaults to None.
Returns:
int: number of columns in data
"""
return self._data.shape[1]
def data(self, index, role=Qt.ItemDataRole.DisplayRole) -> str | None:
if index.isValid():
if role == Qt.ItemDataRole.DisplayRole:
return str(self._data.iloc[index.row(), index.column()])
return None
def headerData(self, col, orientation, role):
if orientation == Qt.Orientation.Horizontal and role == Qt.ItemDataRole.DisplayRole:
return self._data.columns[col]
return None
from .app import App
from .controls_chart import *
from .equipment_usage import *
from .functions import *
from .gel_checker import *
from .info_tab import *
from .misc import *
from .omni_search import *
from .pop_ups import *
from .submission_details import *
from .submission_table import *
from .submission_widget import *
from .summary import *
from .turnaround import *
from .omni_add_edit import *
from .omni_manager_pydant import *
from .concentrations import Concentrations
from .controls_chart import ControlsViewer
from .date_type_picker import DateTypePicker
from .equipment_usage import EquipmentUsage, RoleComboBox
from .functions import select_open_file, select_save_file, save_pdf
from .gel_checker import GelBox, ControlsForm
from .info_tab import InfoPane
from .misc import StartEndDatePicker, CheckableComboBox, Pagifier
from .omni_add_edit import AddEdit, EditProperty
from .omni_search import SearchBox, SearchResults, FieldSearch
from .pop_ups import QuestionAsker, AlertPop, HTMLPop, ObjectSelector
from .procedure_creation import ProcedureCreation
from .sample_checker import SampleChecker
from .submission_details import SubmissionDetails, SubmissionComment
from .submission_table import SubmissionsTree, ClientSubmissionRunModel
from .submission_widget import MyQComboBox, MyQDateEdit, SubmissionFormContainer, SubmissionFormWidget, ClientSubmissionFormWidget
from .summary import Summary
from .turnaround import TurnaroundMaker

View File

@@ -13,7 +13,7 @@ from PyQt6.QtGui import QAction
from pathlib import Path
from markdown import markdown
from pandas import ExcelWriter
from backend.db.models import Reagent
# from backend.db.models import ReagentLot
from tools import (
check_if_app, Settings, Report, jinja_template_loading, check_authorization, page_size, is_power_user,
under_development
@@ -22,9 +22,8 @@ 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, ClientSubmissionRunModel
from .submission_table import SubmissionsTree, ClientSubmissionRunModel
from .submission_widget import SubmissionFormContainer
from .controls_chart import ControlsViewer
from .summary import Summary
from .turnaround import TurnaroundTime
from .concentrations import Concentrations
@@ -132,7 +131,7 @@ class App(QMainWindow):
self.table_widget.pager.current_page.textChanged.connect(self.update_data)
self.editReagentAction.triggered.connect(self.edit_reagent)
self.manageOrgsAction.triggered.connect(self.manage_orgs)
self.manageKitsAction.triggered.connect(self.manage_kits)
# self.manageKitsAction.triggered.connect(self.manage_kits)
def showAbout(self):
"""
@@ -181,7 +180,8 @@ class App(QMainWindow):
@check_authorization
def edit_reagent(self, *args, **kwargs):
dlg = SearchBox(parent=self, object_type=Reagent, extras=[dict(name='Role', field="reagentrole")])
from backend.db.models import ReagentLot
dlg = SearchBox(parent=self, object_type=ReagentLot, extras=ReagentLot.get_searchables())
dlg.exec()
def update_data(self):
@@ -195,24 +195,23 @@ class App(QMainWindow):
new_org = dlg.parse_form()
new_org.save()
def manage_kits(self, *args, **kwargs):
from frontend.widgets.omni_manager_pydant import ManagerWindow as ManagerWindowPyd
dlg = ManagerWindowPyd(parent=self, object_type=KitType, extras=[], add_edit='edit', managers=set())
if dlg.exec():
# logger.debug("\n\nBeginning parsing\n\n")
output = dlg.parse_form()
# logger.debug(f"Kit output: {pformat(output.__dict__)}")
# logger.debug("\n\nBeginning transformation\n\n")
sql = output.to_sql()
assert isinstance(sql, KitType)
sql.save()
# def manage_kits(self, *args, **kwargs):
# from frontend.widgets.omni_manager_pydant import ManagerWindow as ManagerWindowPyd
# dlg = ManagerWindowPyd(parent=self, object_type=KitType, extras=[], add_edit='edit', managers=set())
# if dlg.exec():
# output = dlg.parse_form()
# sql = output.to_sql()
# assert isinstance(sql, KitType)
# sql.save()
@under_development
def submissions_to_excel(self, *args, **kwargs):
from backend.db.models import Run
dlg = DateTypePicker(self)
if dlg.exec():
output = dlg.parse_form()
df = BasicRun.archive_submissions(**output)
# TODO: Move to ClientSubmissions
df = Run.archive_submissions(**output)
filepath = select_save_file(self, f"Submissions {output['start_date']}-{output['end_date']}", "xlsx")
writer = ExcelWriter(filepath, "openpyxl")
df.to_excel(writer)
@@ -254,7 +253,6 @@ class AddSubForm(QWidget):
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=ClientSubmissionRunModel(self))
self.pager = Pagifier(page_max=self.sub_wid.total_count / page_size)
self.sheetlayout.addWidget(self.sub_wid)
@@ -265,12 +263,10 @@ 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 = 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 = None
self.tab3.layout.addWidget(self.pcr_viewer)
self.tab3.setLayout(self.tab3.layout)

View File

@@ -43,10 +43,8 @@ class Concentrations(InfoPane):
None
"""
include = self.pos_neg.get_checked()
# logger.debug(f"Include: {include}")
super().update_data()
months = self.diff_month(self.start_date, self.end_date)
# logger.debug(f"Box checked: {self.all_box.isChecked()}")
chart_settings = dict(start_date=self.start_date, end_date=self.end_date,
include=include)
self.report_obj = ConcentrationMaker(**chart_settings)

View File

@@ -108,7 +108,6 @@ class ControlsViewer(InfoPane):
parent=self,
months=months
)
# logger.debug(f"Chart settings: {chart_settings}")
self.fig = self.archetype.instance_class.make_chart(chart_settings=chart_settings, parent=self, ctx=self.app.ctx)
self.report_obj = ChartReportMaker(df=self.fig.df, sheet_name=self.archetype.name)
if issubclass(self.fig.__class__, CustomFigure):

View File

@@ -1,13 +1,19 @@
"""
"""
from PyQt6.QtWidgets import (
QVBoxLayout, QDialog, QDialogButtonBox
)
from .misc import CheckableComboBox, StartEndDatePicker
from backend.db.models.procedures import SubmissionType
import logging
logger = logging.getLogger(f"submissions.{__name__}")
class DateTypePicker(QDialog):
def __init__(self, parent):
from backend.db.models.procedures import SubmissionType
super().__init__(parent)
self.layout = QVBoxLayout()
self.setFixedWidth(500)
@@ -27,10 +33,7 @@ class DateTypePicker(QDialog):
self.setLayout(self.layout)
def parse_form(self):
# sub_types = [self.typepicker.itemText(i) for i in range(self.typepicker.count()) if self.typepicker.itemChecked(i)]
sub_types = self.typepicker.get_checked()
start_date = self.datepicker.start_date.date().toPyDate()
end_date = self.datepicker.end_date.date().toPyDate()
return dict(submissiontype=sub_types, start_date=start_date, end_date=end_date)

View File

@@ -1,91 +1,97 @@
'''
"""
Creates forms that the user can enter equipment info into.
'''
"""
import sys, logging
from pprint import pformat
from PyQt6.QtCore import Qt, QSignalBlocker
from PyQt6.QtWidgets import (
QDialog, QComboBox, QCheckBox, QLabel, QWidget, QVBoxLayout, QDialogButtonBox, QGridLayout
)
from backend.db.models import Equipment, Run, Process, Procedure
from backend.validators.pydant import PydEquipment, PydEquipmentRole, PydTips
import logging
from typing import Generator
from PyQt6.QtCore import Qt, pyqtSlot, QSignalBlocker
from PyQt6.QtWebChannel import QWebChannel
from PyQt6.QtWebEngineWidgets import QWebEngineView
from PyQt6.QtWidgets import (
QDialog, QVBoxLayout, QDialogButtonBox, QGridLayout, QWidget, QCheckBox, QComboBox, QLabel
)
from backend import Process
from backend.db.models import Equipment
from backend.validators.pydant import PydProcedure, PydEquipmentRole, PydTips, PydEquipment
from tools import get_application_from_parent, render_details_template
logger = logging.getLogger(f"submissions.{__name__}")
class EquipmentUsage(QDialog):
def __init__(self, parent, procedure: Procedure):
def __init__(self, parent, procedure: PydProcedure):
super().__init__(parent)
self.procedure = procedure
self.setWindowTitle(f"Equipment Checklist - {procedure.name}")
self.used_equipment = self.procedure.equipment
# self.kit = self.procedure.kittype
self.kit = self.procedure.kittype
self.opt_equipment = procedure.proceduretype.get_equipment()
self.layout = QVBoxLayout()
self.app = get_application_from_parent(parent)
self.webview = QWebEngineView(parent=self)
self.webview.setContextMenuPolicy(Qt.ContextMenuPolicy.NoContextMenu)
self.webview.setMinimumSize(1200, 800)
self.webview.setMaximumWidth(1200)
# NOTE: Decide if exporting should be allowed.
self.layout = QGridLayout()
# NOTE: button to export a pdf version
self.layout.addWidget(self.webview, 1, 0, 10, 10)
self.setLayout(self.layout)
self.populate_form()
def populate_form(self):
"""
Create form widgets
"""
self.setFixedWidth(self.webview.width() + 20)
# NOTE: setup channel
self.channel = QWebChannel()
self.channel.registerObject('backend', self)
html = self.construct_html(procedure=procedure)
self.webview.setHtml(html)
self.webview.page().setWebChannel(self.channel)
QBtn = QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel
self.buttonBox = QDialogButtonBox(QBtn)
self.buttonBox.accepted.connect(self.accept)
self.buttonBox.rejected.connect(self.reject)
label = self.LabelRow(parent=self)
self.layout.addWidget(label)
for equipment in self.opt_equipment:
widg = equipment.to_form(parent=self, used=self.used_equipment)
self.layout.addWidget(widg)
widg.update_processes()
self.layout.addWidget(self.buttonBox)
self.layout.addWidget(self.buttonBox, 11, 1, 1, 1)
def parse_form(self) -> Generator[PydEquipment, None, None]:
"""
Pull info from all RoleComboBox widgets
@classmethod
def construct_html(cls, procedure: PydProcedure, child: bool = False):
proceduretype = procedure.proceduretype
proceduretype_dict = proceduretype.details_dict()
run = procedure.run
html = render_details_template(
template_name="support/equipment_usage",
css_in=[],
js_in=[],
proceduretype=proceduretype_dict,
run=run.details_dict(),
procedure=procedure.__dict__,
child=child
)
return html
Returns:
Generator[PydEquipment, None, None]: All equipment pulled from widgets
"""
for widget in self.findChildren(QWidget):
match widget:
case RoleComboBox():
if widget.check.isChecked():
item = widget.parse_form()
if item:
yield item
@pyqtSlot(str, str, str, str)
def update_equipment(self, equipmentrole: str, equipment: str, process: str, tips: str):
try:
equipment_of_interest = next(
(item for item in self.procedure.equipment if item.equipmentrole == equipmentrole))
except StopIteration:
equipment_of_interest = None
equipment = Equipment.query(name=equipment)
if equipment_of_interest:
eoi = self.procedure.equipment.pop(self.procedure.equipment.index(equipment_of_interest))
else:
continue
else:
continue
case _:
continue
eoi = equipment.to_pydantic(proceduretype=self.procedure.proceduretype)
eoi.name = equipment.name
eoi.asset_number = equipment.asset_number
eoi.nickname = equipment.nickname
process = next((prcss for prcss in equipment.process if prcss.name == process))
eoi.process = process.to_pydantic()
tips = next((tps for tps in equipment.tips if tps.name == tips))
eoi.tips = tips.to_pydantic()
self.procedure.equipment.append(eoi)
logger.debug(f"Updated equipment: {self.procedure.equipment}")
class LabelRow(QWidget):
"""Provides column headers"""
def __init__(self, parent) -> None:
super().__init__(parent)
self.layout = QGridLayout()
self.check = QCheckBox()
self.layout.addWidget(self.check, 0, 0)
self.check.stateChanged.connect(self.check_all)
for iii, item in enumerate(["Role", "Equipment", "Process", "Tips"], start=1):
label = QLabel(item)
label.setMaximumWidth(200)
label.setMinimumWidth(200)
self.layout.addWidget(label, 0, iii, alignment=Qt.AlignmentFlag.AlignRight)
self.setLayout(self.layout)
def check_all(self):
"""
Toggles all checkboxes in the form
"""
for object in self.parent().findChildren(QCheckBox):
object.setChecked(self.check.isChecked())
def save_procedure(self):
sql, _ = self.procedure.to_sql()
sql.save()
class RoleComboBox(QWidget):
@@ -124,7 +130,6 @@ class RoleComboBox(QWidget):
"""
equip = self.box.currentText()
equip2 = next((item for item in self.role.equipment if item.name == equip), self.role.equipment[0])
logger.debug(f"Equip2: {equip2}")
with QSignalBlocker(self.process) as blocker:
self.process.clear()
self.process.addItems([item for item in equip2.process if item in self.role.process])

View File

@@ -1,131 +0,0 @@
'''
Creates forms that the user can enter equipment info into.
'''
import sys
from pprint import pformat
from PyQt6.QtCore import Qt, QSignalBlocker, pyqtSlot
from PyQt6.QtWebChannel import QWebChannel
from PyQt6.QtWebEngineWidgets import QWebEngineView
from PyQt6.QtWidgets import (
QDialog, QComboBox, QCheckBox, QLabel, QWidget, QVBoxLayout, QDialogButtonBox, QGridLayout
)
from backend.db.models import Equipment, Run, Process, Procedure, Tips
from backend.validators.pydant import PydEquipment, PydEquipmentRole, PydTips, PydProcedure
import logging
from typing import Generator
from tools import get_application_from_parent, render_details_template, flatten_list
logger = logging.getLogger(f"submissions.{__name__}")
class EquipmentUsage(QDialog):
def __init__(self, parent, procedure: PydProcedure):
super().__init__(parent)
self.procedure = procedure
self.setWindowTitle(f"Equipment Checklist - {procedure.name}")
self.used_equipment = self.procedure.equipment
self.kit = self.procedure.kittype
self.opt_equipment = procedure.proceduretype.get_equipment()
self.layout = QVBoxLayout()
self.app = get_application_from_parent(parent)
self.webview = QWebEngineView(parent=self)
self.webview.setContextMenuPolicy(Qt.ContextMenuPolicy.NoContextMenu)
self.webview.setMinimumSize(1200, 800)
self.webview.setMaximumWidth(1200)
# NOTE: Decide if exporting should be allowed.
# self.webview.loadFinished.connect(self.activate_export)
self.layout = QGridLayout()
# NOTE: button to export a pdf version
self.layout.addWidget(self.webview, 1, 0, 10, 10)
self.setLayout(self.layout)
self.setFixedWidth(self.webview.width() + 20)
# NOTE: setup channel
self.channel = QWebChannel()
self.channel.registerObject('backend', self)
html = self.construct_html(procedure=procedure)
self.webview.setHtml(html)
self.webview.page().setWebChannel(self.channel)
QBtn = QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel
self.buttonBox = QDialogButtonBox(QBtn)
self.buttonBox.accepted.connect(self.accept)
self.buttonBox.rejected.connect(self.reject)
self.layout.addWidget(self.buttonBox, 11, 1, 1, 1)
@classmethod
def construct_html(cls, procedure: PydProcedure, child: bool = False):
proceduretype = procedure.proceduretype
proceduretype_dict = proceduretype.details_dict()
run = procedure.run
# proceduretype_dict['equipment_json'] = flatten_list([item['equipment_json'] for item in proceduretype_dict['equipment']])
# proceduretype_dict['equipment_json'] = [
# {'name': 'Liquid Handler', 'equipment': [
# {'name': 'Other', 'asset_number': 'XXX', 'processes': [
# {'name': 'Trust Me', 'tips': ['Blah']},
# {'name': 'No Me', 'tips': ['Blah', 'Crane']}
# ]
# },
# {'name': 'Biomek', 'asset_number': '5015530', 'processes': [
# {'name': 'Sample Addition', 'tips': ['Axygen 20uL']
# }
# ]
# }
# ]
# }
# ]
# if procedure.equipment:
# for equipmentrole in proceduretype_dict['equipment']:
# # NOTE: Check if procedure equipment is present and move to head of the list if so.
# try:
# relevant_procedure_item = next((equipment for equipment in procedure.equipment if
# equipment.equipmentrole == equipmentrole['name']))
# except StopIteration:
# continue
# item_in_er_list = next((equipment for equipment in equipmentrole['equipment'] if
# equipment['name'] == relevant_procedure_item.name))
# equipmentrole['equipment'].insert(0, equipmentrole['equipment'].pop(
# equipmentrole['equipment'].index(item_in_er_list)))
html = render_details_template(
template_name="support/equipment_usage",
css_in=[],
js_in=[],
proceduretype=proceduretype_dict,
run=run.details_dict(),
procedure=procedure.__dict__,
child=child
)
return html
@pyqtSlot(str, str, str, str)
def update_equipment(self, equipmentrole: str, equipment: str, process: str, tips: str):
try:
equipment_of_interest = next(
(item for item in self.procedure.equipment if item.equipmentrole == equipmentrole))
except StopIteration:
equipment_of_interest = None
equipment = Equipment.query(name=equipment)
if equipment_of_interest:
eoi = self.procedure.equipment.pop(self.procedure.equipment.index(equipment_of_interest))
else:
eoi = equipment.to_pydantic(proceduretype=self.procedure.proceduretype)
eoi.name = equipment.name
eoi.asset_number = equipment.asset_number
eoi.nickname = equipment.nickname
process = next((prcss for prcss in equipment.process if prcss.name == process))
eoi.process = process.to_pydantic()
tips = next((tps for tps in equipment.tips if tps.name == tips))
eoi.tips = tips.to_pydantic()
self.procedure.equipment.append(eoi)
logger.debug(f"Updated equipment: {self.procedure.equipment}")
def save_procedure(self):
sql, _ = self.procedure.to_sql()
logger.debug(pformat(sql.__dict__))
# import pickle
# with open("sql.pickle", "wb") as f:
# pickle.dump(sql, f)
# with open("pyd.pickle", "wb") as f:
# pickle.dump(self.procedure, f)
sql.save()

View File

@@ -39,7 +39,7 @@ def select_open_file(obj: QMainWindow, file_extension: str | None = None) -> Pat
logger.warning(f"No file selected, cancelling.")
return
obj.last_dir = fname.parent
logger.debug(f"File selected: {fname}")
logger.info(f"File selected: {fname}")
return fname

View File

@@ -5,10 +5,9 @@ from operator import itemgetter
from PyQt6.QtWidgets import (
QWidget, QDialog, QGridLayout, QLabel, QLineEdit, QDialogButtonBox, QTextEdit, QComboBox
)
import pyqtgraph as pg
from PyQt6.QtGui import QIcon
from PIL import Image
import logging, numpy as np
import logging, numpy as np, pyqtgraph as pg
from pprint import pformat
from typing import Tuple, List
from pathlib import Path

View File

@@ -5,7 +5,7 @@ from datetime import date
from PyQt6.QtCore import QSignalBlocker
from PyQt6.QtWebEngineWidgets import QWebEngineView
from PyQt6.QtWidgets import QWidget, QGridLayout
from tools import Report, report_result, Result
from tools import Report, report_result, Alert
from .misc import StartEndDatePicker
from .functions import select_save_file, save_pdf
import logging
@@ -34,7 +34,6 @@ class InfoPane(QWidget):
report = Report()
self.start_date = self.datepicker.start_date.date().toPyDate()
self.end_date = self.datepicker.end_date.date().toPyDate()
# logger.debug(f"Start date: {self.start_date}, End date: {self.end_date}")
if self.datepicker.start_date.date() > self.datepicker.end_date.date():
lastmonth = self.datepicker.end_date.date().addDays(-31)
msg = f"Start date after end date is not allowed! Setting to {lastmonth.toString()}."
@@ -43,7 +42,7 @@ class InfoPane(QWidget):
with QSignalBlocker(self.datepicker.start_date) as blocker:
self.datepicker.start_date.setDate(lastmonth)
self.update_data()
report.add_result(Result(owner=self.__str__(), msg=msg, status="Warning"))
report.add_result(Alert(owner=self.__str__(), msg=msg, status="Warning"))
return report
@classmethod

View File

@@ -1,7 +1,7 @@
"""
Contains miscellaneous widgets for frontend functions
"""
import math
import math, logging
from PyQt6.QtGui import QStandardItem, QIcon
from PyQt6.QtWidgets import (
QLabel, QLineEdit, QComboBox, QDateEdit, QPushButton, QWidget,
@@ -10,7 +10,6 @@ from PyQt6.QtWidgets import (
from PyQt6.QtCore import Qt, QDate, QSize
from tools import jinja_template_loading
from backend.db.models import *
import logging
logger = logging.getLogger(f"submissions.{__name__}")

View File

@@ -3,7 +3,7 @@ A widget to handle adding/updating any database object.
"""
from datetime import date
from pprint import pformat
from typing import Any, Tuple
from typing import Any, Tuple, List
from pydantic import BaseModel
from PyQt6.QtWidgets import (
QLabel, QDialog, QWidget, QLineEdit, QGridLayout, QComboBox, QDialogButtonBox, QDateEdit, QSpinBox, QDoubleSpinBox,
@@ -13,6 +13,8 @@ from sqlalchemy import String, TIMESTAMP, INTEGER, FLOAT, JSON, BLOB
from sqlalchemy.orm import ColumnProperty
import logging
from sqlalchemy.orm.relationships import _RelationshipDeclared
from backend.db.models import BaseClass
from backend.validators.pydant import PydBaseClass
from tools import Report, report_result
logger = logging.getLogger(f"submissions.{__name__}")
@@ -20,19 +22,17 @@ logger = logging.getLogger(f"submissions.{__name__}")
class AddEdit(QDialog):
def __init__(self, parent, instance: Any | None = None, managers: set = set()):
def __init__(self, parent, instance: Any | None = None, managers: set = set(), disabled: List[str] = []):
super().__init__(parent)
# logger.debug(f"Managers: {managers}")
logger.debug(f"Disable = {disabled}")
self.instance = instance
self.object_type = instance.__class__
self.managers = managers
# logger.debug(f"Managers: {managers}")
self.layout = QGridLayout(self)
QBtn = QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel
self.buttonBox = QDialogButtonBox(QBtn)
self.buttonBox.accepted.connect(self.accept)
self.buttonBox.rejected.connect(self.reject)
# logger.debug(f"Fields: {pformat(self.instance.omnigui_instance_dict)}")
fields = {k: v for k, v in self.instance.omnigui_instance_dict.items() if "id" not in k}
# NOTE: Move 'name' to the front
try:
@@ -41,13 +41,13 @@ class AddEdit(QDialog):
pass
height_counter = 0
for key, field in fields.items():
disable = key in disabled
try:
value = getattr(self.instance, key)
except AttributeError:
value = None
try:
logger.debug(f"{key} property: {type(field['class_attr'].property)}")
widget = EditProperty(self, key=key, column_type=field, value=value)
widget = EditProperty(self, key=key, column_type=field, value=value, disable=disable)
except AttributeError as e:
logger.error(f"Problem setting widget {key}: {e}")
continue
@@ -55,7 +55,7 @@ class AddEdit(QDialog):
self.layout.addWidget(widget, self.layout.rowCount(), 0)
height_counter += 1
self.layout.addWidget(self.buttonBox)
self.setWindowTitle(f"Add/Edit {self.object_type.__name__} - Manager: {self.managers}")
self.setWindowTitle(f"Add/Edit {self.object_type.__name__}")# - Manager: {self.managers}")
self.setMinimumSize(600, 50 * height_counter)
self.setLayout(self.layout)
@@ -64,11 +64,8 @@ class AddEdit(QDialog):
report = Report()
parsed = {result[0].strip(":"): result[1] for result in
[item.parse_form() for item in self.findChildren(EditProperty)] if result[0]}
# logger.debug(f"Parsed form: {parsed}")
model = self.object_type.pydantic_model
# logger.debug(f"Model type: {model.__name__}")
if model.__name__ == "PydElastic":
# 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 contact.
@@ -78,8 +75,9 @@ class AddEdit(QDialog):
class EditProperty(QWidget):
def __init__(self, parent: AddEdit, key: str, column_type: Any, value):
def __init__(self, parent: AddEdit, key: str, column_type: Any, value, disable: bool):
super().__init__(parent)
logger.debug(f"Widget column type for {key}: {column_type}")
self.name = key
self.label = QLabel(key.title().replace("_", " "))
self.layout = QGridLayout()
@@ -88,6 +86,7 @@ class EditProperty(QWidget):
self.property_class = column_type['class_attr'].property.entity.class_
except AttributeError:
self.property_class = None
logger.debug(f"Property class: {self.property_class}")
try:
self.is_list = column_type['class_attr'].property.uselist
except AttributeError:
@@ -96,23 +95,26 @@ class EditProperty(QWidget):
case ColumnProperty():
self.column_property_set(column_type, value=value)
case _RelationshipDeclared():
if not self.property_class.skip_on_edit:
try:
check = self.property_class.skip_on_edit
except AttributeError:
check = False
if not check:
self.relationship_property_set(column_type, value=value)
else:
return
case _:
logger.error(f"{column_type} not a supported type.")
return
self.widget.setDisabled(disable)
self.layout.addWidget(self.label, 0, 0, 1, 1)
self.layout.addWidget(self.widget, 0, 1, 1, 3)
self.setLayout(self.layout)
def relationship_property_set(self, relationship, value=None):
self.widget = QComboBox()
# logger.debug(self.parent().managers)
for manager in self.parent().managers:
if self.name in manager.aliases:
# logger.debug(f"Name: {self.name} is in aliases: {manager.aliases}")
choices = [manager.name]
self.widget.setEnabled(False)
break
@@ -127,11 +129,17 @@ class EditProperty(QWidget):
if isinstance(instance_value, list):
instance_value = next((item.name for item in instance_value), None)
if instance_value:
match instance_value:
case x if issubclass(instance_value.__class__, BaseClass):
instance_value = instance_value.name
case x if issubclass(instance_value.__class__, PydBaseClass):
instance_value = instance_value.name
case _:
pass
choices.insert(0, choices.pop(choices.index(instance_value)))
self.widget.addItems(choices)
def column_property_set(self, column_property, value=None):
# logger.debug(f"Column Property: {column_property['class_attr'].expression} {column_property}, Value: {value}")
match column_property['class_attr'].expression.type:
case String():
if value is None:
@@ -176,7 +184,6 @@ class EditProperty(QWidget):
check = self.widget
except AttributeError:
return None, None
# match self.widget
match check:
case QLineEdit():
value = self.widget.text()

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,6 @@
"""
Search box that performs fuzzy search for various object types
"""
from copy import deepcopy
from pprint import pformat
from typing import Tuple, Any, List, Generator
from pandas import DataFrame
@@ -10,8 +9,8 @@ from PyQt6.QtWidgets import (
QLabel, QVBoxLayout, QDialog,
QTableView, QWidget, QLineEdit, QGridLayout, QComboBox, QDialogButtonBox
)
from .submission_table import pandasModel
import logging
from . import pandasModel
import logging, sys
logger = logging.getLogger(f"submissions.{__name__}")
@@ -72,18 +71,14 @@ class SearchBox(QDialog):
self.object_type = self.original_type
else:
self.object_type = self.original_type.find_regular_subclass(self.sub_class.currentText())
# logger.debug(f"Object type: {self.object_type} - {self.object_type.searchables}")
# logger.debug(f"Original type: {self.original_type} - {self.original_type.searchables}")
for item in self.object_type.searchables:
if item['field'] in [item['field'] for item in search_fields]:
logger.debug(f"Already have {item['field']}")
for item in self.object_type.get_searchables():
if item in [thing for thing in search_fields]:
continue
else:
search_fields.append(item)
logger.debug(f"Search fields: {search_fields}")
for iii, searchable in enumerate(search_fields):
widget = FieldSearch(parent=self, label=searchable['label'], field_name=searchable['field'])
widget.setObjectName(searchable['field'])
widget = FieldSearch(parent=self, label=searchable, field_name=searchable)
widget.setObjectName(searchable)
self.layout.addWidget(widget, 1 + iii, 0)
widget.search_widget.textChanged.connect(self.update_data)
self.update_data()
@@ -168,11 +163,10 @@ class SearchResults(QTableView):
self.context = kwargs
self.parent = parent
self.object_type = object_type
try:
self.extras = extras + [item for item in deepcopy(self.object_type.searchables)]
except AttributeError:
self.extras = extras
# logger.debug(f"Extras: {self.extras}")
# try:
# self.extras = extras + [item for item in deepcopy(self.object_type.searchables)]
# except AttributeError:
# self.extras = extras
def setData(self, df: DataFrame) -> None:
"""
@@ -181,10 +175,11 @@ class SearchResults(QTableView):
self.data = df
try:
self.columns_of_interest = [dict(name=item['field'], column=self.data.columns.get_loc(item['field'])) for
item in self.extras]
self.columns_of_interest = [dict(name=item, column=self.data.columns.get_loc(item)) for
item in self.object_type.get_searchables()]
except KeyError:
self.columns_of_interest = []
logger.debug(f"Columns of Interest: {pformat(self.columns_of_interest)}")
try:
self.data['id'] = self.data['id'].apply(str)
self.data['id'] = self.data['id'].str.zfill(3)
@@ -209,10 +204,13 @@ class SearchResults(QTableView):
None
"""
context = {item['name']: x.sibling(x.row(), item['column']).data() for item in self.columns_of_interest}
logger.debug(f"Context: {pformat(context)}")
try:
object = self.object_type.query(**context)
except KeyError:
except KeyError as e:
logger.error(e)
object = None
logger.debug(f"Object: {object}")
try:
object.edit_from_search(obj=self.parent, **context)
except AttributeError as e:

View File

@@ -1,18 +1,15 @@
"""
Main module to construct the procedure form
"""
from __future__ import annotations
import sys, logging, os, re, datetime
from pathlib import Path
import sys, logging, re, datetime
from pprint import pformat
from PyQt6.QtCore import pyqtSlot, Qt
from PyQt6.QtGui import QContextMenuEvent, QAction
from PyQt6.QtWebChannel import QWebChannel
from PyQt6.QtWebEngineWidgets import QWebEngineView
from PyQt6.QtWidgets import QDialog, QGridLayout, QMenu, QDialogButtonBox
from typing import TYPE_CHECKING, Any, List
from PyQt6.QtWidgets import QDialog, QGridLayout, QDialogButtonBox
from typing import TYPE_CHECKING, List
if TYPE_CHECKING:
from backend.db.models import Run, Procedure
from backend.validators import PydProcedure, PydEquipment
from tools import get_application_from_parent, render_details_template, sanitize_object_for_json
@@ -26,7 +23,6 @@ class ProcedureCreation(QDialog):
self.edit = edit
self.run = procedure.run
self.procedure = procedure
# logger.debug(f"procedure: {pformat(self.procedure.__dict__)}")
self.proceduretype = procedure.proceduretype
self.setWindowTitle(f"New {self.proceduretype.name} for {self.run.rsl_plate_number}")
self.plate_map = self.proceduretype.construct_plate_map(sample_dicts=self.procedure.sample)
@@ -56,10 +52,18 @@ class ProcedureCreation(QDialog):
def set_html(self):
from .equipment_usage_2 import EquipmentUsage
from .equipment_usage import EquipmentUsage
proceduretype_dict = self.proceduretype.details_dict()
# NOTE: Add --New-- as an option for reagents.
for key, value in self.procedure.reagentrole.items():
try:
check = "--New--" in [v['name'] for v in value]
except TypeError:
try:
check = "--New--" in [v.name for v in value]
except (TypeError, AttributeError):
check = True
if not check:
value.append(dict(name="--New--"))
if self.procedure.equipment:
for equipmentrole in proceduretype_dict['equipment']:
@@ -73,29 +77,25 @@ class ProcedureCreation(QDialog):
equipment['name'] == relevant_procedure_item.name))
equipmentrole['equipment'].insert(0, equipmentrole['equipment'].pop(
equipmentrole['equipment'].index(item_in_er_list)))
proceduretype_dict['equipment_section'] = EquipmentUsage.construct_html(procedure=self.procedure, child=True)
proceduretype_dict['equipment'] = [sanitize_object_for_json(object) for object in proceduretype_dict['equipment']]
logger.debug(proceduretype_dict['equipment'])
self.update_equipment = EquipmentUsage.update_equipment
regex = re.compile(r".*R\d$")
proceduretype_dict['previous'] = [""] + [item.name for item in self.run.procedure if item.proceduretype == self.proceduretype and not bool(regex.match(item.name))]
html = render_details_template(
template_name="procedure_creation",
# css_in=['new_context_menu'],
js_in=["procedure_form", "grid_drag", "context_menu"],
proceduretype=proceduretype_dict,
run=self.run.details_dict(),
# procedure=self.procedure.__dict__,
procedure=self.procedure,
plate_map=self.plate_map,
edit=self.edit
)
# with open("procedure_creation.html", "w") as f:
# f.write(html)
self.webview.setHtml(html)
@pyqtSlot(str, str, str, str)
def update_equipment(self, equipmentrole: str, equipment: str, process: str, tips: str):
from backend.db.models import Equipment
# logger.debug("Updating equipment")
def update_equipment(self, equipmentrole: str, equipment: str, processversion: str, tips: str):
from backend.db.models import Equipment, ProcessVersion, TipsLot
try:
equipment_of_interest = next(
(item for item in self.procedure.equipment if item.equipmentrole == equipmentrole))
@@ -109,17 +109,26 @@ class ProcedureCreation(QDialog):
eoi.name = equipment.name
eoi.asset_number = equipment.asset_number
eoi.nickname = equipment.nickname
# logger.warning("Setting processes.")
eoi.process = [process for process in equipment.get_processes(equipmentrole=equipmentrole)]
process_name, version = processversion.split("-v")
processversion = ProcessVersion.query(name=process_name, version=version, limit=1)
eoi.processversion = processversion.to_pydantic()
try:
tips_manufacturer, tipsref, lot = [item if item != "" else None for item in tips.split("-")]
tips = TipsLot.query(manufacturer=tips_manufacturer, ref=tipsref, lot=lot)
eoi.tips = tips
except ValueError:
logger.warning(f"No tips info to unpack")
self.procedure.equipment.append(eoi)
# logger.debug(f"Updated equipment: {pformat(self.procedure.equipment)}")
@pyqtSlot(str, str)
def text_changed(self, key: str, new_value: str):
logger.debug(f"New value for {key}: {new_value}")
match key:
case "rsl_plate_num":
setattr(self.procedure.run, key, new_value)
case "repeat_of":
from backend.db.models import Procedure
parent = Procedure.query(name=new_value, limit=1)
self.procedure.repeat_of = parent
case _:
attribute = getattr(self.procedure, key)
match attribute:
@@ -127,19 +136,14 @@ class ProcedureCreation(QDialog):
attribute['value'] = new_value.strip('\"')
case _:
setattr(self.procedure, key, new_value.strip('\"'))
logger.debug(f"Set value for {key}: {getattr(self.procedure, key)}")
@pyqtSlot(str, bool)
def check_toggle(self, key: str, ischecked: bool):
logger.debug(f"{key} is checked: {ischecked}")
setattr(self.procedure, key, ischecked)
@pyqtSlot(str)
def update_kit(self, kittype):
self.procedure.update_kittype_reagentroles(kittype=kittype)
logger.debug({k: v for k, v in self.procedure.__dict__.items() if k != "plate_map"})
self.set_html()
@pyqtSlot(list)
@@ -152,36 +156,27 @@ class ProcedureCreation(QDialog):
@pyqtSlot(str, str, str, str)
def add_new_reagent(self, reagentrole: str, name: str, lot: str, expiry: str):
from backend.validators.pydant import PydReagent
from backend.validators.pydant import PydReagentLot
expiry = datetime.datetime.strptime(expiry, "%Y-%m-%d")
pyd = PydReagent(reagentrole=reagentrole, name=name, lot=lot, expiry=expiry)
logger.debug(pyd)
logger.debug(f"{reagentrole}, {name}, {lot}, {expiry}")
pyd = PydReagentLot(reagentrole=reagentrole, name=name, lot=lot, expiry=expiry)
self.procedure.reagentrole[reagentrole].insert(0, pyd)
logger.debug(pformat(self.procedure.__dict__))
self.set_html()
@pyqtSlot(str, str)
def update_reagent(self, reagentrole: str, name_lot_expiry: str):
logger.debug(f"{reagentrole}: {name_lot_expiry}")
try:
name, lot, expiry = name_lot_expiry.split(" - ")
except ValueError as e:
logger.debug(f"Couldn't perform split due to {e}")
return
self.procedure.update_reagents(reagentrole=reagentrole, name=name, lot=lot, expiry=expiry)
def return_sql(self, new: bool = False):
return self.procedure.to_sql(new=new)
@pyqtSlot(str, result=list)
def get_reagent_names(self, reagentrole_name: str):
from backend.db.models import ReagentRole
reagentrole = ReagentRole.query(name=reagentrole_name)
return [item.name for item in reagentrole.get_reagents(proceduretype=self.procedure.proceduretype)]
# class ProcedureWebViewer(QWebEngineView):
#
# def __init__(self, *args, **kwargs):
# super().__init__(*args, **kwargs)
#
# def contextMenuEvent(self, event: QContextMenuEvent):
# self.menu = self.page().createStandardContextMenu()
# self.menu = self.createStandardContextMenu()
# add_sample = QAction("Add Sample")
# self.menu = QMenu()
# self.menu.addAction(add_sample)
# self.menu.popup(event.globalPos())
def return_sql(self, new: bool = False):
output = self.procedure.to_sql(new=new)
return output

View File

@@ -0,0 +1,96 @@
"""
"""
from __future__ import annotations
import json
import logging, sys
from pprint import pformat
from typing import List, Generator
from PyQt6.QtWidgets import (QDialog, QGridLayout, QDialogButtonBox)
from PyQt6.QtWebEngineWidgets import QWebEngineView
from PyQt6.QtWebChannel import QWebChannel
from PyQt6.QtCore import pyqtSlot
from tools import render_details_template, row_keys
from backend.db.models import Procedure, ProcedureSampleAssociation, Results
logger = logging.getLogger(f"submissions.{__name__}")
class ResultsSampleMatcher(QDialog):
def __init__(self, parent, results_var_name: str, results: Generator[dict, None, None], samples:List[str],
procedure:Procedure, results_type: str):
super().__init__(parent=parent)
self.procedure = procedure
self.results_type = results_type
self.results_var_name = results_var_name
results = [item for item in results]
html = render_details_template("results_sample_match", results=results, results_var_name=self.results_var_name, samples=samples)
self.webview = QWebEngineView()
self.layout = QGridLayout()
self.setLayout(self.layout)
self.channel = QWebChannel()
self.channel.registerObject('backend', self)
self.webview.setHtml(html)
self.webview.page().setWebChannel(self.channel)
self.layout.addWidget(self.webview)
QBtn = QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel
self.buttonBox = QDialogButtonBox(QBtn)
self.buttonBox.accepted.connect(self.accept)
self.buttonBox.rejected.connect(self.reject)
self.layout.addWidget(self.buttonBox)
self.output = []
@pyqtSlot(bool, str, str, str)
def set_match(self, enabled: bool, sample: str, result_text:str, result: str):
logger.debug(f"Sample: {sample}")
if ":" in sample:
sample_id = sample.split(":")[0]
well = sample.split(":")[1]
row = row_keys[well[0]]
column = int(well[1:])
else:
row = None
column = None
result = "".join([r for r in result]).replace("\'", "\"")
try:
result = json.loads(result)
except json.decoder.JSONDecoder:
logger.error("Could not decode json.")
logger.debug(f"Search: {self.procedure}, {sample_id}, {row}, {column}")
association = ProcedureSampleAssociation.query(procedure=self.procedure, sample=sample_id, row=row, column=column)
if enabled:
result = Results(sampleprocedureassociation=association, result=result, result_type=self.results_type)
self.output.append(result)
else:
try:
result = next(
(item for item in self.output if str(item.result[self.results_var_name]) == result_text)
)
except StopIteration:
logger.error(f"Couldn't find association for {result_text}")
return
self.output.remove(result)
@pyqtSlot(str, str)
def update_match(self, sample: str, result_text: str):
if ":" in sample:
sample_id = sample.split(":")[0]
well = sample.split(":")[1]
row = row_keys[well[0]]
column = int(well[1:])
else:
row = None
column = None
logger.debug(f"Search: {self.procedure}, {sample_id}, {row}, {column}")
association = ProcedureSampleAssociation.query(procedure=self.procedure, sample=sample_id, row=row, column=column)
logger.debug(association)
try:
result = next(
(item for item in self.output if str(item.result[self.results_var_name]) == result_text)
)
except StopIteration:
logger.error(f"Couldn't find association for {result_text}")
return
result.sampleprocedureassociation = association
logger.debug(f"Output: {pformat(self.output)}")

View File

@@ -1,5 +1,7 @@
"""
"""
import logging
from pathlib import Path
from typing import List
from PyQt6.QtCore import Qt, pyqtSlot
from PyQt6.QtWebChannel import QWebChannel
@@ -22,7 +24,6 @@ class SampleChecker(QDialog):
self.rsl_plate_number = RSLNamer.construct_new_plate_name(clientsubmission.to_dict())
else:
self.rsl_plate_number = clientsubmission
logger.debug(f"RSL Plate number: {self.rsl_plate_number}")
self.samples = samples
self.setWindowTitle(title)
self.app = get_application_from_parent(parent)
@@ -35,16 +36,11 @@ 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])
# with open(template_path.joinpath("css", "styles.css"), "r") as f:
# css = [f.read()]
try:
samples = self.formatted_list
except AttributeError as e:
logger.error(f"Problem getting sample list: {e}")
samples = []
# html = template.render(samples=samples, css=css, rsl_plate_number=self.rsl_plate_number)
html = render_details_template(template_name="sample_checker", samples=samples, rsl_plate_number=self.rsl_plate_number)
self.webview.setHtml(html)
self.webview.page().setWebChannel(self.channel)
@@ -55,13 +51,8 @@ class SampleChecker(QDialog):
self.layout.addWidget(self.buttonBox, 11, 9, 1, 1, alignment=Qt.AlignmentFlag.AlignRight)
self.setLayout(self.layout)
# with open("sample_checker_rendered.html", "w") as f:
# f.write(html)
logger.debug(f"HTML sample checker written!")
@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}")
try:
item = next((sample for sample in self.samples if int(submission_rank) == sample.submission_rank))
except StopIteration:
@@ -71,7 +62,6 @@ class SampleChecker(QDialog):
@pyqtSlot(int, bool)
def enable_sample(self, submission_rank: int, 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:
@@ -81,14 +71,12 @@ class SampleChecker(QDialog):
@pyqtSlot(str)
def set_rsl_plate_number(self, rsl_plate_number: str):
logger.debug(f"RSL plate num: {rsl_plate_number}")
self.rsl_plate_number = rsl_plate_number
@property
def formatted_list(self) -> List[dict]:
output = []
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"

View File

@@ -62,14 +62,9 @@ class SubmissionDetails(QDialog):
css = f.read()
key = object.__class__.__name__.lower()
d = {key: details}
logger.debug(f"Using details: {pformat(d['procedure']['equipment'])}")
html = template.render(**d, css=[css])
self.webview.setHtml(html)
self.setWindowTitle(f"{object.__class__.__name__} Details - {object.name}")
# with open(f"{object.__class__.__name__}_details_rendered.html", "w") as f:
# f.write(html)
# pass
def activate_export(self) -> None:
"""
@@ -96,10 +91,10 @@ class SubmissionDetails(QDialog):
@pyqtSlot(str)
def equipment_details(self, equipment: str | Equipment):
logger.debug(f"Equipment details")
if isinstance(equipment, str):
equipment = Equipment.query(name=equipment)
base_dict = equipment.to_sub_dict(full_data=True)
# base_dict = equipment.to_sub_dict(full_data=True)
base_dict = equipment.details_dict()
template = equipment.details_template
template_path = Path(template.environment.loader.__getattribute__("searchpath")[0])
with open(template_path.joinpath("css", "styles.css"), "r") as f:
@@ -110,10 +105,10 @@ class SubmissionDetails(QDialog):
@pyqtSlot(str)
def process_details(self, process: str | Process):
logger.debug(f"Process details")
if isinstance(process, str):
process = Process.query(name=process)
base_dict = process.to_sub_dict(full_data=True)
# base_dict = process.to_sub_dict(full_data=True)
base_dict = process.details_dict()
template = process.details_template
template_path = Path(template.environment.loader.__getattribute__("searchpath")[0])
with open(template_path.joinpath("css", "styles.css"), "r") as f:
@@ -124,10 +119,10 @@ class SubmissionDetails(QDialog):
@pyqtSlot(str)
def tips_details(self, tips: str | Tips):
logger.debug(f"Equipment details: {tips}")
if isinstance(tips, str):
tips = Tips.query(lot=tips)
base_dict = tips.to_sub_dict(full_data=True)
# base_dict = tips.to_sub_dict(full_data=True)
base_dict = tips.details_dict()
template = tips.details_template
template_path = Path(template.environment.loader.__getattribute__("searchpath")[0])
with open(template_path.joinpath("css", "styles.css"), "r") as f:
@@ -144,10 +139,10 @@ class SubmissionDetails(QDialog):
Args:
sample (str): Submitter Id of the sample.
"""
logger.debug(f"Sample details.")
if isinstance(sample, str):
sample = Sample.query(sample_id=sample)
base_dict = sample.to_sub_dict(full_data=True)
# base_dict = sample.to_sub_dict(full_data=True)
base_dict = sample.details_dict()
exclude = ['procedure', 'excluded', 'colour', 'tooltip']
base_dict['excluded'] = exclude
template = sample.details_template
@@ -155,8 +150,6 @@ class SubmissionDetails(QDialog):
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.sample_id}.html", 'w') as f:
# f.write(html)
self.webview.setHtml(html)
self.setWindowTitle(f"Sample Details - {sample.sample_id}")
@@ -169,13 +162,13 @@ class SubmissionDetails(QDialog):
kit (str | KitType): Name of kittype.
reagent (str | Reagent): Lot number of the reagent
"""
logger.debug(f"Reagent details.")
if isinstance(reagent, str):
reagent = Reagent.query(lot=reagent)
if isinstance(proceduretype, str):
self.proceduretype = ProcedureType.query(name=proceduretype)
base_dict = reagent.to_sub_dict(proceduretype=self.proceduretype, full_data=True)
# base_dict = reagent.to_sub_dict(proceduretype=self.proceduretype, full_data=True)
# base_dict = reagent.details_dict(proceduretype=self.proceduretype, full_data=True)
base_dict = reagent.details_dict()
env = jinja_template_loading()
temp_name = "reagent_details.html"
try:
@@ -221,7 +214,6 @@ class SubmissionDetails(QDialog):
Args:
run (str | BasicRun): Submission of interest.
"""
logger.debug(f"Run details.")
if isinstance(run, str):
run = Run.query(name=run)
self.rsl_plate_number = run.rsl_plate_number
@@ -234,7 +226,6 @@ class SubmissionDetails(QDialog):
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 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)
@@ -273,7 +264,6 @@ class SubmissionComment(QDialog):
"""
def __init__(self, parent, submission: Run) -> None:
logger.debug(parent)
super().__init__(parent)
self.app = get_application_from_parent(parent)
self.submission = submission
@@ -293,7 +283,7 @@ class SubmissionComment(QDialog):
self.layout.addWidget(self.buttonBox, alignment=Qt.AlignmentFlag.AlignBottom)
self.setLayout(self.layout)
def parse_form(self) -> List[dict]:
def parse_form(self) -> dict:
"""
Adds comment to procedure object.
"""

View File

@@ -1,260 +1,18 @@
"""
Contains widgets specific to the procedure summary and procedure details.
"""
import sys, logging, re
import sys, logging
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, QContextMenuEvent
from typing import Dict, List
# from backend import Procedure
from backend.db.models.submissions import Run, ClientSubmission
from backend.db.models.procedures import Procedure
from tools import Report, Result, report_result, get_application_from_parent
from .functions import select_open_file
from PyQt6.QtWidgets import QMenu, QTreeView, QAbstractItemView
from PyQt6.QtCore import QModelIndex
from PyQt6.QtGui import QAction, QCursor, QStandardItemModel, QStandardItem, QContextMenuEvent
from typing import List
from backend.db.models import Run, ClientSubmission, Procedure
from tools import get_application_from_parent
logger = logging.getLogger(f"submissions.{__name__}")
class pandasModel(QAbstractTableModel):
"""
pandas model for inserting summary sheet into gui
NOTE: Copied from Stack Overflow. I have no idea how it actually works.
"""
def __init__(self, data) -> None:
QAbstractTableModel.__init__(self)
self._data = data
def rowCount(self, parent=None) -> int:
"""
does what it says
Args:
parent (_type_, optional): _description_. Defaults to None.
Returns:
int: number of rows in data
"""
return self._data.shape[0]
def columnCount(self, parent=None) -> int:
"""
does what it says
Args:
parent (_type_, optional): _description_. Defaults to None.
Returns:
int: number of columns in data
"""
return self._data.shape[1]
def data(self, index, role=Qt.ItemDataRole.DisplayRole) -> str | None:
if index.isValid():
if role == Qt.ItemDataRole.DisplayRole:
return str(self._data.iloc[index.row(), index.column()])
return None
def headerData(self, col, orientation, role):
if orientation == Qt.Orientation.Horizontal and role == Qt.ItemDataRole.DisplayRole:
return self._data.columns[col]
return None
class SubmissionsSheet(QTableView):
"""
presents procedure summary to user in tab1
"""
def __init__(self, parent) -> None:
super().__init__(parent)
self.app = self.parent()
self.report = Report()
try:
page_size = self.app.page_size
except AttributeError:
page_size = 250
self.set_data(page=1, page_size=page_size)
self.resizeColumnsToContents()
self.resizeRowsToContents()
self.setSortingEnabled(True)
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 = 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)
except KeyError as e:
logger.error(f"Could not alter id to string due to {e}")
proxyModel = QSortFilterProxyModel()
proxyModel.setSourceModel(pandasModel(self.data))
self.setModel(proxyModel)
def contextMenuEvent(self, event):
"""
Creates actions for right click menu events.
Args:
event (_type_): the item of interest
"""
# NOTE: Get current row index
id = self.selectionModel().currentIndex()
# NOTE: Convert to data in id column (i.e. column 0)
id = id.sibling(id.row(), 0).data()
submission = Run.query(id=id)
self.menu = QMenu(self)
self.con_actions = submission.custom_context_events()
for k in self.con_actions.keys():
action = QAction(k, self)
action.triggered.connect(lambda _, action_name=k: self.triggered_action(action_name=action_name))
self.menu.addAction(action)
# NOTE: add other required actions
self.menu.popup(QCursor.pos())
def triggered_action(self, action_name: str):
"""
Calls the triggered action from the context menu
Args:
action_name (str): name of the action from the menu
"""
func = self.con_actions[action_name]
func(obj=self)
@report_result
def link_extractions(self):
"""
Pull extraction logs into the db
"""
report = Report()
result = self.link_extractions_function()
report.add_result(result)
return report
def link_extractions_function(self):
"""
Link extractions from runlogs to imported procedure
Args:
obj (QMainWindow): original app window
Returns:
Tuple[QMainWindow, dict]: Collection of new main app window and result dict
"""
report = Report()
fname = select_open_file(self, file_extension="csv")
with open(fname.__str__(), 'r') as f:
# NOTE: split csv on commas
runs = [col.strip().split(",") for col in f.readlines()]
count = 0
for run in runs:
new_run = dict(
start_time=run[0].strip(),
rsl_plate_number=run[1].strip(),
sample_count=run[2].strip(),
status=run[3].strip(),
experiment_name=run[4].strip(),
end_time=run[5].strip()
)
# 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 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:
count += 1
except AttributeError:
continue
sub.set_attribute('extraction_info', new_run)
sub.save()
report.add_result(Result(msg=f"We added {count} logs to the database.", status='Information'))
return report
@report_result
def link_pcr(self):
"""
Pull pcr logs into the db
"""
report = Report()
result = self.link_pcr_function()
report.add_result(result)
return report
def link_pcr_function(self):
"""
Link PCR data from procedure logs to an imported procedure
Args:
obj (QMainWindow): original app window
Returns:
Tuple[QMainWindow, dict]: Collection of new main app window and result dict
"""
report = Report()
fname = select_open_file(self, file_extension="csv")
with open(fname.__str__(), 'r') as f:
# NOTE: split csv rows on comma
runs = [col.strip().split(",") for col in f.readlines()]
count = 0
for run in runs:
new_run = dict(
start_time=run[0].strip(),
rsl_plate_number=run[1].strip(),
biomek_status=run[2].strip(),
quant_status=run[3].strip(),
experiment_name=run[4].strip(),
end_time=run[5].strip()
)
# 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)
# NOTE: check if pcr_info already exists
sub.save()
report.add_result(Result(msg=f"We added {count} logs to the database.", status='Information'))
return report
# class ClientSubmissionDelegate(QStyledItemDelegate):
#
# def __init__(self, parent=None):
# super(ClientSubmissionDelegate, self).__init__(parent)
# pixmapi = QStyle.StandardPixmap.SP_ToolBarHorizontalExtensionButton
# icon1 = QWidget().style().standardIcon(pixmapi)
# pixmapi = QStyle.StandardPixmap.SP_ToolBarVerticalExtensionButton
# icon2 = QWidget().style().standardIcon(pixmapi)
# self._plus_icon = icon1
# self._minus_icon = icon2
#
# def initStyleOption(self, 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
@@ -264,20 +22,14 @@ class SubmissionsTree(QTreeView):
super(SubmissionsTree, self).__init__(parent)
self.app = get_application_from_parent(parent)
self.total_count = ClientSubmission.__database_session__.query(ClientSubmission).count()
# self.setIndentation(0)
self.setExpandsOnDoubleClick(False)
# self.clicked.connect(self.on_clicked)
# delegate1 = ClientSubmissionDelegate(self)
# self.setItemDelegateForColumn(0, delegate1)
self.model = model
self.model: ClientSubmissionRunModel = model
header_labels = ["Name", "Submission Type", "Client Lab", "Submitted Date"]
self.model.setHorizontalHeaderLabels(header_labels)
self.setModel(self.model)
# self.header().setSectionResizeMode(0, QHeaderView.sectionResizeMode(self,0).ResizeToContents)
self.setSelectionBehavior(QAbstractItemView.selectionBehavior(self).SelectRows)
# 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)
self.setStyleSheet("""
QTreeView {
background-color: #f5f5f5;
@@ -294,20 +46,14 @@ class SubmissionsTree(QTreeView):
}
""")
# Enable alternating row colors
# Note: Enable alternating row colors
self.setAlternatingRowColors(True)
self.setIndentation(20)
self.setItemsExpandable(True)
# self.expanded.connect(self.expand_item)
for ii in range(2):
self.setSortingEnabled(True)
for ii, _ in enumerate(header_labels):
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 expand_item(self, event: QModelIndex):
logger.debug(f"Data: {event.data()}")
logger.debug(f"Parent {event.parent().data()}")
@@ -327,18 +73,11 @@ class SubmissionsTree(QTreeView):
"""
indexes = self.selectedIndexes()
dicto = next((item.data(1) for item in indexes if item.data(1)))
logger.debug(f"Dicto: {pformat(dicto)}")
query_obj = dicto['item_type'].query(name=dicto['query_str'], limit=1)
logger.debug(f"Querying: {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
logger.debug(f"Context menu actions: {self.con_actions}")
for key in self.con_actions.keys():
logger.debug(key)
match key.lower():
case "add procedure":
action = QMenu(self.menu)
@@ -362,7 +101,7 @@ class SubmissionsTree(QTreeView):
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
# NOTE: add other required actions
self.menu.popup(QCursor.pos())
def set_data(self, page: int = 1, page_size: int = 250) -> None:
@@ -370,38 +109,33 @@ class SubmissionsTree(QTreeView):
sets data in model
"""
self.clear()
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()
self.data = [item.to_dict(full_data=True) for item in ClientSubmission.query(chronologic=True, page=page, page_size=page_size)]
root = self.model.invisibleRootItem()
for submission in self.data:
group_str = f"{submission['submissiontype']}-{submission['submitter_plate_id']}-{submission['submitted_date']}"
submission_item = self.model.add_child(parent=root, child=dict(
submission_item: QStandardItem = self.model.add_child(parent=root, child=dict(
name=group_str,
client=submission['clientlab'],
date=submission['submitted_date'],
type=submission['submissiontype'],
query_str=submission['submitter_plate_id'],
item_type=ClientSubmission
))
# logger.debug(f"Added {submission_item}")
), additions=True)
for run in submission['run']:
# self.model.append_element_to_group(group_item=group_item, element=run)
run_item = self.model.add_child(parent=submission_item, child=dict(
name=run['plate_number'],
query_str=run['plate_number'],
item_type=Run
))
# logger.debug(f"Added {run_item}")
for procedure in run['procedures']:
procedure_item = self.model.add_child(parent=run_item, child=dict(
name=procedure['name'],
query_str=procedure['name'],
item_type=Procedure
))
# logger.debug(f"Added {procedure_item}")
def _populateTree(self, children, parent):
for child in children:
logger.debug(child)
child_item = QStandardItem(child['name'])
parent.appendRow(child_item)
if isinstance(children, List):
@@ -409,22 +143,13 @@ class SubmissionsTree(QTreeView):
def clear(self):
if self.model != None:
# self.model.clear() # works
self.model.setRowCount(0) # works
def show_details(self, sel: QModelIndex):
# id = self.selectionModel().currentIndex()
# NOTE: Convert to data in id column (i.e. column 0)
# id = id.sibling(id.row(), 1)
indexes = self.selectedIndexes()
dicto = next((item.data(1) for item in indexes if item.data(1)))
# try:
# id = int(id.data())
# except ValueError:
# return
# Run.query(id=id).show_details(self)
obj = dicto['item_type'].query(name=dicto['query_str'], limit=1)
logger.debug(obj)
obj.show_details(self)
def link_extractions(self):
@@ -436,19 +161,19 @@ class SubmissionsTree(QTreeView):
class ClientSubmissionRunModel(QStandardItemModel):
def __init__(self, parent):
super().__init__(parent)
def __init__(self, parent=None):
super(ClientSubmissionRunModel, self).__init__(parent)
# headers = ["", "id", "Plate Number", "Started Date", "Completed Date", "Signed By"]
# self.setColumnCount(len(headers))
# self.setHorizontalHeaderLabels(headers)
def add_child(self, parent: QStandardItem, child:dict):
def add_child(self, parent: QStandardItem, child:dict, additions:bool=False) -> QStandardItem:
item = QStandardItem(child['name'])
item.setData(dict(item_type=child['item_type'], query_str=child['query_str']), 1)
parent.appendRow(item)
if additions:
item_client = QStandardItem(child['client'])
item_date = QStandardItem(child['date'])
item_type = QStandardItem(child['type'])
parent.appendRow([item, item_type, item_client, item_date])
else:
parent.appendRow([item])
item.setEditable(False)
return item

View File

@@ -1,24 +1,22 @@
"""
Contains all procedure related frontend functions
"""
import sys
import sys, logging
from PyQt6.QtWidgets import (
QWidget, QPushButton, QVBoxLayout,
QComboBox, QDateEdit, QLineEdit, QLabel, QCheckBox, QHBoxLayout, QGridLayout
)
from PyQt6.QtCore import pyqtSignal, Qt, QSignalBlocker
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 tools import Report, Alert, check_not_nan, main_form_style, report_result, get_application_from_parent
from backend.validators import PydReagent, PydClientSubmission, PydSample
from backend.db import (
ClientLab, SubmissionType, Reagent,
from backend.db.models import (
ClientLab, SubmissionType, Reagent, ReagentLot,
ReagentRole, ProcedureTypeReagentRoleAssociation, Run, ClientSubmission
)
from pprint import pformat
from .pop_ups import QuestionAsker, AlertPop
from .pop_ups import QuestionAsker
from .omni_add_edit import AddEdit
from typing import List, Tuple
from datetime import date
@@ -118,49 +116,28 @@ class SubmissionFormContainer(QWidget):
if isinstance(fname, bool) or fname is None:
fname = select_open_file(self, file_extension="xlsx")
if not fname:
report.add_result(Result(msg=f"File {fname.__str__()} not found.", status="critical"))
report.add_result(Alert(msg=f"File {fname.__str__()} not found.", status="critical"))
return report
# NOTE: create sheetparser using excel sheet and context from gui
# try:
# self.clientsubmissionparser = ClientSubmissionInfoParser(filepath=fname)
# except PermissionError:
# logger.error(f"Couldn't get permission to access file: {fname}")
# return
# except AttributeError:
# self.clientsubmissionparser = ClientSubmissionInfoParser(filepath=fname)
# try:
# # self.prsr = SheetParser(filepath=fname)
# self.sampleparser = ClientSubmissionSampleParser(filepath=fname)
# except PermissionError:
# logger.error(f"Couldn't get permission to access file: {fname}")
# return
# except AttributeError:
# self.sampleparser = ClientSubmissionSampleParser(filepath=fname)
# self.pydclientsubmission = self.clientsubmissionparser.to_pydantic()
# self.pydsamples = self.sampleparser.to_pydantic()
# logger.debug(f"Samples: {pformat(self.pydclientsubmission.sample)}")
self.clientsubmission_manager = DefaultClientSubmissionManager(parent=self, input_object=fname)
self.pydclientsubmission = self.clientsubmission_manager.to_pydantic()
checker = SampleChecker(self, "Sample Checker", self.pydclientsubmission.sample)
if checker.exec():
# logger.debug(pformat(self.pydclientsubmission.sample))
try:
assert isinstance(self.pydclientsubmission, PydClientSubmission)
except AssertionError as e:
logger.error(f"Got wrong type for {self.pydclientsubmission}: {type(self.pydclientsubmission)}")
raise e
self.form = self.pydclientsubmission.to_form(parent=self)
# self.form.samples = self.pydsamples
self.layout().addWidget(self.form)
else:
message = "Submission cancelled."
logger.warning(message)
report.add_result(Result(msg=message, owner=self.__class__.__name__, status="Warning"))
report.add_result(Alert(msg=message, owner=self.__class__.__name__, status="Warning"))
return report
@report_result
def add_reagent(self, instance: Reagent | None = None):
def add_reagent(self, instance: ReagentLot | None = None):
"""
Action to create new reagent in DB.
@@ -172,7 +149,7 @@ class SubmissionFormContainer(QWidget):
"""
report = Report()
if not instance:
instance = Reagent()
instance = ReagentLot()
dlg = AddEdit(parent=self, instance=instance)
if dlg.exec():
reagent = dlg.parse_form()
@@ -180,7 +157,7 @@ class SubmissionFormContainer(QWidget):
# NOTE: send reagent to db
sqlobj = reagent.to_sql()
sqlobj.save()
report.add_result(Result(owner=__name__, code=0, msg="New reagent created.", status="Information"))
report.add_result(Alert(owner=__name__, code=0, msg="New reagent created.", status="Information"))
return reagent, report
@@ -195,14 +172,11 @@ class SubmissionFormWidget(QWidget):
self.pyd = pyd
self.missing_info = []
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()):
logger.debug(f"Pydantic field: {k}")
if k in self.ignore:
logger.warning(f"{k} in form_ignore {self.ignore}, not creating widget")
continue
@@ -218,7 +192,6 @@ class SubmissionFormWidget(QWidget):
value = self.pyd.model_extra[k]
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.submissiontype,
run_object=Run(), disable=check)
if add_widget is not None:
@@ -230,7 +203,6 @@ 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.kittype)
self.setLayout(self.layout)
def disable_reagents(self):
@@ -298,7 +270,6 @@ class SubmissionFormWidget(QWidget):
if isinstance(reagent, self.ReagentFormWidget) or isinstance(reagent, QPushButton):
reagent.setParent(None)
reagents, integrity_report, missing_reagents = self.pyd.check_kit_integrity(extraction_kit=self.extraction_kit)
# logger.debug(f"Reagents: {reagents}")
expiry_report = self.pyd.check_reagent_expiries(exempt=missing_reagents)
for reagent in reagents:
add_widget = self.ReagentFormWidget(parent=self, reagent=reagent, extraction_kit=self.extraction_kit)
@@ -364,34 +335,6 @@ class SubmissionFormWidget(QWidget):
return report
base_submission = self.pyd.to_sql()
# NOTE: check output message for issues
# try:
# trigger = result.results[-1]
# code = trigger.code
# except IndexError as e:
# logger.error(result.results)
# logger.error(f"Problem getting error code: {e}")
# code = 0
# match code:
# # NOTE: code 0: everything is fine.
# case 0:
# pass
# # NOTE: code 1: ask for overwrite
# case 1:
# dlg = QuestionAsker(title=f"Review {base_submission.rsl_plate_number}?", message=trigger.msg)
# if dlg.exec():
# # NOTE: Do not add duplicate reagents.
# pass
# else:
# self.app.ctx.database_session.rollback()
# report.add_result(Result(msg="Overwrite cancelled", status="Information"))
# return report
# # NOTE: code 2: No RSL plate number given
# case 2:
# report.add_result(result)
# return report
# case _:
# pass
# NOTE: add reagents to procedure object
if base_submission is None:
return
for reagent in base_submission.reagents:
@@ -443,14 +386,13 @@ class SubmissionFormWidget(QWidget):
if reagent is not None:
reagents.append(reagent)
else:
report.add_result(Result(msg="Failed integrity check", status="Critical"))
report.add_result(Alert(msg="Failed integrity check", status="Critical"))
return report
case self.InfoItem():
field, value = widget.parse_form()
if field is not None:
info[field] = value
self.pyd.reagents = reagents
# logger.debug(f"Reagents from form: {reagents}")
for item in self.recover:
if hasattr(self, item):
value = getattr(self, item)
@@ -558,29 +500,29 @@ class SubmissionFormWidget(QWidget):
# NOTE: set combobox values to lookedup values
add_widget.addItems(labs)
add_widget.setToolTip("Select submitting lab.")
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 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 '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 kittype in list, skipping move to top of list.")
obj.ext_kit = value
else:
logger.error(f"Couldn't find {obj.prsr.sub['kittype']}")
obj.ext_kit = uses[0]
add_widget.addItems(uses)
add_widget.setToolTip("Select extraction kittype.")
parent.extraction_kit = add_widget.currentText()
# 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 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 '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 kittype in list, skipping move to top of list.")
# obj.ext_kit = value
# else:
# logger.error(f"Couldn't find {obj.prsr.sub['kittype']}")
# obj.ext_kit = uses[0]
# add_widget.addItems(uses)
# add_widget.setToolTip("Select extraction kittype.")
# parent.extraction_kit = add_widget.currentText()
case 'submission_category':
add_widget = MyQComboBox(scrollWidget=parent)
categories = ['Diagnostic', "Surveillance", "Research"]
@@ -813,11 +755,8 @@ class ClientSubmissionFormWidget(SubmissionFormWidget):
self.disabler.setHidden(True)
except AttributeError:
pass
# save_btn = QPushButton("Save")
self.sample = samples
logger.debug(f"Samples: {self.sample}")
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)
@@ -840,13 +779,12 @@ class ClientSubmissionFormWidget(SubmissionFormWidget):
if reagent is not None:
reagents.append(reagent)
else:
report.add_result(Result(msg="Failed integrity check", status="Critical"))
report.add_result(Alert(msg="Failed integrity check", status="Critical"))
return report
case self.InfoItem():
field, value = widget.parse_form()
if field is not None:
info[field] = value
# logger.debug(f"Reagents from form: {reagents}")
for item in self.recover:
if hasattr(self, item):
value = getattr(self, item)
@@ -865,7 +803,6 @@ class ClientSubmissionFormWidget(SubmissionFormWidget):
@report_result
def create_new_submission(self, *args) -> Report:
pyd = self.to_pydantic()
logger.debug(f"Pydantic: {pyd}")
sql = pyd.to_sql()
for sample in pyd.sample:
if isinstance(sample, PydSample):
@@ -874,9 +811,7 @@ class ClientSubmissionFormWidget(SubmissionFormWidget):
if sample.sample_id.lower() in ["", "blank"]:
continue
sample.save()
# if sample not in sql.sample:
sql.add_sample(sample=sample)
logger.debug(pformat(sql.__dict__))
try:
del sql._misc_info['sample']
except KeyError:

View File

@@ -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 ClientLab
from backend.db.models import ClientLab
from backend.excel.reports import ReportMaker
from .misc import CheckableComboBox
import logging

View File

@@ -4,7 +4,7 @@ Pane showing turnaround time summary.
from PyQt6.QtWidgets import QWidget, QPushButton, QComboBox, QLabel
from .info_tab import InfoPane
from backend.excel.reports import TurnaroundMaker
from backend.db import SubmissionType
from backend.db.models import SubmissionType
from frontend.visualizations.turnaround_chart import TurnaroundChart
import logging

View File

@@ -104,8 +104,6 @@ div.gallery {
padding: 5px;
}
.plate {
display: inline-grid;
grid-auto-flow: column;
@@ -189,3 +187,9 @@ ul.no-bullets {
display: grid;
grid-auto-flow: column;
}
.disable_section {
pointer-events: none;
opacity: 0.4;
}

View File

@@ -194,7 +194,7 @@ function contextListener() {
function clickListener() {
document.addEventListener( "click", function(e) {
var clickeElIsLink = clickInsideElement( e, contextMenuLinkClassName );
backend.log(e.target.id)
if ( clickeElIsLink ) {
e.preventDefault();
menuItemListener( clickeElIsLink );

View File

@@ -42,7 +42,7 @@ var changed_it = new Event('change');
var reagentRoles = document.getElementsByClassName("reagentrole");
for(let i = 0; i < reagentRoles.length; i++) {
reagentRoles[i].addEventListener("change", function() {
reagentRoles[i].addEventListener("change", async function() {
if (reagentRoles[i].value.includes("--New--")) {
// alert("Create new reagent.")
var br = document.createElement("br");
@@ -50,9 +50,15 @@ for(let i = 0; i < reagentRoles.length; i++) {
var new_form = document.createElement("form");
new_form.setAttribute("class", "new_reagent_form")
new_form.setAttribute("id", reagentRoles[i].id + "_addition")
var rr_name = document.createElement("input");
rr_name.setAttribute("type", "text");
var rr_name = document.createElement("select");
rr_name.setAttribute("id", "new_" + reagentRoles[i].id + "_name");
var rr_options = await backend.get_reagent_names(reagentRoles[i].id).then(
function(result) {
result.forEach( function(item) {
rr_name.options.add( new Option(item));
});
}
);
var rr_name_label = document.createElement("label");
rr_name_label.setAttribute("for", "new_" + reagentRoles[i].id + "_name");
rr_name_label.innerHTML = "Name:";

View File

@@ -56,10 +56,15 @@
{% endif %}
{% if procedure['sample'] %}
<button type="button"><h3><u>Procedure Samples:</u></h3></button>
{% if procedure['platemap']|length > 5 %}
<br>
{{ procedure['platemap'] }}
{% else %}
<p>{% for sample in procedure['sample'] %}
&nbsp;&nbsp;&nbsp;&nbsp;<a class="{% if sample['active'] %}data-link {% else %}unused {% endif %}sample" id="{{ sample['sample_id'] }}">{{ sample['sample_id']}}</a><br>
<a class="{% if sample['active'] %}data-link {% else %}unused {% endif %}sample" id="{{ sample['sample_id'] }}">{{ sample['sample_id']}}</a><br>
{% endfor %}</p>
{% endif %}
{% endif %}
{% endblock %}
{% if not child %}
</body>

View File

@@ -0,0 +1,54 @@
{% extends "details.html" %}
{% block head %}
{{ super() }}
<title>Matching results</title>
{% endblock %}
{% block body %}
{% for result in results %}
<div class="resultholder" style="border-style: solid; border-width: 2px" data="{{ result }}">
<input type="checkbox" id="{{ loop.index }}_check" class="checker">&nbsp;&nbsp;
<span id="{{ loop.index }}_var", class="variable" data-value="{{ result }}">{{ result[results_var_name] }}</span>&nbsp;&nbsp;
<select id="{{ loop.index }}_select" class="selecter" disabled>
{% for sample in samples %}
{% if sample.well %}
<option value="{{ sample.sample.sample_id }}:{{ sample.well }}">{{ sample.sample.sample_id }}:{{ sample.well }}</option>
{% else %}
<option value="{{ sample.sample.sample_id }}">{{ sample.sample.sample_id }}</option>
{% endif %}
{% endfor %}
</select>
</div>
{% endfor %}
{% endblock %}
{% block script %}
<script>
var holders = document.getElementsByClassName("resultholder");
for(let i = 0; i < holders.length; i++) {
console.log(i);
holders[i].getElementsByClassName("checker")[0].addEventListener("change", function(){
if ( this.checked ) {
holders[i].getElementsByClassName("selecter")[0].disabled = false;
} else {
holders[i].getElementsByClassName("selecter")[0].disabled = true;
}
var enabled = this.checked;
var sample = holders[i].getElementsByClassName("selecter")[0].value;
var result = holders[i].getElementsByClassName("variable")[0].dataset.value;
var result_text = holders[i].getElementsByClassName("variable")[0].textContent
backend.set_match(enabled, sample, result_text, result);
});
holders[i].getElementsByClassName("selecter")[0].addEventListener("change", function(){
var sample = this.value;
var result_text = holders[i].getElementsByClassName("variable")[0].textContent
backend.update_match(sample, result_text);
});
}
</script>
{{ super() }}
{% endblock %}

View File

@@ -1,7 +1,11 @@
<div class="plate" id="plate-container" style="grid-template-columns: repeat({{ plate_columns }}, {{ vw }}vw);grid-template-rows: repeat({{ plate_rows }}, {{ vw }}vw);">
{% for sample in samples %}
<div class="well" draggable="true" id="{{ sample['well_id'] }}" style="background-color: {{ sample['background_color'] }};">
<div class="well" draggable="true" id="{{ sample['sample_id'] }}" style="background-color: {{ sample['background_color'] }};">
{% if creation %}
<p style="font-size: 0.7em; text-align: center; word-wrap: break-word;">{{ sample['sample_id'] }}</p>
{% else %}
<a class="data-link sample" id="{{ sample['sample_id'] }}">{{ sample['sample_id']}}</a><br>
{% endif %}
</div>
{% endfor %}
</div>

View File

@@ -1,18 +1,17 @@
'''
"""
Contains miscellaenous functions used by both frontend and backend.
'''
"""
from __future__ import annotations
import builtins, importlib, time, logging, re, yaml, sys, os, stat, platform, getpass, json, numpy as np, pandas as pd
import itertools
import builtins, importlib, time, logging, re, yaml, sys, os, stat, platform, getpass, json, numpy as np, pandas as pd, \
itertools, openpyxl
from copy import copy
from collections import OrderedDict
from datetime import date, datetime, timedelta
from json import JSONDecodeError
from pprint import pformat
from threading import Thread
from inspect import getmembers, isfunction, stack
from types import NoneType
from dateutil.easter import easter
from dateutil.parser import parse
from jinja2 import Environment, FileSystemLoader
from logging import handlers, Logger
from pathlib import Path
@@ -60,7 +59,6 @@ main_form_style = '''
page_size = 250
# micro_char = uni_char = "\u03BC"
def divide_chunks(input_list: list, chunk_count: int) -> Generator[Any, Any, None]:
"""
@@ -447,18 +445,19 @@ def jinja_template_loading() -> Environment:
return env
def render_details_template(template_name:str, css_in:List[str]|str=[], js_in:List[str]|str=[], **kwargs) -> str:
def render_details_template(template_name: str, css_in: List[str] | str = [], js_in: List[str] | str = [],
**kwargs) -> str:
if isinstance(css_in, str):
css_in = [css_in]
env = jinja_template_loading()
html_folder = Path(env.loader.__getattribute__("searchpath")[0])
css_in = ["styles"] + css_in
css_in = [project_path.joinpath("src", "submissions", "templates", "css", f"{c}.css") for c in css_in]
css_in = [html_folder.joinpath("css", f"{c}.css") for c in css_in]
if isinstance(js_in, str):
js_in = [js_in]
js_in = ["details"] + js_in
js_in = [project_path.joinpath("src", "submissions", "templates", "js", f"{j}.js") for j in js_in]
env = jinja_template_loading()
js_in = [html_folder.joinpath("js", f"{j}.js") for j in js_in]
template = env.get_template(f"{template_name}.html")
# template_path = Path(template.environment.loader.__getattribute__("searchpath")[0])
css_out = []
for css in css_in:
with open(css, "r") as f:
@@ -467,7 +466,6 @@ def render_details_template(template_name:str, css_in:List[str]|str=[], js_in:Li
for js in js_in:
with open(js, "r") as f:
js_out.append(f.read())
# logger.debug(f"Kwargs: {kwargs}")
return template.render(css=css_out, js=js_out, **kwargs)
@@ -489,10 +487,10 @@ def convert_well_to_row_column(input_str: str) -> Tuple[int, int]:
return None, None
return row, column
# Copy a sheet with style, format, layout, ect. from one Excel file to another Excel file
# Please add the ..path\\+\\file.. and ..sheet_name.. according to your desire.
import openpyxl
from copy import copy
def copy_xl_sheet(source_sheet, target_sheet):
@@ -509,8 +507,8 @@ def copy_sheet_attributes(source_sheet, target_sheet):
target_sheet.page_margins = copy(source_sheet.page_margins)
target_sheet.freeze_panes = copy(source_sheet.freeze_panes)
# set row dimensions
# So you cannot copy the row_dimensions attribute. Does not work (because of meta data in the attribute I think). So we copy every row's row_dimensions. That seems to work.
# NOTE: set row dimensions
# NOTE: So you cannot copy the row_dimensions attribute. Does not work (because of meta data in the attribute I think). So we copy every row's row_dimensions. That seems to work.
for rn in range(len(source_sheet.row_dimensions)):
target_sheet.row_dimensions[rn] = copy(source_sheet.row_dimensions[rn])
@@ -519,12 +517,15 @@ def copy_sheet_attributes(source_sheet, target_sheet):
else:
target_sheet.sheet_format.defaultColWidth = copy(source_sheet.sheet_format.defaultColWidth)
# set specific column width and hidden property
# we cannot copy the entire column_dimensions attribute so we copy selected attributes
# NOTE: set specific column width and hidden property
# NOTE: we cannot copy the entire column_dimensions attribute so we copy selected attributes
for key, value in source_sheet.column_dimensions.items():
target_sheet.column_dimensions[key].min = copy(source_sheet.column_dimensions[key].min) # Excel actually groups multiple columns under 1 key. Use the min max attribute to also group the columns in the targetSheet
target_sheet.column_dimensions[key].max = copy(source_sheet.column_dimensions[key].max) # https://stackoverflow.com/questions/36417278/openpyxl-can-not-read-consecutive-hidden-columns discussed the issue. Note that this is also the case for the width, not onl;y the hidden property
target_sheet.column_dimensions[key].width = copy(source_sheet.column_dimensions[key].width) # set width for every column
target_sheet.column_dimensions[key].min = copy(source_sheet.column_dimensions[
key].min) # Excel actually groups multiple columns under 1 key. Use the min max attribute to also group the columns in the targetSheet
target_sheet.column_dimensions[key].max = copy(source_sheet.column_dimensions[
key].max) # https://stackoverflow.com/questions/36417278/openpyxl-can-not-read-consecutive-hidden-columns discussed the issue. Note that this is also the case for the width, not onl;y the hidden property
target_sheet.column_dimensions[key].width = copy(
source_sheet.column_dimensions[key].width) # set width for every column
target_sheet.column_dimensions[key].hidden = copy(source_sheet.column_dimensions[key].hidden)
@@ -535,10 +536,8 @@ def copy_cells(source_sheet, target_sheet):
if isinstance(source_cell, openpyxl.cell.read_only.EmptyCell):
continue
target_cell = target_sheet.cell(column=c + 1, row=r + 1)
target_cell._value = source_cell._value
target_cell.data_type = source_cell.data_type
if source_cell.has_style:
target_cell.font = copy(source_cell.font)
target_cell.border = copy(source_cell.border)
@@ -546,10 +545,8 @@ def copy_cells(source_sheet, target_sheet):
target_cell.number_format = copy(source_cell.number_format)
target_cell.protection = copy(source_cell.protection)
target_cell.alignment = copy(source_cell.alignment)
if not isinstance(source_cell, openpyxl.cell.ReadOnlyCell) and source_cell.hyperlink:
target_cell._hyperlink = copy(source_cell.hyperlink)
if not isinstance(source_cell, openpyxl.cell.ReadOnlyCell) and source_cell.comment:
target_cell.comment = copy(source_cell.comment)
@@ -567,6 +564,7 @@ def list_str_comparator(input_str:str, listy: List[str], mode: Literal["starts_w
else:
return False
def sort_dict_by_list(dictionary: dict, order_list: list) -> dict:
output = OrderedDict()
for item in order_list:
@@ -602,14 +600,12 @@ def setup_lookup(func):
elif v is not None:
sanitized_kwargs[k] = v
return func(*args, **sanitized_kwargs)
return wrapper
def check_object_in_manager(manager: list, object_name: object) -> Tuple[Any, bool]:
if manager is None:
return None, False
# logger.debug(f"Manager: {manager}, aliases: {manager.aliases}, Key: {object_name}")
if object_name in manager.aliases:
return manager, True
relationships = [getattr(manager.__class__, item) for item in dir(manager.__class__)
@@ -617,21 +613,17 @@ def check_object_in_manager(manager: list, object_name: object) -> Tuple[Any, bo
relationships = [item for item in relationships if isinstance(item.property, _RelationshipDeclared)]
for relationship in relationships:
if relationship.key == object_name and "association" not in relationship.key:
logger.debug(f"Checking {relationship.key}")
try:
rel_obj = getattr(manager, relationship.key)
if rel_obj is not None:
logger.debug(f"Returning {rel_obj}")
return rel_obj, False
except AttributeError:
pass
if "association" in relationship.key:
try:
logger.debug(f"Checking association {relationship.key}")
rel_obj = next((getattr(item, object_name) for item in getattr(manager, relationship.key)
if getattr(item, object_name) is not None), None)
if rel_obj is not None:
logger.debug(f"Returning {rel_obj}")
return rel_obj, False
except AttributeError:
pass
@@ -652,7 +644,7 @@ def get_application_from_parent(widget):
return widget
class Result(BaseModel, arbitrary_types_allowed=True):
class Alert(BaseModel, arbitrary_types_allowed=True):
owner: str = Field(default="", validate_default=True)
code: int = Field(default=0)
msg: str | Exception
@@ -711,7 +703,7 @@ class Result(BaseModel, arbitrary_types_allowed=True):
class Report(BaseModel):
results: List[Result] = Field(default=[])
results: List[Alert] = Field(default=[])
def __repr__(self):
return f"<Report(result_count:{len(self.results)})>"
@@ -724,10 +716,10 @@ class Report(BaseModel):
Takes a result object or all results in another report and adds them to this one.
Args:
result (Result | Report | None): Results to be added.
result (Alert | Report | None): Results to be added.
"""
match result:
case Result():
case Alert():
logger.info(f"Adding {result} to results.")
try:
self.results.append(result)
@@ -790,7 +782,8 @@ def yaml_regex_creator(loader, node):
nodes = loader.construct_sequence(node)
name = nodes[0].replace(" ", "_")
abbr = nodes[1]
return f"(?P<{name}>RSL(?:-|_)?{abbr}(?:-|_)?20\d{2}-?\d{2}-?\d{2}(?:(_|-)?\d?([^_0123456789\sA-QS-Z]|$)?R?\d?)?)"
# return f"(?P<{name}>RSL(?:-|_)?{abbr}(?:-|_)?20\d{2}-?\d{2}-?\d{2}(?:(_|-)?\d?([^_0123456789\sA-QS-Z]|$)?R?\d?)?)"
return f"(?P<{name}>RSL(?:-|_)?{abbr}(?:-|_)?20\\d{2}-?\\d{2}-?\\d{2}(?:(_|-)?\\d?([^_0123456789\\sA-QS-Z]|$)?R?\\d?)?)"
def super_splitter(ins_str: str, substring: str, idx: int) -> str:
@@ -860,9 +853,8 @@ def check_authorization(func):
logger.error(error_msg)
report = Report()
report.add_result(
Result(owner=func.__str__(), code=1, msg=error_msg, status="warning"))
Alert(owner=func.__str__(), code=1, msg=error_msg, status="warning"))
return report, kwargs
return wrapper
@@ -885,10 +877,9 @@ def under_development(func):
logger.error(error_msg)
report = Report()
report.add_result(
Result(owner=func.__str__(), code=1, msg=error_msg,
Alert(owner=func.__str__(), code=1, msg=error_msg,
status="warning"))
return report
return wrapper
@@ -906,7 +897,6 @@ def report_result(func):
@wraps(func)
def wrapper(*args, **kwargs):
# logger.info(f"Report result being called by {func.__name__}")
output = func(*args, **kwargs)
match output:
case Report():
@@ -931,6 +921,7 @@ def report_result(func):
logger.error(f"Problem reporting due to {e}")
logger.error(result.msg)
if output:
logger.info(f"Report result being called by {func.__name__}")
if is_list_etc(output):
true_output = tuple(item for item in output if not isinstance(item, Report))
if len(true_output) == 1:
@@ -943,7 +934,6 @@ def report_result(func):
else:
true_output = None
return true_output
return wrapper
@@ -962,11 +952,32 @@ def is_list_etc(object):
def create_holidays_for_year(year: int | None = None) -> List[date]:
def find_nth_monday(year, month, occurence: int | None = None, day: int | None = None):
if not occurence:
occurence = 1
"""
Gives stat holidays for the input year.
Args:
year (int | None, optional): The input year as an integer. Defaults to None.
Returns:
List[date]
"""
def find_nth_monday(year, month, occurrence: int | None = None, day: int | None = None) -> date:
"""
Gets the nth (eg 2nd) monday of the given month.
Args:
year (int): The year the month occurs in.
month (int): The month of interest.
occurrence (int): The n in nth.
day (int): The day of the month to start after.
Returns:
date
"""
if not occurrence:
occurrence = 1
if not day:
day = occurence * 7
day = occurrence * 7
max_days = (date(2012, month + 1, 1) - date(2012, month, 1)).days
if day > max_days:
day = max_days
@@ -977,17 +988,16 @@ def create_holidays_for_year(year: int | None = None) -> List[date]:
offset = -d.weekday() # weekday == 0 means Monday
output = d + timedelta(offset)
return output.date()
if not year:
year = date.today().year
# NOTE: Includes New Year's day for next year.
# NOTE: Static holidays. Includes New Year's day for next year.
holidays = [date(year, 1, 1), date(year, 7, 1), date(year, 9, 30),
date(year, 11, 11), date(year, 12, 25), date(year, 12, 26),
date(year + 1, 1, 1)]
# NOTE: Labour Day
holidays.append(find_nth_monday(year, 9))
# NOTE: Thanksgiving
holidays.append(find_nth_monday(year, 10, occurence=2))
holidays.append(find_nth_monday(year, 10, occurrence=2))
# NOTE: Victoria Day
holidays.append(find_nth_monday(year, 5, day=25))
# NOTE: Easter, etc
@@ -1007,7 +1017,6 @@ def check_dictionary_inclusion_equality(listo: List[dict] | dict, dicto: dict) -
Returns:
bool: True if dicto is equal to any dictionary in the list.
"""
# logger.debug(f"Comparing: {listo} and {dicto}")
if isinstance(dicto, list) and isinstance(listo, list):
return listo == dicto
elif isinstance(dicto, dict) and isinstance(listo, dict):
@@ -1018,22 +1027,43 @@ def check_dictionary_inclusion_equality(listo: List[dict] | dict, dicto: dict) -
raise TypeError(f"Unsupported variable: {type(listo)}")
def flatten_list(input_list: list):
def flatten_list(input_list: list) -> list:
"""
Takes nested lists and returns a single flat list.
Args:
input_list (list): input nested list.
Returns:
list:
"""
return list(itertools.chain.from_iterable(input_list))
def sanitize_object_for_json(input_dict: dict) -> dict:
def sanitize_object_for_json(input_dict: dict) -> dict | str:
"""
Takes an object and makes sure its components can be converted to JSON
Args:
input_dict (dict): Dictionary of interest
Returns:
dict:
"""
if not isinstance(input_dict, dict):
match input_dict:
case int() | float() | bool():
pass
case _:
try:
js = json.dumps(input_dict)
input_dict = json.dumps(input_dict)
except TypeError:
match input_dict:
case str():
pass
case _:
input_dict = str(input_dict)
return input_dict
# return input_dict
return input_dict.strip('\"')
output = {}
for key, value in input_dict.items():
match value:
@@ -1041,22 +1071,43 @@ def sanitize_object_for_json(input_dict: dict) -> dict:
value = [sanitize_object_for_json(object) for object in value]
case dict():
value = sanitize_object_for_json(value)
case _:
try:
js = json.dumps(value)
value = json.dumps(value)
except TypeError:
match value:
case str():
pass
case _:
value = str(value)
if isinstance(value, str):
value = value.strip('\"')
output[key] = value
return output
def create_plate_grid(rows: int, columns: int):
matrix = np.array([[0 for yyy in range(1, columns + 1)] for xxx in range(1, rows + 1)])
def create_plate_grid(rows: int, columns: int) -> dict:
"""
Makes an x by y array to represent a plate.
Args:
rows (int): Number of rows.
columns (int): Number of columns
Returns:
dict: cell number : (row, column)
"""
# NOTE: columns/rows
# matrix = np.array([[0 for yyy in range(1, columns + 1)] for xxx in range(1, rows + 1)])
# NOTE: rows/columns
matrix = np.array([[0 for xxx in range(1, rows + 1)] for yyy in range(1, columns + 1)])
return {iii: (item[0][1] + 1, item[0][0] + 1) for iii, item in enumerate(np.ndenumerate(matrix), start=1)}
class classproperty(property):
"""
Allows for properties on classes as well as objects.
"""
def __get__(self, owner_self, owner_cls):
return self.fget(owner_cls)
@@ -1396,6 +1447,16 @@ class Settings(BaseSettings, extra="allow"):
@classmethod
def get_alembic_db_path(cls, alembic_path, mode=Literal['path', 'schema', 'user', 'pass']) -> Path | str:
"""
Retrieves database variables from alembic.ini file.
Args:
alembic_path (Any): Path of the alembic.ini file.
mode (Literal['path', 'schema', 'user', 'pass']): Variable of interest.
Returns:
Path | str
"""
c = ConfigParser()
c.read(alembic_path)
url = c['alembic']['sqlalchemy.url']

View File

@@ -32,8 +32,10 @@ a = Analysis(
binaries=[],
datas=[
("src\\config.yml", "files"),
("src\\submissions\\templates\\*", "files\\templates"),
("src\\submissions\\templates\\css\\*", "files\\templates\\css"),
("src\\submissions\\templates\\*.html", "files\\templates"),
("src\\submissions\\templates\\css\\*.css", "files\\templates\\css"),
("src\\submissions\\templates\\js\\*.js", "files\\templates\\js"),
("src\\submissions\\templates\\support\\*", "files\\templates\\support"),
("docs\\build", "files\\docs"),
("src\\submissions\\resources\\*", "files\\resources"),
("alembic.ini", "files"),
@@ -51,12 +53,32 @@ a = Analysis(
)
pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher)
#exe = EXE(
# pyz,
# a.scripts,
# a.binaries,
# a.datas,
# [],
# name=f"{__project__}_{__version__}",
# debug=True,
# bootloader_ignore_signals=False,
# strip=False,
# upx=True,
# upx_exclude=[],
# runtime_tmpdir=None,
# console=True,
# disable_windowed_traceback=False,
# argv_emulation=False,
# target_arch=None,
# codesign_identity=None,
# entitlements_file=None,
#)
exe = EXE(
pyz,
a.scripts,
[],
exclude_binaries=True,
name=f"{__project__}_{__version__}",
name=f"{__project__}_{__version__}_2",
debug=True,
bootloader_ignore_signals=False,
strip=False,