From e763e7273d68fbc780d5954bb0a14505e063ceda Mon Sep 17 00:00:00 2001 From: Landon Wark Date: Wed, 18 Jan 2023 14:00:25 -0600 Subject: [PATCH] initial commit --- alembic.ini | 105 ++++ alembic/README | 1 + alembic/env.py | 85 ++++ alembic/script.py.mako | 24 + .../versions/4cba0c1ffe03_initial_commit.py | 155 ++++++ src/submissions/README.md | 0 src/submissions/__init__.py | 4 + src/submissions/__main__.py | 97 ++++ src/submissions/backend/__init__.py | 0 src/submissions/backend/db/__init__.py | 213 ++++++++ .../backend/db/functions/__init__.py | 0 src/submissions/backend/db/models/__init__.py | 11 + src/submissions/backend/db/models/controls.py | 36 ++ src/submissions/backend/db/models/kits.py | 68 +++ .../backend/db/models/organizations.py | 34 ++ src/submissions/backend/db/models/samples.py | 27 + .../backend/db/models/submissions.py | 103 ++++ src/submissions/backend/excel/parser.py | 122 +++++ src/submissions/backend/excel/reports.py | 13 + src/submissions/configure/__init__.py | 194 ++++++++ src/submissions/frontend/__init__.py | 471 ++++++++++++++++++ .../frontend/custom_widgets/__init__.py | 298 +++++++++++ .../frontend/static/css/data_browser.css | 40 ++ .../frontend/static/css/styles.css | 0 .../templates/submission_details.txt | 9 + tests/test_database_functions.py | 8 + 26 files changed, 2118 insertions(+) create mode 100644 alembic.ini create mode 100644 alembic/README create mode 100644 alembic/env.py create mode 100644 alembic/script.py.mako create mode 100644 alembic/versions/4cba0c1ffe03_initial_commit.py create mode 100644 src/submissions/README.md create mode 100644 src/submissions/__init__.py create mode 100644 src/submissions/__main__.py create mode 100644 src/submissions/backend/__init__.py create mode 100644 src/submissions/backend/db/__init__.py create mode 100644 src/submissions/backend/db/functions/__init__.py create mode 100644 src/submissions/backend/db/models/__init__.py create mode 100644 src/submissions/backend/db/models/controls.py create mode 100644 src/submissions/backend/db/models/kits.py create mode 100644 src/submissions/backend/db/models/organizations.py create mode 100644 src/submissions/backend/db/models/samples.py create mode 100644 src/submissions/backend/db/models/submissions.py create mode 100644 src/submissions/backend/excel/parser.py create mode 100644 src/submissions/backend/excel/reports.py create mode 100644 src/submissions/configure/__init__.py create mode 100644 src/submissions/frontend/__init__.py create mode 100644 src/submissions/frontend/custom_widgets/__init__.py create mode 100644 src/submissions/frontend/static/css/data_browser.css create mode 100644 src/submissions/frontend/static/css/styles.css create mode 100644 src/submissions/templates/submission_details.txt create mode 100644 tests/test_database_functions.py diff --git a/alembic.ini b/alembic.ini new file mode 100644 index 0000000..9dfe5a6 --- /dev/null +++ b/alembic.ini @@ -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 diff --git a/alembic/README b/alembic/README new file mode 100644 index 0000000..98e4f9c --- /dev/null +++ b/alembic/README @@ -0,0 +1 @@ +Generic single-database configuration. \ No newline at end of file diff --git a/alembic/env.py b/alembic/env.py new file mode 100644 index 0000000..2a2c78f --- /dev/null +++ b/alembic/env.py @@ -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() diff --git a/alembic/script.py.mako b/alembic/script.py.mako new file mode 100644 index 0000000..55df286 --- /dev/null +++ b/alembic/script.py.mako @@ -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"} diff --git a/alembic/versions/4cba0c1ffe03_initial_commit.py b/alembic/versions/4cba0c1ffe03_initial_commit.py new file mode 100644 index 0000000..689b0f9 --- /dev/null +++ b/alembic/versions/4cba0c1ffe03_initial_commit.py @@ -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 ### diff --git a/src/submissions/README.md b/src/submissions/README.md new file mode 100644 index 0000000..e69de29 diff --git a/src/submissions/__init__.py b/src/submissions/__init__.py new file mode 100644 index 0000000..f7d78a8 --- /dev/null +++ b/src/submissions/__init__.py @@ -0,0 +1,4 @@ +# __init__.py + +# Version of the realpython-reader package +__version__ = "1.0.0" \ No newline at end of file diff --git a/src/submissions/__main__.py b/src/submissions/__main__.py new file mode 100644 index 0000000..3ff7971 --- /dev/null +++ b/src/submissions/__main__.py @@ -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(""" +# # +# # +# #

Hello world!

+# #

First para

+# # +# # +# # +# # +# # """) + + + +# 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() \ No newline at end of file diff --git a/src/submissions/backend/__init__.py b/src/submissions/backend/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/submissions/backend/db/__init__.py b/src/submissions/backend/db/__init__.py new file mode 100644 index 0000000..241069b --- /dev/null +++ b/src/submissions/backend/db/__init__.py @@ -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 \ No newline at end of file diff --git a/src/submissions/backend/db/functions/__init__.py b/src/submissions/backend/db/functions/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/submissions/backend/db/models/__init__.py b/src/submissions/backend/db/models/__init__.py new file mode 100644 index 0000000..651be90 --- /dev/null +++ b/src/submissions/backend/db/models/__init__.py @@ -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 \ No newline at end of file diff --git a/src/submissions/backend/db/models/controls.py b/src/submissions/backend/db/models/controls.py new file mode 100644 index 0000000..f2b8569 --- /dev/null +++ b/src/submissions/backend/db/models/controls.py @@ -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") + diff --git a/src/submissions/backend/db/models/kits.py b/src/submissions/backend/db/models/kits.py new file mode 100644 index 0000000..7d91ab7 --- /dev/null +++ b/src/submissions/backend/db/models/kits.py @@ -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") + } + + + \ No newline at end of file diff --git a/src/submissions/backend/db/models/organizations.py b/src/submissions/backend/db/models/organizations.py new file mode 100644 index 0000000..9cba80c --- /dev/null +++ b/src/submissions/backend/db/models/organizations.py @@ -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")) + diff --git a/src/submissions/backend/db/models/samples.py b/src/submissions/backend/db/models/samples.py new file mode 100644 index 0000000..8ef23fd --- /dev/null +++ b/src/submissions/backend/db/models/samples.py @@ -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)) + + + + diff --git a/src/submissions/backend/db/models/submissions.py b/src/submissions/backend/db/models/submissions.py new file mode 100644 index 0000000..8df2f83 --- /dev/null +++ b/src/submissions/backend/db/models/submissions.py @@ -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"} \ No newline at end of file diff --git a/src/submissions/backend/excel/parser.py b/src/submissions/backend/excel/parser.py new file mode 100644 index 0000000..a05857f --- /dev/null +++ b/src/submissions/backend/excel/parser.py @@ -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] \ No newline at end of file diff --git a/src/submissions/backend/excel/reports.py b/src/submissions/backend/excel/reports.py new file mode 100644 index 0000000..48a55b6 --- /dev/null +++ b/src/submissions/backend/excel/reports.py @@ -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 \ No newline at end of file diff --git a/src/submissions/configure/__init__.py b/src/submissions/configure/__init__.py new file mode 100644 index 0000000..f6cac7c --- /dev/null +++ b/src/submissions/configure/__init__.py @@ -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) + diff --git a/src/submissions/frontend/__init__.py b/src/submissions/frontend/__init__.py new file mode 100644 index 0000000..3e398fa --- /dev/null +++ b/src/submissions/frontend/__init__.py @@ -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$) | + (?P^submitted_date$) | + (?P)^submitting_lab$ | + (?P^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 += plotly.offline.plot(fig, output_type='div', include_plotlyjs='cdn', auto_open=True, image = 'png', image_filename='plot_image') + 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 = '''Hello World''' + # 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() \ No newline at end of file diff --git a/src/submissions/frontend/custom_widgets/__init__.py b/src/submissions/frontend/custom_widgets/__init__.py new file mode 100644 index 0000000..130048e --- /dev/null +++ b/src/submissions/frontend/custom_widgets/__init__.py @@ -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) \ No newline at end of file diff --git a/src/submissions/frontend/static/css/data_browser.css b/src/submissions/frontend/static/css/data_browser.css new file mode 100644 index 0000000..35bdbd1 --- /dev/null +++ b/src/submissions/frontend/static/css/data_browser.css @@ -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; +} \ No newline at end of file diff --git a/src/submissions/frontend/static/css/styles.css b/src/submissions/frontend/static/css/styles.css new file mode 100644 index 0000000..e69de29 diff --git a/src/submissions/templates/submission_details.txt b/src/submissions/templates/submission_details.txt new file mode 100644 index 0000000..27d1e9c --- /dev/null +++ b/src/submissions/templates/submission_details.txt @@ -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 %} \ No newline at end of file diff --git a/tests/test_database_functions.py b/tests/test_database_functions.py new file mode 100644 index 0000000..be3b4df --- /dev/null +++ b/tests/test_database_functions.py @@ -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) \ No newline at end of file