initial commit

This commit is contained in:
Landon Wark
2023-01-18 14:00:25 -06:00
commit e763e7273d
26 changed files with 2118 additions and 0 deletions

105
alembic.ini Normal file
View File

@@ -0,0 +1,105 @@
# A generic, single database configuration.
[alembic]
# path to migration scripts
script_location = ./alembic
# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
# Uncomment the line below if you want the files to be prepended with date and time
# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file
# for all available tokens
# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s
# sys.path path, will be prepended to sys.path if present.
# defaults to the current working directory.
prepend_sys_path = .
# timezone to use when rendering the date within the migration file
# as well as the filename.
# If specified, requires the python-dateutil library that can be
# installed by adding `alembic[tz]` to the pip requirements
# string value is passed to dateutil.tz.gettz()
# leave blank for localtime
# timezone =
# max length of characters to apply to the
# "slug" field
# truncate_slug_length = 40
# set to 'true' to run the environment during
# the 'revision' command, regardless of autogenerate
# revision_environment = false
# set to 'true' to allow .pyc and .pyo files without
# a source .py file to be detected as revisions in the
# versions/ directory
# sourceless = false
# version location specification; This defaults
# to ./alembic/versions. When using multiple version
# directories, initial revisions must be specified with --version-path.
# The path separator used here should be the separator specified by "version_path_separator" below.
# version_locations = %(here)s/bar:%(here)s/bat:./alembic/versions
# version path separator; As mentioned above, this is the character used to split
# version_locations. The default within new alembic.ini files is "os", which uses os.pathsep.
# If this key is omitted entirely, it falls back to the legacy behavior of splitting on spaces and/or commas.
# Valid values for version_path_separator are:
#
# version_path_separator = :
# version_path_separator = ;
# version_path_separator = space
version_path_separator = os # Use os.pathsep. Default configuration used for new projects.
# the output encoding used when revision files
# are written from script.py.mako
# output_encoding = utf-8
sqlalchemy.url = sqlite:///submissions.db
[post_write_hooks]
# post_write_hooks defines scripts or Python functions that are run
# on newly generated revision scripts. See the documentation for further
# detail and examples
# format using "black" - use the console_scripts runner, against the "black" entrypoint
# hooks = black
# black.type = console_scripts
# black.entrypoint = black
# black.options = -l 79 REVISION_SCRIPT_FILENAME
# Logging configuration
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
qualname =
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S

1
alembic/README Normal file
View File

@@ -0,0 +1 @@
Generic single-database configuration.

85
alembic/env.py Normal file
View File

@@ -0,0 +1,85 @@
from logging.config import fileConfig
from sqlalchemy import engine_from_config
from sqlalchemy import pool
from alembic import context
import sys
from pathlib import Path
sys.path.append(Path(__file__).parents[1].joinpath("src").resolve().__str__())
print(sys.path)
# this is the Alembic Config object, which provides
# access to the values within the .ini file in use.
config = context.config
# Interpret the config file for Python logging.
# This line sets up loggers basically.
if config.config_file_name is not None:
fileConfig(config.config_file_name)
# add your model's MetaData object here
# for 'autogenerate' support
# from myapp import mymodel
# target_metadata = mymodel.Base.metadata
from submissions.backend.db.models import Base
target_metadata = [Base.metadata]
# other values from the config, defined by the needs of env.py,
# can be acquired:
# my_important_option = config.get_main_option("my_important_option")
# ... etc.
def run_migrations_offline() -> None:
"""Run migrations in 'offline' mode.
This configures the context with just a URL
and not an Engine, though an Engine is acceptable
here as well. By skipping the Engine creation
we don't even need a DBAPI to be available.
Calls to context.execute() here emit the given string to the
script output.
"""
url = config.get_main_option("sqlalchemy.url")
context.configure(
url=url,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
# must be set to true for sqlite workaround
render_as_batch=True,
)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online() -> None:
"""Run migrations in 'online' mode.
In this scenario we need to create an Engine
and associate a connection with the context.
"""
connectable = engine_from_config(
config.get_section(config.config_ini_section),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
with connectable.connect() as connection:
context.configure(
connection=connection, target_metadata=target_metadata,
render_as_batch=True,
)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()

24
alembic/script.py.mako Normal file
View File

@@ -0,0 +1,24 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
# revision identifiers, used by Alembic.
revision = ${repr(up_revision)}
down_revision = ${repr(down_revision)}
branch_labels = ${repr(branch_labels)}
depends_on = ${repr(depends_on)}
def upgrade() -> None:
${upgrades if upgrades else "pass"}
def downgrade() -> None:
${downgrades if downgrades else "pass"}

View File

@@ -0,0 +1,155 @@
"""initial commit
Revision ID: 4cba0c1ffe03
Revises:
Create Date: 2023-01-18 08:59:34.382715
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '4cba0c1ffe03'
down_revision = None
branch_labels = None
depends_on = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('_contacts',
sa.Column('id', sa.INTEGER(), nullable=False),
sa.Column('name', sa.String(length=64), nullable=True),
sa.Column('email', sa.String(length=64), nullable=True),
sa.Column('phone', sa.String(length=32), nullable=True),
sa.PrimaryKeyConstraint('id')
)
op.create_table('_control_types',
sa.Column('id', sa.INTEGER(), nullable=False),
sa.Column('name', sa.String(length=255), nullable=True),
sa.Column('targets', sa.JSON(), nullable=True),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('name')
)
op.create_table('_kits',
sa.Column('id', sa.INTEGER(), nullable=False),
sa.Column('name', sa.String(length=64), nullable=True),
sa.Column('used_for', sa.JSON(), nullable=True),
sa.Column('cost_per_run', sa.FLOAT(precision=2), nullable=True),
sa.Column('reagent_types_id', sa.INTEGER(), nullable=True),
sa.ForeignKeyConstraint(['reagent_types_id'], ['_reagent_types.id'], name='fk_KT_reagentstype_id', ondelete='SET NULL', use_alter=True),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('name')
)
op.create_table('_reagent_types',
sa.Column('id', sa.INTEGER(), nullable=False),
sa.Column('name', sa.String(length=64), nullable=True),
sa.Column('kit_id', sa.INTEGER(), nullable=True),
sa.Column('eol_ext', sa.Interval(), nullable=True),
sa.ForeignKeyConstraint(['kit_id'], ['_kits.id'], name='fk_RT_kits_id', ondelete='SET NULL', use_alter=True),
sa.PrimaryKeyConstraint('id')
)
op.create_table('_ww_samples',
sa.Column('id', sa.INTEGER(), nullable=False),
sa.Column('ww_processing_num', sa.String(length=64), nullable=True),
sa.Column('ww_sample_full_id', sa.String(length=64), nullable=True),
sa.Column('rsl_number', sa.String(length=64), nullable=True),
sa.Column('collection_date', sa.TIMESTAMP(), nullable=True),
sa.Column('testing_type', sa.String(length=64), nullable=True),
sa.Column('site_status', sa.String(length=64), nullable=True),
sa.Column('notes', sa.String(length=2000), nullable=True),
sa.Column('ct_n1', sa.FLOAT(precision=2), nullable=True),
sa.Column('ct_n2', sa.FLOAT(precision=2), nullable=True),
sa.Column('seq_submitted', sa.BOOLEAN(), nullable=True),
sa.Column('ww_seq_run_id', sa.String(length=64), nullable=True),
sa.Column('sample_type', sa.String(length=8), nullable=True),
sa.PrimaryKeyConstraint('id')
)
op.create_table('_control_samples',
sa.Column('id', sa.INTEGER(), nullable=False),
sa.Column('parent_id', sa.String(), nullable=True),
sa.Column('name', sa.String(length=255), nullable=True),
sa.Column('submitted_date', sa.TIMESTAMP(), nullable=True),
sa.Column('contains', sa.JSON(), nullable=True),
sa.Column('matches', sa.JSON(), nullable=True),
sa.Column('kraken', sa.JSON(), nullable=True),
sa.ForeignKeyConstraint(['parent_id'], ['_control_types.id'], name='fk_control_parent_id'),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('name')
)
op.create_table('_organizations',
sa.Column('id', sa.INTEGER(), nullable=False),
sa.Column('name', sa.String(length=64), nullable=True),
sa.Column('cost_centre', sa.String(), nullable=True),
sa.Column('contact_ids', sa.INTEGER(), nullable=True),
sa.ForeignKeyConstraint(['contact_ids'], ['_contacts.id'], name='fk_org_contact_id', ondelete='SET NULL'),
sa.PrimaryKeyConstraint('id')
)
op.create_table('_reagents',
sa.Column('id', sa.INTEGER(), nullable=False),
sa.Column('type_id', sa.INTEGER(), nullable=True),
sa.Column('name', sa.String(length=64), nullable=True),
sa.Column('lot', sa.String(length=64), nullable=True),
sa.Column('expiry', sa.TIMESTAMP(), nullable=True),
sa.ForeignKeyConstraint(['type_id'], ['_reagent_types.id'], name='fk_reagent_type_id', ondelete='SET NULL'),
sa.PrimaryKeyConstraint('id')
)
op.create_table('_reagentstypes_kittypes',
sa.Column('reagent_types_id', sa.INTEGER(), nullable=True),
sa.Column('kits_id', sa.INTEGER(), nullable=True),
sa.ForeignKeyConstraint(['kits_id'], ['_kits.id'], ),
sa.ForeignKeyConstraint(['reagent_types_id'], ['_reagent_types.id'], )
)
op.create_table('_orgs_contacts',
sa.Column('org_id', sa.INTEGER(), nullable=True),
sa.Column('contact_id', sa.INTEGER(), nullable=True),
sa.ForeignKeyConstraint(['contact_id'], ['_contacts.id'], ),
sa.ForeignKeyConstraint(['org_id'], ['_organizations.id'], )
)
op.create_table('_submissions',
sa.Column('id', sa.INTEGER(), nullable=False),
sa.Column('rsl_plate_num', sa.String(length=32), nullable=True),
sa.Column('submitter_plate_num', sa.String(length=127), nullable=True),
sa.Column('submitted_date', sa.TIMESTAMP(), nullable=True),
sa.Column('submitting_lab_id', sa.INTEGER(), nullable=True),
sa.Column('sample_count', sa.INTEGER(), nullable=True),
sa.Column('extraction_kit_id', sa.INTEGER(), nullable=True),
sa.Column('submission_type', sa.String(length=32), nullable=True),
sa.Column('technician', sa.String(length=64), nullable=True),
sa.Column('reagents_id', sa.String(), nullable=True),
sa.Column('control_id', sa.INTEGER(), nullable=True),
sa.Column('sample_id', sa.String(), nullable=True),
sa.ForeignKeyConstraint(['control_id'], ['_control_samples.id'], name='fk_BC_control_id', ondelete='SET NULL'),
sa.ForeignKeyConstraint(['extraction_kit_id'], ['_kits.id'], ondelete='SET NULL'),
sa.ForeignKeyConstraint(['reagents_id'], ['_reagents.id'], name='fk_BS_reagents_id', ondelete='SET NULL'),
sa.ForeignKeyConstraint(['sample_id'], ['_ww_samples.id'], name='fk_WW_sample_id', ondelete='SET NULL'),
sa.ForeignKeyConstraint(['submitting_lab_id'], ['_organizations.id'], ondelete='SET NULL'),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('rsl_plate_num'),
sa.UniqueConstraint('submitter_plate_num')
)
op.create_table('_reagents_submissions',
sa.Column('reagent_id', sa.INTEGER(), nullable=True),
sa.Column('submission_id', sa.INTEGER(), nullable=True),
sa.ForeignKeyConstraint(['reagent_id'], ['_reagents.id'], ),
sa.ForeignKeyConstraint(['submission_id'], ['_submissions.id'], )
)
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table('_reagents_submissions')
op.drop_table('_submissions')
op.drop_table('_orgs_contacts')
op.drop_table('_reagentstypes_kittypes')
op.drop_table('_reagents')
op.drop_table('_organizations')
op.drop_table('_control_samples')
op.drop_table('_ww_samples')
op.drop_table('_reagent_types')
op.drop_table('_kits')
op.drop_table('_control_types')
op.drop_table('_contacts')
# ### end Alembic commands ###

View File

View File

@@ -0,0 +1,4 @@
# __init__.py
# Version of the realpython-reader package
__version__ = "1.0.0"

View File

@@ -0,0 +1,97 @@
import sys
from pathlib import Path
from configure import get_config, create_database_session, setup_logger
ctx = get_config(None)
from PyQt6.QtWidgets import QApplication
from frontend import App
logger = setup_logger(verbose=True)
ctx["database_session"] = create_database_session(Path(ctx['database']))
if __name__ == '__main__':
app = QApplication(sys.argv)
ex = App(ctx=ctx)
sys.exit(app.exec())
# from pathlib import Path
# from tkinter import *
# from tkinter import filedialog as fd
# from tkinter import ttk
# from tkinterhtml import HtmlFrame
# from xl_parser import SheetParser
# class Window(Frame):
# def __init__(self, master=None):
# Frame.__init__(self, master)
# self.master = master
# # Frame.pack_propagate(False)
# menu = Menu(self.master)
# self.master.config(menu=menu)
# fileMenu = Menu(menu)
# fileMenu.add_command(label="Import", command=self.import_callback)
# fileMenu.add_command(label="Exit", command=self.exitProgram)
# menu.add_cascade(label="File", menu=fileMenu)
# editMenu = Menu(menu)
# editMenu.add_command(label="Undo")
# editMenu.add_command(label="Redo")
# menu.add_cascade(label="Edit", menu=editMenu)
# tab_parent = ttk.Notebook(self.master)
# self.add_sample_tab = ttk.Frame(tab_parent)
# self.control_view_tab = HtmlFrame(tab_parent)
# tab_parent.add(self.add_sample_tab, text="Add Sample")
# tab_parent.add(self.control_view_tab, text="Controls View")
# tab_parent.pack()
# with open("L:\Robotics Laboratory Support\Quality\Robotics Support Laboratory Extraction Controls\MCS-SSTI.html", "r") as f:
# data = f.read()
# # frame =
# # frame.set_content(data)
# # self.control_view_tab.set_content("""
# # <html>
# # <body>
# # <h1>Hello world!</h1>
# # <p>First para</p>
# # <ul>
# # <li>first list item</li>
# # <li>second list item</li>
# # </ul>
# # <img src="http://findicons.com/files/icons/638/magic_people/128/magic_ball.png"/>
# # </body>
# # </html>
# # """)
# def exitProgram(self):
# exit()
# def import_callback(self):
# name= fd.askopenfilename()
# prsr = SheetParser(Path(name), **ctx)
# for item in prsr.sub:
# lbl=Label(self.add_sample_tab, text=item, fg='red', font=("Helvetica", 16))
# lbl.pack()
# txtfld=Entry(self.add_sample_tab, text="Data not set", bd=2)
# txtfld.pack()
# txtfld.delete(0,END)
# txtfld.insert(0,prsr.sub[item])
# root = Tk()
# app = Window(root)
# # for item in test_data:
# # lbl=Label(root, text=item, fg='red', font=("Helvetica", 16))
# # lbl.pack()
# # txtfld=Entry(root, text="", bd=2)
# # txtfld.pack()
# # txtfld.delete(0,END)
# # txtfld.insert(0,test_data[item])
# root.wm_title("Tkinter window")
# root.mainloop()

View File

View File

@@ -0,0 +1,213 @@
from . import models
import pandas as pd
# from sqlite3 import IntegrityError
from sqlalchemy.exc import IntegrityError
import logging
import datetime
from sqlalchemy import and_
import uuid
import base64
logger = logging.getLogger(__name__)
def get_kits_by_use( ctx:dict, kittype_str:str|None) -> list:
pass
# ctx dict should contain the database session
def store_submission(ctx:dict, base_submission:models.BasicSubmission) -> None:
ctx['database_session'].add(base_submission)
try:
ctx['database_session'].commit()
except IntegrityError:
ctx['database_session'].rollback()
return {"message":"This plate number already exists, so we can't add it."}
return None
def store_reagent(ctx:dict, reagent:models.Reagent) -> None:
print(reagent.__dict__)
ctx['database_session'].add(reagent)
ctx['database_session'].commit()
def construct_submission_info(ctx:dict, info_dict:dict) -> models.BasicSubmission:
query = info_dict['submission_type'].replace(" ", "")
model = getattr(models, query)
info_dict['submission_type'] = info_dict['submission_type'].replace(" ", "_").lower()
instance = model()
for item in info_dict:
print(f"Setting {item} to {info_dict[item]}")
match item:
case "extraction_kit":
q_str = info_dict[item]
print(f"Looking up kit {q_str}")
field_value = lookup_kittype_by_name(ctx=ctx, name=q_str)
print(f"Got {field_value} for kit {q_str}")
case "submitting_lab":
q_str = info_dict[item].replace(" ", "_").lower()
print(f"looking up organization: {q_str}")
field_value = lookup_org_by_name(ctx=ctx, name=q_str)
print(f"Got {field_value} for organization {q_str}")
case "submitter_plate_num":
# Because of unique constraint, the submitter plate number cannot be None, so...
if info_dict[item] == None:
info_dict[item] = uuid.uuid4().hex.upper()
case _:
field_value = info_dict[item]
try:
setattr(instance, item, field_value)
except AttributeError:
print(f"Could not set attribute: {item} to {info_dict[item]}")
continue
return instance
# looked_up = []
# for reagent in reagents:
# my_reagent = lookup_reagent(reagent)
# print(my_reagent)
# looked_up.append(my_reagent)
# print(looked_up)
# instance.reagents = looked_up
# ctx['database_session'].add(instance)
# ctx['database_session'].commit()
def construct_reagent(ctx:dict, info_dict:dict) -> models.Reagent:
reagent = models.Reagent()
for item in info_dict:
print(f"Reagent info item: {item}")
match item:
case "lot":
reagent.lot = info_dict[item].upper()
case "expiry":
reagent.expiry = info_dict[item]
case "type":
reagent.type = lookup_reagenttype_by_name(ctx=ctx, rt_name=info_dict[item].replace(" ", "_").lower())
try:
reagent.expiry = reagent.expiry + reagent.type.eol_ext
except TypeError as e:
print(f"WE got a type error: {e}.")
except AttributeError:
pass
return reagent
def lookup_reagent(ctx:dict, reagent_lot:str):
lookedup = ctx['database_session'].query(models.Reagent).filter(models.Reagent.lot==reagent_lot).first()
return lookedup
def get_all_reagenttype_names(ctx:dict) -> list[str]:
lookedup = [item.__str__() for item in ctx['database_session'].query(models.ReagentType).all()]
return lookedup
def lookup_reagenttype_by_name(ctx:dict, rt_name:str) -> models.ReagentType:
print(f"Looking up ReagentType by name: {rt_name}")
lookedup = ctx['database_session'].query(models.ReagentType).filter(models.ReagentType.name==rt_name).first()
print(f"Found ReagentType: {lookedup}")
return lookedup
def lookup_kittype_by_use(ctx:dict, used_by:str) -> list[models.KitType]:
# return [item for item in
return ctx['database_session'].query(models.KitType).filter(models.KitType.used_for.contains(used_by))
def lookup_kittype_by_name(ctx:dict, name:str) -> models.KitType:
print(f"Querying kittype: {name}")
return ctx['database_session'].query(models.KitType).filter(models.KitType.name==name).first()
def lookup_regent_by_type_name(ctx:dict, type_name:str) -> list[models.ReagentType]:
# return [item for item in ctx['database_session'].query(models.Reagent).join(models.Reagent.type, aliased=True).filter(models.ReagentType.name==type_name).all()]
return ctx['database_session'].query(models.Reagent).join(models.Reagent.type, aliased=True).filter(models.ReagentType.name==type_name).all()
def lookup_regent_by_type_name_and_kit_name(ctx:dict, type_name:str, kit_name:str) -> list[models.Reagent]:
# Hang on, this is going to be a long one.
by_type = ctx['database_session'].query(models.Reagent).join(models.Reagent.type, aliased=True).filter(models.ReagentType.name.endswith(type_name))
add_in = by_type.join(models.ReagentType.kits).filter(models.KitType.name==kit_name)
return add_in
def lookup_all_submissions_by_type(ctx:dict, type:str|None=None):
if type == None:
subs = ctx['database_session'].query(models.BasicSubmission).all()
else:
subs = ctx['database_session'].query(models.BasicSubmission).filter(models.BasicSubmission.submission_type==type).all()
return subs
def lookup_all_orgs(ctx:dict) -> list[models.Organization]:
return ctx['database_session'].query(models.Organization).all()
def lookup_org_by_name(ctx:dict, name:str|None) -> models.Organization:
print(f"Querying organization: {name}")
return ctx['database_session'].query(models.Organization).filter(models.Organization.name==name).first()
def submissions_to_df(ctx:dict, type:str|None=None):
print(f"Type: {type}")
subs = [item.to_dict() for item in lookup_all_submissions_by_type(ctx=ctx, type=type)]
df = pd.DataFrame.from_records(subs)
return df
def lookup_submission_by_id(ctx:dict, id:int) -> models.BasicSubmission:
return ctx['database_session'].query(models.BasicSubmission).filter(models.BasicSubmission.id==id).first()
def create_submission_details(ctx:dict, sub_id:int) -> dict:
pass
def lookup_submissions_by_date_range(ctx:dict, start_date:datetime.date, end_date:datetime.date) -> list[models.BasicSubmission]:
return ctx['database_session'].query(models.BasicSubmission).filter(and_(models.BasicSubmission.submitted_date > start_date, models.BasicSubmission.submitted_date < end_date)).all()
def get_all_Control_Types_names(ctx:dict) -> list[models.ControlType]:
"""
Grabs all control type names from db.
Args:
settings (dict): settings passed down from click. Defaults to {}.
Returns:
list: names list
"""
conTypes = ctx['database_session'].query(models.ControlType).all()
conTypes = [conType.name for conType in conTypes]
logger.debug(f"Control Types: {conTypes}")
return conTypes
def create_kit_from_yaml(ctx:dict, exp:dict) -> None:
"""
Create and store a new kit in the database based on a .yml file
Args:
ctx (dict): Context dictionary passed down from frontend
exp (dict): Experiment dictionary created from yaml file
"""
if base64.b64encode(exp['password']) != b'cnNsX3N1Ym1pNTVpb25z':
print(f"Not the correct password.")
return
for type in exp:
if type == "password":
continue
for kt in exp[type]['kits']:
kit = models.KitType(name=kt, used_for=[type.replace("_", " ").title()], cost_per_run=exp[type]["kits"][kt]["cost"])
for r in exp[type]['kits'][kt]['reagenttypes']:
look_up = ctx['database_session'].query(models.ReagentType).filter(models.ReagentType.name==r).first()
if look_up == None:
rt = models.ReagentType(name=r.replace(" ", "_").lower(), eol_ext=datetime.timedelta(30*exp[type]['kits'][kt]['reagenttypes'][r]['eol_ext']), kits=[kit])
else:
rt = look_up
rt.kits.append(kit)
ctx['database_session'].add(rt)
print(rt.__dict__)
print(kit.__dict__)
ctx['database_session'].add(kit)
ctx['database_session'].commit()
def lookup_all_sample_types(ctx:dict) -> list[str]:
uses = [item.used_for for item in ctx['database_session'].query(models.KitType).all()]
uses = list(set([item for sublist in uses for item in sublist]))
return uses

View File

@@ -0,0 +1,11 @@
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import relationship
Base = declarative_base()
metadata = Base.metadata
from .controls import Control, ControlType
from .kits import KitType, ReagentType, Reagent
from .submissions import BasicSubmission, BacterialCulture, Wastewater
from .organizations import Organization, Contact
from .samples import Sample

View File

@@ -0,0 +1,36 @@
from . import Base
from sqlalchemy import Column, String, TIMESTAMP, text, JSON, INTEGER, ForeignKey, UniqueConstraint
from sqlalchemy.orm import relationship
class ControlType(Base):
"""
Base class of a control archetype.
"""
__tablename__ = '_control_types'
id = Column(INTEGER, primary_key=True) #: primary key
name = Column(String(255), unique=True) #: controltype name (e.g. MCS)
targets = Column(JSON) #: organisms checked for
# instances_id = Column(INTEGER, ForeignKey("_control_samples.id", ondelete="SET NULL", name="fk_ctype_instances_id"))
instances = relationship("Control", back_populates="controltype") #: control samples created of this type.
# UniqueConstraint('name', name='uq_controltype_name')
class Control(Base):
"""
Base class of a control sample.
"""
__tablename__ = '_control_samples'
id = Column(INTEGER, primary_key=True) #: primary key
parent_id = Column(String, ForeignKey("_control_types.id", name="fk_control_parent_id")) #: primary key of control type
controltype = relationship("ControlType", back_populates="instances", foreign_keys=[parent_id]) #: reference to parent control type
name = Column(String(255), unique=True) #: Sample ID
submitted_date = Column(TIMESTAMP) #: Date submitted to Robotics
contains = Column(JSON) #: unstructured hashes in contains.tsv for each organism
matches = Column(JSON) #: unstructured hashes in matches.tsv for each organism
kraken = Column(JSON) #: unstructured output from kraken_report
# UniqueConstraint('name', name='uq_control_name')
submissions = relationship("BacterialCulture", back_populates="control")

View File

@@ -0,0 +1,68 @@
from . import Base
from sqlalchemy import Column, String, TIMESTAMP, JSON, INTEGER, ForeignKey, Interval, Table, FLOAT
from sqlalchemy.orm import relationship
reagenttypes_kittypes = Table("_reagentstypes_kittypes", Base.metadata, Column("reagent_types_id", INTEGER, ForeignKey("_reagent_types.id")), Column("kits_id", INTEGER, ForeignKey("_kits.id")))
class KitType(Base):
__tablename__ = "_kits"
id = Column(INTEGER, primary_key=True) #: primary key
name = Column(String(64), unique=True)
submissions = relationship("BasicSubmission", back_populates="extraction_kit")
used_for = Column(JSON)
cost_per_run = Column(FLOAT(2))
reagent_types = relationship("ReagentType", back_populates="kits", uselist=True, secondary=reagenttypes_kittypes)
reagent_types_id = Column(INTEGER, ForeignKey("_reagent_types.id", ondelete='SET NULL', use_alter=True, name="fk_KT_reagentstype_id"))
def __str__(self):
return self.name
class ReagentType(Base):
__tablename__ = "_reagent_types"
id = Column(INTEGER, primary_key=True) #: primary key
name = Column(String(64))
kit_id = Column(INTEGER, ForeignKey("_kits.id", ondelete="SET NULL", use_alter=True, name="fk_RT_kits_id"))
kits = relationship("KitType", back_populates="reagent_types", uselist=True, foreign_keys=[kit_id])
instances = relationship("Reagent", back_populates="type")
# instances_id = Column(INTEGER, ForeignKey("_reagents.id", ondelete='SET NULL'))
eol_ext = Column(Interval())
def __str__(self):
return self.name
class Reagent(Base):
__tablename__ = "_reagents"
id = Column(INTEGER, primary_key=True) #: primary key
type = relationship("ReagentType", back_populates="instances")
type_id = Column(INTEGER, ForeignKey("_reagent_types.id", ondelete='SET NULL', name="fk_reagent_type_id"))
name = Column(String(64))
lot = Column(String(64))
expiry = Column(TIMESTAMP)
submissions = relationship("BasicSubmission", back_populates="reagents", uselist=True)
def __str__(self):
return self.lot
def to_sub_dict(self):
try:
type = self.type.name.replace("_", " ").title()
except AttributeError:
type = "Unknown"
return {
"type": type,
"lot": self.lot,
"expiry": self.expiry.strftime("%Y-%m-%d")
}

View File

@@ -0,0 +1,34 @@
from . import Base
from sqlalchemy import Column, String, TIMESTAMP, JSON, Float, INTEGER, ForeignKey, UniqueConstraint, Table
from sqlalchemy.orm import relationship, validates
orgs_contacts = Table("_orgs_contacts", Base.metadata, Column("org_id", INTEGER, ForeignKey("_organizations.id")), Column("contact_id", INTEGER, ForeignKey("_contacts.id")))
class Organization(Base):
__tablename__ = "_organizations"
id = Column(INTEGER, primary_key=True) #: primary key
name = Column(String(64))
submissions = relationship("BasicSubmission", back_populates="submitting_lab")
cost_centre = Column(String())
contacts = relationship("Contact", back_populates="organization", secondary=orgs_contacts)
contact_ids = Column(INTEGER, ForeignKey("_contacts.id", ondelete="SET NULL", name="fk_org_contact_id"))
def __str__(self):
return self.name.replace("_", " ").title()
class Contact(Base):
__tablename__ = "_contacts"
id = id = Column(INTEGER, primary_key=True) #: primary key
name = Column(String(64))
email = Column(String(64))
phone = Column(String(32))
organization = relationship("Organization", back_populates="contacts", uselist=True)
# organization_id = Column(INTEGER, ForeignKey("_organizations.id"))

View File

@@ -0,0 +1,27 @@
from . import Base
from sqlalchemy import Column, String, TIMESTAMP, text, JSON, INTEGER, ForeignKey, FLOAT, BOOLEAN
from sqlalchemy.orm import relationship, relationships
class Sample(Base):
__tablename__ = "_ww_samples"
id = Column(INTEGER, primary_key=True) #: primary key
ww_processing_num = Column(String(64))
ww_sample_full_id = Column(String(64))
rsl_number = Column(String(64))
rsl_plate = relationship("Wastewater", back_populates="samples")
collection_date = Column(TIMESTAMP) #: Date submission received
testing_type = Column(String(64))
site_status = Column(String(64))
notes = Column(String(2000))
ct_n1 = Column(FLOAT(2))
ct_n2 = Column(FLOAT(2))
seq_submitted = Column(BOOLEAN())
ww_seq_run_id = Column(String(64))
sample_type = Column(String(8))

View File

@@ -0,0 +1,103 @@
from . import Base
from sqlalchemy import Column, String, TIMESTAMP, text, JSON, INTEGER, ForeignKey, UniqueConstraint, Table
from sqlalchemy.orm import relationship, relationships
from datetime import datetime as dt
reagents_submissions = Table("_reagents_submissions", Base.metadata, Column("reagent_id", INTEGER, ForeignKey("_reagents.id")), Column("submission_id", INTEGER, ForeignKey("_submissions.id")))
class BasicSubmission(Base):
# TODO: Figure out if I want seperate tables for different sample types.
__tablename__ = "_submissions"
id = Column(INTEGER, primary_key=True) #: primary key
rsl_plate_num = Column(String(32), unique=True) #: RSL name (e.g. RSL-22-0012)
submitter_plate_num = Column(String(127), unique=True) #: The number given to the submission by the submitting lab
submitted_date = Column(TIMESTAMP) #: Date submission received
submitting_lab = relationship("Organization", back_populates="submissions") #: client
submitting_lab_id = Column(INTEGER, ForeignKey("_organizations.id", ondelete="SET NULL"))
sample_count = Column(INTEGER) #: Number of samples in the submission
extraction_kit = relationship("KitType", back_populates="submissions") #: The extraction kit used
extraction_kit_id = Column(INTEGER, ForeignKey("_kits.id", ondelete="SET NULL"))
submission_type = Column(String(32))
technician = Column(String(64))
# Move this into custom types?
reagents = relationship("Reagent", back_populates="submissions", secondary=reagents_submissions)
reagents_id = Column(String, ForeignKey("_reagents.id", ondelete="SET NULL", name="fk_BS_reagents_id"))
__mapper_args__ = {
"polymorphic_identity": "basic_submission",
"polymorphic_on": submission_type,
"with_polymorphic": "*",
}
def to_dict(self):
print(self.submitting_lab)
try:
sub_lab = self.submitting_lab.name
except AttributeError:
sub_lab = None
try:
sub_lab = sub_lab.replace("_", " ").title()
except AttributeError:
pass
try:
ext_kit = self.extraction_kit.name
except AttributeError:
ext_kit = None
output = {
"id": self.id,
"Plate Number": self.rsl_plate_num,
"Submission Type": self.submission_type.replace("_", " ").title(),
"Submitter Plate Number": self.submitter_plate_num,
"Submitted Date": self.submitted_date.strftime("%Y-%m-%d"),
"Submitting Lab": sub_lab,
"Sample Count": self.sample_count,
"Extraction Kit": ext_kit,
"Technician": self.technician,
}
return output
def report_dict(self):
try:
sub_lab = self.submitting_lab.name
except AttributeError:
sub_lab = None
try:
sub_lab = sub_lab.replace("_", " ").title()
except AttributeError:
pass
try:
ext_kit = self.extraction_kit.name
except AttributeError:
ext_kit = None
try:
cost = self.extraction_kit.cost_per_run
except AttributeError:
cost = None
output = {
"id": self.id,
"Plate Number": self.rsl_plate_num,
"Submission Type": self.submission_type.replace("_", " ").title(),
"Submitter Plate Number": self.submitter_plate_num,
"Submitted Date": self.submitted_date.strftime("%Y-%m-%d"),
"Submitting Lab": sub_lab,
"Sample Count": self.sample_count,
"Extraction Kit": ext_kit,
"Cost": cost
}
return output
# Below are the custom submission
class BacterialCulture(BasicSubmission):
control = relationship("Control", back_populates="submissions") #: A control sample added to submission
control_id = Column(INTEGER, ForeignKey("_control_samples.id", ondelete="SET NULL", name="fk_BC_control_id"))
__mapper_args__ = {"polymorphic_identity": "bacterial_culture", "polymorphic_load": "inline"}
class Wastewater(BasicSubmission):
samples = relationship("Sample", back_populates="rsl_plate")
sample_id = Column(String, ForeignKey("_ww_samples.id", ondelete="SET NULL", name="fk_WW_sample_id"))
__mapper_args__ = {"polymorphic_identity": "wastewater", "polymorphic_load": "inline"}

View File

@@ -0,0 +1,122 @@
import pandas as pd
from pathlib import Path
from datetime import datetime
import logging
from collections import OrderedDict
import re
logger = logging.getLogger(f"submissions.{__name__}")
class SheetParser(object):
def __init__(self, filepath:Path|None = None, **kwargs):
for kwarg in kwargs:
setattr(self, f"_{kwarg}", kwargs[kwarg])
if filepath == None:
self.xl = None
else:
try:
self.xl = pd.ExcelFile(filepath.__str__())
except ValueError:
self.xl = None
self.sub = OrderedDict()
self.sub['submission_type'] = self._type_decider()
parse = getattr(self, f"_parse_{self.sub['submission_type'].lower()}")
parse()
def _type_decider(self):
try:
for type in self._submission_types:
if self.xl.sheet_names == self._submission_types[type]['excel_map']:
return type.title()
return "Unknown"
except:
return "Unknown"
def _parse_unknown(self):
self.sub = None
def _parse_generic(self, sheet_name:str):
submission_info = self.xl.parse(sheet_name=sheet_name)
self.sub['submitter_plate_num'] = submission_info.iloc[0][1]
self.sub['rsl_plate_num'] = str(submission_info.iloc[10][1])
self.sub['submitted_date'] = submission_info.iloc[1][1].date()#.strftime("%Y-%m-%d")
self.sub['submitting_lab'] = submission_info.iloc[0][3]
self.sub['sample_count'] = str(submission_info.iloc[2][3])
self.sub['extraction_kit'] = submission_info.iloc[3][3]
return submission_info
def _parse_bacterial_culture(self):
# submission_info = self.xl.parse("Sample List")
submission_info = self._parse_generic("Sample List")
# iloc is [row][column] and the first row is set as header row so -2
tech = str(submission_info.iloc[11][1])
if tech == "nan":
tech = "Unknown"
elif len(tech.split(",")) > 1:
tech_reg = re.compile(r"[A-Z]{2}")
tech = ", ".join(tech_reg.findall(tech))
self.sub['technician'] = tech
# reagents
self.sub['lot_wash_1'] = submission_info.iloc[1][6]
self.sub['lot_wash_2'] = submission_info.iloc[2][6]
self.sub['lot_binding_buffer'] = submission_info.iloc[3][6]
self.sub['lot_magnetic_beads'] = submission_info.iloc[4][6]
self.sub['lot_lysis_buffer'] = submission_info.iloc[5][6]
self.sub['lot_elution_buffer'] = submission_info.iloc[6][6]
self.sub['lot_isopropanol'] = submission_info.iloc[9][6]
self.sub['lot_ethanol'] = submission_info.iloc[10][6]
self.sub['lot_positive_control'] = submission_info.iloc[103][1]
self.sub['lot_plate'] = submission_info.iloc[12][6]
def _parse_wastewater(self):
# submission_info = self.xl.parse("WW Submissions (ENTER HERE)")
submission_info = self._parse_generic("WW Submissions (ENTER HERE)")
enrichment_info = self.xl.parse("Enrichment Worksheet")
extraction_info = self.xl.parse("Extraction Worksheet")
qprc_info = self.xl.parse("qPCR Worksheet")
# iloc is [row][column] and the first row is set as header row so -2
# self.sub['submitter_plate_num'] = submission_info.iloc[0][1]
# self.sub['rsl_plate_num'] = str(submission_info.iloc[10][1])
# self.sub['submitted_date'] = submission_info.iloc[1][1].date()#.strftime("%Y-%m-%d")
# self.sub['submitting_lab'] = submission_info.iloc[0][3]
# self.sub['sample_count'] = str(submission_info.iloc[2][3])
# self.sub['extraction_kit'] = submission_info.iloc[3][3]
self.sub['technician'] = f"Enr: {enrichment_info.columns[2]}, Ext: {extraction_info.columns[2]}, PCR: {qprc_info.columns[2]}"
# reagents
self.sub['lot_lysis_buffer'] = enrichment_info.iloc[0][14]
self.sub['lot_proteinase_K'] = enrichment_info.iloc[1][14]
self.sub['lot_magnetic_virus_particles'] = enrichment_info.iloc[2][14]
self.sub['lot_enrichment_reagent_1'] = enrichment_info.iloc[3][14]
self.sub['lot_binding_buffer'] = extraction_info.iloc[0][14]
self.sub['lot_magnetic_beads'] = extraction_info.iloc[1][14]
self.sub['lot_wash'] = extraction_info.iloc[2][14]
self.sub['lot_ethanol'] = extraction_info.iloc[3][14]
self.sub['lot_elution_buffer'] = extraction_info.iloc[4][14]
self.sub['lot_master_mix'] = qprc_info.iloc[0][14]
self.sub['lot_pre_mix_1'] = qprc_info.iloc[1][14]
self.sub['lot_pre_mix_2'] = qprc_info.iloc[2][14]
self.sub['lot_positive_control'] = qprc_info.iloc[3][14]
self.sub['lot_ddh2o'] = qprc_info.iloc[4][14]
# tech = str(submission_info.iloc[11][1])
# if tech == "nan":
# tech = "Unknown"
# elif len(tech.split(",")) > 1:
# tech_reg = re.compile(r"[A-Z]{2}")
# tech = ", ".join(tech_reg.findall(tech))
# self.sub['lot_wash_1'] = submission_info.iloc[1][6]
# self.sub['lot_wash_2'] = submission_info.iloc[2][6]
# self.sub['lot_binding_buffer'] = submission_info.iloc[3][6]
# self.sub['lot_magnetic_beads'] = submission_info.iloc[4][6]
# self.sub['lot_lysis_buffer'] = submission_info.iloc[5][6]
# self.sub['lot_elution_buffer'] = submission_info.iloc[6][6]
# self.sub['lot_isopropanol'] = submission_info.iloc[9][6]
# self.sub['lot_ethanol'] = submission_info.iloc[10][6]
# self.sub['lot_positive_control'] = None #submission_info.iloc[103][1]
# self.sub['lot_plate'] = submission_info.iloc[12][6]

View File

@@ -0,0 +1,13 @@
from pandas import DataFrame
import numpy as np
def make_report_xlsx(records:list[dict]) -> DataFrame:
df = DataFrame.from_records(records)
df = df.sort_values("Submitting Lab")
# table = df.pivot_table(values="Cost", index=["Submitting Lab", "Extraction Kit"], columns=["Cost", "Sample Count"], aggfunc={'Cost':np.sum,'Sample Count':np.sum})
df2 = df.groupby(["Submitting Lab", "Extraction Kit"]).agg({'Cost': ['sum', 'count'], 'Sample Count':['sum']})
# df2['Cost'] = df2['Cost'].map('${:,.2f}'.format)
print(df2.columns)
# df2['Cost']['sum'] = df2['Cost']['sum'].apply('${:,.2f}'.format)
df2.iloc[:, (df2.columns.get_level_values(1)=='sum') & (df2.columns.get_level_values(0)=='Cost')] = df2.iloc[:, (df2.columns.get_level_values(1)=='sum') & (df2.columns.get_level_values(0)=='Cost')].applymap('${:,.2f}'.format)
return df2

View File

@@ -0,0 +1,194 @@
import yaml
import sys, os, stat, platform
import logging
from logging import handlers
from pathlib import Path
from sqlalchemy.orm import Session
from sqlalchemy import create_engine
logger = logging.getLogger(__name__)
package_dir = Path(__file__).parents[2].resolve()
logger.debug(f"Package dir: {package_dir}")
if platform.system == "Windows":
os_config_dir = "AppData"
logger.debug(f"Got platform Windows, config_dir: {os_config_dir}")
else:
os_config_dir = ".config"
logger.debug(f"Got platform other, config_dir: {os_config_dir}")
main_aux_dir = Path.home().joinpath(f"{os_config_dir}/submissions")
CONFIGDIR = main_aux_dir.joinpath("config")
LOGDIR = main_aux_dir.joinpath("logs")
class GroupWriteRotatingFileHandler(handlers.RotatingFileHandler):
def doRollover(self):
"""
Override base class method to make the new log file group writable.
"""
# Rotate the file first.
handlers.RotatingFileHandler.doRollover(self)
# Add group write to the current permissions.
currMode = os.stat(self.baseFilename).st_mode
os.chmod(self.baseFilename, currMode | stat.S_IWGRP)
def _open(self):
prevumask=os.umask(0o002)
#os.fdopen(os.open('/path/to/file', os.O_WRONLY, 0600))
rtv=handlers.RotatingFileHandler._open(self)
os.umask(prevumask)
return rtv
class StreamToLogger(object):
"""
Fake file-like stream object that redirects writes to a logger instance.
"""
def __init__(self, logger, log_level=logging.INFO):
self.logger = logger
self.log_level = log_level
self.linebuf = ''
def write(self, buf):
for line in buf.rstrip().splitlines():
self.logger.log(self.log_level, line.rstrip())
def get_config(settings_path: str|None) -> dict:
"""Get configuration settings from path or default if blank.
Args:
settings_path (str, optional): _description_. Defaults to "".
Returns:
setting: dictionary of settings.
"""
# with open("C:\\Users\\lwark\\Desktop\\packagedir.txt", "w") as f:
# f.write(package_dir.__str__())
def join(loader, node):
seq = loader.construct_sequence(node)
return ''.join([str(i) for i in seq])
## register the tag handler
yaml.add_constructor('!join', join)
# if user hasn't defined config path in cli args
if settings_path == None:
# Check user .config/ozma directory
# if Path.exists(Path.joinpath(CONFIGDIR, "config.yml")):
# settings_path = Path.joinpath(CONFIGDIR, "config.yml")
if CONFIGDIR.joinpath("config.yml").exists():
settings_path = CONFIGDIR.joinpath("config.yml")
# Check user .ozma directory
elif Path.home().joinpath(".submissions", "config.yml").exists():
settings_path = Path.home().joinpath(".submissions", "config.yml")
# finally look in the local config
else:
if getattr(sys, 'frozen', False):
settings_path = Path(sys._MEIPASS).joinpath("files", "config.yml")
else:
settings_path = package_dir.joinpath('config.yml')
else:
if Path(settings_path).is_dir():
settings_path = settings_path.joinpath("config.yml")
elif Path(settings_path).is_file():
settings_path = settings_path
else:
logger.error("No config.yml file found. Using empty dictionary.")
return {}
logger.debug(f"Using {settings_path} for config file.")
with open(settings_path, "r") as stream:
try:
settings = yaml.load(stream, Loader=yaml.Loader)
except yaml.YAMLError as exc:
logger.error(f'Error reading yaml file {settings_path}: {exc}')
return {}
return settings
def create_database_session(database_path: Path|None) -> Session:
"""Get database settings from path or default if blank.
Args:
database_path (str, optional): _description_. Defaults to "".
Returns:
database_path: string of database path
"""
if database_path == None:
if Path.home().joinpath(".submissions", "submissions.db").exists():
database_path = Path.home().joinpath(".submissions", "submissions.db")
# finally, look in the local dir
else:
database_path = package_dir.joinpath("submissions.db")
else:
if database_path.is_dir():
database_path = database_path.joinpath("submissions.db")
elif database_path.is_file():
database_path = database_path
else:
logger.error("No database file found. Exiting program.")
sys.exit()
logger.debug(f"Using {database_path} for database file.")
engine = create_engine(f"sqlite:///{database_path}")
session = Session(engine)
return session
def setup_logger(verbose:bool=False):
"""Set logger levels using settings.
Args:
verbose (bool, optional): _description_. Defaults to False.
Returns:
logger: logger object
"""
logger = logging.getLogger("submissions")
logger.setLevel(logging.DEBUG)
# create file handler which logs even debug messages
try:
fh = GroupWriteRotatingFileHandler(LOGDIR.joinpath('submissions.log'), mode='a', maxBytes=100000, backupCount=3, encoding=None, delay=False)
except FileNotFoundError as e:
Path(LOGDIR).mkdir(parents=True, exist_ok=True)
fh = GroupWriteRotatingFileHandler(LOGDIR.joinpath('submissions.log'), mode='a', maxBytes=100000, backupCount=3, encoding=None, delay=False)
fh.setLevel(logging.DEBUG)
fh.name = "File"
# create console handler with a higher log level
ch = logging.StreamHandler()
if verbose:
ch.setLevel(logging.DEBUG)
else:
ch.setLevel(logging.WARNING)
ch.name = "Stream"
# create formatter and add it to the handlers
formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s')
fh.setFormatter(formatter)
ch.setFormatter(formatter)
ch.setLevel(logging.ERROR)
# add the handlers to the logger
logger.addHandler(fh)
logger.addHandler(ch)
stderr_logger = logging.getLogger('STDERR')
return logger
# sl = StreamToLogger(stderr_logger, logging.ERROR)
# sys.stderr = sl
def set_logger_verbosity(verbosity):
"""Does what it says.
"""
handler = [item for item in logger.parent.handlers if item.name == "Stream"][0]
match verbosity:
case 3:
handler.setLevel(logging.DEBUG)
case 2:
handler.setLevel(logging.INFO)
case 1:
handler.setLevel(logging.WARNING)

View File

@@ -0,0 +1,471 @@
import re
from PyQt6.QtWidgets import (
QMainWindow, QLabel, QToolBar, QStatusBar,
QTabWidget, QWidget, QVBoxLayout,
QPushButton, QMenuBar, QFileDialog,
QLineEdit, QMessageBox, QComboBox, QDateEdit, QHBoxLayout,
QSpinBox
)
from PyQt6.QtGui import QAction, QIcon
from PyQt6.QtCore import QDateTime, QDate
from PyQt6.QtCore import pyqtSlot
from PyQt6.QtWebEngineWidgets import QWebEngineView
import pandas as pd
from pathlib import Path
import plotly
import plotly.express as px
import yaml
from backend.excel.parser import SheetParser
from backend.db import (construct_submission_info, lookup_reagent,
construct_reagent, store_reagent, store_submission, lookup_kittype_by_use,
lookup_regent_by_type_name_and_kit_name, lookup_all_orgs, lookup_submissions_by_date_range,
get_all_Control_Types_names, create_kit_from_yaml
)
from backend.excel.reports import make_report_xlsx
import numpy
from frontend.custom_widgets import AddReagentQuestion, AddReagentForm, SubmissionsSheet, ReportDatePicker, KitAdder
import logging
import difflib
logger = logging.getLogger(__name__)
logger.info("Hello, I am a logger")
class App(QMainWindow):
# class App(QScrollArea):
def __init__(self, ctx: dict = {}):
super().__init__()
self.ctx = ctx
self.title = 'Submissions App - PyQT6'
self.left = 0
self.top = 0
self.width = 1300
self.height = 1000
self.setWindowTitle(self.title)
self.setGeometry(self.left, self.top, self.width, self.height)
self.table_widget = AddSubForm(self)
self.setCentralWidget(self.table_widget)
self._createActions()
self._createMenuBar()
self._createToolBar()
self._connectActions()
self.renderPage()
self.show()
def _createMenuBar(self):
menuBar = self.menuBar()
fileMenu = menuBar.addMenu("&File")
# menuBar.addMenu(fileMenu)
# Creating menus using a title
editMenu = menuBar.addMenu("&Edit")
reportMenu = menuBar.addMenu("&Reports")
helpMenu = menuBar.addMenu("&Help")
fileMenu.addAction(self.importAction)
reportMenu.addAction(self.generateReportAction)
def _createToolBar(self):
toolbar = QToolBar("My main toolbar")
self.addToolBar(toolbar)
toolbar.addAction(self.addReagentAction)
toolbar.addAction(self.addKitAction)
def _createActions(self):
self.importAction = QAction("&Import", self)
self.addReagentAction = QAction("Add Reagent", self)
self.generateReportAction = QAction("Make Report", self)
self.addKitAction = QAction("Add Kit", self)
def _connectActions(self):
self.importAction.triggered.connect(self.importSubmission)
self.addReagentAction.triggered.connect(self.add_reagent)
self.generateReportAction.triggered.connect(self.generateReport)
self.addKitAction.triggered.connect(self.add_kit)
def importSubmission(self):
logger.debug(self.ctx)
home_dir = str(Path(self.ctx["directory_path"]))
fname = Path(QFileDialog.getOpenFileName(self, 'Open file', home_dir)[0])
logger.debug(f"Attempting to parse file: {fname}")
assert fname.exists()
try:
prsr = SheetParser(fname, **self.ctx)
except PermissionError:
return
print(f"prsr.sub = {prsr.sub}")
# replace formlayout with tab1.layout
for item in self.table_widget.formlayout.parentWidget().findChildren(QWidget):
item.setParent(None)
variable_parser = re.compile(r"""
# (?x)
(?P<extraction_kit>^extraction_kit$) |
(?P<submitted_date>^submitted_date$) |
(?P<submitting_lab>)^submitting_lab$ |
(?P<reagent>^lot_.*$)
""", re.VERBOSE)
for item in prsr.sub:
logger.debug(f"Item: {item}")
self.table_widget.formlayout.addWidget(QLabel(item.replace("_", " ").title()))
try:
mo = variable_parser.fullmatch(item).lastgroup
except AttributeError:
mo = "other"
match mo:
case 'submitting_lab':
print(f"{item}: {prsr.sub[item]}")
add_widget = QComboBox()
labs = [item.__str__() for item in lookup_all_orgs(ctx=self.ctx)]
try:
labs = difflib.get_close_matches(prsr.sub[item], labs, len(labs), 0)
except TypeError:
pass
add_widget.addItems(labs)
case 'extraction_kit':
if prsr.sub[item] == 'nan':
msg = QMessageBox()
# msg.setIcon(QMessageBox.critical)
msg.setText("Error")
msg.setInformativeText('You need to enter a value for extraction kit.')
msg.setWindowTitle("Error")
msg.exec()
break
add_widget = QComboBox()
uses = [item.__str__() for item in lookup_kittype_by_use(ctx=self.ctx, used_by=prsr.sub['submission_type'])]
if len(uses) > 0:
add_widget.addItems(uses)
else:
add_widget.addItems(['bacterial_culture'])
case 'submitted_date':
add_widget = QDateEdit(calendarPopup=True)
# add_widget.setDateTime(QDateTime.date(prsr.sub[item]))
add_widget.setDate(prsr.sub[item])
case 'reagent':
add_widget = QComboBox()
add_widget.setEditable(True)
# Ensure that all reagenttypes have a name that matches the items in the excel parser
query_var = item.replace("lot_", "")
print(f"Query for: {query_var}")
if isinstance(prsr.sub[item], numpy.float64):
print(f"{prsr.sub[item]} is a numpy float!")
try:
prsr.sub[item] = int(prsr.sub[item])
except ValueError:
pass
relevant_reagents = [item.__str__() for item in lookup_regent_by_type_name_and_kit_name(ctx=self.ctx, type_name=query_var, kit_name=prsr.sub['extraction_kit'])]
print(f"Relevant reagents: {relevant_reagents}")
if prsr.sub[item] not in relevant_reagents and prsr.sub[item] != 'nan':
try:
check = not numpy.isnan(prsr.sub[item])
except TypeError:
check = True
if check:
relevant_reagents.insert(0, str(prsr.sub[item]))
logger.debug(f"Relevant reagents: {relevant_reagents}")
add_widget.addItems(relevant_reagents)
case _:
add_widget = QLineEdit()
add_widget.setText(str(prsr.sub[item]).replace("_", " "))
self.table_widget.formlayout.addWidget(add_widget)
submit_btn = QPushButton("Submit")
self.table_widget.formlayout.addWidget(submit_btn)
submit_btn.clicked.connect(self.submit_new_sample)
def renderPage(self):
"""
Test function for plotly chart rendering
"""
df = pd.read_excel("C:\\Users\\lwark\\Desktop\\test_df.xlsx", engine="openpyxl")
fig = px.bar(df, x="submitted_date", y="kraken_percent", color="genus", title="Long-Form Input")
fig.update_layout(
xaxis_title="Submitted Date (* - Date parsed from fastq file creation date)",
yaxis_title="Kraken Percent",
showlegend=True,
barmode='stack'
)
html = '<html><body>'
html += plotly.offline.plot(fig, output_type='div', include_plotlyjs='cdn', auto_open=True, image = 'png', image_filename='plot_image')
html += '</body></html>'
self.table_widget.webengineview.setHtml(html)
self.table_widget.webengineview.update()
def submit_new_sample(self):
labels, values = self.extract_form_info(self.table_widget.tab1)
info = {item[0]:item[1] for item in zip(labels, values) if not item[0].startswith("lot_")}
reagents = {item[0]:item[1] for item in zip(labels, values) if item[0].startswith("lot_")}
logger.debug(f"Reagents: {reagents}")
parsed_reagents = []
for reagent in reagents:
wanted_reagent = lookup_reagent(ctx=self.ctx, reagent_lot=reagents[reagent])
logger.debug(wanted_reagent)
if wanted_reagent == None:
dlg = AddReagentQuestion(reagent_type=reagent, reagent_lot=reagents[reagent])
if dlg.exec():
wanted_reagent = self.add_reagent(reagent_lot=reagents[reagent], reagent_type=reagent.replace("lot_", ""))
else:
logger.debug("Will not add reagent.")
if wanted_reagent != None:
parsed_reagents.append(wanted_reagent)
logger.debug(info)
base_submission = construct_submission_info(ctx=self.ctx, info_dict=info)
for reagent in parsed_reagents:
base_submission.reagents.append(reagent)
result = store_submission(ctx=self.ctx, base_submission=base_submission)
if result != None:
msg = QMessageBox()
# msg.setIcon(QMessageBox.critical)
msg.setText("Error")
msg.setInformativeText(result['message'])
msg.setWindowTitle("Error")
msg.exec()
self.table_widget.sub_wid.setData()
def add_reagent(self, reagent_lot:str|None=None, reagent_type:str|None=None):
if isinstance(reagent_lot, bool):
reagent_lot = ""
dlg = AddReagentForm(ctx=self.ctx, reagent_lot=reagent_lot, reagent_type=reagent_type)
if dlg.exec():
labels, values = self.extract_form_info(dlg)
info = {item[0]:item[1] for item in zip(labels, values)}
logger.debug(f"Reagent info: {info}")
reagent = construct_reagent(ctx=self.ctx, info_dict=info)
store_reagent(ctx=self.ctx, reagent=reagent)
return reagent
def extract_form_info(self, object):
labels = []
values = []
for item in object.layout.parentWidget().findChildren(QWidget):
match item:
case QLabel():
labels.append(item.text().replace(" ", "_").lower())
case QLineEdit():
# ad hoc check to prevent double reporting of qdatedit under lineedit for some reason
if not isinstance(prev_item, QDateEdit) and not isinstance(prev_item, QComboBox) and not isinstance(prev_item, QSpinBox):
print(f"Previous: {prev_item}")
print(f"Item: {item}")
values.append(item.text())
case QComboBox():
values.append(item.currentText())
case QDateEdit():
values.append(item.date().toPyDate())
prev_item = item
return labels, values
def generateReport(self):
dlg = ReportDatePicker()
if dlg.exec():
labels, values = self.extract_form_info(dlg)
info = {item[0]:item[1] for item in zip(labels, values)}
subs = lookup_submissions_by_date_range(ctx=self.ctx, start_date=info['start_date'], end_date=info['end_date'])
records = [item.report_dict() for item in subs]
df = make_report_xlsx(records=records)
home_dir = Path(self.ctx["directory_path"]).joinpath(f"Submissions_{info['start_date']}-{info['end_date']}.xlsx").resolve().__str__()
fname = Path(QFileDialog.getSaveFileName(self, "Save File", home_dir, filter=".xlsx")[0])
try:
df.to_excel(fname, engine="openpyxl")
except PermissionError:
pass
def add_kit(self):
home_dir = str(Path(self.ctx["directory_path"]))
fname = Path(QFileDialog.getOpenFileName(self, 'Open file', home_dir)[0])
assert fname.exists()
with open(fname.__str__(), "r") as stream:
try:
exp = yaml.load(stream, Loader=yaml.Loader)
except yaml.YAMLError as exc:
logger.error(f'Error reading yaml file {fname}: {exc}')
return {}
create_kit_from_yaml(ctx=self.ctx, exp=exp)
class AddSubForm(QWidget):
def __init__(self, parent):
super(QWidget, self).__init__(parent)
self.layout = QVBoxLayout(self)
# Initialize tab screen
self.tabs = QTabWidget()
self.tab1 = QWidget()
self.tab2 = QWidget()
self.tab3 = QWidget()
self.tabs.resize(300,200)
# Add tabs
self.tabs.addTab(self.tab1,"Submissions")
self.tabs.addTab(self.tab2,"Controls")
self.tabs.addTab(self.tab3, "Add Kit")
# Create first tab
# self.scroller = QWidget()
# self.scroller.layout = QVBoxLayout(self)
# self.scroller.setLayout(self.scroller.layout)
# self.tab1.setMaximumHeight(1000)
self.formwidget = QWidget(self)
self.formlayout = QVBoxLayout(self)
self.formwidget.setLayout(self.formlayout)
self.formwidget.setFixedWidth(300)
self.sheetwidget = QWidget(self)
self.sheetlayout = QVBoxLayout(self)
self.sheetwidget.setLayout(self.sheetlayout)
self.sub_wid = SubmissionsSheet(parent.ctx)
self.sheetlayout.addWidget(self.sub_wid)
self.tab1.layout = QHBoxLayout(self)
self.tab1.setLayout(self.tab1.layout)
# self.tab1.layout.addLayout(self.formlayout)
self.tab1.layout.addWidget(self.formwidget)
self.tab1.layout.addWidget(self.sheetwidget)
# self.tab1.layout.addLayout(self.sheetlayout)
# self.tab1.setWidgetResizable(True)
# self.tab1.setVerticalScrollBar(QScrollBar())
# self.tab1.layout.addWidget(self.scroller)
# self.tab1.setWidget(self.scroller)
# self.tab1.setMinimumHeight(300)
self.webengineview = QWebEngineView()
# data = '''<html>Hello World</html>'''
# self.webengineview.setHtml(data)
self.tab2.layout = QVBoxLayout(self)
self.control_typer = QComboBox()
con_types = get_all_Control_Types_names(ctx=parent.ctx)
self.control_typer.addItems(con_types)
self.tab2.layout.addWidget(self.control_typer)
self.tab2.layout.addWidget(self.webengineview)
self.tab2.setLayout(self.tab2.layout)
# Add tabs to widget
adder = KitAdder(parent_ctx=parent.ctx)
self.tab3.layout = QVBoxLayout(self)
self.tab3.layout.addWidget(adder)
self.tab3.setLayout(self.tab3.layout)
self.layout.addWidget(self.tabs)
self.setLayout(self.layout)
# @pyqtSlot()
# def on_click(self):
# print("\n")
# for currentQTableWidgetItem in self.tableWidget.selectedItems():
# print(currentQTableWidgetItem.row(), currentQTableWidgetItem.column(), currentQTableWidgetItem.text())
# import sys
# from pathlib import Path
# from textual import events
# from textual.app import App, ComposeResult
# from textual.containers import Container, Vertical
# from textual.reactive import var
# from textual.widgets import DirectoryTree, Footer, Header, Input, Label
# from textual.css.query import NoMatches
# sys.path.append(Path(__file__).absolute().parents[1].__str__())
# from backend.excel.parser import SheetParser
# class FormField(Input):
# def on_mount(self):
# self.placeholder = "Value not set."
# def update(self, input:str):
# self.value = input
# class DataBrowser(App):
# """
# File browser input
# """
# CSS_PATH = "static/css/data_browser.css"
# BINDINGS = [
# ("ctrl+f", "toggle_files", "Toggle Files"),
# ("ctrl+q", "quit", "Quit"),
# ]
# show_tree = var(True)
# context = {}
# def watch_show_tree(self, show_tree: bool) -> None:
# """Called when show_tree is modified."""
# self.set_class(show_tree, "-show-tree")
# def compose(self) -> ComposeResult:
# """Compose our UI."""
# if 'directory_path' in self.context:
# path = self.context['directory_path']
# else:
# path = "."
# yield Header()
# yield Container(
# DirectoryTree(path, id="tree-view"),
# Vertical(
# Label("[b]File Name[/b]", classes='box'), FormField(id="file-name", classes='box'),
# # Label("[b]Sample Type[/b]", classes='box'), FormField(id="sample-type", classes='box'),
# id="form-view"
# )
# )
# yield Footer()
# def on_mount(self, event: events.Mount) -> None:
# self.query_one(DirectoryTree).focus()
# def on_directory_tree_file_selected(self, event: DirectoryTree.FileSelected) -> None:
# """Called when the user click a file in the directory tree."""
# event.stop()
# sample = SheetParser(Path(event.path), **self.context)
# sample_view = self.query_one("#file-name", FormField)
# # sample_type = self.query_one("#sample-type", FormField)
# sample_view.update(event.path)
# # sample_type.update(sample.sub['sample_type'])
# form_view = self.query_one("#form-view", Vertical)
# if sample.sub != None:
# for var in sample.sub.keys():
# # if var == "sample_type":
# # continue
# try:
# deleter = self.query_one(f"#{var}_label")
# deleter.remove()
# except NoMatches:
# pass
# try:
# deleter = self.query_one(f"#{var}")
# deleter.remove()
# except NoMatches:
# pass
# form_view.mount(Label(var.replace("_", " ").upper(), id=f"{var}_label", classes='box added'))
# form_view.mount(FormField(id=var, classes='box added', value=sample.sub[var]))
# else:
# adds = self.query(".added")
# for add in adds:
# add.remove()
# def action_toggle_files(self) -> None:
# """Called in response to key binding."""
# self.show_tree = not self.show_tree
# if __name__ == "__main__":
# app = DataBrowser()
# app.run()

View File

@@ -0,0 +1,298 @@
from PyQt6.QtWidgets import (
QLabel, QVBoxLayout,
QLineEdit, QComboBox, QDialog,
QDialogButtonBox, QDateEdit, QTableView,
QTextEdit, QSizePolicy, QWidget,
QGridLayout, QPushButton, QSpinBox,
QScrollBar
)
from PyQt6.QtCore import Qt, QDate, QAbstractTableModel
from PyQt6.QtGui import QFontMetrics
from backend.db import get_all_reagenttype_names, submissions_to_df, lookup_submission_by_id, lookup_all_sample_types, create_kit_from_yaml
from jinja2 import Environment, FileSystemLoader
import sys
from pathlib import Path
if getattr(sys, 'frozen', False):
loader_path = Path(sys._MEIPASS).joinpath("files", "templates")
else:
loader_path = Path(__file__).parents[2].joinpath('templates').absolute().__str__()
loader = FileSystemLoader(loader_path)
env = Environment(loader=loader)
class AddReagentQuestion(QDialog):
def __init__(self, reagent_type:str, reagent_lot:str):
super().__init__()
self.setWindowTitle(f"Add {reagent_lot}?")
QBtn = QDialogButtonBox.StandardButton.Yes | QDialogButtonBox.StandardButton.No
self.buttonBox = QDialogButtonBox(QBtn)
self.buttonBox.accepted.connect(self.accept)
self.buttonBox.rejected.connect(self.reject)
self.layout = QVBoxLayout()
message = QLabel(f"Couldn't find reagent type {reagent_type.replace('_', ' ').title()}: {reagent_lot} in the database.\nWould you like to add it?")
self.layout.addWidget(message)
self.layout.addWidget(self.buttonBox)
self.setLayout(self.layout)
class AddReagentForm(QDialog):
def __init__(self, ctx:dict, reagent_lot:str|None, reagent_type:str|None):
super().__init__()
if reagent_lot == None:
reagent_lot = ""
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)
lot_input = QLineEdit()
lot_input.setText(reagent_lot)
exp_input = QDateEdit(calendarPopup=True)
exp_input.setDate(QDate.currentDate())
type_input = QComboBox()
type_input.addItems([item.replace("_", " ").title() for item in get_all_reagenttype_names(ctx=ctx)])
print(f"Trying to find index of {reagent_type}")
try:
reagent_type = reagent_type.replace("_", " ").title()
except AttributeError:
reagent_type = None
index = type_input.findText(reagent_type, Qt.MatchFlag.MatchEndsWith)
if index >= 0:
type_input.setCurrentIndex(index)
self.layout = QVBoxLayout()
self.layout.addWidget(QLabel("Lot"))
self.layout.addWidget(lot_input)
self.layout.addWidget(QLabel("Expiry"))
self.layout.addWidget(exp_input)
self.layout.addWidget(QLabel("Type"))
self.layout.addWidget(type_input)
self.layout.addWidget(self.buttonBox)
self.setLayout(self.layout)
class pandasModel(QAbstractTableModel):
def __init__(self, data):
QAbstractTableModel.__init__(self)
self._data = data
def rowCount(self, parent=None):
return self._data.shape[0]
def columnCount(self, parnet=None):
return self._data.shape[1]
def data(self, index, role=Qt.ItemDataRole.DisplayRole):
if index.isValid():
if role == Qt.ItemDataRole.DisplayRole:
return str(self._data.iloc[index.row(), index.column()])
return None
def headerData(self, col, orientation, role):
if orientation == Qt.Orientation.Horizontal and role == Qt.ItemDataRole.DisplayRole:
return self._data.columns[col]
return None
class SubmissionsSheet(QTableView):
def __init__(self, ctx:dict):
super().__init__()
self.ctx = ctx
self.setData()
self.resizeColumnsToContents()
self.resizeRowsToContents()
# self.clicked.connect(self.test)
self.doubleClicked.connect(self.show_details)
def setData(self):
# horHeaders = []
# for n, key in enumerate(sorted(self.data.keys())):
# horHeaders.append(key)
# for m, item in enumerate(self.data[key]):
# newitem = QTableWidgetItem(item)
# self.setItem(m, n, newitem)
# self.setHorizontalHeaderLabels(horHeaders)
self.data = submissions_to_df(ctx=self.ctx)
self.model = pandasModel(self.data)
self.setModel(self.model)
# self.resize(800,600)
def show_details(self, item):
index=(self.selectionModel().currentIndex())
# print(index)
value=index.sibling(index.row(),0).data()
dlg = SubmissionDetails(ctx=self.ctx, id=value)
if dlg.exec():
pass
class SubmissionDetails(QDialog):
def __init__(self, ctx:dict, id:int) -> None:
super().__init__()
self.setWindowTitle("Submission Details")
data = lookup_submission_by_id(ctx=ctx, id=id)
base_dict = data.to_dict()
base_dict['reagents'] = [item.to_sub_dict() for item in data.reagents]
template = env.get_template("submission_details.txt")
text = template.render(sub=base_dict)
txt_field = QTextEdit(self)
txt_field.setReadOnly(True)
txt_field.document().setPlainText(text)
font = txt_field.document().defaultFont()
fontMetrics = QFontMetrics(font)
textSize = fontMetrics.size(0, txt_field.toPlainText())
w = textSize.width() + 10
h = textSize.height() + 10
txt_field.setMinimumSize(w, h)
txt_field.setMaximumSize(w, h)
txt_field.resize(w, h)
# txt_field.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.MinimumExpanding)
QBtn = QDialogButtonBox.StandardButton.Ok
# self.buttonBox = QDialogButtonBox(QBtn)
# self.buttonBox.accepted.connect(self.accept)
txt_field.setText(text)
self.layout = QVBoxLayout()
self.layout.addWidget(txt_field)
# self.layout.addStretch()
class ReportDatePicker(QDialog):
def __init__(self) -> None:
super().__init__()
self.setWindowTitle("Select Report Date Range")
QBtn = QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel
self.buttonBox = QDialogButtonBox(QBtn)
self.buttonBox.accepted.connect(self.accept)
self.buttonBox.rejected.connect(self.reject)
start_date = QDateEdit(calendarPopup=True)
start_date.setDate(QDate.currentDate())
end_date = QDateEdit(calendarPopup=True)
end_date.setDate(QDate.currentDate())
self.layout = QVBoxLayout()
self.layout.addWidget(QLabel("Start Date"))
self.layout.addWidget(start_date)
self.layout.addWidget(QLabel("End Date"))
self.layout.addWidget(end_date)
self.layout.addWidget(self.buttonBox)
self.setLayout(self.layout)
class KitAdder(QWidget):
def __init__(self, parent_ctx:dict):
super().__init__()
self.ctx = parent_ctx
self.grid = QGridLayout()
self.setLayout(self.grid)
self.submit_btn = QPushButton("Submit")
self.grid.addWidget(self.submit_btn,0,0,1,1)
self.grid.addWidget(QLabel("Password:"),1,0)
self.grid.addWidget(QLineEdit(),1,1)
self.grid.addWidget(QLabel("Kit Name:"),2,0)
self.grid.addWidget(QLineEdit(),2,1)
self.grid.addWidget(QLabel("Used For Sample Type:"),3,0)
used_for = QComboBox()
used_for.addItems(lookup_all_sample_types(ctx=parent_ctx))
used_for.setEditable(True)
self.grid.addWidget(used_for,3,1)
self.grid.addWidget(QLabel("Cost per run:"),4,0)
cost = QSpinBox()
cost.setMinimum(0)
cost.setMaximum(9999)
self.grid.addWidget(cost,4,1)
self.add_RT_btn = QPushButton("Add Reagent Type")
self.grid.addWidget(self.add_RT_btn)
self.add_RT_btn.clicked.connect(self.add_RT)
self.submit_btn.clicked.connect(self.submit)
def add_RT(self):
maxrow = self.grid.rowCount()
self.grid.addWidget(ReagentTypeForm(parent_ctx=self.ctx), maxrow + 1,0,1,2)
def submit(self):
labels, values, reagents = self.extract_form_info(self)
info = {item[0]:item[1] for item in zip(labels, values)}
print(info)
# info['reagenttypes'] = reagents
# del info['name']
# del info['extension_of_life_(months)']
yml_type = {}
yml_type['password'] = info['password']
used = info['used_for_sample_type'].replace(" ", "_").lower()
yml_type[used] = {}
yml_type[used]['kits'] = {}
yml_type[used]['kits'][info['kit_name']] = {}
yml_type[used]['kits'][info['kit_name']]['cost'] = info['cost_per_run']
yml_type[used]['kits'][info['kit_name']]['reagenttypes'] = reagents
print(yml_type)
create_kit_from_yaml(ctx=self.ctx, exp=yml_type)
def extract_form_info(self, object):
labels = []
values = []
reagents = {}
for item in object.findChildren(QWidget):
print(item.parentWidget())
# if not isinstance(item.parentWidget(), ReagentTypeForm):
match item:
case QLabel():
labels.append(item.text().replace(" ", "_").strip(":").lower())
case QLineEdit():
# ad hoc check to prevent double reporting of qdatedit under lineedit for some reason
if not isinstance(prev_item, QDateEdit) and not isinstance(prev_item, QComboBox) and not isinstance(prev_item, QSpinBox) and not isinstance(prev_item, QScrollBar):
print(f"Previous: {prev_item}")
print(f"Item: {item}, {item.text()}")
values.append(item.text())
case QComboBox():
values.append(item.currentText())
case QDateEdit():
values.append(item.date().toPyDate())
case QSpinBox():
values.append(item.value())
case ReagentTypeForm():
re_labels, re_values, _ = self.extract_form_info(item)
reagent = {item[0]:item[1] for item in zip(re_labels, re_values)}
print(reagent)
# reagent = {reagent['name:']:{'eol':reagent['extension_of_life_(months):']}}
reagents[reagent['name']] = {'eol_ext':int(reagent['extension_of_life_(months)'])}
prev_item = item
return labels, values, reagents
class ReagentTypeForm(QWidget):
def __init__(self, parent_ctx:dict) -> None:
super().__init__()
grid = QGridLayout()
self.setLayout(grid)
grid.addWidget(QLabel("Name:"),0,0)
reagent_getter = QComboBox()
reagent_getter.addItems(get_all_reagenttype_names(ctx=parent_ctx))
reagent_getter.setEditable(True)
grid.addWidget(reagent_getter,0,1)
grid.addWidget(QLabel("Extension of Life (months):"),0,2)
eol = QSpinBox()
eol.setMinimum(0)
grid.addWidget(eol, 0,3)

View File

@@ -0,0 +1,40 @@
Screen {
}
.box {
height: 3;
border: solid green;
}
#tree-view {
display: none;
scrollbar-gutter: stable;
overflow: auto;
width: auto;
height: 100%;
dock: left;
}
DataBrowser.-show-tree #tree-view {
display: block;
max-width: 50%;
}
#code-view {
overflow: auto scroll;
min-width: 100%;
}
#code {
width: auto;
}
FormField {
width: 70%;
height: 3;
padding: 1 2;
background: $primary;
border: $secondary tall;
content-align: center middle;
}

View File

@@ -0,0 +1,9 @@
{% for key, value in sub.items() if key != 'reagents' %}
{{ key }}: {{ value }}
{% endfor %}
Reagents:
{% for item in sub['reagents'] %}
{{ item['type'] }}: {{ item['lot'] }} (EXP: {{ item['expiry'] }})
{% endfor %}

View File

@@ -0,0 +1,8 @@
from sqlalchemy import create_engine
from sqlalchemy.orm import Session
from src.submissions.backend.db.models import *
from src.submissions.backend.db import get_kits_by_use
engine = create_engine("sqlite+pysqlite:///:memory:", echo=True, future=True)
session = Session(engine)
metadata.create_all(engine)