ReagentLot add/edit updated.

This commit is contained in:
lwark
2025-09-10 12:39:02 -05:00
parent c9396d6c41
commit ba4912cab7
18 changed files with 1862 additions and 1734 deletions

View File

@@ -2,11 +2,12 @@
Contains all models for sqlalchemy
"""
from __future__ import annotations
import sys, logging, json
import sys, logging, json, inspect
from dateutil.parser import parse
from jinja2 import TemplateNotFound
from pandas import DataFrame
from pydantic import BaseModel
from sqlalchemy import Column, INTEGER, String, JSON
from sqlalchemy import Column, INTEGER, String, JSON, DATETIME
from sqlalchemy.ext.associationproxy import AssociationProxy
from sqlalchemy.orm import DeclarativeMeta, declarative_base, Query, Session, InstrumentedAttribute, ColumnProperty
from sqlalchemy.ext.declarative import declared_attr
@@ -36,10 +37,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)
@@ -155,6 +152,33 @@ 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 isinstance(item[1], _RelationshipDeclared):
# if "association" in item[0]:
# 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:
"""
@@ -296,7 +320,6 @@ class BaseClass(Base):
except AttributeError:
check = False
if check:
logger.debug("Got uselist")
try:
query = query.filter(attr.contains(v))
except ArgumentError:
@@ -358,16 +381,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:
@@ -418,7 +439,7 @@ class BaseClass(Base):
try:
template = env.get_template(temp_name)
except TemplateNotFound as e:
logger.error(f"Couldn't find template {e}")
# logger.error(f"Couldn't find template {e}")
template = env.get_template("details.html")
return template
@@ -505,7 +526,6 @@ class BaseClass(Base):
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 = value
else:
@@ -626,7 +646,7 @@ 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:

View File

@@ -194,6 +194,7 @@ class Reagent(BaseClass, LogMixin):
Concrete reagent instance
"""
skip_on_edit = False
id = Column(INTEGER, primary_key=True) #: primary key
reagentrole = relationship("ReagentRole", back_populates="reagent",
secondary=reagentrole_reagent) #: joined parent ReagentRole
@@ -319,15 +320,7 @@ class Reagent(BaseClass, LogMixin):
except AttributeError as e:
logger.error(f"Could not set {key} due to {e}")
@check_authorization
def edit_from_search(self, obj, **kwargs):
from frontend.widgets.omni_add_edit import AddEdit
dlg = AddEdit(parent=None, instance=self)
if dlg.exec():
pyd = dlg.parse_form()
for field in pyd.model_fields:
self.set_attribute(field, pyd.__getattribute__(field))
self.save()
@classproperty
def add_edit_tooltips(self):
@@ -418,6 +411,15 @@ class ReagentLot(BaseClass):
pass
setattr(self, key, value)
@check_authorization
def edit_from_search(self, obj, **kwargs):
from frontend.widgets.omni_add_edit import AddEdit
dlg = AddEdit(parent=None, instance=self, disabled=['reagent'])
if dlg.exec():
pyd = dlg.parse_form()
for field in pyd.model_fields:
self.set_attribute(field, pyd.__getattribute__(field))
self.save()
class Discount(BaseClass):
"""
@@ -952,6 +954,7 @@ class Procedure(BaseClass):
output['sample'] = active_samples + inactive_samples
output['reagent'] = [reagent.details_dict() for reagent in output['procedurereagentlotassociation']]
output['equipment'] = [equipment.details_dict() for equipment in output['procedureequipmentassociation']]
logger.debug(f"equipment: {pformat([item for item in output['equipment']])}")
output['repeat'] = self.repeat
output['run'] = self.run.name
output['excluded'] += self.get_default_info("details_ignore")
@@ -963,14 +966,6 @@ class Procedure(BaseClass):
def to_pydantic(self, **kwargs):
from backend.validators.pydant import PydReagent
output = super().to_pydantic()
try:
output.kittype = dict(value=output.kittype['name'], missing=False)
except KeyError:
try:
output.kittype = dict(value=output.kittype['value'], missing=False)
except KeyError as e:
logger.error(f"Output.kittype: {output.kittype}")
raise e
output.sample = [item.to_pydantic() for item in output.proceduresampleassociation]
reagents = []
for reagent in output.reagent:
@@ -985,6 +980,7 @@ class Procedure(BaseClass):
output.result = [item.to_pydantic() for item in self.results]
output.sample_results = flatten_list(
[[result.to_pydantic() for result in item.results] for item in self.proceduresampleassociation])
return output
def create_proceduresampleassociations(self, sample):
@@ -1607,7 +1603,7 @@ class Equipment(BaseClass, LogMixin):
from backend.validators.pydant import PydEquipment
creation_dict = self.details_dict()
processes = self.get_processes(equipmentrole=equipmentrole)
creation_dict['process'] = processes
creation_dict['processes'] = processes
creation_dict['equipmentrole'] = equipmentrole or creation_dict['equipmentrole']
return PydEquipment(**creation_dict)
@@ -2187,6 +2183,7 @@ class ProcedureEquipmentAssociation(BaseClass):
output.update(relevant)
output['misc_info'] = misc
output['equipment_role'] = self.equipmentrole
output['processes'] = [item for item in self.equipment.get_processes(equipmentrole=output['equipment_role'])]
try:
output['processversion'] = self.processversion.details_dict()
except AttributeError:

View File

@@ -116,7 +116,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:
@@ -303,7 +302,6 @@ 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)
@@ -455,13 +453,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():

View File

@@ -36,16 +36,17 @@ class DefaultWriter(object):
value = value['name']
except (KeyError, ValueError):
return
# logger.debug(f"Value type: {type(value)}")
match value:
case x if issubclass(value.__class__, BaseClass):
value = value.name
case x if issubclass(value.__class__, PydBaseClass):
logger.warning(f"PydBaseClass: {value}")
value = value.name
case bytes() | list():
value = None
case datetime() | date():
value = value.strftime("%Y-%m-%d")
case _:
value = str(value)
return value
@@ -80,9 +81,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"Rows for {self.__class__.__name__}:\tstart: {self.start_row}, end: {self.end_row}")
return workbook
def delineate_start_row(self, start_row: int = 1):
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
"""
logger.debug(f"{self.__class__.__name__} will start looking for blank rows at {start_row}")
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
@@ -135,6 +146,7 @@ class DefaultKEYVALUEWriter(DefaultWriter):
dictionary = sort_dict_by_list(dictionary=dictionary, order_list=self.key_order)
for ii, (k, v) in enumerate(dictionary.items(), start=self.start_row):
value = self.stringify_value(value=v)
logger.debug(f"{self.__class__.__name__} attempting to write {value}")
if value is None:
continue
self.worksheet.cell(column=1, row=ii, value=self.prettify_key(k))
@@ -159,7 +171,9 @@ 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
logger.debug(f"End row has been delineated as {start_row} + {len(self.pydant_obj)} + 1 = {end_row}")
return end_row
def pad_samples_to_length(self, row_count,
mode: Literal["submission", "procedure"] = "submission"): #, column_names):
@@ -206,6 +220,7 @@ class DefaultTABLEWriter(DefaultWriter):
value = object.improved_dict()[header.lower().replace(" ", "_")]
except (AttributeError, KeyError):
value = ""
# logger.debug(f"{self.__class__.__name__} attempting to write {value}")
self.worksheet.cell(row=write_row, column=column, value=self.stringify_value(value))
self.worksheet = self.postwrite(self.worksheet)
return workbook

View File

@@ -15,7 +15,7 @@ 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']
def __init__(self, pydant_obj, *args, **kwargs):
@@ -25,23 +25,23 @@ class ProcedureInfoWriter(DefaultKEYVALUEWriter):
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:
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
@@ -52,12 +52,12 @@ class ProcedureEquipmentWriter(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.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:
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
@@ -68,10 +68,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.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:
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

@@ -66,19 +66,19 @@ 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)
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:
Writer = getattr(results_writers, f"{result.result_type}InfoWriter")

File diff suppressed because it is too large Load Diff

View File

@@ -321,7 +321,7 @@ class PydTips(PydBaseClass):
SubmissionTipsAssociation: Association between queried tips and procedure
"""
report = Report()
tips = TipsLot.query(name=self.name, limit=1)
tips = TipsLot.query(lot=self.lot, limit=1)
return tips, report
@@ -329,7 +329,8 @@ class PydEquipment(PydBaseClass):
asset_number: str
name: str
nickname: str | None
process: List[PydProcess] | PydProcess | None
processes: List[PydProcess] | PydProcess | None
processversion: PydProcess | None
equipmentrole: str | PydEquipmentRole | None
tips: List[PydTips] | PydTips | None = Field(default=[])
@@ -347,7 +348,7 @@ class PydEquipment(PydBaseClass):
value = value.name
return value
@field_validator('process', mode='before')
@field_validator('processes', mode='before')
@classmethod
def process_to_pydantic(cls, value, values):
if isinstance(value, GeneratorType):
@@ -355,16 +356,25 @@ class PydEquipment(PydBaseClass):
value = convert_nans_to_nones(value)
if not value:
value = []
if isinstance(value, ProcessVersion):
match value:
case ProcessVersion():
value = value.to_pydantic(pyd_model_name="PydProcess")
else:
case _:
try:
d: Process = next((process for process in value if values.data['name'] in [item.name for item in process.equipment]), None)
if d:
value = d.to_pydantic()
# d: Process = next((process for process in value if values.data['name'] in [item.name for item in process.equipment]), None)
for process in value:
match process:
case Process():
if values.data['name'] in [item.name for item in process.equipment]:
return process.to_pydantic()
return None
case str():
return process
# else:
# value = []
except AttributeError as e:
logger.error(f"Process Validation error due to {e}")
pass
value = []
return value
@field_validator('tips', mode='before')
@@ -386,7 +396,7 @@ class PydEquipment(PydBaseClass):
value = d.to_pydantic()
except AttributeError as e:
logger.error(f"Process Validation error due to {e}")
pass
value = []
return value
@report_result
@@ -1347,6 +1357,7 @@ class PydProcedure(PydBaseClass, arbitrary_types_allowed=True):
def to_sql(self, new: bool = False):
from backend.db.models import RunSampleAssociation, ProcedureSampleAssociation
logger.debug(f"incoming pyd: {pformat([item.__dict__ for item in self.equipment])}")
if new:
sql = Procedure()
else:
@@ -1408,6 +1419,7 @@ class PydProcedure(PydBaseClass, arbitrary_types_allowed=True):
procedure_rank=sample.procedure_rank)
for equipment in self.equipment:
equip, _ = equipment.to_sql()
logger.debug(f"Equipment:\n{pformat(equip.__dict__)}")
if isinstance(equipment.process, list):
equipment.process = equipment.process[0]
if isinstance(equipment.tips, list):
@@ -1420,10 +1432,13 @@ class PydProcedure(PydBaseClass, arbitrary_types_allowed=True):
equipmentrole=equip.equipmentrole[0])
process = equipment.process.to_sql()
equip_assoc.processversion = process
logger.debug(f"Tips: {type(equipment.tips)}")
try:
tipslot = equipment.tips.to_sql()
logger.debug(f"Tipslot: {tipslot.__dict__}")
except AttributeError:
tipslot = None
equip_assoc.tipslot = tipslot
return sql, None
@@ -1560,6 +1575,13 @@ class PydClientSubmission(PydBaseClass):
value = int(value)
return value
@field_validator("cost_centre", mode="before")
@classmethod
def str_to_dict(cls, value):
if isinstance(value, str):
value = dict(value=value)
return value
def to_form(self, parent: QWidget, samples: List = [], disable: list | None = None):
"""
Converts this instance into a frontend.widgets.submission_widget.SubmissionFormWidget

View File

@@ -35,7 +35,7 @@ class ConcentrationsChart(CustomFigure):
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

@@ -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
@@ -181,7 +181,7 @@ class App(QMainWindow):
@check_authorization
def edit_reagent(self, *args, **kwargs):
dlg = SearchBox(parent=self, object_type=Reagent, extras=[dict(name='Role', field="reagentrole")])
dlg = SearchBox(parent=self, object_type=ReagentLot, extras=[dict(name='Role', field="reagentrole")])
dlg.exec()
def update_data(self):

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,20 +22,19 @@ 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}
logger.debug(f"Fields: {pformat(fields)}")
# NOTE: Move 'name' to the front
try:
fields = {'name': fields.pop('name'), **fields}
@@ -41,13 +42,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 +56,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 +65,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 +76,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 +87,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 +96,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 +130,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 +185,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

@@ -72,18 +72,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()
@@ -172,7 +168,6 @@ class SearchResults(QTableView):
self.extras = extras + [item for item in deepcopy(self.object_type.searchables)]
except AttributeError:
self.extras = extras
# logger.debug(f"Extras: {self.extras}")
def setData(self, df: DataFrame) -> None:
"""

View File

@@ -163,4 +163,5 @@ class ProcedureCreation(QDialog):
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)
output = self.procedure.to_sql(new=new)
return output

View File

@@ -264,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
@@ -284,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

@@ -12,7 +12,7 @@ from pathlib import Path
from tools import Report, Result, 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,
ClientLab, SubmissionType, Reagent, ReagentLot,
ReagentRole, ProcedureTypeReagentRoleAssociation, Run, ClientSubmission
)
from pprint import pformat
@@ -137,7 +137,7 @@ class SubmissionFormContainer(QWidget):
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.
@@ -149,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()

View File

@@ -1,18 +1,16 @@
'''
"""
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 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 +58,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,16 +444,18 @@ 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 = []
@@ -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
@@ -862,7 +854,6 @@ def check_authorization(func):
report.add_result(
Result(owner=func.__str__(), code=1, msg=error_msg, status="warning"))
return report, kwargs
return wrapper
@@ -888,7 +879,6 @@ def under_development(func):
Result(owner=func.__str__(), code=1, msg=error_msg,
status="warning"))
return report
return wrapper
@@ -906,7 +896,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 +920,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 +933,6 @@ def report_result(func):
else:
true_output = None
return true_output
return wrapper
@@ -962,11 +951,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 +987,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 +1016,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 +1026,39 @@ 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:
"""
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:
input_dict = str(input_dict)
return input_dict
# return input_dict
output = {}
for key, value in input_dict.items():
match value:
@@ -1041,22 +1066,37 @@ 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:
value = str(value)
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 +1436,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,