Post code-cleanup
This commit is contained in:
@@ -180,9 +180,9 @@ class KitType(BaseClass):
|
||||
pass
|
||||
case _:
|
||||
raise ValueError(f"Wrong variable type: {type(submission_type)} used!")
|
||||
logger.debug(f"Submission type: {submission_type}, Kit: {self}")
|
||||
# logger.debug(f"Submission type: {submission_type}, Kit: {self}")
|
||||
assocs = [item for item in self.kit_reagentrole_associations if item.submission_type == submission_type]
|
||||
logger.debug(f"Associations: {assocs}")
|
||||
# logger.debug(f"Associations: {assocs}")
|
||||
# NOTE: rescue with submission type's default kit.
|
||||
if not assocs:
|
||||
logger.error(
|
||||
@@ -211,7 +211,7 @@ class KitType(BaseClass):
|
||||
# except TypeError:
|
||||
# continue
|
||||
output = {assoc.reagent_role.name: assoc.uses for assoc in assocs}
|
||||
logger.debug(f"Output: {output}")
|
||||
# logger.debug(f"Output: {output}")
|
||||
return output, new_kit
|
||||
|
||||
@classmethod
|
||||
@@ -1718,7 +1718,7 @@ class SubmissionEquipmentAssociation(BaseClass):
|
||||
|
||||
@classmethod
|
||||
@setup_lookup
|
||||
def query(cls, equipment_id: int, submission_id: int, role: str | None = None, limit: int = 0, **kwargs) \
|
||||
def query(cls, equipment_id: int|None=None, submission_id: int|None=None, role: str | None = None, limit: int = 0, **kwargs) \
|
||||
-> Any | List[Any]:
|
||||
query: Query = cls.__database_session__.query(cls)
|
||||
query = query.filter(cls.equipment_id == equipment_id)
|
||||
@@ -2013,7 +2013,8 @@ class SubmissionTipsAssociation(BaseClass):
|
||||
|
||||
@classmethod
|
||||
def query_or_create(cls, tips, submission, role: str, **kwargs):
|
||||
instance = cls.query(tip_id=tips.id, role=role, submission_id=submission.id, limit=1, **kwargs)
|
||||
kwargs['limit'] = 1
|
||||
instance = cls.query(tip_id=tips.id, role=role, submission_id=submission.id, **kwargs)
|
||||
if instance is None:
|
||||
instance = SubmissionTipsAssociation(submission=submission, tips=tips, role_name=role)
|
||||
return instance
|
||||
|
||||
@@ -185,3 +185,6 @@ class Contact(BaseClass):
|
||||
pass
|
||||
return cls.execute_query(query=query, limit=limit)
|
||||
|
||||
def to_pydantic(self) -> "PydContact":
|
||||
from backend.validators import PydContact
|
||||
return PydContact(name=self.name, email=self.email, phone=self.phone)
|
||||
|
||||
@@ -279,7 +279,7 @@ class ReagentParser(object):
|
||||
# submission_type = submission_type['value']
|
||||
# if isinstance(submission_type, str):
|
||||
# submission_type = SubmissionType.query(name=submission_type)
|
||||
logger.debug("Running kit map")
|
||||
# logger.debug("Running kit map")
|
||||
associations, self.kit_object = self.kit_object.construct_xl_map_for_use(submission_type=self.submission_type_obj)
|
||||
reagent_map = {k: v for k, v in associations.items() if k != 'info'}
|
||||
try:
|
||||
|
||||
@@ -158,12 +158,15 @@ class InfoWriter(object):
|
||||
match k:
|
||||
case "custom":
|
||||
continue
|
||||
# case "comment":
|
||||
|
||||
# NOTE: merge all comments to fit in single cell.
|
||||
if k == "comment" and isinstance(v['value'], list):
|
||||
json_join = [item['text'] for item in v['value'] if 'text' in item.keys()]
|
||||
v['value'] = "\n".join(json_join)
|
||||
case "comment":
|
||||
# NOTE: merge all comments to fit in single cell.
|
||||
if isinstance(v['value'], list):
|
||||
json_join = [item['text'] for item in v['value'] if 'text' in item.keys()]
|
||||
v['value'] = "\n".join(json_join)
|
||||
case thing if thing in self.sub_object.timestamps:
|
||||
v['value'] = v['value'].date()
|
||||
case _:
|
||||
pass
|
||||
final_info[k] = v
|
||||
try:
|
||||
locations = v['locations']
|
||||
@@ -252,6 +255,11 @@ class ReagentWriter(object):
|
||||
for v in reagent.values():
|
||||
if not isinstance(v, dict):
|
||||
continue
|
||||
match v['value']:
|
||||
case datetime():
|
||||
v['value'] = v['value'].date()
|
||||
case _:
|
||||
pass
|
||||
sheet.cell(row=v['row'], column=v['column'], value=v['value'])
|
||||
return self.xl
|
||||
|
||||
|
||||
@@ -641,7 +641,7 @@ class PydSubmission(BaseModel, extra='allow'):
|
||||
@field_validator("contact")
|
||||
@classmethod
|
||||
def get_contact_from_org(cls, value, values):
|
||||
logger.debug(f"Value coming in: {value}")
|
||||
# logger.debug(f"Value coming in: {value}")
|
||||
match value:
|
||||
case dict():
|
||||
if isinstance(value['value'], tuple):
|
||||
@@ -650,12 +650,12 @@ class PydSubmission(BaseModel, extra='allow'):
|
||||
value = dict(value=value[0], missing=False)
|
||||
case _:
|
||||
value = dict(value=value, missing=False)
|
||||
logger.debug(f"Value after match: {value}")
|
||||
# logger.debug(f"Value after match: {value}")
|
||||
check = Contact.query(name=value['value'])
|
||||
logger.debug(f"Check came back with {check}")
|
||||
# logger.debug(f"Check came back with {check}")
|
||||
if not isinstance(check, Contact):
|
||||
org = values.data['submitting_lab']['value']
|
||||
logger.debug(f"Checking organization: {org}")
|
||||
# logger.debug(f"Checking organization: {org}")
|
||||
if isinstance(org, str):
|
||||
org = Organization.query(name=values.data['submitting_lab']['value'], limit=1)
|
||||
if isinstance(org, Organization):
|
||||
@@ -666,10 +666,10 @@ class PydSubmission(BaseModel, extra='allow'):
|
||||
if isinstance(contact, tuple):
|
||||
contact = contact[0]
|
||||
value = dict(value=f"Defaulted to: {contact}", missing=False)
|
||||
logger.debug(f"Value after query: {value}")
|
||||
# logger.debug(f"Value after query: {value}")
|
||||
return value
|
||||
else:
|
||||
logger.debug(f"Value after bypass check: {value}")
|
||||
# logger.debug(f"Value after bypass check: {value}")
|
||||
return value
|
||||
|
||||
def __init__(self, run_custom: bool = False, **data):
|
||||
@@ -879,6 +879,7 @@ class PydSubmission(BaseModel, extra='allow'):
|
||||
Converts this instance into a frontend.widgets.submission_widget.SubmissionFormWidget
|
||||
|
||||
Args:
|
||||
disable (list, optional): a list of widgets to be disabled in the form. Defaults to None.
|
||||
parent (QWidget): parent widget of the constructed object
|
||||
|
||||
Returns:
|
||||
@@ -911,7 +912,7 @@ class PydSubmission(BaseModel, extra='allow'):
|
||||
|
||||
# @report_result
|
||||
def check_kit_integrity(self, extraction_kit: str | dict | None = None, exempt: List[PydReagent] = []) -> Tuple[
|
||||
List[PydReagent], Report]:
|
||||
List[PydReagent], Report, List[PydReagent]]:
|
||||
"""
|
||||
Ensures all reagents expected in kit are listed in Submission
|
||||
|
||||
@@ -933,13 +934,11 @@ class PydSubmission(BaseModel, extra='allow'):
|
||||
ext_kit.get_reagents(required=True, submission_type=self.submission_type['value'])]
|
||||
# NOTE: Exclude any reagenttype found in this pyd not expected in kit.
|
||||
expected_check = [item.role for item in ext_kit_rtypes]
|
||||
logger.debug(self.reagents)
|
||||
output_reagents = [rt for rt in self.reagents if rt.role in expected_check]
|
||||
missing_check = [item.role for item in output_reagents]
|
||||
missing_reagents = [rt for rt in ext_kit_rtypes if rt.role not in missing_check and rt.role not in exempt]
|
||||
# logger.debug(f"Missing reagents: {missing_reagents}")
|
||||
missing_reagents += [rt for rt in output_reagents if rt.missing]
|
||||
logger.debug(pformat(missing_reagents))
|
||||
output_reagents += [rt for rt in missing_reagents if rt not in output_reagents]
|
||||
# NOTE: if lists are equal return no problem
|
||||
if len(missing_reagents) == 0:
|
||||
@@ -956,13 +955,13 @@ class PydSubmission(BaseModel, extra='allow'):
|
||||
expired = []
|
||||
for reagent in self.reagents:
|
||||
if reagent not in exempt:
|
||||
role_expiry = ReagentRole.query(name=reagent.role).eol_ext
|
||||
role_eol = ReagentRole.query(name=reagent.role).eol_ext
|
||||
try:
|
||||
dt = datetime.combine(reagent.expiry, datetime.max.time())
|
||||
except TypeError:
|
||||
continue
|
||||
if datetime.now() > dt + role_expiry:
|
||||
expired.append(f"{reagent.role}, {reagent.lot}: {reagent.expiry} + {role_expiry.days}")
|
||||
if datetime.now() > dt + role_eol:
|
||||
expired.append(f"{reagent.role}, {reagent.lot}: {reagent.expiry.date()} + {role_eol.days}")
|
||||
if expired:
|
||||
output = '\n'.join(expired)
|
||||
result = Result(status="Warning",
|
||||
@@ -996,11 +995,12 @@ class PydContact(BaseModel):
|
||||
area_regex = re.compile(r"^\(?(\d{3})\)?(-| )?")
|
||||
if len(value) > 8:
|
||||
match = area_regex.match(value)
|
||||
logger.debug(f"Match: {match.group(1)}")
|
||||
# logger.debug(f"Match: {match.group(1)}")
|
||||
value = area_regex.sub(f"({match.group(1).strip()}) ", value)
|
||||
logger.debug(f"Output phone: {value}")
|
||||
# logger.debug(f"Output phone: {value}")
|
||||
return value
|
||||
|
||||
@report_result
|
||||
def to_sql(self) -> Tuple[Contact, Report]:
|
||||
"""
|
||||
Converts this instance into a backend.db.models.organization. Contact instance.
|
||||
@@ -1036,6 +1036,18 @@ class PydOrganization(BaseModel):
|
||||
cost_centre: str
|
||||
contacts: List[PydContact] | None
|
||||
|
||||
@field_validator("contacts", mode="before")
|
||||
@classmethod
|
||||
def string_to_list(cls, value):
|
||||
if isinstance(value, str):
|
||||
value = Contact.query(name=value)
|
||||
try:
|
||||
value = [value.to_pydantic()]
|
||||
except AttributeError:
|
||||
return None
|
||||
return value
|
||||
|
||||
|
||||
def to_sql(self) -> Organization:
|
||||
"""
|
||||
Converts this instance into a backend.db.models.organization.Organization instance.
|
||||
@@ -1047,10 +1059,14 @@ class PydOrganization(BaseModel):
|
||||
for field in self.model_fields:
|
||||
match field:
|
||||
case "contacts":
|
||||
value = [item.to_sql() for item in getattr(self, field)]
|
||||
value = getattr(self, field)
|
||||
if value:
|
||||
value = [item.to_sql() for item in value if item]
|
||||
case _:
|
||||
value = getattr(self, field)
|
||||
instance.__setattr__(name=field, value=value)
|
||||
logger.debug(f"Setting {field} to {value}")
|
||||
if value:
|
||||
setattr(instance, field, value)
|
||||
return instance
|
||||
|
||||
|
||||
@@ -1105,7 +1121,8 @@ class PydKit(BaseModel):
|
||||
instance = KitType.query(name=self.name)
|
||||
if instance is None:
|
||||
instance = KitType(name=self.name)
|
||||
[item.to_sql(instance) for item in self.reagent_roles]
|
||||
for role in self.reagent_roles:
|
||||
role.to_sql(instance)
|
||||
return instance, report
|
||||
|
||||
|
||||
@@ -1162,7 +1179,8 @@ class PydIridaControl(BaseModel, extra='ignore'):
|
||||
contains: list | dict #: unstructured hashes in contains.tsv for each organism
|
||||
matches: list | dict #: unstructured hashes in matches.tsv for each organism
|
||||
kraken: list | dict #: unstructured output from kraken_report
|
||||
subtype: str #: EN-NOS, MCS-NOS, etc
|
||||
# subtype: str #: EN-NOS, MCS-NOS, etc
|
||||
subtype: Literal["ATCC49226", "ATCC49619", "EN-NOS", "EN-SSTI", "MCS-NOS", "MCS-SSTI", "SN-NOS", "SN-SSTI"]
|
||||
refseq_version: str #: version of refseq used in fastq parsing
|
||||
kraken2_version: str
|
||||
kraken2_db_version: str
|
||||
@@ -1171,6 +1189,13 @@ class PydIridaControl(BaseModel, extra='ignore'):
|
||||
submission_id: int
|
||||
controltype_name: str
|
||||
|
||||
@field_validator("refseq_version", "kraken2_version", "kraken2_db_version", mode='before')
|
||||
@classmethod
|
||||
def enforce_string(cls, value):
|
||||
if not value:
|
||||
value = ""
|
||||
return value
|
||||
|
||||
def to_sql(self):
|
||||
instance = IridaControl.query(name=self.name)
|
||||
if not instance:
|
||||
|
||||
@@ -7,7 +7,6 @@ from PyQt6.QtWidgets import QWidget
|
||||
import plotly, logging
|
||||
from plotly.graph_objects import Figure
|
||||
import pandas as pd
|
||||
from frontend.widgets.functions import select_save_file
|
||||
from tools import divide_chunks
|
||||
|
||||
logger = logging.getLogger(f"submissions.{__name__}")
|
||||
@@ -65,7 +64,8 @@ class CustomFigure(Figure):
|
||||
)
|
||||
assert isinstance(self, CustomFigure)
|
||||
|
||||
def make_plotly_buttons(self, months: int = 6) -> Generator[dict, None, None]:
|
||||
@classmethod
|
||||
def make_plotly_buttons(cls, months: int = 6) -> Generator[dict, None, None]:
|
||||
"""
|
||||
Creates html buttons to zoom in on date areas
|
||||
|
||||
@@ -115,7 +115,8 @@ class CustomFigure(Figure):
|
||||
{"yaxis.title.text": mode},
|
||||
])
|
||||
|
||||
def to_html(self) -> str:
|
||||
@property
|
||||
def html(self) -> str:
|
||||
"""
|
||||
Creates final html code from plotly
|
||||
|
||||
|
||||
@@ -19,10 +19,10 @@ class IridaFigure(CustomFigure):
|
||||
|
||||
super().__init__(df=df, modes=modes, settings=settings)
|
||||
self.df = df
|
||||
try:
|
||||
months = int(settings['months'])
|
||||
except KeyError:
|
||||
months = 6
|
||||
# try:
|
||||
# months = int(settings['months'])
|
||||
# except KeyError:
|
||||
# months = 6
|
||||
self.construct_chart(df=df, modes=modes, start_date=settings['start_date'], end_date=settings['end_date'])
|
||||
|
||||
|
||||
|
||||
@@ -17,10 +17,10 @@ class PCRFigure(CustomFigure):
|
||||
months: int = 6):
|
||||
super().__init__(df=df, modes=modes, settings=settings)
|
||||
self.df = df
|
||||
try:
|
||||
months = int(settings['months'])
|
||||
except KeyError:
|
||||
months = 6
|
||||
# try:
|
||||
# months = int(settings['months'])
|
||||
# except KeyError:
|
||||
# months = 6
|
||||
self.construct_chart(df=df)
|
||||
|
||||
def construct_chart(self, df: pd.DataFrame):
|
||||
|
||||
@@ -19,10 +19,10 @@ class TurnaroundChart(CustomFigure):
|
||||
months: int = 6):
|
||||
super().__init__(df=df, modes=modes, settings=settings)
|
||||
self.df = df
|
||||
try:
|
||||
months = int(settings['months'])
|
||||
except KeyError:
|
||||
months = 6
|
||||
# try:
|
||||
# months = int(settings['months'])
|
||||
# except KeyError:
|
||||
# months = 6
|
||||
self.construct_chart()
|
||||
if threshold:
|
||||
self.add_hline(y=threshold)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""
|
||||
Constructs main application.
|
||||
"""
|
||||
import getpass
|
||||
from pprint import pformat
|
||||
from PyQt6.QtCore import qInstallMessageHandler
|
||||
from PyQt6.QtWidgets import (
|
||||
@@ -13,9 +14,10 @@ from pathlib import Path
|
||||
from markdown import markdown
|
||||
from __init__ import project_path
|
||||
from backend import SubmissionType, Reagent, BasicSample, Organization, KitType
|
||||
from tools import check_if_app, Settings, Report, jinja_template_loading, check_authorization, page_size, is_power_user
|
||||
from tools import (
|
||||
check_if_app, Settings, Report, jinja_template_loading, check_authorization, page_size, is_power_user, under_development
|
||||
)
|
||||
from .functions import select_save_file, select_open_file
|
||||
# from datetime import date
|
||||
from .pop_ups import HTMLPop, AlertPop
|
||||
from .misc import Pagifier
|
||||
import logging, webbrowser, sys, shutil
|
||||
@@ -84,7 +86,8 @@ class App(QMainWindow):
|
||||
maintenanceMenu.addAction(self.joinPCRAction)
|
||||
editMenu.addAction(self.editReagentAction)
|
||||
editMenu.addAction(self.manageOrgsAction)
|
||||
# editMenu.addAction(self.manageKitsAction)
|
||||
if getpass.getuser() == "lwark":
|
||||
editMenu.addAction(self.manageKitsAction)
|
||||
if not is_power_user():
|
||||
editMenu.setEnabled(False)
|
||||
|
||||
@@ -119,7 +122,7 @@ class App(QMainWindow):
|
||||
connect menu and tool bar item to functions
|
||||
"""
|
||||
self.importAction.triggered.connect(self.table_widget.formwidget.importSubmission)
|
||||
self.addReagentAction.triggered.connect(self.table_widget.formwidget.new_add_reagent)
|
||||
self.addReagentAction.triggered.connect(self.table_widget.formwidget.add_reagent)
|
||||
self.joinExtractionAction.triggered.connect(self.table_widget.sub_wid.link_extractions)
|
||||
self.joinPCRAction.triggered.connect(self.table_widget.sub_wid.link_pcr)
|
||||
self.helpAction.triggered.connect(self.showAbout)
|
||||
@@ -177,6 +180,11 @@ class App(QMainWindow):
|
||||
dlg = SearchBox(self, object_type=BasicSample, extras=[])
|
||||
dlg.exec()
|
||||
|
||||
@check_authorization
|
||||
def edit_reagent(self, *args, **kwargs):
|
||||
dlg = SearchBox(parent=self, object_type=Reagent, extras=[dict(name='Role', field="role")])
|
||||
dlg.exec()
|
||||
|
||||
def export_ST_yaml(self):
|
||||
"""
|
||||
Copies submission type yaml to file system for editing and remport
|
||||
@@ -191,13 +199,18 @@ class App(QMainWindow):
|
||||
fname = select_save_file(obj=self, default_name="Submission Type Template.yml", extension="yml")
|
||||
shutil.copyfile(yaml_path, fname)
|
||||
|
||||
@check_authorization
|
||||
def edit_reagent(self, *args, **kwargs):
|
||||
dlg = SearchBox(parent=self, object_type=Reagent, extras=[dict(name='Role', field="role")])
|
||||
dlg.exec()
|
||||
|
||||
@check_authorization
|
||||
def import_ST_yaml(self, *args, **kwargs):
|
||||
"""
|
||||
Imports a yml form into a submission type.
|
||||
|
||||
Args:
|
||||
*args ():
|
||||
**kwargs ():
|
||||
|
||||
Returns:
|
||||
|
||||
"""
|
||||
fname = select_open_file(obj=self, file_extension="yml")
|
||||
if not fname:
|
||||
logger.info(f"Import cancelled.")
|
||||
@@ -220,9 +233,11 @@ class App(QMainWindow):
|
||||
dlg = ManagerWindow(parent=self, object_type=Organization, extras=[])
|
||||
if dlg.exec():
|
||||
new_org = dlg.parse_form()
|
||||
new_org.save()
|
||||
# logger.debug(new_org.__dict__)
|
||||
|
||||
def manage_kits(self):
|
||||
@under_development
|
||||
def manage_kits(self, *args, **kwargs):
|
||||
dlg = ManagerWindow(parent=self, object_type=KitType, extras=[])
|
||||
if dlg.exec():
|
||||
print(dlg.parse_form())
|
||||
|
||||
@@ -99,18 +99,24 @@ class ControlsViewer(InfoPane):
|
||||
self.mode_sub_type = self.mode_sub_typer.currentText()
|
||||
months = self.diff_month(self.start_date, self.end_date)
|
||||
# NOTE: query all controls using the type/start and end dates from the gui
|
||||
chart_settings = dict(sub_type=self.con_sub_type, start_date=self.start_date, end_date=self.end_date,
|
||||
mode=self.mode,
|
||||
sub_mode=self.mode_sub_type, parent=self, months=months)
|
||||
chart_settings = dict(
|
||||
sub_type=self.con_sub_type,
|
||||
start_date=self.start_date,
|
||||
end_date=self.end_date,
|
||||
mode=self.mode,
|
||||
sub_mode=self.mode_sub_type,
|
||||
parent=self,
|
||||
months=months
|
||||
)
|
||||
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):
|
||||
self.save_button.setEnabled(True)
|
||||
# NOTE: construct html for webview
|
||||
try:
|
||||
html = self.fig.to_html()
|
||||
except AttributeError:
|
||||
html = ""
|
||||
self.webview.setHtml(html)
|
||||
# try:
|
||||
# html = self.fig.html
|
||||
# except AttributeError:
|
||||
# html = ""
|
||||
self.webview.setHtml(self.fig.html)
|
||||
self.webview.update()
|
||||
return report
|
||||
|
||||
@@ -3,8 +3,9 @@ Creates forms that the user can enter equipment info into.
|
||||
'''
|
||||
from pprint import pformat
|
||||
from PyQt6.QtCore import Qt, QSignalBlocker
|
||||
from PyQt6.QtWidgets import (QDialog, QComboBox, QCheckBox,
|
||||
QLabel, QWidget, QVBoxLayout, QDialogButtonBox, QGridLayout)
|
||||
from PyQt6.QtWidgets import (
|
||||
QDialog, QComboBox, QCheckBox, QLabel, QWidget, QVBoxLayout, QDialogButtonBox, QGridLayout
|
||||
)
|
||||
from backend.db.models import Equipment, BasicSubmission, Process
|
||||
from backend.validators.pydant import PydEquipment, PydEquipmentRole, PydTips
|
||||
import logging
|
||||
@@ -36,8 +37,8 @@ class EquipmentUsage(QDialog):
|
||||
self.buttonBox.rejected.connect(self.reject)
|
||||
label = self.LabelRow(parent=self)
|
||||
self.layout.addWidget(label)
|
||||
for eq in self.opt_equipment:
|
||||
widg = eq.to_form(parent=self, used=self.used_equipment)
|
||||
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)
|
||||
@@ -64,6 +65,7 @@ class EquipmentUsage(QDialog):
|
||||
continue
|
||||
|
||||
class LabelRow(QWidget):
|
||||
"""Provides column headers"""
|
||||
|
||||
def __init__(self, parent) -> None:
|
||||
super().__init__(parent)
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
'''
|
||||
"""
|
||||
functions used by all windows in the application's frontend
|
||||
'''
|
||||
"""
|
||||
from pathlib import Path
|
||||
import logging
|
||||
from PyQt6.QtCore import QMarginsF
|
||||
from PyQt6.QtGui import QPageLayout, QPageSize
|
||||
from PyQt6.QtWebEngineWidgets import QWebEngineView
|
||||
from PyQt6.QtWidgets import QMainWindow, QFileDialog
|
||||
|
||||
logger = logging.getLogger(f"submissions.{__name__}")
|
||||
@@ -60,3 +63,21 @@ def select_save_file(obj: QMainWindow, default_name: str, extension: str) -> Pat
|
||||
fname = Path(QFileDialog.getSaveFileName(obj, "Save File", home_dir, filter=f"{extension}(*.{extension})")[0])
|
||||
obj.last_dir = fname.parent
|
||||
return fname
|
||||
|
||||
|
||||
def save_pdf(obj: QWebEngineView, filename: Path):
|
||||
"""
|
||||
Handles printing to PDF
|
||||
|
||||
Args:
|
||||
obj (): Parent object
|
||||
filename (): Where to save pdf.
|
||||
|
||||
Returns:
|
||||
None
|
||||
"""
|
||||
page_layout = QPageLayout()
|
||||
page_layout.setPageSize(QPageSize(QPageSize.PageSizeId.A4))
|
||||
page_layout.setOrientation(QPageLayout.Orientation.Portrait)
|
||||
page_layout.setMargins(QMarginsF(25, 25, 25, 25))
|
||||
obj.page().printToPdf(filename.absolute().__str__(), page_layout)
|
||||
|
||||
@@ -2,15 +2,13 @@
|
||||
Gel box for artic quality control
|
||||
"""
|
||||
from operator import itemgetter
|
||||
from PyQt6.QtWidgets import (QWidget, QDialog, QGridLayout,
|
||||
QLabel, QLineEdit, QDialogButtonBox,
|
||||
QTextEdit, QComboBox
|
||||
)
|
||||
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 numpy as np
|
||||
import logging
|
||||
import logging, numpy as np
|
||||
from pprint import pformat
|
||||
from typing import Tuple, List
|
||||
from pathlib import Path
|
||||
@@ -103,7 +101,8 @@ class ControlsForm(QWidget):
|
||||
except TypeError:
|
||||
tt_text = None
|
||||
for iii, item in enumerate(
|
||||
["Negative Control Key", "Description", "Results - 65 C", "Results - 63 C", "Results - Spike"]):
|
||||
["Negative Control Key", "Description", "Results - 65 C", "Results - 63 C", "Results - Spike"]
|
||||
):
|
||||
label = QLabel(item)
|
||||
self.layout.addWidget(label, 0, iii, 1, 1)
|
||||
if iii > 1:
|
||||
|
||||
@@ -6,8 +6,8 @@ from PyQt6.QtCore import QSignalBlocker
|
||||
from PyQt6.QtWebEngineWidgets import QWebEngineView
|
||||
from PyQt6.QtWidgets import QWidget, QGridLayout
|
||||
from tools import Report, report_result, Result
|
||||
from .misc import StartEndDatePicker, save_pdf
|
||||
from .functions import select_save_file
|
||||
from .misc import StartEndDatePicker
|
||||
from .functions import select_save_file, save_pdf
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(f"submissions.{__name__}")
|
||||
@@ -38,8 +38,7 @@ class InfoPane(QWidget):
|
||||
lastmonth = self.datepicker.end_date.date().addDays(-31)
|
||||
msg = f"Start date after end date is not allowed! Setting to {lastmonth.toString()}."
|
||||
logger.warning(msg)
|
||||
# NOTE: block signal that will rerun controls getter and set start date
|
||||
# Without triggering this function again
|
||||
# NOTE: block signal that will rerun controls getter and set start date without triggering this function again
|
||||
with QSignalBlocker(self.datepicker.start_date) as blocker:
|
||||
self.datepicker.start_date.setDate(lastmonth)
|
||||
self.update_data()
|
||||
|
||||
@@ -3,12 +3,10 @@ Contains miscellaneous widgets for frontend functions
|
||||
"""
|
||||
import math
|
||||
from datetime import date
|
||||
from PyQt6.QtGui import QPageLayout, QPageSize, QStandardItem, QIcon
|
||||
from PyQt6.QtWebEngineWidgets import QWebEngineView
|
||||
from PyQt6.QtGui import QStandardItem, QIcon
|
||||
from PyQt6.QtWidgets import (
|
||||
QLabel, QVBoxLayout,
|
||||
QLineEdit, QComboBox, QDialog,
|
||||
QDialogButtonBox, QDateEdit, QPushButton, QWidget, QHBoxLayout, QSizePolicy
|
||||
QLabel, QLineEdit, QComboBox, QDateEdit, QPushButton, QWidget,
|
||||
QHBoxLayout, QSizePolicy
|
||||
)
|
||||
from PyQt6.QtCore import Qt, QDate, QSize, QMarginsF
|
||||
from tools import jinja_template_loading
|
||||
@@ -20,96 +18,98 @@ logger = logging.getLogger(f"submissions.{__name__}")
|
||||
env = jinja_template_loading()
|
||||
|
||||
|
||||
class AddReagentForm(QDialog):
|
||||
"""
|
||||
dialog to add gather info about new reagent
|
||||
"""
|
||||
|
||||
def __init__(self, reagent_lot: str | None = None, reagent_role: str | None = None, expiry: date | None = None,
|
||||
reagent_name: str | None = None, kit: str | KitType | None = None) -> None:
|
||||
super().__init__()
|
||||
if reagent_name is None:
|
||||
reagent_name = reagent_role
|
||||
self.setWindowTitle("Add Reagent")
|
||||
QBtn = QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel
|
||||
self.buttonBox = QDialogButtonBox(QBtn)
|
||||
self.buttonBox.accepted.connect(self.accept)
|
||||
self.buttonBox.rejected.connect(self.reject)
|
||||
# NOTE: widget to get lot info
|
||||
self.name_input = QComboBox()
|
||||
self.name_input.setObjectName("name")
|
||||
self.name_input.setEditable(True)
|
||||
self.name_input.setCurrentText(reagent_name)
|
||||
self.lot_input = QLineEdit()
|
||||
self.lot_input.setObjectName("lot")
|
||||
self.lot_input.setText(reagent_lot)
|
||||
# NOTE: widget to get expiry info
|
||||
self.exp_input = QDateEdit(calendarPopup=True)
|
||||
self.exp_input.setObjectName('expiry')
|
||||
# NOTE: if expiry is not passed in from gui, use today
|
||||
if expiry is None:
|
||||
self.exp_input.setDate(QDate(1970, 1, 1))
|
||||
else:
|
||||
try:
|
||||
self.exp_input.setDate(expiry)
|
||||
except TypeError:
|
||||
self.exp_input.setDate(QDate(1970, 1, 1))
|
||||
# NOTE: widget to get reagent type info
|
||||
self.type_input = QComboBox()
|
||||
self.type_input.setObjectName('role')
|
||||
if kit:
|
||||
match kit:
|
||||
case str():
|
||||
kit = KitType.query(name=kit)
|
||||
case _:
|
||||
pass
|
||||
self.type_input.addItems([item.name for item in ReagentRole.query() if kit in item.kit_types])
|
||||
else:
|
||||
self.type_input.addItems([item.name for item in ReagentRole.query()])
|
||||
# NOTE: convert input to user-friendly string?
|
||||
try:
|
||||
reagent_role = reagent_role.replace("_", " ").title()
|
||||
except AttributeError:
|
||||
reagent_role = None
|
||||
# NOTE: set parsed reagent type to top of list
|
||||
index = self.type_input.findText(reagent_role, Qt.MatchFlag.MatchEndsWith)
|
||||
if index >= 0:
|
||||
self.type_input.setCurrentIndex(index)
|
||||
self.layout = QVBoxLayout()
|
||||
self.layout.addWidget(QLabel("Name:"))
|
||||
self.layout.addWidget(self.name_input)
|
||||
self.layout.addWidget(QLabel("Lot:"))
|
||||
self.layout.addWidget(self.lot_input)
|
||||
self.layout.addWidget(
|
||||
QLabel("Expiry:\n(use exact date on reagent.\nEOL will be calculated from kit automatically)"))
|
||||
self.layout.addWidget(self.exp_input)
|
||||
self.layout.addWidget(QLabel("Type:"))
|
||||
self.layout.addWidget(self.type_input)
|
||||
self.layout.addWidget(self.buttonBox)
|
||||
self.setLayout(self.layout)
|
||||
self.type_input.currentTextChanged.connect(self.update_names)
|
||||
|
||||
def parse_form(self) -> dict:
|
||||
"""
|
||||
Converts information in form to dict.
|
||||
|
||||
Returns:
|
||||
dict: Output info
|
||||
"""
|
||||
return dict(name=self.name_input.currentText().strip(),
|
||||
lot=self.lot_input.text().strip(),
|
||||
expiry=self.exp_input.date().toPyDate(),
|
||||
role=self.type_input.currentText().strip())
|
||||
|
||||
def update_names(self):
|
||||
"""
|
||||
Updates reagent names form field with examples from reagent type
|
||||
"""
|
||||
self.name_input.clear()
|
||||
lookup = Reagent.query(role=self.type_input.currentText())
|
||||
self.name_input.addItems(list(set([item.name for item in lookup])))
|
||||
|
||||
|
||||
# class AddReagentForm(QDialog):
|
||||
# """
|
||||
# dialog to add gather info about new reagent (Defunct)
|
||||
# """
|
||||
#
|
||||
# def __init__(self, reagent_lot: str | None = None, reagent_role: str | None = None, expiry: date | None = None,
|
||||
# reagent_name: str | None = None, kit: str | KitType | None = None) -> None:
|
||||
# super().__init__()
|
||||
# if reagent_name is None:
|
||||
# reagent_name = reagent_role
|
||||
# self.setWindowTitle("Add Reagent")
|
||||
# QBtn = QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel
|
||||
# self.buttonBox = QDialogButtonBox(QBtn)
|
||||
# self.buttonBox.accepted.connect(self.accept)
|
||||
# self.buttonBox.rejected.connect(self.reject)
|
||||
# # NOTE: widget to get lot info
|
||||
# self.name_input = QComboBox()
|
||||
# self.name_input.setObjectName("name")
|
||||
# self.name_input.setEditable(True)
|
||||
# self.name_input.setCurrentText(reagent_name)
|
||||
# self.lot_input = QLineEdit()
|
||||
# self.lot_input.setObjectName("lot")
|
||||
# self.lot_input.setText(reagent_lot)
|
||||
# # NOTE: widget to get expiry info
|
||||
# self.expiry_input = QDateEdit(calendarPopup=True)
|
||||
# self.expiry_input.setObjectName('expiry')
|
||||
# # NOTE: if expiry is not passed in from gui, use today
|
||||
# if expiry is None:
|
||||
# logger.warning(f"Did not receive expiry, setting to 1970, 1, 1")
|
||||
# self.expiry_input.setDate(QDate(1970, 1, 1))
|
||||
# else:
|
||||
# try:
|
||||
# self.expiry_input.setDate(expiry)
|
||||
# except TypeError:
|
||||
# self.expiry_input.setDate(QDate(1970, 1, 1))
|
||||
# # NOTE: widget to get reagent type info
|
||||
# self.role_input = QComboBox()
|
||||
# self.role_input.setObjectName('role')
|
||||
# if kit:
|
||||
# match kit:
|
||||
# case str():
|
||||
# kit = KitType.query(name=kit)
|
||||
# case _:
|
||||
# pass
|
||||
# self.role_input.addItems([item.name for item in ReagentRole.query() if kit in item.kit_types])
|
||||
# else:
|
||||
# self.role_input.addItems([item.name for item in ReagentRole.query()])
|
||||
# # NOTE: convert input to user-friendly string?
|
||||
# try:
|
||||
# reagent_role = reagent_role.replace("_", " ").title()
|
||||
# except AttributeError:
|
||||
# reagent_role = None
|
||||
# # NOTE: set parsed reagent type to top of list
|
||||
# index = self.role_input.findText(reagent_role, Qt.MatchFlag.MatchEndsWith)
|
||||
# if index >= 0:
|
||||
# self.role_input.setCurrentIndex(index)
|
||||
# self.layout = QVBoxLayout()
|
||||
# self.layout.addWidget(QLabel("Name:"))
|
||||
# self.layout.addWidget(self.name_input)
|
||||
# self.layout.addWidget(QLabel("Lot:"))
|
||||
# self.layout.addWidget(self.lot_input)
|
||||
# self.layout.addWidget(
|
||||
# QLabel("Expiry:\n(use exact date on reagent.\nEOL will be calculated from kit automatically)")
|
||||
# )
|
||||
# self.layout.addWidget(self.expiry_input)
|
||||
# self.layout.addWidget(QLabel("Type:"))
|
||||
# self.layout.addWidget(self.role_input)
|
||||
# self.layout.addWidget(self.buttonBox)
|
||||
# self.setLayout(self.layout)
|
||||
# self.role_input.currentTextChanged.connect(self.update_names)
|
||||
#
|
||||
# def parse_form(self) -> dict:
|
||||
# """
|
||||
# Converts information in form to dict.
|
||||
#
|
||||
# Returns:
|
||||
# dict: Output info
|
||||
# """
|
||||
# return dict(name=self.name_input.currentText().strip(),
|
||||
# lot=self.lot_input.text().strip(),
|
||||
# expiry=self.expiry_input.date().toPyDate(),
|
||||
# role=self.role_input.currentText().strip())
|
||||
#
|
||||
# def update_names(self):
|
||||
# """
|
||||
# Updates reagent names form field with examples from reagent type
|
||||
# """
|
||||
# self.name_input.clear()
|
||||
# lookup = Reagent.query(role=self.role_input.currentText())
|
||||
# self.name_input.addItems(list(set([item.name for item in lookup])))
|
||||
#
|
||||
#
|
||||
class StartEndDatePicker(QWidget):
|
||||
"""
|
||||
custom widget to pick start and end dates for controls graphs
|
||||
@@ -135,12 +135,12 @@ class StartEndDatePicker(QWidget):
|
||||
return QSize(80, 20)
|
||||
|
||||
|
||||
def save_pdf(obj: QWebEngineView, filename: Path):
|
||||
page_layout = QPageLayout()
|
||||
page_layout.setPageSize(QPageSize(QPageSize.PageSizeId.A4))
|
||||
page_layout.setOrientation(QPageLayout.Orientation.Portrait)
|
||||
page_layout.setMargins(QMarginsF(25, 25, 25, 25))
|
||||
obj.page().printToPdf(filename.absolute().__str__(), page_layout)
|
||||
# def save_pdf(obj: QWebEngineView, filename: Path):
|
||||
# page_layout = QPageLayout()
|
||||
# page_layout.setPageSize(QPageSize(QPageSize.PageSizeId.A4))
|
||||
# page_layout.setOrientation(QPageLayout.Orientation.Portrait)
|
||||
# page_layout.setMargins(QMarginsF(25, 25, 25, 25))
|
||||
# obj.page().printToPdf(filename.absolute().__str__(), page_layout)
|
||||
|
||||
|
||||
# NOTE: subclass
|
||||
|
||||
@@ -1,17 +1,18 @@
|
||||
"""
|
||||
A widget to handle adding/updating any database object.
|
||||
"""
|
||||
from datetime import date
|
||||
from pprint import pformat
|
||||
from typing import Any, List, Tuple
|
||||
from typing import Any, Tuple
|
||||
from pydantic import BaseModel
|
||||
from PyQt6.QtWidgets import (
|
||||
QLabel, QDialog, QTableView, QWidget, QLineEdit, QGridLayout, QComboBox, QPushButton, QDialogButtonBox, QDateEdit
|
||||
QLabel, QDialog, QWidget, QLineEdit, QGridLayout, QComboBox, QDialogButtonBox, QDateEdit, QSpinBox, QDoubleSpinBox
|
||||
)
|
||||
from sqlalchemy import String, TIMESTAMP
|
||||
from sqlalchemy import String, TIMESTAMP, INTEGER, FLOAT
|
||||
from sqlalchemy.orm import InstrumentedAttribute, ColumnProperty
|
||||
import logging
|
||||
|
||||
from sqlalchemy.orm.relationships import _RelationshipDeclared
|
||||
|
||||
from tools import Report, Result, report_result
|
||||
from tools import Report, report_result
|
||||
|
||||
logger = logging.getLogger(f"submissions.{__name__}")
|
||||
|
||||
@@ -23,7 +24,6 @@ class AddEdit(QDialog):
|
||||
self.instance = instance
|
||||
self.object_type = instance.__class__
|
||||
self.layout = QGridLayout(self)
|
||||
# logger.debug(f"Manager: {manager}")
|
||||
QBtn = QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel
|
||||
self.buttonBox = QDialogButtonBox(QBtn)
|
||||
self.buttonBox.accepted.connect(self.accept)
|
||||
@@ -36,7 +36,6 @@ class AddEdit(QDialog):
|
||||
fields = {'name': fields.pop('name'), **fields}
|
||||
except KeyError:
|
||||
pass
|
||||
# logger.debug(pformat(fields, indent=4))
|
||||
height_counter = 0
|
||||
for key, field in fields.items():
|
||||
try:
|
||||
@@ -45,9 +44,6 @@ class AddEdit(QDialog):
|
||||
value = None
|
||||
try:
|
||||
logger.debug(f"{key} property: {type(field['class_attr'].property)}")
|
||||
# widget = EditProperty(self, key=key, column_type=field.property.expression.type,
|
||||
# value=getattr(self.instance, key))
|
||||
# logger.debug(f"Column type: {field}, Value: {value}")
|
||||
widget = EditProperty(self, key=key, column_type=field, value=value)
|
||||
except AttributeError as e:
|
||||
logger.error(f"Problem setting widget {key}: {e}")
|
||||
@@ -64,15 +60,11 @@ class AddEdit(QDialog):
|
||||
def parse_form(self) -> Tuple[BaseModel, Report]:
|
||||
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(parsed)
|
||||
# logger.debug(parsed)
|
||||
model = self.object_type.pydantic_model
|
||||
# NOTE: Hand-off to pydantic model for validation.
|
||||
# NOTE: Also, why am I not just using the toSQL method here. I could write one for contacts.
|
||||
model = model(**parsed)
|
||||
# output, result = model.to_sql()
|
||||
# report.add_result(result)
|
||||
# if len(report.results) < 1:
|
||||
# report.add_result(Result(msg="Added new regeant.", icon="Information", owner=__name__))
|
||||
return model, report
|
||||
|
||||
|
||||
@@ -84,7 +76,7 @@ class EditProperty(QWidget):
|
||||
self.label = QLabel(key.title().replace("_", " "))
|
||||
self.layout = QGridLayout()
|
||||
self.layout.addWidget(self.label, 0, 0, 1, 1)
|
||||
self.setObjectName(f"{key}:")
|
||||
self.setObjectName(key)
|
||||
match column_type['class_attr'].property:
|
||||
case ColumnProperty():
|
||||
self.column_property_set(column_type, value=value)
|
||||
@@ -97,15 +89,15 @@ class EditProperty(QWidget):
|
||||
self.setLayout(self.layout)
|
||||
|
||||
def relationship_property_set(self, relationship_property, value=None):
|
||||
# print(relationship_property)
|
||||
self.property_class = relationship_property['class_attr'].property.entity.class_
|
||||
self.is_list = relationship_property['class_attr'].property.uselist
|
||||
choices = [item.name for item in self.property_class.query()]
|
||||
choices = [""] + [item.name for item in self.property_class.query()]
|
||||
try:
|
||||
instance_value = getattr(self.parent().instance, self.name)
|
||||
instance_value = getattr(self.parent().instance, self.objectName())
|
||||
except AttributeError:
|
||||
logger.error(f"Unable to get instance {self.parent().instance} attribute: {self.name}")
|
||||
logger.error(f"Unable to get instance {self.parent().instance} attribute: {self.objectName()}")
|
||||
instance_value = None
|
||||
# NOTE: get the value for the current instance and move it to the front.
|
||||
if isinstance(instance_value, list):
|
||||
instance_value = next((item.name for item in instance_value), None)
|
||||
if instance_value:
|
||||
@@ -120,6 +112,16 @@ class EditProperty(QWidget):
|
||||
value = ""
|
||||
self.widget = QLineEdit(self)
|
||||
self.widget.setText(value)
|
||||
case INTEGER():
|
||||
if not value:
|
||||
value = 1
|
||||
self.widget = QSpinBox()
|
||||
self.widget.setValue(value)
|
||||
case FLOAT():
|
||||
if not value:
|
||||
value = 1.0
|
||||
self.widget = QDoubleSpinBox()
|
||||
self.widget.setValue(value)
|
||||
case TIMESTAMP():
|
||||
self.widget = QDateEdit(self, calendarPopup=True)
|
||||
if not value:
|
||||
@@ -129,12 +131,13 @@ class EditProperty(QWidget):
|
||||
logger.error(f"{column_property} not a supported property.")
|
||||
self.widget = None
|
||||
try:
|
||||
tooltip_text = self.parent().object_type.add_edit_tooltips[self.name]
|
||||
tooltip_text = self.parent().object_type.add_edit_tooltips[self.objectName()]
|
||||
self.widget.setToolTip(tooltip_text)
|
||||
except KeyError:
|
||||
pass
|
||||
|
||||
def parse_form(self):
|
||||
# NOTE: Make sure there's a widget.
|
||||
try:
|
||||
check = self.widget
|
||||
except AttributeError:
|
||||
@@ -146,10 +149,12 @@ class EditProperty(QWidget):
|
||||
value = self.widget.date().toPyDate()
|
||||
case QComboBox():
|
||||
value = self.widget.currentText()
|
||||
case QSpinBox() | QDoubleSpinBox():
|
||||
value = self.widget.value()
|
||||
# if self.is_list:
|
||||
# value = [self.property_class.query(name=prelim)]
|
||||
# else:
|
||||
# value = self.property_class.query(name=prelim)
|
||||
case _:
|
||||
value = None
|
||||
return self.name, value
|
||||
return self.objectName(), value
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
from operator import itemgetter
|
||||
"""
|
||||
Provides a screen for managing all attributes of a database object.
|
||||
"""
|
||||
from typing import Any, List
|
||||
from PyQt6.QtCore import QSortFilterProxyModel, Qt
|
||||
from PyQt6.QtGui import QAction, QCursor
|
||||
@@ -49,21 +51,22 @@ class ManagerWindow(QDialog):
|
||||
self.layout.addWidget(self.sub_class, 0, 0)
|
||||
else:
|
||||
self.sub_class = None
|
||||
# self.layout.addWidget(self.buttonBox, self.layout.rowCount(), 0)
|
||||
self.options = QComboBox(self)
|
||||
self.options.setObjectName("options")
|
||||
self.update_options()
|
||||
self.setLayout(self.layout)
|
||||
self.setWindowTitle(f"Manage {self.object_type.__name__}")
|
||||
|
||||
def update_options(self):
|
||||
def update_options(self) -> None:
|
||||
"""
|
||||
Changes form inputs based on sample type
|
||||
"""
|
||||
|
||||
if self.sub_class:
|
||||
self.object_type = getattr(db, self.sub_class.currentText())
|
||||
options = [item.name for item in self.object_type.query()]
|
||||
logger.debug(f"self.instance: {self.instance}")
|
||||
if self.instance:
|
||||
options.insert(0, options.pop(options.index(self.instance.name)))
|
||||
self.options.clear()
|
||||
self.options.addItems(options)
|
||||
self.options.setEditable(False)
|
||||
@@ -75,23 +78,34 @@ class ManagerWindow(QDialog):
|
||||
self.add_button.clicked.connect(self.add_new)
|
||||
self.update_data()
|
||||
|
||||
def update_data(self):
|
||||
def update_data(self) -> None:
|
||||
"""
|
||||
Performs updating of widgets on first run and after options change.
|
||||
|
||||
Returns:
|
||||
None
|
||||
"""
|
||||
# NOTE: Remove all old widgets.
|
||||
deletes = [item for item in self.findChildren(EditProperty)] + \
|
||||
[item for item in self.findChildren(EditRelationship)] + \
|
||||
[item for item in self.findChildren(QDialogButtonBox)]
|
||||
for item in deletes:
|
||||
item.setParent(None)
|
||||
# NOTE: Find the instance this manager will update
|
||||
self.instance = self.object_type.query(name=self.options.currentText())
|
||||
fields = {k: v for k, v in self.object_type.__dict__.items() if
|
||||
isinstance(v, InstrumentedAttribute) and k != "id"}
|
||||
for key, field in fields.items():
|
||||
# logger.debug(f"Key: {key}, Value: {field}")
|
||||
match field.property:
|
||||
# NOTE: ColumnProperties will be directly edited.
|
||||
case ColumnProperty():
|
||||
# NOTE: field.property.expression.type gives db column type eg. STRING or TIMESTAMP
|
||||
widget = EditProperty(self, key=key, column_type=field.property.expression.type,
|
||||
value=getattr(self.instance, key))
|
||||
# NOTE: RelationshipDeclareds will be given a list of existing related objects.
|
||||
case _RelationshipDeclared():
|
||||
if key != "submissions":
|
||||
# NOTE: field.comparator.entity.class_ gives the relationship class
|
||||
widget = EditRelationship(self, key=key, entity=field.comparator.entity.class_,
|
||||
value=getattr(self.instance, key))
|
||||
else:
|
||||
@@ -100,11 +114,18 @@ class ManagerWindow(QDialog):
|
||||
continue
|
||||
if widget:
|
||||
self.layout.addWidget(widget, self.layout.rowCount(), 0, 1, 2)
|
||||
# NOTE: Add OK|Cancel to bottom of dialog.
|
||||
self.layout.addWidget(self.buttonBox, self.layout.rowCount(), 0, 1, 2)
|
||||
|
||||
def parse_form(self):
|
||||
def parse_form(self) -> Any:
|
||||
"""
|
||||
Returns the instance associated with this window.
|
||||
|
||||
Returns:
|
||||
Any: The instance with updated fields.
|
||||
"""
|
||||
# TODO: Need Relationship property here too?
|
||||
results = [item.parse_form() for item in self.findChildren(EditProperty)]
|
||||
# logger.debug(results)
|
||||
for result in results:
|
||||
# logger.debug(result)
|
||||
self.instance.__setattr__(result[0], result[1])
|
||||
@@ -113,9 +134,10 @@ class ManagerWindow(QDialog):
|
||||
def add_new(self):
|
||||
dlg = AddEdit(parent=self, instance=self.object_type(), manager=self.object_type.__name__.lower())
|
||||
if dlg.exec():
|
||||
new_instance = dlg.parse_form()
|
||||
# logger.debug(new_instance.__dict__)
|
||||
new_pyd = dlg.parse_form()
|
||||
new_instance = new_pyd.to_sql()
|
||||
new_instance.save()
|
||||
self.instance = new_instance
|
||||
self.update_options()
|
||||
|
||||
|
||||
@@ -222,10 +244,13 @@ class EditRelationship(QWidget):
|
||||
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}")
|
||||
logger.error(f"Could not alter id to string due to KeyError: {e}")
|
||||
proxy_model = QSortFilterProxyModel()
|
||||
proxy_model.setSourceModel(pandasModel(self.data))
|
||||
self.table.setModel(proxy_model)
|
||||
self.table.resizeColumnsToContents()
|
||||
self.table.resizeRowsToContents()
|
||||
self.table.setSortingEnabled(True)
|
||||
self.table.doubleClicked.connect(self.parse_row)
|
||||
|
||||
def contextMenuEvent(self, event):
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
Search box that performs fuzzy search for various object types
|
||||
"""
|
||||
from pprint import pformat
|
||||
from typing import Tuple, Any, List
|
||||
from typing import Tuple, Any, List, Generator
|
||||
from pandas import DataFrame
|
||||
from PyQt6.QtCore import QSortFilterProxyModel
|
||||
from PyQt6.QtCore import QSortFilterProxyModel, QModelIndex
|
||||
from PyQt6.QtWidgets import (
|
||||
QLabel, QVBoxLayout, QDialog,
|
||||
QTableView, QWidget, QLineEdit, QGridLayout, QComboBox, QDialogButtonBox
|
||||
@@ -74,7 +74,6 @@ class SearchBox(QDialog):
|
||||
search_fields = []
|
||||
for iii, searchable in enumerate(search_fields):
|
||||
widget = FieldSearch(parent=self, label=searchable['label'], field_name=searchable['field'])
|
||||
# widget = FieldSearch(parent=self, label=k, field_name=v)
|
||||
widget.setObjectName(searchable['field'])
|
||||
self.layout.addWidget(widget, 1 + iii, 0)
|
||||
widget.search_widget.textChanged.connect(self.update_data)
|
||||
@@ -100,11 +99,17 @@ class SearchBox(QDialog):
|
||||
# NOTE: Setting results moved to here from __init__ 202411118
|
||||
self.results.setData(df=data)
|
||||
|
||||
def return_selected_rows(self):
|
||||
rows = sorted(set(index.row() for index in
|
||||
self.results.selectedIndexes()))
|
||||
def return_selected_rows(self) -> Generator[dict, None, None]:
|
||||
"""
|
||||
Yields data from selected rows
|
||||
|
||||
Returns:
|
||||
dict: Dictionary of column name: data
|
||||
"""
|
||||
rows = sorted(set(index.row() for index in self.results.selectedIndexes()))
|
||||
for index in rows:
|
||||
output = {column:self.results.model().data(self.results.model().index(index, ii)) for ii, column in enumerate(self.results.data.columns)}
|
||||
output = {column: self.results.model().data(self.results.model().index(index, ii)) for ii, column in
|
||||
enumerate(self.results.data.columns)}
|
||||
yield output
|
||||
|
||||
|
||||
@@ -130,7 +135,13 @@ class FieldSearch(QWidget):
|
||||
"""
|
||||
self.parent().update_data()
|
||||
|
||||
def parse_form(self) -> Tuple:
|
||||
def parse_form(self) -> Tuple[str, str]:
|
||||
"""
|
||||
Gets object name and widget value.
|
||||
|
||||
Returns:
|
||||
Tuple(str, str): Key, value to be used in constructing a dictionary.
|
||||
"""
|
||||
field_value = self.search_widget.text()
|
||||
if field_value == "":
|
||||
field_value = None
|
||||
@@ -147,6 +158,7 @@ class SearchResults(QTableView):
|
||||
self.context = kwargs
|
||||
self.parent = parent
|
||||
self.object_type = object_type
|
||||
|
||||
try:
|
||||
self.extras = extras + self.object_type.searchables
|
||||
except AttributeError:
|
||||
@@ -160,7 +172,8 @@ 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['field'], column=self.data.columns.get_loc(item['field'])) for
|
||||
item in self.extras]
|
||||
except KeyError:
|
||||
self.columns_of_interest = []
|
||||
try:
|
||||
@@ -171,9 +184,21 @@ class SearchResults(QTableView):
|
||||
proxy_model = QSortFilterProxyModel()
|
||||
proxy_model.setSourceModel(pandasModel(self.data))
|
||||
self.setModel(proxy_model)
|
||||
self.resizeColumnsToContents()
|
||||
self.resizeRowsToContents()
|
||||
self.setSortingEnabled(True)
|
||||
self.doubleClicked.connect(self.parse_row)
|
||||
|
||||
def parse_row(self, x):
|
||||
def parse_row(self, x: QModelIndex) -> None:
|
||||
"""
|
||||
Runs the self.object_type edit from search method for row X.
|
||||
|
||||
Args:
|
||||
x (QModelIndex): Row to be parsed.
|
||||
|
||||
Returns:
|
||||
None
|
||||
"""
|
||||
context = {item['name']: x.sibling(x.row(), item['column']).data() for item in self.columns_of_interest}
|
||||
try:
|
||||
object = self.object_type.query(**context)
|
||||
|
||||
@@ -8,9 +8,8 @@ from PyQt6.QtWebChannel import QWebChannel
|
||||
from PyQt6.QtCore import Qt, pyqtSlot
|
||||
from jinja2 import TemplateNotFound
|
||||
from backend.db.models import BasicSubmission, BasicSample, Reagent, KitType
|
||||
from tools import is_power_user, jinja_template_loading, timezone
|
||||
from .functions import select_save_file
|
||||
from .misc import save_pdf
|
||||
from tools import is_power_user, jinja_template_loading, timezone, get_application_from_parent
|
||||
from .functions import select_save_file, save_pdf
|
||||
from pathlib import Path
|
||||
import logging
|
||||
from getpass import getuser
|
||||
@@ -30,13 +29,11 @@ class SubmissionDetails(QDialog):
|
||||
def __init__(self, parent, sub: BasicSubmission | BasicSample | Reagent) -> None:
|
||||
|
||||
super().__init__(parent)
|
||||
try:
|
||||
self.app = parent.parent().parent().parent().parent().parent().parent()
|
||||
except AttributeError:
|
||||
self.app = None
|
||||
self.app = get_application_from_parent(parent)
|
||||
self.webview = QWebEngineView(parent=self)
|
||||
self.webview.setMinimumSize(900, 500)
|
||||
self.webview.setMaximumWidth(900)
|
||||
# NOTE: Decide if exporting should be allowed.
|
||||
self.webview.loadFinished.connect(self.activate_export)
|
||||
self.layout = QGridLayout()
|
||||
# NOTE: button to export a pdf version
|
||||
@@ -61,9 +58,16 @@ class SubmissionDetails(QDialog):
|
||||
self.sample_details(sample=sub)
|
||||
case Reagent():
|
||||
self.reagent_details(reagent=sub)
|
||||
# NOTE: Used to maintain javascript functions.
|
||||
self.webview.page().setWebChannel(self.channel)
|
||||
|
||||
def activate_export(self):
|
||||
def activate_export(self) -> None:
|
||||
"""
|
||||
Determines if export pdf should be active.
|
||||
|
||||
Returns:
|
||||
None
|
||||
"""
|
||||
title = self.webview.title()
|
||||
self.setWindowTitle(title)
|
||||
if "Submission" in title:
|
||||
@@ -103,6 +107,13 @@ class SubmissionDetails(QDialog):
|
||||
|
||||
@pyqtSlot(str, str)
|
||||
def reagent_details(self, reagent: str | Reagent, kit: str | KitType):
|
||||
"""
|
||||
Changes details view to summary of Reagent
|
||||
|
||||
Args:
|
||||
kit (str | KitType): Name of kit.
|
||||
reagent (str | Reagent): Lot number of the reagent
|
||||
"""
|
||||
if isinstance(reagent, str):
|
||||
reagent = Reagent.query(lot=reagent)
|
||||
if isinstance(kit, str):
|
||||
@@ -124,6 +135,17 @@ class SubmissionDetails(QDialog):
|
||||
|
||||
@pyqtSlot(str, str, str)
|
||||
def update_reagent(self, old_lot: str, new_lot: str, expiry: str):
|
||||
"""
|
||||
Designed to allow editing reagent in details view (depreciated)
|
||||
|
||||
Args:
|
||||
old_lot ():
|
||||
new_lot ():
|
||||
expiry ():
|
||||
|
||||
Returns:
|
||||
|
||||
"""
|
||||
expiry = datetime.strptime(expiry, "%Y-%m-%d")
|
||||
reagent = Reagent.query(lot=old_lot)
|
||||
if reagent:
|
||||
@@ -157,7 +179,16 @@ class SubmissionDetails(QDialog):
|
||||
self.webview.setHtml(self.html)
|
||||
|
||||
@pyqtSlot(str)
|
||||
def sign_off(self, submission: str | BasicSubmission):
|
||||
def sign_off(self, submission: str | BasicSubmission) -> None:
|
||||
"""
|
||||
Allows power user to signify a submission is complete.
|
||||
|
||||
Args:
|
||||
submission (str | BasicSubmission): Submission to be completed
|
||||
|
||||
Returns:
|
||||
None
|
||||
"""
|
||||
logger.info(f"Signing off on {submission} - ({getuser()})")
|
||||
if isinstance(submission, str):
|
||||
submission = BasicSubmission.query(rsl_plate_num=submission)
|
||||
@@ -183,10 +214,7 @@ class SubmissionComment(QDialog):
|
||||
def __init__(self, parent, submission: BasicSubmission) -> None:
|
||||
|
||||
super().__init__(parent)
|
||||
try:
|
||||
self.app = parent.parent().parent().parent().parent().parent().parent
|
||||
except AttributeError:
|
||||
pass
|
||||
self.app = get_application_from_parent(parent)
|
||||
self.submission = submission
|
||||
self.setWindowTitle(f"{self.submission.rsl_plate_num} Submission Comment")
|
||||
# NOTE: create text field
|
||||
|
||||
@@ -65,12 +65,6 @@ class SubmissionsSheet(QTableView):
|
||||
"""
|
||||
|
||||
def __init__(self, parent) -> None:
|
||||
"""
|
||||
initialize
|
||||
|
||||
Args:
|
||||
ctx (dict): settings passed from gui
|
||||
"""
|
||||
super().__init__(parent)
|
||||
self.app = self.parent()
|
||||
self.report = Report()
|
||||
@@ -107,7 +101,9 @@ class SubmissionsSheet(QTableView):
|
||||
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 = BasicSubmission.query(id=id)
|
||||
self.menu = QMenu(self)
|
||||
|
||||
@@ -9,16 +9,15 @@ 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
|
||||
from tools import Report, Result, check_not_nan, main_form_style, report_result, get_application_from_parent
|
||||
from backend.excel.parser import SheetParser
|
||||
from backend.validators import PydSubmission, PydReagent
|
||||
from backend.db import (
|
||||
KitType, Organization, SubmissionType, Reagent,
|
||||
Organization, SubmissionType, Reagent,
|
||||
ReagentRole, KitTypeReagentRoleAssociation, BasicSubmission
|
||||
)
|
||||
from pprint import pformat
|
||||
from .pop_ups import QuestionAsker, AlertPop
|
||||
from .misc import AddReagentForm
|
||||
from .omni_add_edit import AddEdit
|
||||
from typing import List, Tuple
|
||||
from datetime import date
|
||||
@@ -67,7 +66,6 @@ class SubmissionFormContainer(QWidget):
|
||||
def __init__(self, parent: QWidget) -> None:
|
||||
super().__init__(parent)
|
||||
self.app = self.parent().parent()
|
||||
self.report = Report()
|
||||
self.setStyleSheet('background-color: light grey;')
|
||||
self.setAcceptDrops(True)
|
||||
# NOTE: if import_drag is emitted, importSubmission will fire
|
||||
@@ -97,12 +95,12 @@ class SubmissionFormContainer(QWidget):
|
||||
"""
|
||||
self.app.raise_()
|
||||
self.app.activateWindow()
|
||||
self.report = Report()
|
||||
report = Report()
|
||||
self.import_submission_function(fname)
|
||||
return self.report
|
||||
return report
|
||||
|
||||
@report_result
|
||||
def import_submission_function(self, fname: Path | None = None):
|
||||
def import_submission_function(self, fname: Path | None = None) -> Report:
|
||||
"""
|
||||
Import a new submission to the app window
|
||||
|
||||
@@ -110,10 +108,11 @@ class SubmissionFormContainer(QWidget):
|
||||
obj (QMainWindow): original app window
|
||||
|
||||
Returns:
|
||||
Tuple[QMainWindow, dict|None]: Collection of new main app window and result dict
|
||||
Report: Object to give results of import.
|
||||
"""
|
||||
logger.info(f"\n\nStarting Import...\n\n")
|
||||
report = Report()
|
||||
# NOTE: Clear any previous forms.
|
||||
try:
|
||||
self.form.setParent(None)
|
||||
except AttributeError:
|
||||
@@ -141,7 +140,16 @@ class SubmissionFormContainer(QWidget):
|
||||
return report
|
||||
|
||||
@report_result
|
||||
def new_add_reagent(self, instance: Reagent | None = None):
|
||||
def add_reagent(self, instance: Reagent | None = None):
|
||||
"""
|
||||
Action to create new reagent in DB.
|
||||
|
||||
Args:
|
||||
instance (Reagent | None): Blank reagent instance to be edited and then added.
|
||||
|
||||
Returns:
|
||||
models.Reagent: the constructed reagent object to add to submission
|
||||
"""
|
||||
report = Report()
|
||||
if not instance:
|
||||
instance = Reagent()
|
||||
@@ -149,48 +157,12 @@ class SubmissionFormContainer(QWidget):
|
||||
if dlg.exec():
|
||||
reagent = dlg.parse_form()
|
||||
reagent.missing = False
|
||||
# logger.debug(f"Reagent: {reagent}, result: {result}")
|
||||
# report.add_result(result)
|
||||
# NOTE: send reagent to db
|
||||
sqlobj = reagent.to_sql()
|
||||
sqlobj.save()
|
||||
logger.debug(f"Reagent added!")
|
||||
report.add_result(Result(owner=__name__, code=0, msg="New reagent created.", status="Information"))
|
||||
# report.add_result(result)
|
||||
return reagent, report
|
||||
|
||||
@report_result
|
||||
def add_reagent(self, reagent_lot: str | None = None, reagent_role: str | None = None, expiry: date | None = None,
|
||||
name: str | None = None, kit: str | KitType | None = None) -> Tuple[PydReagent, Report]:
|
||||
"""
|
||||
Action to create new reagent in DB.
|
||||
|
||||
Args:
|
||||
reagent_lot (str | None, optional): Parsed reagent from import form. Defaults to None.
|
||||
reagent_role (str | None, optional): Parsed reagent type from import form. Defaults to None.
|
||||
expiry (date | None, optional): Parsed reagent expiry data. Defaults to None.
|
||||
name (str | None, optional): Parsed reagent name. Defaults to None.
|
||||
|
||||
Returns:
|
||||
models.Reagent: the constructed reagent object to add to submission
|
||||
"""
|
||||
report = Report()
|
||||
if isinstance(reagent_lot, bool):
|
||||
reagent_lot = ""
|
||||
# NOTE: create form
|
||||
dlg = AddReagentForm(reagent_lot=reagent_lot, reagent_role=reagent_role, expiry=expiry, reagent_name=name,
|
||||
kit=kit)
|
||||
if dlg.exec():
|
||||
# NOTE: extract form info
|
||||
info = dlg.parse_form()
|
||||
# NOTE: create reagent object
|
||||
reagent = PydReagent(ctx=self.app.ctx, **info, missing=False)
|
||||
# NOTE: send reagent to db
|
||||
sqlobj = reagent.to_sql()
|
||||
sqlobj.save()
|
||||
# report.add_result(result)
|
||||
return reagent
|
||||
|
||||
|
||||
class SubmissionFormWidget(QWidget):
|
||||
update_reagent_fields = ['extraction_kit']
|
||||
@@ -199,12 +171,12 @@ class SubmissionFormWidget(QWidget):
|
||||
super().__init__(parent)
|
||||
if disable is None:
|
||||
disable = []
|
||||
self.app = parent.app
|
||||
self.app = get_application_from_parent(parent)
|
||||
self.pyd = submission
|
||||
self.missing_info = []
|
||||
self.submission_type = SubmissionType.query(name=self.pyd.submission_type['value'])
|
||||
st = self.submission_type.submission_class
|
||||
defaults = st.get_default_info("form_recover", "form_ignore", submission_type=self.pyd.submission_type['value'])
|
||||
basic_submission_class = self.submission_type.submission_class
|
||||
defaults = basic_submission_class.get_default_info("form_recover", "form_ignore", submission_type=self.pyd.submission_type['value'])
|
||||
self.recover = defaults['form_recover']
|
||||
self.ignore = defaults['form_ignore']
|
||||
self.layout = QVBoxLayout()
|
||||
@@ -225,7 +197,7 @@ class SubmissionFormWidget(QWidget):
|
||||
except KeyError:
|
||||
value = dict(value=None, missing=True)
|
||||
add_widget = self.create_widget(key=k, value=value, submission_type=self.submission_type,
|
||||
sub_obj=st, disable=check)
|
||||
sub_obj=basic_submission_class, disable=check)
|
||||
if add_widget is not None:
|
||||
self.layout.addWidget(add_widget)
|
||||
if k in self.__class__.update_reagent_fields:
|
||||
@@ -302,10 +274,9 @@ 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}")
|
||||
# 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)
|
||||
self.layout.addWidget(add_widget)
|
||||
report.add_result(integrity_report)
|
||||
@@ -340,9 +311,12 @@ class SubmissionFormWidget(QWidget):
|
||||
Returns:
|
||||
List[QWidget]: Widgets matching filter
|
||||
"""
|
||||
query = self.findChildren(QWidget)
|
||||
if object_name is not None:
|
||||
query = [widget for widget in query if widget.objectName() == object_name]
|
||||
if object_name:
|
||||
query = self.findChildren(QWidget, name=object_name)
|
||||
else:
|
||||
query = self.findChildren(QWidget)
|
||||
# if object_name is not None:
|
||||
# query = [widget for widget in query if widget.objectName() == object_name]
|
||||
return query
|
||||
|
||||
@report_result
|
||||
@@ -581,13 +555,13 @@ class SubmissionFormWidget(QWidget):
|
||||
parent.extraction_kit = add_widget.currentText()
|
||||
case 'submission_category':
|
||||
add_widget = MyQComboBox(scrollWidget=parent)
|
||||
cats = ['Diagnostic', "Surveillance", "Research"]
|
||||
cats += [item.name for item in SubmissionType.query()]
|
||||
categories = ['Diagnostic', "Surveillance", "Research"]
|
||||
categories += [item.name for item in SubmissionType.query()]
|
||||
try:
|
||||
cats.insert(0, cats.pop(cats.index(value)))
|
||||
categories.insert(0, categories.pop(categories.index(value)))
|
||||
except ValueError:
|
||||
cats.insert(0, cats.pop(cats.index(submission_type)))
|
||||
add_widget.addItems(cats)
|
||||
categories.insert(0, categories.pop(categories.index(submission_type)))
|
||||
add_widget.addItems(categories)
|
||||
add_widget.setToolTip("Enter submission category or select from list.")
|
||||
case _:
|
||||
if key in sub_obj.timestamps:
|
||||
@@ -655,7 +629,8 @@ class SubmissionFormWidget(QWidget):
|
||||
|
||||
def __init__(self, parent: QWidget, reagent: PydReagent, extraction_kit: str):
|
||||
super().__init__(parent)
|
||||
self.app = self.parent().parent().parent().parent().parent().parent().parent().parent()
|
||||
self.parent = parent
|
||||
self.app = get_application_from_parent(parent)
|
||||
self.reagent = reagent
|
||||
self.extraction_kit = extraction_kit
|
||||
layout = QGridLayout()
|
||||
@@ -684,10 +659,11 @@ class SubmissionFormWidget(QWidget):
|
||||
def disable(self):
|
||||
self.lot.setEnabled(self.check.isChecked())
|
||||
self.label.setEnabled(self.check.isChecked())
|
||||
if not any([item.lot.isEnabled() for item in self.parent().findChildren(self.__class__)]):
|
||||
self.parent().disabler.checkbox.setChecked(False)
|
||||
else:
|
||||
self.parent().disabler.checkbox.setChecked(True)
|
||||
with QSignalBlocker(self.parent.disabler.checkbox) as blocker:
|
||||
if any([item.lot.isEnabled() for item in self.parent.findChildren(self.__class__)]):
|
||||
self.parent.disabler.checkbox.setChecked(True)
|
||||
else:
|
||||
self.parent.disabler.checkbox.setChecked(False)
|
||||
|
||||
@report_result
|
||||
def parse_form(self) -> Tuple[PydReagent | None, Report]:
|
||||
@@ -703,31 +679,23 @@ class SubmissionFormWidget(QWidget):
|
||||
lot = self.lot.currentText()
|
||||
wanted_reagent, new = Reagent.query_or_create(lot=lot, role=self.reagent.role, expiry=self.reagent.expiry)
|
||||
# NOTE: if reagent doesn't exist in database, offer to add it (uses App.add_reagent)
|
||||
logger.debug(f"Wanted reagent: {wanted_reagent}, New: {new}")
|
||||
# if wanted_reagent is None:
|
||||
if new:
|
||||
dlg = QuestionAsker(title=f"Add {lot}?",
|
||||
message=f"Couldn't find reagent type {self.reagent.role}: {lot} in the database.\n\nWould you like to add it?")
|
||||
|
||||
if dlg.exec():
|
||||
wanted_reagent = self.parent().parent().new_add_reagent(instance=wanted_reagent)
|
||||
logger.debug(f"Reagent added!")
|
||||
report.add_result(Result(owner=__name__, code=0, msg="New reagent created.", status="Information"))
|
||||
return wanted_reagent, report
|
||||
else:
|
||||
# NOTE: In this case we will have an empty reagent and the submission will fail kit integrity check
|
||||
|
||||
return None, report
|
||||
else:
|
||||
# NOTE: Since this now gets passed in directly from the parser -> pyd -> form and the parser gets the name
|
||||
# from the db, it should no longer be necessary to query the db with reagent/kit, but with rt name directly.
|
||||
# NOTE: Since this now gets passed in directly from the parser -> pyd -> form and the parser gets the name from the db, it should no longer be necessary to query the db with reagent/kit, but with rt name directly.
|
||||
rt = ReagentRole.query(name=self.reagent.role)
|
||||
logger.debug(f"Reagent role: {rt}")
|
||||
if rt is None:
|
||||
rt = ReagentRole.query(kit_type=self.extraction_kit, reagent=wanted_reagent)
|
||||
final = PydReagent(name=wanted_reagent.name, lot=wanted_reagent.lot, role=rt.name,
|
||||
expiry=wanted_reagent.expiry.date(), missing=False)
|
||||
logger.debug(f"Final Reagent: {final}")
|
||||
return final, report
|
||||
|
||||
def updated(self):
|
||||
@@ -781,10 +749,11 @@ class SubmissionFormWidget(QWidget):
|
||||
looked_up_reg = None
|
||||
if looked_up_reg:
|
||||
try:
|
||||
relevant_reagents.remove(str(looked_up_reg.lot))
|
||||
# relevant_reagents.remove(str(looked_up_reg.lot))
|
||||
relevant_reagents.insert(0, relevant_reagents.pop(relevant_reagents.index(looked_up_reg.lot)))
|
||||
except ValueError as e:
|
||||
logger.error(f"Error reordering relevant reagents: {e}")
|
||||
relevant_reagents.insert(0, str(looked_up_reg.lot))
|
||||
# relevant_reagents.insert(0, str(looked_up_reg.lot))
|
||||
else:
|
||||
if len(relevant_reagents) > 1:
|
||||
idx = relevant_reagents.index(str(reagent.lot))
|
||||
|
||||
@@ -32,7 +32,13 @@ class Summary(InfoPane):
|
||||
self.update_data()
|
||||
|
||||
|
||||
def update_data(self):
|
||||
def update_data(self) -> None:
|
||||
"""
|
||||
Sets data in the info pane
|
||||
|
||||
Returns:
|
||||
None
|
||||
"""
|
||||
super().update_data()
|
||||
orgs = [self.org_select.itemText(i) for i in range(self.org_select.count()) if self.org_select.itemChecked(i)]
|
||||
self.report_obj = ReportMaker(start_date=self.start_date, end_date=self.end_date, organizations=orgs)
|
||||
|
||||
@@ -31,7 +31,13 @@ class TurnaroundTime(InfoPane):
|
||||
self.submission_typer.currentTextChanged.connect(self.update_data)
|
||||
self.update_data()
|
||||
|
||||
def update_data(self):
|
||||
def update_data(self) -> None:
|
||||
"""
|
||||
Sets data in the info pane
|
||||
|
||||
Returns:
|
||||
None
|
||||
"""
|
||||
super().update_data()
|
||||
months = self.diff_month(self.start_date, self.end_date)
|
||||
chart_settings = dict(start_date=self.start_date, end_date=self.end_date)
|
||||
@@ -47,4 +53,4 @@ class TurnaroundTime(InfoPane):
|
||||
else:
|
||||
threshold = None
|
||||
self.fig = TurnaroundChart(df=self.report_obj.df, settings=chart_settings, modes=[], threshold=threshold, months=months)
|
||||
self.webview.setHtml(self.fig.to_html())
|
||||
self.webview.setHtml(self.fig.html)
|
||||
|
||||
@@ -10,11 +10,12 @@
|
||||
<h2><u>Reagent Details for {{ reagent['name'] }} - {{ reagent['lot'] }}</u></h2>
|
||||
{{ super() }}
|
||||
<p>{% for key, value in reagent.items() if key not in reagent['excluded'] %}
|
||||
<b>{{ key | replace("_", " ") | title }}: </b>{% if permission and key in reagent['editable']%}<input type={% if key=='expiry' %}"date"{% else %}"text"{% endif %} id="{{ key }}" name="{{ key }}" value="{{ value }}">{% else %}{{ value }}{% endif %}<br>
|
||||
<!-- <b>{{ key | replace("_", " ") | title }}: </b>{% if permission and key in reagent['editable']%}<input type={% if key=='expiry' %}"date"{% else %}"text"{% endif %} id="{{ key }}" name="{{ key }}" value="{{ value }}">{% else %}{{ value }}{% endif %}<br>-->
|
||||
<b>{{ key | replace("_", " ") | title }}: </b>{{ value }}<br>
|
||||
{% endfor %}</p>
|
||||
{% if permission %}
|
||||
<button type="button" id="save_btn">Save</button>
|
||||
{% endif %}
|
||||
<!-- {% if permission %}-->
|
||||
<!-- <button type="button" id="save_btn">Save</button>-->
|
||||
<!-- {% endif %}-->
|
||||
{% if reagent['submissions'] %}<h2>Submissions:</h2>
|
||||
{% for submission in reagent['submissions'] %}
|
||||
<p><b><a class="data-link" id="{{ submission }}">{{ submission }}:</a></b> {{ reagent['role'] }}</p>
|
||||
|
||||
@@ -10,6 +10,8 @@ from json import JSONDecodeError
|
||||
import logging, re, yaml, sys, os, stat, platform, getpass, json, numpy as np, pandas as pd
|
||||
from threading import Thread
|
||||
from inspect import getmembers, isfunction, stack
|
||||
from types import GeneratorType
|
||||
|
||||
from dateutil.easter import easter
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
from logging import handlers
|
||||
@@ -18,7 +20,7 @@ from sqlalchemy.orm import Session
|
||||
from sqlalchemy import create_engine, text, MetaData
|
||||
from pydantic import field_validator, BaseModel, Field
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
from typing import Any, Tuple, Literal, List
|
||||
from typing import Any, Tuple, Literal, List, Generator
|
||||
from __init__ import project_path
|
||||
from configparser import ConfigParser
|
||||
from tkinter import Tk # NOTE: This is for choosing database path before app is created.
|
||||
@@ -38,7 +40,7 @@ if platform.system() == "Windows":
|
||||
logger.info(f"Got platform Windows, config_dir: {os_config_dir}")
|
||||
else:
|
||||
os_config_dir = ".config"
|
||||
logger.info(f"Got platform other, config_dir: {os_config_dir}")
|
||||
logger.info(f"Got platform {platform.system()}, config_dir: {os_config_dir}")
|
||||
|
||||
main_aux_dir = Path.home().joinpath(f"{os_config_dir}/submissions")
|
||||
|
||||
@@ -58,7 +60,7 @@ main_form_style = '''
|
||||
page_size = 250
|
||||
|
||||
|
||||
def divide_chunks(input_list: list, chunk_count: int):
|
||||
def divide_chunks(input_list: list, chunk_count: int) -> Generator[Any, Any, None]:
|
||||
"""
|
||||
Divides a list into {chunk_count} equal parts
|
||||
|
||||
@@ -179,7 +181,7 @@ def check_not_nan(cell_contents) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def convert_nans_to_nones(input_str) -> str | None:
|
||||
def convert_nans_to_nones(input_str:str) -> str | None:
|
||||
"""
|
||||
Get rid of various "nan", "NAN", "NaN", etc/
|
||||
|
||||
@@ -289,12 +291,10 @@ class Settings(BaseSettings, extra="allow"):
|
||||
@classmethod
|
||||
def set_schema(cls, value):
|
||||
if value is None:
|
||||
# print("No value for dir path")
|
||||
if check_if_app():
|
||||
alembic_path = Path(sys._MEIPASS).joinpath("files", "alembic.ini")
|
||||
else:
|
||||
alembic_path = project_path.joinpath("alembic.ini")
|
||||
# print(f"Getting alembic path: {alembic_path}")
|
||||
value = cls.get_alembic_db_path(alembic_path=alembic_path, mode='schema')
|
||||
if value is None:
|
||||
value = "sqlite"
|
||||
@@ -321,14 +321,11 @@ class Settings(BaseSettings, extra="allow"):
|
||||
if value is None:
|
||||
match values.data['database_schema']:
|
||||
case "sqlite":
|
||||
# print("No value for dir path")
|
||||
if check_if_app():
|
||||
alembic_path = Path(sys._MEIPASS).joinpath("files", "alembic.ini")
|
||||
else:
|
||||
alembic_path = project_path.joinpath("alembic.ini")
|
||||
# print(f"Getting alembic path: {alembic_path}")
|
||||
value = cls.get_alembic_db_path(alembic_path=alembic_path, mode='path').parent
|
||||
# print(f"Using {value}")
|
||||
case _:
|
||||
Tk().withdraw() # we don't want a full GUI, so keep the root window from appearing
|
||||
value = Path(askdirectory(
|
||||
@@ -340,9 +337,7 @@ class Settings(BaseSettings, extra="allow"):
|
||||
except AttributeError:
|
||||
check = False
|
||||
if not check:
|
||||
# print(f"No directory found, using Documents/submissions")
|
||||
value.mkdir(exist_ok=True)
|
||||
# print(f"Final return of directory_path: {value}")
|
||||
return value
|
||||
|
||||
@field_validator('database_path', mode="before")
|
||||
@@ -360,7 +355,6 @@ class Settings(BaseSettings, extra="allow"):
|
||||
alembic_path = Path(sys._MEIPASS).joinpath("files", "alembic.ini")
|
||||
else:
|
||||
alembic_path = project_path.joinpath("alembic.ini")
|
||||
# print(f"Getting alembic path: {alembic_path}")
|
||||
value = cls.get_alembic_db_path(alembic_path=alembic_path, mode='path').parent
|
||||
return value
|
||||
|
||||
@@ -372,7 +366,6 @@ class Settings(BaseSettings, extra="allow"):
|
||||
alembic_path = Path(sys._MEIPASS).joinpath("files", "alembic.ini")
|
||||
else:
|
||||
alembic_path = project_path.joinpath("alembic.ini")
|
||||
# print(f"Getting alembic path: {alembic_path}")
|
||||
value = cls.get_alembic_db_path(alembic_path=alembic_path, mode='path').stem
|
||||
return value
|
||||
|
||||
@@ -384,9 +377,7 @@ class Settings(BaseSettings, extra="allow"):
|
||||
alembic_path = Path(sys._MEIPASS).joinpath("files", "alembic.ini")
|
||||
else:
|
||||
alembic_path = project_path.joinpath("alembic.ini")
|
||||
# print(f"Getting alembic path: {alembic_path}")
|
||||
value = cls.get_alembic_db_path(alembic_path=alembic_path, mode='user')
|
||||
# print(f"Got {value} for user")
|
||||
return value
|
||||
|
||||
@field_validator("database_password", mode='before')
|
||||
@@ -397,9 +388,7 @@ class Settings(BaseSettings, extra="allow"):
|
||||
alembic_path = Path(sys._MEIPASS).joinpath("files", "alembic.ini")
|
||||
else:
|
||||
alembic_path = project_path.joinpath("alembic.ini")
|
||||
# print(f"Getting alembic path: {alembic_path}")
|
||||
value = cls.get_alembic_db_path(alembic_path=alembic_path, mode='pass')
|
||||
# print(f"Got {value} for pass")
|
||||
return value
|
||||
|
||||
@field_validator('database_session', mode="before")
|
||||
@@ -421,7 +410,6 @@ class Settings(BaseSettings, extra="allow"):
|
||||
"{{ values['database_schema'] }}://{{ value }}/{{ db_name }}?driver=ODBC+Driver+18+for+SQL+Server&TrustServerCertificate=yes&Trusted_Connection=yes"
|
||||
)
|
||||
case _:
|
||||
# print(pprint.pprint(values.data))
|
||||
tmp = jinja_template_loading().from_string(
|
||||
"{% if values['database_user'] %}{{ values['database_user'] }}{% if values['database_password'] %}:{{ values['database_password'] }}{% endif %}{% endif %}@{{ values['database_path'] }}")
|
||||
value = tmp.render(values=values.data)
|
||||
@@ -444,7 +432,6 @@ class Settings(BaseSettings, extra="allow"):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.set_from_db()
|
||||
self.set_scripts()
|
||||
# pprint(f"User settings:\n{self.__dict__}")
|
||||
|
||||
def set_from_db(self):
|
||||
if 'pytest' in sys.modules:
|
||||
@@ -453,11 +440,8 @@ class Settings(BaseSettings, extra="allow"):
|
||||
teardown_scripts=dict(goodbye=None)
|
||||
)
|
||||
else:
|
||||
# print(f"Hello from database settings getter.")
|
||||
# print(self.__dict__)
|
||||
session = self.database_session
|
||||
metadata = MetaData()
|
||||
# print(self.database_session.get_bind())
|
||||
try:
|
||||
metadata.reflect(bind=session.get_bind())
|
||||
except AttributeError as e:
|
||||
@@ -467,7 +451,6 @@ class Settings(BaseSettings, extra="allow"):
|
||||
print(f"Couldn't find _configitems in {metadata.tables.keys()}.")
|
||||
return
|
||||
config_items = session.execute(text("SELECT * FROM _configitem")).all()
|
||||
# print(f"Config: {pprint.pprint(config_items)}")
|
||||
output = {}
|
||||
for item in config_items:
|
||||
try:
|
||||
@@ -488,6 +471,7 @@ class Settings(BaseSettings, extra="allow"):
|
||||
p = Path(__file__).parents[2].joinpath("scripts").absolute()
|
||||
if p.__str__() not in sys.path:
|
||||
sys.path.append(p.__str__())
|
||||
# NOTE: Get all .py files that don't have __ in them.
|
||||
modules = p.glob("[!__]*.py")
|
||||
for module in modules:
|
||||
mod = importlib.import_module(module.stem)
|
||||
@@ -495,6 +479,7 @@ class Settings(BaseSettings, extra="allow"):
|
||||
name = function[0]
|
||||
func = function[1]
|
||||
# NOTE: assign function based on its name being in config: startup/teardown
|
||||
# NOTE: scripts must be registered using {name: Null} in the database
|
||||
if name in self.startup_scripts.keys():
|
||||
self.startup_scripts[name] = func
|
||||
if name in self.teardown_scripts.keys():
|
||||
@@ -543,14 +528,12 @@ class Settings(BaseSettings, extra="allow"):
|
||||
try:
|
||||
return url[:url.index("@")].split(":")[0]
|
||||
except (IndexError, ValueError) as e:
|
||||
# print(f"Error on user: {e}")
|
||||
return None
|
||||
case "pass":
|
||||
url = re.sub(r"^.*//", "", url)
|
||||
try:
|
||||
return url[:url.index("@")].split(":")[1]
|
||||
except (IndexError, ValueError) as e:
|
||||
# print(f"Error on user: {e}")
|
||||
return None
|
||||
|
||||
def save(self, settings_path: Path):
|
||||
@@ -592,7 +575,6 @@ def get_config(settings_path: Path | str | None = None) -> Settings:
|
||||
def join(loader, node):
|
||||
seq = loader.construct_sequence(node)
|
||||
return ''.join([str(i) for i in seq])
|
||||
|
||||
# NOTE: register the tag handler
|
||||
yaml.add_constructor('!join', join)
|
||||
# NOTE: make directories
|
||||
@@ -624,7 +606,6 @@ def get_config(settings_path: Path | str | None = None) -> Settings:
|
||||
# NOTE: copy settings to config directory
|
||||
settings = Settings(**default_settings)
|
||||
settings.save(settings_path=CONFIGDIR.joinpath("config.yml"))
|
||||
# print(f"Default settings: {pprint.pprint(settings.__dict__)}")
|
||||
return settings
|
||||
else:
|
||||
# NOTE: check if user defined path is directory
|
||||
@@ -829,10 +810,23 @@ def setup_lookup(func):
|
||||
elif v is not None:
|
||||
sanitized_kwargs[k] = v
|
||||
return func(*args, **sanitized_kwargs)
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
def get_application_from_parent(widget):
|
||||
try:
|
||||
return widget.app
|
||||
except AttributeError:
|
||||
logger.info("Using recursion to get application object.")
|
||||
from frontend.widgets.app import App
|
||||
while not isinstance(widget, App):
|
||||
try:
|
||||
widget = widget.parent()
|
||||
except AttributeError:
|
||||
return widget
|
||||
return widget
|
||||
|
||||
|
||||
class Result(BaseModel, arbitrary_types_allowed=True):
|
||||
owner: str = Field(default="", validate_default=True)
|
||||
code: int = Field(default=0)
|
||||
@@ -937,20 +931,20 @@ def rreplace(s: str, old: str, new: str) -> str:
|
||||
return (s[::-1].replace(old[::-1], new[::-1], 1))[::-1]
|
||||
|
||||
|
||||
def remove_key_from_list_of_dicts(input: list, key: str) -> list:
|
||||
def remove_key_from_list_of_dicts(input_list: list, key: str) -> list:
|
||||
"""
|
||||
Removes a key from all dictionaries in a list of dictionaries
|
||||
|
||||
Args:
|
||||
input (list): Input list of dicts
|
||||
input_list (list): Input list of dicts
|
||||
key (str): Name of key to remove.
|
||||
|
||||
Returns:
|
||||
list: List of updated dictionaries
|
||||
"""
|
||||
for item in input:
|
||||
for item in input_list:
|
||||
del item[key]
|
||||
return input
|
||||
return input_list
|
||||
|
||||
|
||||
def yaml_regex_creator(loader, node):
|
||||
@@ -963,6 +957,7 @@ def yaml_regex_creator(loader, node):
|
||||
|
||||
def super_splitter(ins_str: str, substring: str, idx: int) -> str:
|
||||
"""
|
||||
Splits string on substring at index
|
||||
|
||||
Args:
|
||||
ins_str (str): input string
|
||||
@@ -978,6 +973,20 @@ def super_splitter(ins_str: str, substring: str, idx: int) -> str:
|
||||
return ins_str
|
||||
|
||||
|
||||
def is_developer() -> bool:
|
||||
"""
|
||||
Checks if user is in list of super users
|
||||
|
||||
Returns:
|
||||
bool: True if yes, False if no.
|
||||
"""
|
||||
try:
|
||||
check = getpass.getuser() in ctx.super_users
|
||||
except:
|
||||
check = False
|
||||
return check
|
||||
|
||||
|
||||
def is_power_user() -> bool:
|
||||
"""
|
||||
Checks if user is in list of power users
|
||||
@@ -1000,21 +1009,49 @@ def check_authorization(func):
|
||||
func (function): Function to be used.
|
||||
"""
|
||||
|
||||
@wraps(func)
|
||||
@report_result
|
||||
def wrapper(*args, **kwargs):
|
||||
logger.info(f"Checking authorization")
|
||||
if is_power_user():
|
||||
error_msg = f"User {getpass.getuser()} is not authorized for this function."
|
||||
auth_func = is_power_user
|
||||
if auth_func():
|
||||
return func(*args, **kwargs)
|
||||
else:
|
||||
logger.error(f"User {getpass.getuser()} is not authorized for this function.")
|
||||
logger.error(error_msg)
|
||||
report = Report()
|
||||
report.add_result(
|
||||
Result(owner=func.__str__(), code=1, msg="This user does not have permission for this function.",
|
||||
status="warning"))
|
||||
Result(owner=func.__str__(), code=1, msg=error_msg, status="warning"))
|
||||
return report
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
def under_development(func):
|
||||
"""
|
||||
Decorator to check if user is authorized to access function
|
||||
|
||||
Args:
|
||||
func (function): Function to be used.
|
||||
"""
|
||||
|
||||
@wraps(func)
|
||||
@report_result
|
||||
def wrapper(*args, **kwargs):
|
||||
logger.warning(f"This feature is under development")
|
||||
if is_developer():
|
||||
return func(*args, **kwargs)
|
||||
else:
|
||||
error_msg = f"User {getpass.getuser()} is not authorized for this function."
|
||||
logger.error(error_msg)
|
||||
report = Report()
|
||||
report.add_result(
|
||||
Result(owner=func.__str__(), code=1, msg=error_msg,
|
||||
status="warning"))
|
||||
return report
|
||||
return wrapper
|
||||
|
||||
|
||||
def report_result(func):
|
||||
"""
|
||||
Decorator to display any reports returned from a function.
|
||||
@@ -1036,14 +1073,9 @@ def report_result(func):
|
||||
case Report():
|
||||
report = output
|
||||
case tuple():
|
||||
# try:
|
||||
report = next((item for item in output if isinstance(item, Report)), None)
|
||||
# except IndexError:
|
||||
# report = None
|
||||
case _:
|
||||
report = Report()
|
||||
# return report
|
||||
# logger.info(f"Got report: {report}")
|
||||
try:
|
||||
results = report.results
|
||||
except AttributeError:
|
||||
@@ -1058,13 +1090,11 @@ def report_result(func):
|
||||
logger.error(result.msg)
|
||||
if output:
|
||||
true_output = tuple(item for item in output if not isinstance(item, Report))
|
||||
# logger.debug(f"True output: {true_output}")
|
||||
if len(true_output) == 1:
|
||||
true_output = true_output[0]
|
||||
else:
|
||||
true_output = None
|
||||
return true_output
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
@@ -1084,20 +1114,19 @@ 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
|
||||
# Includes New Year's day for next year.
|
||||
# NOTE: 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)]
|
||||
# Labour Day
|
||||
# NOTE: Labour Day
|
||||
holidays.append(find_nth_monday(year, 9))
|
||||
# Thanksgiving
|
||||
# NOTE: Thanksgiving
|
||||
holidays.append(find_nth_monday(year, 10, occurence=2))
|
||||
# Victoria Day
|
||||
# NOTE: Victoria Day
|
||||
holidays.append(find_nth_monday(year, 5, day=25))
|
||||
# Easter, etc
|
||||
# NOTE: Easter, etc
|
||||
holidays.append(easter(year) - timedelta(days=2))
|
||||
holidays.append(easter(year) + timedelta(days=1))
|
||||
return sorted(holidays)
|
||||
@@ -1107,8 +1136,7 @@ class classproperty(property):
|
||||
def __get__(self, owner_self, owner_cls):
|
||||
return self.fget(owner_cls)
|
||||
|
||||
|
||||
# NOTE: Monkey patching... hooray!
|
||||
builtins.classproperty = classproperty
|
||||
|
||||
|
||||
ctx = get_config(None)
|
||||
|
||||
Reference in New Issue
Block a user