From 4bc5e08ac644aada8d4da049ffd41fa0b2461de9 Mon Sep 17 00:00:00 2001 From: lwark Date: Thu, 25 Jul 2024 08:41:44 -0500 Subject: [PATCH] Added Postgres support. --- CHANGELOG.md | 5 + README.md | 16 +- TODO.md | 4 +- ...it.py => 0746f7e2c10e_rebuild_20240723.py} | 146 ++++++++++++---- ...d4368e6_made_wastewaterarticassociation.py | 54 ------ ...357860ef_adding_cost_centre_storage_to_.py | 51 ------ ...0426df72f80_adding_gel_image_info_again.py | 34 ---- ..._tweaking_submission_sample_association.py | 38 ----- .../861b52a2004e_adding_fields_to_artic.py | 72 -------- .../874af342c82c_adding_ranking_to_.py | 34 ---- ...6_update_to_submissionsampleassociation.py | 50 ------ ...c25fd9138_splitting_off_sample_info_in_.py | 38 ----- ...8a452fd_attaching_contact_to_submission.py | 40 ----- .../c4201b0ea9fe_adding_gel_image_info.py | 42 ----- alembic/versions/d2b094cfa308_adding_tips.py | 95 ----------- ...9_adding_default_info_to_submissiontype.py | 33 ---- ...87b41f45_adding_source_plates_to_artic_.py | 51 ------ .../f829a8ab292f_adding_configitems_to_db.py | 39 ----- ...dding_source_plates_to_artic_submission.py | 48 ------ alembic_default.ini | 104 ++++++++++++ src/submissions/backend/db/__init__.py | 2 +- src/submissions/backend/db/models/__init__.py | 23 ++- src/submissions/backend/db/models/kits.py | 2 +- .../backend/db/models/submissions.py | 50 ++++-- src/submissions/backend/validators/pydant.py | 92 +++++----- src/submissions/frontend/widgets/app.py | 44 ++--- .../frontend/widgets/equipment_usage.py | 4 +- .../frontend/widgets/gel_checker.py | 47 ++--- src/submissions/frontend/widgets/misc.py | 9 +- .../frontend/widgets/submission_table.py | 62 ++----- .../frontend/widgets/submission_widget.py | 120 +++++++------ src/submissions/tools.py | 160 ++++++++++++------ 32 files changed, 579 insertions(+), 1030 deletions(-) rename alembic/versions/{e3f6770ef515_first_commit.py => 0746f7e2c10e_rebuild_20240723.py} (72%) delete mode 100644 alembic/versions/16b20d4368e6_made_wastewaterarticassociation.py delete mode 100644 alembic/versions/6d2a357860ef_adding_cost_centre_storage_to_.py delete mode 100644 alembic/versions/70426df72f80_adding_gel_image_info_again.py delete mode 100644 alembic/versions/70d5a751f579_tweaking_submission_sample_association.py delete mode 100644 alembic/versions/861b52a2004e_adding_fields_to_artic.py delete mode 100644 alembic/versions/874af342c82c_adding_ranking_to_.py delete mode 100644 alembic/versions/97392dda5436_update_to_submissionsampleassociation.py delete mode 100644 alembic/versions/a04c25fd9138_splitting_off_sample_info_in_.py delete mode 100644 alembic/versions/b744e8a452fd_attaching_contact_to_submission.py delete mode 100644 alembic/versions/c4201b0ea9fe_adding_gel_image_info.py delete mode 100644 alembic/versions/d2b094cfa308_adding_tips.py delete mode 100644 alembic/versions/e6647bd661d9_adding_default_info_to_submissiontype.py delete mode 100644 alembic/versions/f18487b41f45_adding_source_plates_to_artic_.py delete mode 100644 alembic/versions/f829a8ab292f_adding_configitems_to_db.py delete mode 100644 alembic/versions/fabf697c721d_adding_source_plates_to_artic_submission.py create mode 100644 alembic_default.ini diff --git a/CHANGELOG.md b/CHANGELOG.md index ca174ef..1f08b07 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,12 @@ +## 202407.04 + +- Added support for postgresql databases (auto backup not functional). + ## 202407.02 - HTML template for 'About'. - More flexible custom parsers/writers due to custom info items. +- Vastly increased portability of the reporting functions. ## 202407.01 diff --git a/README.md b/README.md index e7588c7..3677b0d 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,8 @@ ## Startup: -1. Open the app using the shortcut in the Submissions folder. For example: L:\\Robotics Laboratory Support\\Submissions\\submissions_v122b.exe - Shortcut.lnk (Version may have changed). +1. Open the app using the shortcut in the Submissions folder: L:\Robotics Laboratory Support\Submissions\Submissions App.lnk. 1. Ignore the large black window of fast scrolling text, it is there for debugging purposes. 2. The 'Submissions' tab should be open by default. + 3. Default settings (config.yml) will be copied to C:\Users\{YOUR USERNAME}\AppData\Local\submissions\config ## Logging in New Run: *should fit 90% of usage cases* @@ -109,3 +110,16 @@ This is meant to import .xslx files created from the Design & Analysis Software 1. Click "Monthly" -> "Link PCR Logs". 2. Chose the .csv file taken from the PCR table runlogs folder. +## SETUP: + +## Download: + +1. Clone or download from github. +2. Enter the downloaded folder. + +## Database: + +1. Copy 'alembic_default.ini' to 'alembic.ini' in the same folder. +2. Open 'alembic.ini' and edit 'sqlalchemy.url' to the desired path of the database. + 1. The path by default is sqlite based. Postgresql support is available. + 2. Postgres path \ No newline at end of file diff --git a/TODO.md b/TODO.md index cb61bbb..21f31ca 100644 --- a/TODO.md +++ b/TODO.md @@ -2,7 +2,7 @@ - The hardest part of this is going to be the sample parsing. I'm onto using the cell formulas in the plate map to suss out the location in the lookup table, but it could get a little recursive up in here. - [ ] Create a default info return function. - [x] Parse comment from excel sheet. -- [ ] Make reporting better. +- [x] Make reporting better. - [x] Build master query method? - Obviously there will need to be extensions, but I feel the attr method I have in Submissions could work. - [x] Fix Artic RSLNamer @@ -23,7 +23,7 @@ - [x] Merge BasicSubmission.find_subclasses and BasicSubmission.find_polymorphic_subclass - [x] Fix updating of Extraction Kit in submission form widget. - [x] Fix cropping of gel image. -- [ ] Create Tips ... *sigh*. +- [x] Create Tips ... *sigh*. - [x] Create platemap image from html for export to pdf. - [x] Move plate map maker to submission. - [x] Finish Equipment Parser (add in regex to id asset_number) diff --git a/alembic/versions/e3f6770ef515_first_commit.py b/alembic/versions/0746f7e2c10e_rebuild_20240723.py similarity index 72% rename from alembic/versions/e3f6770ef515_first_commit.py rename to alembic/versions/0746f7e2c10e_rebuild_20240723.py index b6c6d95..7f7cc63 100644 --- a/alembic/versions/e3f6770ef515_first_commit.py +++ b/alembic/versions/0746f7e2c10e_rebuild_20240723.py @@ -1,8 +1,8 @@ -"""First Commit +"""rebuild 20240723 -Revision ID: e3f6770ef515 +Revision ID: 0746f7e2c10e Revises: -Create Date: 2024-01-22 14:01:02.958292 +Create Date: 2024-07-23 14:08:37.436400 """ from alembic import op @@ -10,7 +10,7 @@ import sqlalchemy as sa # revision identifiers, used by Alembic. -revision = 'e3f6770ef515' +revision = '0746f7e2c10e' down_revision = None branch_labels = None depends_on = None @@ -25,6 +25,12 @@ def upgrade() -> None: sa.PrimaryKeyConstraint('id'), sa.UniqueConstraint('submitter_id') ) + op.create_table('_configitem', + sa.Column('id', sa.INTEGER(), nullable=False), + sa.Column('key', sa.String(length=32), nullable=True), + sa.Column('value', sa.JSON(), nullable=True), + sa.PrimaryKeyConstraint('id') + ) op.create_table('_contact', sa.Column('id', sa.INTEGER(), nullable=False), sa.Column('name', sa.String(length=64), nullable=True), @@ -66,9 +72,10 @@ def upgrade() -> None: op.create_table('_process', sa.Column('id', sa.INTEGER(), nullable=False), sa.Column('name', sa.String(length=64), nullable=True), - sa.PrimaryKeyConstraint('id') + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('name') ) - op.create_table('_reagenttype', + op.create_table('_reagentrole', sa.Column('id', sa.INTEGER(), nullable=False), sa.Column('name', sa.String(length=64), nullable=True), sa.Column('eol_ext', sa.Interval(), nullable=True), @@ -78,10 +85,17 @@ def upgrade() -> None: sa.Column('id', sa.INTEGER(), nullable=False), sa.Column('name', sa.String(length=128), nullable=True), sa.Column('info_map', sa.JSON(), nullable=True), - sa.Column('template_file', sa.BLOB(), nullable=True), + sa.Column('defaults', sa.JSON(), nullable=True), + sa.Column('template_file', sa.LargeBinary(), nullable=True), + sa.Column('sample_map', sa.JSON(), nullable=True), sa.PrimaryKeyConstraint('id'), sa.UniqueConstraint('name') ) + op.create_table('_tiprole', + sa.Column('id', sa.INTEGER(), nullable=False), + sa.Column('name', sa.String(length=64), nullable=True), + sa.PrimaryKeyConstraint('id') + ) op.create_table('_bacterialculturesample', sa.Column('id', sa.INTEGER(), nullable=False), sa.Column('organism', sa.String(length=64), nullable=True), @@ -117,17 +131,17 @@ def upgrade() -> None: sa.ForeignKeyConstraint(['equipmentrole_id'], ['_equipmentrole.id'], ), sa.ForeignKeyConstraint(['process_id'], ['_process.id'], ) ) - op.create_table('_kittypereagenttypeassociation', - sa.Column('reagent_types_id', sa.INTEGER(), nullable=False), + op.create_table('_kittypereagentroleassociation', + sa.Column('reagent_roles_id', sa.INTEGER(), nullable=False), sa.Column('kits_id', sa.INTEGER(), nullable=False), sa.Column('submission_type_id', sa.INTEGER(), nullable=False), sa.Column('uses', sa.JSON(), nullable=True), sa.Column('required', sa.INTEGER(), nullable=True), sa.Column('last_used', sa.String(length=32), nullable=True), sa.ForeignKeyConstraint(['kits_id'], ['_kittype.id'], ), - sa.ForeignKeyConstraint(['reagent_types_id'], ['_reagenttype.id'], ), + sa.ForeignKeyConstraint(['reagent_roles_id'], ['_reagentrole.id'], ), sa.ForeignKeyConstraint(['submission_type_id'], ['_submissiontype.id'], ), - sa.PrimaryKeyConstraint('reagent_types_id', 'kits_id', 'submission_type_id') + sa.PrimaryKeyConstraint('reagent_roles_id', 'kits_id', 'submission_type_id') ) op.create_table('_kittypes_processes', sa.Column('process_id', sa.INTEGER(), nullable=True), @@ -141,13 +155,19 @@ def upgrade() -> None: sa.ForeignKeyConstraint(['contact_id'], ['_contact.id'], ), sa.ForeignKeyConstraint(['org_id'], ['_organization.id'], ) ) + op.create_table('_process_tiprole', + sa.Column('process_id', sa.INTEGER(), nullable=True), + sa.Column('tiprole_id', sa.INTEGER(), nullable=True), + sa.ForeignKeyConstraint(['process_id'], ['_process.id'], ), + sa.ForeignKeyConstraint(['tiprole_id'], ['_tiprole.id'], ) + ) op.create_table('_reagent', sa.Column('id', sa.INTEGER(), nullable=False), - sa.Column('type_id', sa.INTEGER(), nullable=True), + sa.Column('role_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'], ['_reagenttype.id'], name='fk_reagent_type_id', ondelete='SET NULL'), + sa.ForeignKeyConstraint(['role_id'], ['_reagentrole.id'], name='fk_reagent_role_id', ondelete='SET NULL'), sa.PrimaryKeyConstraint('id') ) op.create_table('_submissiontypeequipmentroleassociation', @@ -175,6 +195,23 @@ def upgrade() -> None: sa.ForeignKeyConstraint(['equipmentroles_id'], ['_submissiontype.id'], ), sa.ForeignKeyConstraint(['process_id'], ['_process.id'], ) ) + op.create_table('_submissiontypetiproleassociation', + sa.Column('tiprole_id', sa.INTEGER(), nullable=False), + sa.Column('submissiontype_id', sa.INTEGER(), nullable=False), + sa.Column('uses', sa.JSON(), nullable=True), + sa.Column('static', sa.INTEGER(), nullable=True), + sa.ForeignKeyConstraint(['submissiontype_id'], ['_submissiontype.id'], ), + sa.ForeignKeyConstraint(['tiprole_id'], ['_tiprole.id'], ), + sa.PrimaryKeyConstraint('tiprole_id', 'submissiontype_id') + ) + op.create_table('_tips', + sa.Column('id', sa.INTEGER(), nullable=False), + sa.Column('role_id', sa.INTEGER(), nullable=True), + sa.Column('name', sa.String(length=64), nullable=True), + sa.Column('lot', sa.String(length=64), nullable=True), + sa.ForeignKeyConstraint(['role_id'], ['_tiprole.id'], name='fk_tip_role_id', ondelete='SET NULL'), + sa.PrimaryKeyConstraint('id') + ) op.create_table('_wastewatersample', sa.Column('id', sa.INTEGER(), nullable=False), sa.Column('ww_processing_num', sa.String(length=64), nullable=True), @@ -197,13 +234,15 @@ def upgrade() -> None: sa.Column('extraction_kit_id', sa.INTEGER(), nullable=True), sa.Column('submission_type_name', sa.String(), nullable=True), sa.Column('technician', sa.String(length=64), nullable=True), - sa.Column('reagents_id', sa.String(), nullable=True), + sa.Column('reagents_id', sa.INTEGER(), nullable=True), sa.Column('extraction_info', sa.JSON(), nullable=True), - sa.Column('pcr_info', sa.JSON(), nullable=True), sa.Column('run_cost', sa.FLOAT(precision=2), nullable=True), - sa.Column('uploaded_by', sa.String(length=32), nullable=True), + sa.Column('signed_by', sa.String(length=32), nullable=True), sa.Column('comment', sa.JSON(), nullable=True), sa.Column('submission_category', sa.String(length=64), nullable=True), + sa.Column('cost_centre', sa.String(length=64), nullable=True), + sa.Column('contact_id', sa.INTEGER(), nullable=True), + sa.ForeignKeyConstraint(['contact_id'], ['_contact.id'], name='fk_BS_contact_id', ondelete='SET NULL'), sa.ForeignKeyConstraint(['extraction_kit_id'], ['_kittype.id'], name='fk_BS_extkit_id', ondelete='SET NULL'), sa.ForeignKeyConstraint(['reagents_id'], ['_reagent.id'], name='fk_BS_reagents_id', ondelete='SET NULL'), sa.ForeignKeyConstraint(['submission_type_name'], ['_submissiontype.name'], name='fk_BS_subtype_name', ondelete='SET NULL'), @@ -212,11 +251,23 @@ def upgrade() -> None: sa.UniqueConstraint('rsl_plate_num'), sa.UniqueConstraint('submitter_plate_num') ) - op.create_table('_reagenttypes_reagents', + op.create_table('_equipment_tips', + sa.Column('equipment_id', sa.INTEGER(), nullable=True), + sa.Column('tips_id', sa.INTEGER(), nullable=True), + sa.ForeignKeyConstraint(['equipment_id'], ['_equipment.id'], ), + sa.ForeignKeyConstraint(['tips_id'], ['_tips.id'], ) + ) + op.create_table('_reagentroles_reagents', sa.Column('reagent_id', sa.INTEGER(), nullable=True), - sa.Column('reagenttype_id', sa.INTEGER(), nullable=True), + sa.Column('reagentrole_id', sa.INTEGER(), nullable=True), sa.ForeignKeyConstraint(['reagent_id'], ['_reagent.id'], ), - sa.ForeignKeyConstraint(['reagenttype_id'], ['_reagenttype.id'], ) + sa.ForeignKeyConstraint(['reagentrole_id'], ['_reagentrole.id'], ) + ) + op.create_table('_tiproles_tips', + sa.Column('tiprole_id', sa.INTEGER(), nullable=True), + sa.Column('tips_id', sa.INTEGER(), nullable=True), + sa.ForeignKeyConstraint(['tiprole_id'], ['_tiprole.id'], ), + sa.ForeignKeyConstraint(['tips_id'], ['_tips.id'], ) ) op.create_table('_bacterialculture', sa.Column('id', sa.INTEGER(), nullable=False), @@ -225,7 +276,7 @@ def upgrade() -> None: ) op.create_table('_control', sa.Column('id', sa.INTEGER(), nullable=False), - sa.Column('parent_id', sa.String(), nullable=True), + sa.Column('parent_id', sa.INTEGER(), 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), @@ -264,19 +315,31 @@ def upgrade() -> None: sa.PrimaryKeyConstraint('reagent_id', 'submission_id') ) op.create_table('_submissionsampleassociation', + sa.Column('id', sa.INTEGER(), nullable=False), sa.Column('sample_id', sa.INTEGER(), nullable=False), sa.Column('submission_id', sa.INTEGER(), nullable=False), sa.Column('row', sa.INTEGER(), nullable=False), sa.Column('column', sa.INTEGER(), nullable=False), + sa.Column('submission_rank', sa.INTEGER(), nullable=False), sa.Column('base_sub_type', sa.String(), nullable=True), sa.ForeignKeyConstraint(['sample_id'], ['_basicsample.id'], ), sa.ForeignKeyConstraint(['submission_id'], ['_basicsubmission.id'], ), - sa.PrimaryKeyConstraint('submission_id', 'row', 'column') + sa.PrimaryKeyConstraint('submission_id', 'row', 'column'), + sa.UniqueConstraint('id') + ) + op.create_table('_submissiontipsassociation', + sa.Column('tip_id', sa.INTEGER(), nullable=False), + sa.Column('submission_id', sa.INTEGER(), nullable=False), + sa.Column('role_name', sa.String(length=32), nullable=False), + sa.ForeignKeyConstraint(['submission_id'], ['_basicsubmission.id'], ), + sa.ForeignKeyConstraint(['tip_id'], ['_tips.id'], ), + sa.PrimaryKeyConstraint('tip_id', 'submission_id', 'role_name') ) op.create_table('_wastewater', sa.Column('id', sa.INTEGER(), nullable=False), sa.Column('ext_technician', sa.String(length=64), nullable=True), sa.Column('pcr_technician', sa.String(length=64), nullable=True), + sa.Column('pcr_info', sa.JSON(), nullable=True), sa.ForeignKeyConstraint(['id'], ['_basicsubmission.id'], ), sa.PrimaryKeyConstraint('id') ) @@ -284,20 +347,36 @@ def upgrade() -> None: sa.Column('id', sa.INTEGER(), nullable=False), sa.Column('artic_technician', sa.String(length=64), nullable=True), sa.Column('dna_core_submission_number', sa.String(length=64), nullable=True), + sa.Column('pcr_info', sa.JSON(), nullable=True), + sa.Column('gel_image', sa.String(length=64), nullable=True), + sa.Column('gel_info', sa.JSON(), nullable=True), + sa.Column('gel_controls', sa.JSON(), nullable=True), + sa.Column('source_plates', sa.JSON(), nullable=True), + sa.Column('artic_date', sa.TIMESTAMP(), nullable=True), + sa.Column('ngs_date', sa.TIMESTAMP(), nullable=True), + sa.Column('gel_date', sa.TIMESTAMP(), nullable=True), + sa.Column('gel_barcode', sa.String(length=16), nullable=True), sa.ForeignKeyConstraint(['id'], ['_basicsubmission.id'], ), sa.PrimaryKeyConstraint('id') ) + op.create_table('_wastewaterarticassociation', + sa.Column('id', sa.INTEGER(), nullable=False), + sa.Column('source_plate', sa.String(length=16), nullable=True), + sa.Column('source_plate_number', sa.INTEGER(), nullable=True), + sa.Column('source_well', sa.String(length=8), nullable=True), + sa.Column('ct', sa.String(length=8), nullable=True), + sa.ForeignKeyConstraint(['id'], ['_submissionsampleassociation.id'], ), + sa.PrimaryKeyConstraint('id') + ) op.create_table('_wastewaterassociation', - sa.Column('sample_id', sa.INTEGER(), nullable=False), - sa.Column('submission_id', sa.INTEGER(), nullable=False), + sa.Column('id', sa.INTEGER(), nullable=False), sa.Column('ct_n1', sa.FLOAT(precision=2), nullable=True), sa.Column('ct_n2', sa.FLOAT(precision=2), nullable=True), sa.Column('n1_status', sa.String(length=32), nullable=True), sa.Column('n2_status', sa.String(length=32), nullable=True), sa.Column('pcr_results', sa.JSON(), nullable=True), - sa.ForeignKeyConstraint(['sample_id'], ['_submissionsampleassociation.sample_id'], ), - sa.ForeignKeyConstraint(['submission_id'], ['_submissionsampleassociation.submission_id'], ), - sa.PrimaryKeyConstraint('sample_id', 'submission_id') + sa.ForeignKeyConstraint(['id'], ['_submissionsampleassociation.id'], ), + sa.PrimaryKeyConstraint('id') ) # ### end Alembic commands ### @@ -305,30 +384,38 @@ def upgrade() -> None: def downgrade() -> None: # ### commands auto generated by Alembic - please adjust! ### op.drop_table('_wastewaterassociation') + op.drop_table('_wastewaterarticassociation') op.drop_table('_wastewaterartic') op.drop_table('_wastewater') + op.drop_table('_submissiontipsassociation') op.drop_table('_submissionsampleassociation') op.drop_table('_submissionreagentassociation') op.drop_table('_submissionequipmentassociation') op.drop_table('_control') op.drop_table('_bacterialculture') - op.drop_table('_reagenttypes_reagents') + op.drop_table('_tiproles_tips') + op.drop_table('_reagentroles_reagents') + op.drop_table('_equipment_tips') op.drop_table('_basicsubmission') op.drop_table('_wastewatersample') + op.drop_table('_tips') + op.drop_table('_submissiontypetiproleassociation') op.drop_table('_submissiontypes_processes') op.drop_table('_submissiontypekittypeassociation') op.drop_table('_submissiontypeequipmentroleassociation') op.drop_table('_reagent') + op.drop_table('_process_tiprole') op.drop_table('_orgs_contacts') op.drop_table('_kittypes_processes') - op.drop_table('_kittypereagenttypeassociation') + op.drop_table('_kittypereagentroleassociation') op.drop_table('_equipmentroles_processes') op.drop_table('_equipmentroles_equipment') op.drop_table('_equipment_processes') op.drop_table('_discount') op.drop_table('_bacterialculturesample') + op.drop_table('_tiprole') op.drop_table('_submissiontype') - op.drop_table('_reagenttype') + op.drop_table('_reagentrole') op.drop_table('_process') op.drop_table('_organization') op.drop_table('_kittype') @@ -336,5 +423,6 @@ def downgrade() -> None: op.drop_table('_equipment') op.drop_table('_controltype') op.drop_table('_contact') + op.drop_table('_configitem') op.drop_table('_basicsample') # ### end Alembic commands ### diff --git a/alembic/versions/16b20d4368e6_made_wastewaterarticassociation.py b/alembic/versions/16b20d4368e6_made_wastewaterarticassociation.py deleted file mode 100644 index 3906b80..0000000 --- a/alembic/versions/16b20d4368e6_made_wastewaterarticassociation.py +++ /dev/null @@ -1,54 +0,0 @@ -"""Made WastewaterArticAssociation - -Revision ID: 16b20d4368e6 -Revises: d2b094cfa308 -Create Date: 2024-06-13 12:16:48.385516 - -""" -from alembic import op -import sqlalchemy as sa - - -# revision identifiers, used by Alembic. -revision = '16b20d4368e6' -down_revision = 'd2b094cfa308' -branch_labels = None -depends_on = None - - -def upgrade() -> None: - # ### commands auto generated by Alembic - please adjust! ### - op.create_table('_wastewaterarticassociation', - sa.Column('id', sa.INTEGER(), nullable=False), - sa.Column('source_plate', sa.String(length=16), nullable=True), - sa.Column('source_plate_number', sa.INTEGER(), nullable=True), - sa.Column('source_well', sa.String(length=8), nullable=True), - sa.Column('ct', sa.String(length=8), nullable=True), - sa.ForeignKeyConstraint(['id'], ['_submissionsampleassociation.id'], ), - sa.PrimaryKeyConstraint('id') - ) - # with op.batch_alter_table('_submissionsampleassociation', schema=None) as batch_op: - # batch_op.create_unique_constraint(None, ['id']) - # - # with op.batch_alter_table('_submissiontipsassociation', schema=None) as batch_op: - # batch_op.alter_column('role_name', - # existing_type=sa.INTEGER(), - # type_=sa.String(length=32), - # existing_nullable=True) - - # ### end Alembic commands ### - - -def downgrade() -> None: - # ### commands auto generated by Alembic - please adjust! ### - with op.batch_alter_table('_submissiontipsassociation', schema=None) as batch_op: - batch_op.alter_column('role_name', - existing_type=sa.String(length=32), - type_=sa.INTEGER(), - existing_nullable=True) - - with op.batch_alter_table('_submissionsampleassociation', schema=None) as batch_op: - batch_op.drop_constraint(None, type_='unique') - - op.drop_table('_wastewaterarticassociation') - # ### end Alembic commands ### diff --git a/alembic/versions/6d2a357860ef_adding_cost_centre_storage_to_.py b/alembic/versions/6d2a357860ef_adding_cost_centre_storage_to_.py deleted file mode 100644 index 93d6e66..0000000 --- a/alembic/versions/6d2a357860ef_adding_cost_centre_storage_to_.py +++ /dev/null @@ -1,51 +0,0 @@ -"""adding cost centre storage to basicsubmission - -Revision ID: 6d2a357860ef -Revises: e6647bd661d9 -Create Date: 2024-04-24 13:01:14.923814 - -""" -from alembic import op -import sqlalchemy as sa - - -# revision identifiers, used by Alembic. -revision = '6d2a357860ef' -down_revision = 'e6647bd661d9' -branch_labels = None -depends_on = None - - -def upgrade() -> None: - # ### commands auto generated by Alembic - please adjust! ### - # op.drop_table('_alembic_tmp__submissionsampleassociation') - with op.batch_alter_table('_basicsubmission', schema=None) as batch_op: - batch_op.add_column(sa.Column('cost_centre', sa.String(length=64), nullable=True)) - - # with op.batch_alter_table('_submissionsampleassociation', schema=None) as batch_op: - # batch_op.create_unique_constraint(None, ['id']) - - # ### end Alembic commands ### - - -def downgrade() -> None: - # ### commands auto generated by Alembic - please adjust! ### - # with op.batch_alter_table('_submissionsampleassociation', schema=None) as batch_op: - # batch_op.drop_constraint(None, type_='unique') - - with op.batch_alter_table('_basicsubmission', schema=None) as batch_op: - batch_op.drop_column('used_cost_centre') - - op.create_table('_alembic_tmp__submissionsampleassociation', - sa.Column('sample_id', sa.INTEGER(), nullable=False), - sa.Column('submission_id', sa.INTEGER(), nullable=False), - sa.Column('row', sa.INTEGER(), nullable=False), - sa.Column('column', sa.INTEGER(), nullable=False), - sa.Column('base_sub_type', sa.VARCHAR(), nullable=True), - sa.Column('id', sa.INTEGER(), server_default=sa.text('1'), nullable=False), - sa.ForeignKeyConstraint(['sample_id'], ['_basicsample.id'], ), - sa.ForeignKeyConstraint(['submission_id'], ['_basicsubmission.id'], ), - sa.PrimaryKeyConstraint('submission_id', 'row', 'column'), - sa.UniqueConstraint('id', name='ssa_id') - ) - # ### end Alembic commands ### diff --git a/alembic/versions/70426df72f80_adding_gel_image_info_again.py b/alembic/versions/70426df72f80_adding_gel_image_info_again.py deleted file mode 100644 index 44144b7..0000000 --- a/alembic/versions/70426df72f80_adding_gel_image_info_again.py +++ /dev/null @@ -1,34 +0,0 @@ -"""adding gel image, info. Again - -Revision ID: 70426df72f80 -Revises: c4201b0ea9fe -Create Date: 2024-01-30 08:47:22.809841 - -""" -from alembic import op -import sqlalchemy as sa - - -# revision identifiers, used by Alembic. -revision = '70426df72f80' -down_revision = 'c4201b0ea9fe' -branch_labels = None -depends_on = None - - -def upgrade() -> None: - # ### commands auto generated by Alembic - please adjust! ### - with op.batch_alter_table('_wastewaterartic', schema=None) as batch_op: - batch_op.add_column(sa.Column('gel_image', sa.String(length=64), nullable=True)) - batch_op.add_column(sa.Column('gel_info', sa.JSON(), nullable=True)) - - # ### end Alembic commands ### - - -def downgrade() -> None: - # ### commands auto generated by Alembic - please adjust! ### - with op.batch_alter_table('_wastewaterartic', schema=None) as batch_op: - batch_op.drop_column('gel_info') - batch_op.drop_column('gel_image') - - # ### end Alembic commands ### diff --git a/alembic/versions/70d5a751f579_tweaking_submission_sample_association.py b/alembic/versions/70d5a751f579_tweaking_submission_sample_association.py deleted file mode 100644 index 970f97a..0000000 --- a/alembic/versions/70d5a751f579_tweaking_submission_sample_association.py +++ /dev/null @@ -1,38 +0,0 @@ -"""tweaking submission sample association - -Revision ID: 70d5a751f579 -Revises: 97392dda5436 -Create Date: 2024-01-25 13:39:34.163501 - -""" -from alembic import op -import sqlalchemy as sa - - -# revision identifiers, used by Alembic. -revision = '70d5a751f579' -down_revision = '97392dda5436' -branch_labels = None -depends_on = None - - -def upgrade() -> None: - # ### commands auto generated by Alembic - please adjust! ### - with op.batch_alter_table('_submissionsampleassociation', schema=None) as batch_op: - batch_op.alter_column('id', - existing_type=sa.INTEGER(), - nullable=False) - batch_op.create_unique_constraint("ssa_id_unique", ['id']) - - # ### end Alembic commands ### - - -def downgrade() -> None: - # ### commands auto generated by Alembic - please adjust! ### - with op.batch_alter_table('_submissionsampleassociation', schema=None) as batch_op: - batch_op.drop_constraint("ssa_id_unique", type_='unique') - batch_op.alter_column('id', - existing_type=sa.INTEGER(), - nullable=False) - - # ### end Alembic commands ### diff --git a/alembic/versions/861b52a2004e_adding_fields_to_artic.py b/alembic/versions/861b52a2004e_adding_fields_to_artic.py deleted file mode 100644 index ce1cc4e..0000000 --- a/alembic/versions/861b52a2004e_adding_fields_to_artic.py +++ /dev/null @@ -1,72 +0,0 @@ -"""Adding fields to Artic - -Revision ID: 861b52a2004e -Revises: b744e8a452fd -Create Date: 2024-06-05 13:35:19.012337 - -""" -from alembic import op -import sqlalchemy as sa -from sqlalchemy.dialects import sqlite - -# revision identifiers, used by Alembic. -revision = '861b52a2004e' -down_revision = 'b744e8a452fd' -branch_labels = None -depends_on = None - - -def upgrade() -> None: - # ### commands auto generated by Alembic - please adjust! ### - op.drop_table('_alembic_tmp__basicsubmission') - # with op.batch_alter_table('_submissionsampleassociation', schema=None) as batch_op: - # batch_op.create_unique_constraint(None, ['id']) - - with op.batch_alter_table('_wastewaterartic', schema=None) as batch_op: - batch_op.add_column(sa.Column('artic_date', sa.TIMESTAMP(), nullable=True)) - batch_op.add_column(sa.Column('ngs_date', sa.TIMESTAMP(), nullable=True)) - batch_op.add_column(sa.Column('gel_date', sa.TIMESTAMP(), nullable=True)) - batch_op.add_column(sa.Column('gel_barcode', sa.String(length=16), nullable=True)) - - # ### end Alembic commands ### - - -def downgrade() -> None: - # ### commands auto generated by Alembic - please adjust! ### - with op.batch_alter_table('_wastewaterartic', schema=None) as batch_op: - batch_op.drop_column('gel_barcode') - batch_op.drop_column('gel_date') - batch_op.drop_column('ngs_date') - batch_op.drop_column('artic_date') - - # with op.batch_alter_table('_submissionsampleassociation', schema=None) as batch_op: - # batch_op.drop_constraint(None, type_='unique') - - op.create_table('_alembic_tmp__basicsubmission', - sa.Column('id', sa.INTEGER(), nullable=False), - sa.Column('rsl_plate_num', sa.VARCHAR(length=32), nullable=False), - sa.Column('submitter_plate_num', sa.VARCHAR(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_name', sa.VARCHAR(), nullable=True), - sa.Column('technician', sa.VARCHAR(length=64), nullable=True), - sa.Column('reagents_id', sa.VARCHAR(), nullable=True), - sa.Column('extraction_info', sqlite.JSON(), nullable=True), - sa.Column('run_cost', sa.FLOAT(), nullable=True), - sa.Column('signed_by', sa.VARCHAR(length=32), nullable=True), - sa.Column('comment', sqlite.JSON(), nullable=True), - sa.Column('submission_category', sa.VARCHAR(length=64), nullable=True), - sa.Column('cost_centre', sa.VARCHAR(length=64), nullable=True), - sa.Column('contact_id', sa.INTEGER(), nullable=True), - sa.ForeignKeyConstraint(['contact_id'], ['_contact.id'], ondelete='SET NULL'), - sa.ForeignKeyConstraint(['extraction_kit_id'], ['_kittype.id'], ondelete='SET NULL'), - sa.ForeignKeyConstraint(['reagents_id'], ['_reagent.id'], ondelete='SET NULL'), - sa.ForeignKeyConstraint(['submission_type_name'], ['_submissiontype.name'], ondelete='SET NULL'), - sa.ForeignKeyConstraint(['submitting_lab_id'], ['_organization.id'], ondelete='SET NULL'), - sa.PrimaryKeyConstraint('id'), - sa.UniqueConstraint('rsl_plate_num'), - sa.UniqueConstraint('submitter_plate_num') - ) - # ### end Alembic commands ### diff --git a/alembic/versions/874af342c82c_adding_ranking_to_.py b/alembic/versions/874af342c82c_adding_ranking_to_.py deleted file mode 100644 index 4bb4f29..0000000 --- a/alembic/versions/874af342c82c_adding_ranking_to_.py +++ /dev/null @@ -1,34 +0,0 @@ -"""Adding ranking to SubmissionSampleAssociation - -Revision ID: 874af342c82c -Revises: a04c25fd9138 -Create Date: 2024-05-03 15:08:20.194275 - -""" -from alembic import op -import sqlalchemy as sa - - -# revision identifiers, used by Alembic. -revision = '874af342c82c' -down_revision = 'a04c25fd9138' -branch_labels = None -depends_on = None - - -def upgrade() -> None: - # ### commands auto generated by Alembic - please adjust! ### - with op.batch_alter_table('_submissionsampleassociation', schema=None) as batch_op: - batch_op.add_column(sa.Column('submission_rank', sa.INTEGER(), nullable=False, default=1)) - # batch_op.create_unique_constraint(None, ['id']) - - # ### end Alembic commands ### - - -def downgrade() -> None: - # ### commands auto generated by Alembic - please adjust! ### - with op.batch_alter_table('_submissionsampleassociation', schema=None) as batch_op: - # batch_op.drop_constraint(None, type_='unique') - batch_op.drop_column('submission_rank') - - # ### end Alembic commands ### diff --git a/alembic/versions/97392dda5436_update_to_submissionsampleassociation.py b/alembic/versions/97392dda5436_update_to_submissionsampleassociation.py deleted file mode 100644 index a8844f6..0000000 --- a/alembic/versions/97392dda5436_update_to_submissionsampleassociation.py +++ /dev/null @@ -1,50 +0,0 @@ -"""Update to submissionsampleassociation - -Revision ID: 97392dda5436 -Revises: e3f6770ef515 -Create Date: 2024-01-25 09:10:04.384194 - -""" -from alembic import op -import sqlalchemy as sa - - -# revision identifiers, used by Alembic. -revision = '97392dda5436' -down_revision = 'e3f6770ef515' -branch_labels = None -depends_on = None - - -def upgrade() -> None: - # ### commands auto generated by Alembic - please adjust! ### - with op.batch_alter_table('_submissionsampleassociation', schema=None) as batch_op: - batch_op.add_column(sa.Column('id', sa.INTEGER(), autoincrement=True, nullable=True)) - batch_op.create_unique_constraint("submissionsampleassociation_id", ['id']) - - with op.batch_alter_table('_wastewaterassociation', schema=None) as batch_op: - batch_op.add_column(sa.Column('id', sa.INTEGER(), nullable=False)) - # batch_op.drop_constraint("sample_id", type_='foreignkey') - # batch_op.drop_constraint("submission_id", type_='foreignkey') - batch_op.create_foreign_key("fk_subsampassoc_id", '_submissionsampleassociation', ['id'], ['id']) - # batch_op.drop_column('sample_id') - # batch_op.drop_column('submission_id') - - # ### end Alembic commands ### - - -def downgrade() -> None: - # ### commands auto generated by Alembic - please adjust! ### - with op.batch_alter_table('_wastewaterassociation', schema=None) as batch_op: - batch_op.add_column(sa.Column('submission_id', sa.INTEGER(), nullable=False)) - batch_op.add_column(sa.Column('sample_id', sa.INTEGER(), nullable=False)) - batch_op.drop_constraint(None, type_='foreignkey') - batch_op.create_foreign_key(None, '_submissionsampleassociation', ['submission_id'], ['submission_id']) - batch_op.create_foreign_key(None, '_submissionsampleassociation', ['sample_id'], ['sample_id']) - batch_op.drop_column('id') - - with op.batch_alter_table('_submissionsampleassociation', schema=None) as batch_op: - batch_op.drop_constraint(None, type_='unique') - batch_op.drop_column('id') - - # ### end Alembic commands ### diff --git a/alembic/versions/a04c25fd9138_splitting_off_sample_info_in_.py b/alembic/versions/a04c25fd9138_splitting_off_sample_info_in_.py deleted file mode 100644 index 30aac89..0000000 --- a/alembic/versions/a04c25fd9138_splitting_off_sample_info_in_.py +++ /dev/null @@ -1,38 +0,0 @@ -"""splitting off sample info in SubmissionType - -Revision ID: a04c25fd9138 -Revises: 6d2a357860ef -Create Date: 2024-05-01 09:11:44.957532 - -""" -from alembic import op -import sqlalchemy as sa - - -# revision identifiers, used by Alembic. -revision = 'a04c25fd9138' -down_revision = '6d2a357860ef' -branch_labels = None -depends_on = None - - -def upgrade() -> None: - # ### commands auto generated by Alembic - please adjust! ### - # with op.batch_alter_table('_submissionsampleassociation', schema=None) as batch_op: - # batch_op.create_unique_constraint(None, ['id']) - - with op.batch_alter_table('_submissiontype', schema=None) as batch_op: - batch_op.add_column(sa.Column('sample_map', sa.JSON(), nullable=True)) - - # ### end Alembic commands ### - - -def downgrade() -> None: - # ### commands auto generated by Alembic - please adjust! ### - with op.batch_alter_table('_submissiontype', schema=None) as batch_op: - batch_op.drop_column('sample_map') - - # with op.batch_alter_table('_submissionsampleassociation', schema=None) as batch_op: - # batch_op.drop_constraint(None, type_='unique') - - # ### end Alembic commands ### diff --git a/alembic/versions/b744e8a452fd_attaching_contact_to_submission.py b/alembic/versions/b744e8a452fd_attaching_contact_to_submission.py deleted file mode 100644 index bd4850d..0000000 --- a/alembic/versions/b744e8a452fd_attaching_contact_to_submission.py +++ /dev/null @@ -1,40 +0,0 @@ -"""Attaching contact to submission - -Revision ID: b744e8a452fd -Revises: f829a8ab292f -Create Date: 2024-06-04 14:21:38.163431 - -""" -from alembic import op -import sqlalchemy as sa - - -# revision identifiers, used by Alembic. -revision = 'b744e8a452fd' -down_revision = 'f829a8ab292f' -branch_labels = None -depends_on = None - - -def upgrade() -> None: - # ### commands auto generated by Alembic - please adjust! ### - with op.batch_alter_table('_basicsubmission', schema=None) as batch_op: - batch_op.add_column(sa.Column('contact_id', sa.INTEGER(), nullable=True)) - batch_op.create_foreign_key('fk_BS_contact_id', '_contact', ['contact_id'], ['id'], ondelete='SET NULL') - - # with op.batch_alter_table('_submissionsampleassociation', schema=None) as batch_op: - # batch_op.create_unique_constraint(None, ['id']) - - # ### end Alembic commands ### - - -def downgrade() -> None: - # ### commands auto generated by Alembic - please adjust! ### - # with op.batch_alter_table('_submissionsampleassociation', schema=None) as batch_op: - # batch_op.drop_constraint(None, type_='unique') - - with op.batch_alter_table('_basicsubmission', schema=None) as batch_op: - batch_op.drop_constraint('fk_BS_contact_id', type_='foreignkey') - batch_op.drop_column('contact_id') - - # ### end Alembic commands ### diff --git a/alembic/versions/c4201b0ea9fe_adding_gel_image_info.py b/alembic/versions/c4201b0ea9fe_adding_gel_image_info.py deleted file mode 100644 index 7c3cd53..0000000 --- a/alembic/versions/c4201b0ea9fe_adding_gel_image_info.py +++ /dev/null @@ -1,42 +0,0 @@ -"""adding gel image, info - -Revision ID: c4201b0ea9fe -Revises: 70d5a751f579 -Create Date: 2024-01-30 08:42:03.928933 - -""" -from alembic import op -import sqlalchemy as sa - - -# revision identifiers, used by Alembic. -revision = 'c4201b0ea9fe' -down_revision = '70d5a751f579' -branch_labels = None -depends_on = None - - -def upgrade() -> None: - # ### commands auto generated by Alembic - please adjust! ### - with op.batch_alter_table('_submissionsampleassociation', schema=None) as batch_op: - batch_op.create_unique_constraint("unique_ssa_id", ['id']) - - with op.batch_alter_table('_wastewaterassociation', schema=None) as batch_op: - batch_op.alter_column('id', - existing_type=sa.INTEGER(), - nullable=False) - - # ### end Alembic commands ### - - -def downgrade() -> None: - # ### commands auto generated by Alembic - please adjust! ### - with op.batch_alter_table('_wastewaterassociation', schema=None) as batch_op: - batch_op.alter_column('id', - existing_type=sa.INTEGER(), - nullable=True) - - with op.batch_alter_table('_submissionsampleassociation', schema=None) as batch_op: - batch_op.drop_constraint("unique_ssa_id", type_='unique') - - # ### end Alembic commands ### diff --git a/alembic/versions/d2b094cfa308_adding_tips.py b/alembic/versions/d2b094cfa308_adding_tips.py deleted file mode 100644 index 94e1e7a..0000000 --- a/alembic/versions/d2b094cfa308_adding_tips.py +++ /dev/null @@ -1,95 +0,0 @@ -"""Adding tips - -Revision ID: d2b094cfa308 -Revises: 861b52a2004e -Create Date: 2024-06-11 13:16:57.319769 - -""" -from alembic import op -import sqlalchemy as sa - - -# revision identifiers, used by Alembic. -revision = 'd2b094cfa308' -down_revision = '861b52a2004e' -branch_labels = None -depends_on = None - - -def upgrade() -> None: - # ### commands auto generated by Alembic - please adjust! ### - op.create_table('_tiprole', - sa.Column('id', sa.INTEGER(), nullable=False), - sa.Column('name', sa.String(length=64), nullable=True), - sa.PrimaryKeyConstraint('id') - ) - op.create_table('_process_tiprole', - sa.Column('process_id', sa.INTEGER(), nullable=True), - sa.Column('tiprole_id', sa.INTEGER(), nullable=True), - sa.ForeignKeyConstraint(['process_id'], ['_process.id'], ), - sa.ForeignKeyConstraint(['tiprole_id'], ['_tiprole.id'], ) - ) - op.create_table('_submissiontypetiproleassociation', - sa.Column('tiprole_id', sa.INTEGER(), nullable=False), - sa.Column('submissiontype_id', sa.INTEGER(), nullable=False), - sa.Column('uses', sa.JSON(), nullable=True), - sa.Column('static', sa.INTEGER(), nullable=True), - sa.ForeignKeyConstraint(['submissiontype_id'], ['_submissiontype.id'], ), - sa.ForeignKeyConstraint(['tiprole_id'], ['_tiprole.id'], ), - sa.PrimaryKeyConstraint('tiprole_id', 'submissiontype_id') - ) - op.create_table('_tips', - sa.Column('id', sa.INTEGER(), nullable=False), - sa.Column('role_id', sa.INTEGER(), nullable=True), - sa.Column('name', sa.String(length=64), nullable=True), - sa.Column('lot', sa.String(length=64), nullable=True), - sa.ForeignKeyConstraint(['role_id'], ['_tiprole.id'], name='fk_tip_role_id', ondelete='SET NULL'), - sa.PrimaryKeyConstraint('id') - ) - op.create_table('_equipment_tips', - sa.Column('equipment_id', sa.INTEGER(), nullable=True), - sa.Column('tips_id', sa.INTEGER(), nullable=True), - sa.ForeignKeyConstraint(['equipment_id'], ['_equipment.id'], ), - sa.ForeignKeyConstraint(['tips_id'], ['_tips.id'], ) - ) - op.create_table('_submissiontipsassociation', - sa.Column('tip_id', sa.INTEGER(), nullable=False), - sa.Column('submission_id', sa.INTEGER(), nullable=False), - sa.Column('role_name', sa.String(), nullable=False), - # sa.ForeignKeyConstraint(['role_name'], ['_tiprole.name'], ), - sa.ForeignKeyConstraint(['submission_id'], ['_basicsubmission.id'], ), - sa.ForeignKeyConstraint(['tip_id'], ['_tips.id'], ), - sa.PrimaryKeyConstraint('tip_id', 'submission_id', 'role_name') - ) - op.create_table('_tiproles_tips', - sa.Column('tiprole_id', sa.INTEGER(), nullable=True), - sa.Column('tips_id', sa.INTEGER(), nullable=True), - sa.ForeignKeyConstraint(['tiprole_id'], ['_tiprole.id'], ), - sa.ForeignKeyConstraint(['tips_id'], ['_tips.id'], ) - ) - # op.create_table('_submissions_tips', - # sa.Column('submission_id', sa.INTEGER(), nullable=True), - # sa.Column('tips_id', sa.INTEGER(), nullable=True), - # sa.ForeignKeyConstraint(['submission_id'], ['_basicsubmission.id'], ), - # sa.ForeignKeyConstraint(['tips_id'], ['_tips.id'], ) - # ) - # with op.batch_alter_table('_submissionsampleassociation', schema=None) as batch_op: - # batch_op.create_unique_constraint("unique_ssa_id", ['id']) - - # ### end Alembic commands ### - - -def downgrade() -> None: - # ### commands auto generated by Alembic - please adjust! ### - # with op.batch_alter_table('_submissionsampleassociation', schema=None) as batch_op: - # batch_op.drop_constraint(None, type_='unique') - - op.drop_table('_submissions_tips') - op.drop_table('_tiproles_tips') - op.drop_table('_submissiontipassociation') - op.drop_table('_equipment_tips') - op.drop_table('_tips') - op.drop_table('_submissiontypetiproleassociation') - op.drop_table('_process_tiprole') - op.drop_table('_tiprole') - # ### end Alembic commands ### diff --git a/alembic/versions/e6647bd661d9_adding_default_info_to_submissiontype.py b/alembic/versions/e6647bd661d9_adding_default_info_to_submissiontype.py deleted file mode 100644 index 1bd14f5..0000000 --- a/alembic/versions/e6647bd661d9_adding_default_info_to_submissiontype.py +++ /dev/null @@ -1,33 +0,0 @@ -"""adding default info to submissiontype - -Revision ID: e6647bd661d9 -Revises: f18487b41f45 -Create Date: 2024-04-22 12:02:21.512781 - -""" -from alembic import op -import sqlalchemy as sa - - -# revision identifiers, used by Alembic. -revision = 'e6647bd661d9' -down_revision = 'f18487b41f45' -branch_labels = None -depends_on = None - - -def upgrade() -> None: - # ### commands auto generated by Alembic - please adjust! ### - with op.batch_alter_table('_submissiontype', schema=None) as batch_op: - batch_op.add_column(sa.Column('defaults', sa.JSON(), nullable=True)) - - # ### end Alembic commands ### - - -def downgrade() -> None: - # ### commands auto generated by Alembic - please adjust! ### - with op.batch_alter_table('_submissiontype', schema=None) as batch_op: - batch_op.drop_column('defaults') - - - # ### end Alembic commands ### diff --git a/alembic/versions/f18487b41f45_adding_source_plates_to_artic_.py b/alembic/versions/f18487b41f45_adding_source_plates_to_artic_.py deleted file mode 100644 index e76f95d..0000000 --- a/alembic/versions/f18487b41f45_adding_source_plates_to_artic_.py +++ /dev/null @@ -1,51 +0,0 @@ -"""adding source plates to Artic submission...again - -Revision ID: f18487b41f45 -Revises: fabf697c721d -Create Date: 2024-04-17 10:42:30.508213 - -""" -from alembic import op -import sqlalchemy as sa - - -# revision identifiers, used by Alembic. -revision = 'f18487b41f45' -down_revision = 'fabf697c721d' -branch_labels = None -depends_on = None - - -def upgrade() -> None: - # ### commands auto generated by Alembic - please adjust! ### - # op.drop_table('_alembic_tmp__submissionsampleassociation') - # with op.batch_alter_table('_submissionsampleassociation', schema=None) as batch_op: - # batch_op.create_unique_constraint("ssa_id", ['id']) - - with op.batch_alter_table('_wastewaterartic', schema=None) as batch_op: - batch_op.add_column(sa.Column('source_plates', sa.JSON(), nullable=True)) - - # ### end Alembic commands ### - - -def downgrade() -> None: - # ### commands auto generated by Alembic - please adjust! ### - with op.batch_alter_table('_wastewaterartic', schema=None) as batch_op: - batch_op.drop_column('source_plates') - - # with op.batch_alter_table('_submissionsampleassociation', schema=None) as batch_op: - # batch_op.drop_constraint(None, type_='unique') - - # op.create_table('_alembic_tmp__submissionsampleassociation', - # sa.Column('sample_id', sa.INTEGER(), nullable=False), - # sa.Column('submission_id', sa.INTEGER(), nullable=False), - # sa.Column('row', sa.INTEGER(), nullable=False), - # sa.Column('column', sa.INTEGER(), nullable=False), - # sa.Column('base_sub_type', sa.VARCHAR(), nullable=True), - # sa.Column('id', sa.INTEGER(), server_default=sa.text('1'), nullable=False), - # sa.ForeignKeyConstraint(['sample_id'], ['_basicsample.id'], ), - # sa.ForeignKeyConstraint(['submission_id'], ['_basicsubmission.id'], ), - # sa.PrimaryKeyConstraint('submission_id', 'row', 'column'), - # sa.UniqueConstraint('id', name='ssa_unique') - # ) - # ### end Alembic commands ### diff --git a/alembic/versions/f829a8ab292f_adding_configitems_to_db.py b/alembic/versions/f829a8ab292f_adding_configitems_to_db.py deleted file mode 100644 index 638e1f8..0000000 --- a/alembic/versions/f829a8ab292f_adding_configitems_to_db.py +++ /dev/null @@ -1,39 +0,0 @@ -"""Adding configitems to db - -Revision ID: f829a8ab292f -Revises: 874af342c82c -Create Date: 2024-05-15 14:03:11.767480 - -""" -from alembic import op -import sqlalchemy as sa - - -# revision identifiers, used by Alembic. -revision = 'f829a8ab292f' -down_revision = '874af342c82c' -branch_labels = None -depends_on = None - - -def upgrade() -> None: - # ### commands auto generated by Alembic - please adjust! ### - op.create_table('_configitem', - sa.Column('id', sa.INTEGER(), nullable=False), - sa.Column('key', sa.String(), nullable=True), - sa.Column('value', sa.JSON(), nullable=True), - sa.PrimaryKeyConstraint('id') - ) - # with op.batch_alter_table('_submissionsampleassociation', schema=None) as batch_op: - # batch_op.create_unique_constraint(None, ['id']) - - # ### end Alembic commands ### - - -def downgrade() -> None: - # ### commands auto generated by Alembic - please adjust! ### - # with op.batch_alter_table('_submissionsampleassociation', schema=None) as batch_op: - # batch_op.drop_constraint(None, type_='unique') - - op.drop_table('_configitem') - # ### end Alembic commands ### diff --git a/alembic/versions/fabf697c721d_adding_source_plates_to_artic_submission.py b/alembic/versions/fabf697c721d_adding_source_plates_to_artic_submission.py deleted file mode 100644 index c52320c..0000000 --- a/alembic/versions/fabf697c721d_adding_source_plates_to_artic_submission.py +++ /dev/null @@ -1,48 +0,0 @@ -"""adding source plates to Artic submission - -Revision ID: fabf697c721d -Revises: 70426df72f80 -Create Date: 2024-03-06 11:01:34.794411 - -""" -from alembic import op -import sqlalchemy as sa - - -# revision identifiers, used by Alembic. -revision = 'fabf697c721d' -down_revision = '70426df72f80' -branch_labels = None -depends_on = None - - -def upgrade() -> None: - # ### commands auto generated by Alembic - please adjust! ### - # with op.batch_alter_table('_submissionsampleassociation', schema=None) as batch_op: - # batch_op.create_unique_constraint("ssa_unique", ['id']) - - with op.batch_alter_table('_wastewaterartic', schema=None) as batch_op: - batch_op.add_column(sa.Column('source_plates', sa.JSON(), nullable=True)) - - with op.batch_alter_table('_wastewaterassociation', schema=None) as batch_op: - batch_op.alter_column('id', - existing_type=sa.INTEGER(), - nullable=False) - - # ### end Alembic commands ### - - -def downgrade() -> None: - # ### commands auto generated by Alembic - please adjust! ### - with op.batch_alter_table('_wastewaterassociation', schema=None) as batch_op: - batch_op.alter_column('id', - existing_type=sa.INTEGER(), - nullable=True) - - with op.batch_alter_table('_wastewaterartic', schema=None) as batch_op: - batch_op.drop_column('source_plates') - - # with op.batch_alter_table('_submissionsampleassociation', schema=None) as batch_op: - # batch_op.drop_constraint("ssa_unique", type_='unique') - - # ### end Alembic commands ### diff --git a/alembic_default.ini b/alembic_default.ini new file mode 100644 index 0000000..8446f8e --- /dev/null +++ b/alembic_default.ini @@ -0,0 +1,104 @@ +# 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:///L:\Robotics Laboratory Support\Submissions\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/src/submissions/backend/db/__init__.py b/src/submissions/backend/db/__init__.py index 280f15f..de6858c 100644 --- a/src/submissions/backend/db/__init__.py +++ b/src/submissions/backend/db/__init__.py @@ -17,7 +17,7 @@ def set_sqlite_pragma(dbapi_connection, connection_record): connection_record (_type_): _description_ """ cursor = dbapi_connection.cursor() - cursor.execute("PRAGMA foreign_keys=ON") + # cursor.execute("PRAGMA foreign_keys=ON") cursor.close() diff --git a/src/submissions/backend/db/models/__init__.py b/src/submissions/backend/db/models/__init__.py index f054ff1..ddd3cf7 100644 --- a/src/submissions/backend/db/models/__init__.py +++ b/src/submissions/backend/db/models/__init__.py @@ -3,12 +3,13 @@ Contains all models for sqlalchemy """ from __future__ import annotations import sys, logging -from sqlalchemy import Column, INTEGER, String, JSON +from sqlalchemy import Column, INTEGER, String, JSON, inspect from sqlalchemy.orm import DeclarativeMeta, declarative_base, Query, Session from sqlalchemy.ext.declarative import declared_attr from sqlalchemy.exc import ArgumentError from typing import Any, List from pathlib import Path +from tools import report_result # Load testing environment if 'pytest' in sys.modules: @@ -90,7 +91,7 @@ class BaseClass(Base): Returns: dict | list | str: Output of key:value dict or single (list, str) desired variable - """ + """ dicto = dict(singles=['id']) output = {} for k, v in dicto.items(): @@ -110,7 +111,7 @@ class BaseClass(Base): Returns: Any | List[Any]: Result of query execution. - """ + """ return cls.execute_query(**kwargs) @classmethod @@ -152,38 +153,43 @@ class BaseClass(Base): case _: return query.limit(limit).all() + @report_result def save(self): """ Add the object to the database and commit """ # logger.debug(f"Saving object: {pformat(self.__dict__)}") + report = Report() try: self.__database_session__.add(self) self.__database_session__.commit() + # self.__database_session__.merge(self) except Exception as e: logger.critical(f"Problem saving object: {e}") self.__database_session__.rollback() + report.add_result(Result(msg=f"Problem saving object {e}", status="Critical")) + return report class ConfigItem(BaseClass): """ Key:JSON objects to store config settings in database. - """ + """ id = Column(INTEGER, primary_key=True) - key = Column(String(32)) #: Name of the configuration item. - value = Column(JSON) #: Value associated with the config item. + key = Column(String(32)) #: Name of the configuration item. + value = Column(JSON) #: Value associated with the config item. def __repr__(self): return f"ConfigItem({self.key} : {self.value})" @classmethod - def get_config_items(cls, *args) -> ConfigItem|List[ConfigItem]: + def get_config_items(cls, *args) -> ConfigItem | List[ConfigItem]: """ Get desired config items from database Returns: ConfigItem|List[ConfigItem]: Config item(s) - """ + """ config_items = cls.__database_session__.query(cls).all() config_items = [item for item in config_items if item.key in args] if len(args) == 1: @@ -196,4 +202,5 @@ from .controls import * from .organizations import * from .kits import * from .submissions import * + BasicSubmission.reagents.creator = lambda reg: SubmissionReagentAssociation(reagent=reg) diff --git a/src/submissions/backend/db/models/kits.py b/src/submissions/backend/db/models/kits.py index aaa1439..8d9ae06 100644 --- a/src/submissions/backend/db/models/kits.py +++ b/src/submissions/backend/db/models/kits.py @@ -1532,7 +1532,7 @@ class Process(BaseClass): query = cls.__database_session__.query(cls) match name: case str(): - logger.debug(f"Lookup Process with name str {name}") + # logger.debug(f"Lookup Process with name str {name}") query = query.filter(cls.name == name) limit = 1 case _: diff --git a/src/submissions/backend/db/models/submissions.py b/src/submissions/backend/db/models/submissions.py index d1788c9..9c01e4e 100644 --- a/src/submissions/backend/db/models/submissions.py +++ b/src/submissions/backend/db/models/submissions.py @@ -23,7 +23,7 @@ import pandas as pd from openpyxl import Workbook from openpyxl.worksheet.worksheet import Worksheet from openpyxl.drawing.image import Image as OpenpyxlImage -from tools import row_map, setup_lookup, jinja_template_loading, rreplace, row_keys, check_key_or_attr +from tools import row_map, setup_lookup, jinja_template_loading, rreplace, row_keys, check_key_or_attr, Result, Report from datetime import datetime, date from typing import List, Any, Tuple, Literal from dateutil.parser import parse @@ -691,7 +691,8 @@ class BasicSubmission(BaseClass): Args: input_dict (dict): Input sample dictionary - xl (pd.ExcelFile): original xl workbook, used for child classes mostly + xl (Workbook): original xl workbook, used for child classes mostly + custom_fields: Dictionary of locations, ranges, etc to be used by this function Returns: dict: Updated sample dictionary @@ -739,6 +740,7 @@ class BasicSubmission(BaseClass): input_excel (Workbook): initial workbook. info (dict | None, optional): dictionary of additional info. Defaults to None. backup (bool, optional): Whether this is part of a backup operation. Defaults to False. + custom_fields: Dictionary of locations, ranges, etc to be used by this function Returns: Workbook: Updated workbook @@ -1046,14 +1048,16 @@ class BasicSubmission(BaseClass): """ code = 0 msg = "" + report = Report() disallowed = ["id"] if kwargs == {}: raise ValueError("Need to narrow down query or the first available instance will be returned.") - for key in kwargs.keys(): - if key in disallowed: - raise ValueError( - f"{key} is not allowed as a query argument as it could lead to creation of duplicate objects. Use .query() instead.") - instance = cls.query(submission_type=submission_type, limit=1, **kwargs) + sanitized_kwargs = {k: v for k, v in kwargs.items() if k not in disallowed} + # for key in kwargs.keys(): + # if key in disallowed: + # raise ValueError( + # f"{key} is not allowed as a query argument as it could lead to creation of duplicate objects. Use .query() instead.") + instance = cls.query(submission_type=submission_type, limit=1, **sanitized_kwargs) # logger.debug(f"Retrieved instance: {instance}") if instance is None: used_class = cls.find_polymorphic_subclass(attrs=kwargs, polymorphic_identity=submission_type) @@ -1070,7 +1074,8 @@ class BasicSubmission(BaseClass): else: code = 1 msg = "This submission already exists.\nWould you like to overwrite?" - return instance, code, msg + report.add_result(Result(msg=msg, code=code)) + return instance, report # Custom context events for the ui @@ -1135,7 +1140,7 @@ class BasicSubmission(BaseClass): # logger.debug(widg) widg.setParent(None) pyd = self.to_pydantic(backup=True) - form = pyd.to_form(parent=obj) + form = pyd.to_form(parent=obj, disable=['rsl_plate_num']) obj.app.table_widget.formwidget.layout().addWidget(form) def add_comment(self, obj): @@ -1352,13 +1357,29 @@ class Wastewater(BasicSubmission): Args: input_dict (dict): Input sample dictionary + xl (Workbook): xl (Workbook): original xl workbook, used for child classes mostly. + custom_fields: Dictionary of locations, ranges, etc to be used by this function Returns: dict: Updated sample dictionary """ input_dict = super().custom_info_parser(input_dict) + logger.debug(f"Input dict: {pformat(input_dict)}") if xl is not None: - input_dict['csv'] = xl["Copy to import file"] + try: + input_dict['csv'] = xl["Copy to import file"] + except KeyError as e: + logger.error(e) + try: + match input_dict['rsl_plate_num']: + case dict(): + input_dict['csv'] = xl[input_dict['rsl_plate_num']['value']] + case str(): + input_dict['csv'] = xl[input_dict['rsl_plate_num']] + case _: + pass + except Exception as e: + logger.error(f"Error handling couldn't get csv due to: {e}") return input_dict @classmethod @@ -1604,11 +1625,12 @@ class WastewaterArtic(BasicSubmission): Args: input_dict (dict): Input sample dictionary xl (pd.ExcelFile): original xl workbook, used for child classes mostly + custom_fields: Dictionary of locations, ranges, etc to be used by this function Returns: dict: Updated sample dictionary """ - # TODO: Clean up and move range start/stops to db somehow. + from backend.validators import RSLNamer input_dict = super().custom_info_parser(input_dict) egel_section = custom_fields['egel_results'] ws = xl[egel_section['sheet']] @@ -1621,12 +1643,11 @@ class WastewaterArtic(BasicSubmission): source_plates_section = custom_fields['source_plates'] ws = xl[source_plates_section['sheet']] data = [dict(plate=ws.cell(row=ii, column=source_plates_section['plate_column']).value, starting_sample=ws.cell(row=ii, column=source_plates_section['starting_sample_column']).value) for ii in - range(source_plates_section['start_row'], source_plates_section['end_row'])] + range(source_plates_section['start_row'], source_plates_section['end_row']+1)] for datum in data: if datum['plate'] in ["None", None, ""]: continue else: - from backend.validators import RSLNamer datum['plate'] = RSLNamer(filename=datum['plate'], sub_type="Wastewater").parsed_name input_dict['source_plates'] = data return input_dict @@ -1820,6 +1841,7 @@ class WastewaterArtic(BasicSubmission): input_excel (Workbook): initial workbook. info (dict | None, optional): dictionary of additional info. Defaults to None. backup (bool, optional): Whether this is part of a backup operation. Defaults to False. + custom_fields: Dictionary of locations, ranges, etc to be used by this function Returns: Workbook: Updated workbook @@ -2798,7 +2820,7 @@ class WastewaterArticAssociation(SubmissionSampleAssociation): DOC: https://docs.sqlalchemy.org/en/14/orm/extensions/associationproxy.html """ id = Column(INTEGER, ForeignKey("_submissionsampleassociation.id"), primary_key=True) - source_plate = Column(String(16)) + source_plate = Column(String(32)) source_plate_number = Column(INTEGER) source_well = Column(String(8)) ct = Column(String(8)) #: AKA ct for N1 diff --git a/src/submissions/backend/validators/pydant.py b/src/submissions/backend/validators/pydant.py index f4c9fb7..fcdd9ce 100644 --- a/src/submissions/backend/validators/pydant.py +++ b/src/submissions/backend/validators/pydant.py @@ -108,7 +108,7 @@ class PydReagent(BaseModel): Returns: dict: Information dictionary - """ + """ try: extras = list(self.model_extra.keys()) except AttributeError: @@ -161,7 +161,7 @@ class PydReagent(BaseModel): # reagent.reagent_submission_associations.append(assoc) else: assoc = None - report.add_result(Result(owner = __name__, code=0, msg="New reagent created.", status="Information")) + report.add_result(Result(owner=__name__, code=0, msg="New reagent created.", status="Information")) else: if submission is not None and reagent not in submission.reagents: assoc = SubmissionReagentAssociation(reagent=reagent, submission=submission) @@ -217,7 +217,7 @@ class PydSample(BaseModel, extra='allow'): Returns: dict: Information dictionary - """ + """ fields = list(self.model_fields.keys()) + list(self.model_extra.keys()) return {k: getattr(self, k) for k in fields} @@ -254,7 +254,8 @@ class PydSample(BaseModel, extra='allow'): submission=submission, sample=instance, row=row, column=column, id=aid, - submission_rank=submission_rank, **self.model_extra) + submission_rank=submission_rank, + **self.model_extra) # logger.debug(f"Using submission_sample_association: {association}") try: # instance.sample_submission_associations.append(association) @@ -270,7 +271,7 @@ class PydSample(BaseModel, extra='allow'): Returns: dict: Information dictionary - """ + """ try: extras = list(self.model_extra.keys()) except AttributeError: @@ -281,10 +282,10 @@ class PydSample(BaseModel, extra='allow'): class PydTips(BaseModel): name: str - lot: str|None = Field(default=None) + lot: str | None = Field(default=None) role: str - def to_sql(self, submission:BasicSubmission) -> SubmissionTipsAssociation: + def to_sql(self, submission: BasicSubmission) -> SubmissionTipsAssociation: """ Con @@ -293,7 +294,7 @@ class PydTips(BaseModel): Returns: SubmissionTipsAssociation: Association between queried tips and submission - """ + """ tips = Tips.query(name=self.name, lot=self.lot, limit=1) assoc = SubmissionTipsAssociation(submission=submission, tips=tips, role_name=self.role) return assoc @@ -305,7 +306,7 @@ class PydEquipment(BaseModel, extra='ignore'): nickname: str | None processes: List[str] | None role: str | None - tips: List[PydTips]|None = Field(default=None) + tips: List[PydTips] | None = Field(default=None) @field_validator('processes', mode='before') @classmethod @@ -338,23 +339,19 @@ class PydEquipment(BaseModel, extra='ignore'): if equipment is None: return if submission is not None: - assoc = SubmissionEquipmentAssociation(submission=submission, equipment=equipment) - process = Process.query(name=self.processes[0]) - if process is None: - logger.error(f"Found unknown process: {process}.") - # from frontend.widgets.pop_ups import QuestionAsker - # dlg = QuestionAsker(title="Add Process?", - # message=f"Unable to find {self.processes[0]} in the database.\nWould you like to add it?") - # if dlg.exec(): - # kit = submission.extraction_kit - # submission_type = submission.submission_type - # process = Process(name=self.processes[0]) - # process.kit_types.append(kit) - # process.submission_types.append(submission_type) - # process.equipment.append(equipment) - # process.save() - assoc.process = process - assoc.role = self.role + # NOTE: Need to make sure the same association is not added to the submission + + assoc = SubmissionEquipmentAssociation.query(equipment_id=equipment.id, submission_id=submission.id, + role=self.role, limit=1) + if assoc is None: + assoc = SubmissionEquipmentAssociation(submission=submission, equipment=equipment) + process = Process.query(name=self.processes[0]) + if process is None: + logger.error(f"Found unknown process: {process}.") + assoc.process = process + assoc.role = self.role + else: + assoc = None else: assoc = None return equipment, assoc @@ -365,7 +362,7 @@ class PydEquipment(BaseModel, extra='ignore'): Returns: dict: Information dictionary - """ + """ try: extras = list(self.model_extra.keys()) except AttributeError: @@ -441,7 +438,7 @@ class PydSubmission(BaseModel, extra='allow'): return value.date() case int(): return dict(value=datetime.fromordinal(datetime(1900, 1, 1).toordinal() + value['value'] - 2).date(), - missing=True) + missing=True) case str(): string = re.sub(r"(_|-)\d$", "", value['value']) try: @@ -508,7 +505,7 @@ class PydSubmission(BaseModel, extra='allow'): output = "RSL-BS-Test001" else: output = RSLNamer(filename=values.data['filepath'].__str__(), sub_type=sub_type, - data=values.data).parsed_name + data=values.data).parsed_name return dict(value=output, missing=True) @field_validator("technician", mode="before") @@ -637,14 +634,14 @@ class PydSubmission(BaseModel, extra='allow'): self.submission_object = BasicSubmission.find_polymorphic_subclass( polymorphic_identity=self.submission_type['value']) - def set_attribute(self, key:str, value): + def set_attribute(self, key: str, value): """ Better handling of attribute setting. Args: key (str): Name of field to set value (_type_): Value to set field to. - """ + """ self.__setattr__(name=key, value=value) def handle_duplicate_samples(self): @@ -710,7 +707,7 @@ class PydSubmission(BaseModel, extra='allow'): missing_reagents = [reagent for reagent in self.reagents if reagent.missing] return missing_info, missing_reagents - def to_sql(self) -> Tuple[BasicSubmission, Result]: + def to_sql(self) -> Tuple[BasicSubmission, Report]: """ Converts this instance into a backend.db.models.submissions.BasicSubmission instance @@ -718,13 +715,13 @@ class PydSubmission(BaseModel, extra='allow'): Tuple[BasicSubmission, Result]: BasicSubmission instance, result object """ # self.__dict__.update(self.model_extra) + report = Report() dicto = self.improved_dict() - instance, code, msg = BasicSubmission.query_or_create(submission_type=self.submission_type['value'], - rsl_plate_num=self.rsl_plate_num['value']) - result = Result(msg=msg, code=code) + instance, result = BasicSubmission.query_or_create(submission_type=self.submission_type['value'], + rsl_plate_num=self.rsl_plate_num['value']) + report.add_result(result) self.handle_duplicate_samples() # logger.debug(f"Here's our list of duplicate removed samples: {self.samples}") - # for key, value in self.__dict__.items(): for key, value in dicto.items(): if isinstance(value, dict): value = value['value'] @@ -733,13 +730,13 @@ class PydSubmission(BaseModel, extra='allow'): # logger.debug(f"Setting {key} to {value}") match key: case "reagents": - if code == 1: + if report.results[0].code == 1: instance.submission_reagent_associations = [] # logger.debug(f"Looking through {self.reagents}") for reagent in self.reagents: reagent, assoc, _ = reagent.toSQL(submission=instance) # logger.debug(f"Association: {assoc}") - if assoc is not None:# and assoc not in instance.submission_reagent_associations: + if assoc is not None: # and assoc not in instance.submission_reagent_associations: instance.submission_reagent_associations.append(assoc) # instance.reagents.append(reagent) case "samples": @@ -755,10 +752,7 @@ class PydSubmission(BaseModel, extra='allow'): if equip is None: continue equip, association = equip.toSQL(submission=instance) - if association is not None and association not in instance.submission_equipment_associations: - # association.save() - # logger.debug( - # f"Equipment association SQL object to be added to submission: {association.__dict__}") + if association is not None: instance.submission_equipment_associations.append(association) case "tips": for tips in self.tips: @@ -817,9 +811,9 @@ class PydSubmission(BaseModel, extra='allow'): # except AttributeError as e: # logger.debug(f"Something went wrong constructing instance {self.rsl_plate_num}: {e}") # logger.debug(f"Constructed submissions message: {msg}") - return instance, result + return instance, report - def to_form(self, parent: QWidget): + def to_form(self, parent: QWidget, disable:list|None=None): """ Converts this instance into a frontend.widgets.submission_widget.SubmissionFormWidget @@ -830,7 +824,8 @@ class PydSubmission(BaseModel, extra='allow'): SubmissionFormWidget: Submission form widget """ from frontend.widgets.submission_widget import SubmissionFormWidget - return SubmissionFormWidget(parent=parent, submission=self) + logger.debug(f"Disbable: {disable}") + return SubmissionFormWidget(parent=parent, submission=self, disable=disable) def to_writer(self) -> "SheetWriter": """ @@ -838,7 +833,7 @@ class PydSubmission(BaseModel, extra='allow'): Returns: SheetWriter: Sheetwriter object that will perform writing. - """ + """ from backend.excel.writer import SheetWriter return SheetWriter(self) @@ -896,8 +891,8 @@ class PydSubmission(BaseModel, extra='allow'): status="Warning") report.add_result(result) return output_reagents, report - - def export_csv(self, filename:Path|str): + + def export_csv(self, filename: Path | str): try: worksheet = self.csv except AttributeError: @@ -1024,4 +1019,3 @@ class PydEquipmentRole(BaseModel): """ from frontend.widgets.equipment_usage import RoleComboBox return RoleComboBox(parent=parent, role=self, used=used) - diff --git a/src/submissions/frontend/widgets/app.py b/src/submissions/frontend/widgets/app.py index b15f72d..45bd7b1 100644 --- a/src/submissions/frontend/widgets/app.py +++ b/src/submissions/frontend/widgets/app.py @@ -1,6 +1,6 @@ -''' +""" Constructs main application. -''' +""" from PyQt6.QtWidgets import ( QTabWidget, QWidget, QVBoxLayout, QHBoxLayout, QScrollArea, QMainWindow, @@ -13,7 +13,7 @@ from markdown import markdown from tools import check_if_app, Settings, Report, jinja_template_loading from datetime import date -from .pop_ups import AlertPop, HTMLPop +from .pop_ups import HTMLPop from .misc import LogParser import logging, webbrowser, sys, shutil from .submission_table import SubmissionsSheet @@ -36,7 +36,7 @@ class App(QMainWindow): self.report = Report() # NOTE: indicate version and connected database in title bar try: - self.title = f"Submissions App (v{ctx.package.__version__}) - {ctx.database_path}" + self.title = f"Submissions App (v{ctx.package.__version__}) - {ctx.database_session.get_bind().url}" except (AttributeError, KeyError): self.title = f"Submissions App" # NOTE: set initial app position and size @@ -164,27 +164,6 @@ class App(QMainWindow): instr = HTMLPop(html=html, title="Instructions") instr.exec() - - def result_reporter(self): - """ - Report any anomolous results - if any - to the user - - Args: - result (dict | None, optional): The result from a function. Defaults to None. - """ - # logger.debug(f"Running results reporter for: {self.report.results}") - if len(self.report.results) > 0: - # logger.debug(f"We've got some results!") - for result in self.report.results: - # logger.debug(f"Showing result: {result}") - if result is not None: - alert = result.report() - if alert.exec(): - pass - self.report = Report() - else: - self.statusBar().showMessage("Action completed sucessfully.", 5000) - def runSearch(self): dlg = LogParser(self) dlg.exec() @@ -201,12 +180,19 @@ class App(QMainWindow): Copies the database into the backup directory the first time it is opened every month. """ month = date.today().strftime("%Y-%m") + current_month_bak = Path(self.ctx.backup_path).joinpath(f"submissions_backup-{month}").resolve() # logger.debug(f"Here is the db directory: {self.ctx.database_path}") # logger.debug(f"Here is the backup directory: {self.ctx.backup_path}") - current_month_bak = Path(self.ctx.backup_path).joinpath(f"submissions_backup-{month}").resolve().with_suffix(".db") - if not current_month_bak.exists() and "demo" not in self.ctx.database_path.__str__(): - logger.info("No backup found for this month, backing up database.") - shutil.copyfile(self.ctx.database_path, current_month_bak) + match self.ctx.database_schema: + case "sqlite": + current_month_bak = current_month_bak.with_suffix(".db") + if not current_month_bak.exists() and "demo" not in self.ctx.database_path.__str__(): + logger.info("No backup found for this month, backing up database.") + shutil.copyfile(self.ctx.database_path, current_month_bak) + case "postgresql+psycopg2": + logger.warning(f"Backup function not yet implemented for psql") + current_month_bak = current_month_bak.with_suffix(".psql") + class AddSubForm(QWidget): diff --git a/src/submissions/frontend/widgets/equipment_usage.py b/src/submissions/frontend/widgets/equipment_usage.py index df75d27..03472dd 100644 --- a/src/submissions/frontend/widgets/equipment_usage.py +++ b/src/submissions/frontend/widgets/equipment_usage.py @@ -15,7 +15,7 @@ logger = logging.getLogger(f"submissions.{__name__}") class EquipmentUsage(QDialog): - def __init__(self, parent, submission: BasicSubmission) -> QDialog: + def __init__(self, parent, submission: BasicSubmission): super().__init__(parent) self.submission = submission self.setWindowTitle(f"Equipment Checklist - {submission.rsl_plate_num}") @@ -139,7 +139,7 @@ class RoleComboBox(QWidget): Changes what tips are available when process is changed """ process = self.process.currentText().strip() - logger.debug(f"Checking process: {process} for equipment {self.role.name}") + # logger.debug(f"Checking process: {process} for equipment {self.role.name}") process = Process.query(name=process) if process.tip_roles: for iii, tip_role in enumerate(process.tip_roles): diff --git a/src/submissions/frontend/widgets/gel_checker.py b/src/submissions/frontend/widgets/gel_checker.py index 2025128..aca8052 100644 --- a/src/submissions/frontend/widgets/gel_checker.py +++ b/src/submissions/frontend/widgets/gel_checker.py @@ -21,10 +21,10 @@ logger = logging.getLogger(f"submissions.{__name__}") # Main window class class GelBox(QDialog): - def __init__(self, parent, img_path:str|Path, submission:WastewaterArtic): + def __init__(self, parent, img_path: str | Path, submission: WastewaterArtic): super().__init__(parent) # NOTE: setting title - self.setWindowTitle("PyQtGraph") + self.setWindowTitle(f"Gel - {img_path}") self.img_path = img_path self.submission = submission # NOTE: setting geometry @@ -41,7 +41,7 @@ class GelBox(QDialog): def UiComponents(self): """ Create widgets in ui - """ + """ # NOTE: setting configuration options pg.setConfigOptions(antialias=True) # NOTE: creating image view object @@ -49,41 +49,42 @@ class GelBox(QDialog): # NOTE: Create image. # NOTE: For some reason, ImageView wants to flip the image, so we have to rotate and flip the array first. # NOTE: Using the Image.rotate function results in cropped image, so using np. - img = np.flip(np.rot90(np.array(Image.open(self.img_path)),1),0) + img = np.flip(np.rot90(np.array(Image.open(self.img_path)), 1), 0) self.imv.setImage(img) layout = QGridLayout() - layout.addWidget(QLabel("DNA Core Submission Number"),0,1) + layout.addWidget(QLabel("DNA Core Submission Number"), 21, 1) self.core_number = QLineEdit() self.core_number.setText(self.submission.dna_core_submission_number) - layout.addWidget(self.core_number, 0,2) - layout.addWidget(QLabel("Gel Barcode"),0,3) + layout.addWidget(self.core_number, 21, 2) + layout.addWidget(QLabel("Gel Barcode"), 21, 3) self.gel_barcode = QLineEdit() self.gel_barcode.setText(self.submission.gel_barcode) - layout.addWidget(self.gel_barcode, 0, 4) + layout.addWidget(self.gel_barcode, 21, 4) # NOTE: setting this layout to the widget # NOTE: plot window goes on right side, spanning 3 rows - layout.addWidget(self.imv, 1, 1,20,20) + layout.addWidget(self.imv, 0, 1, 20, 20) # NOTE: setting this widget as central widget of the main window try: control_info = sorted(self.submission.gel_controls, key=lambda d: d['location']) except KeyError: control_info = None self.form = ControlsForm(parent=self, control_info=control_info) - layout.addWidget(self.form,22,1,1,4) + layout.addWidget(self.form, 22, 1, 1, 4) QBtn = QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel self.buttonBox = QDialogButtonBox(QBtn) self.buttonBox.accepted.connect(self.accept) self.buttonBox.rejected.connect(self.reject) - layout.addWidget(self.buttonBox, 23, 1, 1, 1)#, alignment=Qt.AlignmentFlag.AlignTop) + layout.addWidget(self.buttonBox, 23, 1, 1, 1) #, alignment=Qt.AlignmentFlag.AlignTop) self.setLayout(layout) - def parse_form(self) -> Tuple[str, str|Path, list]: + + def parse_form(self) -> Tuple[str, str | Path, list]: """ Get relevant values from self/form Returns: Tuple[str, str|Path, list]: output values - """ + """ dna_core_submission_number = self.core_number.text() gel_barcode = self.gel_barcode.text() values, comment = self.form.parse_form() @@ -92,7 +93,7 @@ class GelBox(QDialog): class ControlsForm(QWidget): - def __init__(self, parent, control_info:List=None) -> None: + def __init__(self, parent, control_info: List = None) -> None: super().__init__(parent) self.layout = QGridLayout() columns = [] @@ -101,9 +102,10 @@ class ControlsForm(QWidget): tt_text = "\n".join([f"{item['sample_id']} - CELL {item['location']}" for item in control_info]) except TypeError: tt_text = None - for iii, item in enumerate(["Negative Control Key", "Description", "Results - 65 C", "Results - 63 C", "Results - Spike"]): + for iii, item in enumerate( + ["Negative Control Key", "Description", "Results - 65 C", "Results - 63 C", "Results - Spike"]): label = QLabel(item) - self.layout.addWidget(label, 0, iii,1,1) + self.layout.addWidget(label, 0, iii, 1, 1) if iii > 1: columns.append(item) elif iii == 0: @@ -114,7 +116,8 @@ class ControlsForm(QWidget): label = QLabel(item) self.layout.addWidget(label, iii, 0, 1, 1) rows.append(item) - for iii, item in enumerate(["Processing Negative (PBS)", "Extraction Negative (Extraction buffers ONLY)", "Artic no-template control (mastermix ONLY)"], start=1): + for iii, item in enumerate(["Processing Negative (PBS)", "Extraction Negative (Extraction buffers ONLY)", + "Artic no-template control (mastermix ONLY)"], start=1): label = QLabel(item) self.layout.addWidget(label, iii, 1, 1, 1) for iii in range(3): @@ -125,11 +128,11 @@ class ControlsForm(QWidget): widge.setCurrentIndex(0) widge.setEditable(True) widge.setObjectName(f"{rows[iii]} : {columns[jjj]}") - self.layout.addWidget(widge, iii+1, jjj+2, 1, 1) - self.layout.addWidget(QLabel("Comments:"), 0,5,1,1) + self.layout.addWidget(widge, iii + 1, jjj + 2, 1, 1) + self.layout.addWidget(QLabel("Comments:"), 0, 5, 1, 1) self.comment_field = QTextEdit(self) self.comment_field.setFixedHeight(50) - self.layout.addWidget(self.comment_field, 1,5,4,1) + self.layout.addWidget(self.comment_field, 1, 5, 4, 1) self.setLayout(self.layout) def parse_form(self) -> List[dict]: @@ -138,12 +141,12 @@ class ControlsForm(QWidget): Returns: List[dict]: output of values - """ + """ output = [] for le in self.findChildren(QComboBox): label = [item.strip() for item in le.objectName().split(" : ")] try: - dicto = [item for item in output if item['name']==label[0]][0] + dicto = [item for item in output if item['name'] == label[0]][0] except IndexError: dicto = dict(name=label[0], values=[]) dicto['values'].append(dict(name=label[1], value=le.currentText())) diff --git a/src/submissions/frontend/widgets/misc.py b/src/submissions/frontend/widgets/misc.py index 96d00fa..7dbeae4 100644 --- a/src/submissions/frontend/widgets/misc.py +++ b/src/submissions/frontend/widgets/misc.py @@ -8,7 +8,7 @@ from PyQt6.QtWidgets import ( QDialogButtonBox, QDateEdit, QPushButton, QFormLayout ) from PyQt6.QtCore import Qt, QDate -from tools import jinja_template_loading, Settings +from tools import jinja_template_loading from backend.db.models import * import logging from .pop_ups import AlertPop @@ -45,18 +45,19 @@ class AddReagentForm(QDialog): self.exp_input.setObjectName('expiry') # NOTE: if expiry is not passed in from gui, use today if expiry is None: - self.exp_input.setDate(QDate.currentDate()) + # self.exp_input.setDate(QDate.currentDate()) + self.exp_input.setDate(QDate(1970, 1, 1)) else: try: self.exp_input.setDate(expiry) except TypeError: - self.exp_input.setDate(QDate.currentDate()) + self.exp_input.setDate(QDate(1970, 1, 1)) # NOTE: widget to get reagent type info self.type_input = QComboBox() self.type_input.setObjectName('type') self.type_input.addItems([item.name for item in ReagentRole.query()]) # logger.debug(f"Trying to find index of {reagent_type}") - # NOTE: convert input to user friendly string? + # NOTE: convert input to user-friendly string? try: reagent_role = reagent_role.replace("_", " ").title() except AttributeError: diff --git a/src/submissions/frontend/widgets/submission_table.py b/src/submissions/frontend/widgets/submission_table.py index 002f2c3..170ffbd 100644 --- a/src/submissions/frontend/widgets/submission_table.py +++ b/src/submissions/frontend/widgets/submission_table.py @@ -7,8 +7,8 @@ from PyQt6.QtWidgets import QTableView, QMenu from PyQt6.QtCore import Qt, QAbstractTableModel, QSortFilterProxyModel from PyQt6.QtGui import QAction, QCursor from backend.db.models import BasicSubmission -from backend.excel import make_report_html, make_report_xlsx, ReportMaker -from tools import Report, Result, row_map, get_first_blank_df_row, html_to_pdf +from backend.excel import ReportMaker +from tools import Report, Result, report_result from .functions import select_save_file, select_open_file from .misc import ReportDatePicker import pandas as pd @@ -129,14 +129,15 @@ class SubmissionsSheet(QTableView): func = self.con_actions[action_name] func(obj=self) + @report_result def link_extractions(self): """ Pull extraction logs into the db - """ - self.link_extractions_function() - self.app.report.add_result(self.report) + """ self.report = Report() - self.app.result_reporter() + self.link_extractions_function() + self.report.add_result(self.report) + return self.report def link_extractions_function(self): """ @@ -179,6 +180,7 @@ class SubmissionsSheet(QTableView): sub.save() self.report.add_result(Result(msg=f"We added {count} logs to the database.", status='Information')) + @report_result def link_pcr(self): """ Pull pcr logs into the db @@ -186,7 +188,7 @@ class SubmissionsSheet(QTableView): self.link_pcr_function() self.app.report.add_result(self.report) self.report = Report() - self.app.result_reporter() + return self.report def link_pcr_function(self): """ @@ -225,15 +227,15 @@ class SubmissionsSheet(QTableView): # NOTE: check if pcr_info already exists sub.save() self.report.add_result(Result(msg=f"We added {count} logs to the database.", status='Information')) - + + @report_result def generate_report(self): """ Make a report - """ - self.generate_report_function() - self.app.report.add_result(self.report) + """ self.report = Report() - self.app.result_reporter() + self.generate_report_function() + return self.report def generate_report_function(self): """ @@ -250,43 +252,7 @@ class SubmissionsSheet(QTableView): dlg = ReportDatePicker() if dlg.exec(): info = dlg.parse_form() - # logger.debug(f"Report info: {info}") - # NOTE: find submissions based on date range - subs = BasicSubmission.query(start_date=info['start_date'], end_date=info['end_date']) - # NOTE: convert each object to dict - records = [item.to_dict(report=True) for item in subs] - # logger.debug(f"Records: {pformat(records)}") - # NOTE: make dataframe from record dictionaries - detailed_df, summary_df = make_report_xlsx(records=records) - html = make_report_html(df=summary_df, start_date=info['start_date'], end_date=info['end_date']) - # NOTE: get save location of report fname = select_save_file(obj=self, default_name=f"Submissions_Report_{info['start_date']}-{info['end_date']}.docx", extension="docx") - # html_to_pdf(html=html, output_file=fname) - # writer = pd.ExcelWriter(fname.with_suffix(".xlsx"), engine='openpyxl') - # summary_df.to_excel(writer, sheet_name="Report") - # detailed_df.to_excel(writer, sheet_name="Details", index=False) - # worksheet: Worksheet = writer.sheets['Report'] - # for idx, col in enumerate(summary_df, start=1): # loop through all columns - # series = summary_df[col] - # max_len = max(( - # series.astype(str).map(len).max(), # len of largest item - # len(str(series.name)) # len of column name/header - # )) + 20 # adding a little extra space - # try: - # # NOTE: Convert idx to letter - # col_letter = chr(ord('@') + idx) - # worksheet.column_dimensions[col_letter].width = max_len - # except ValueError: - # pass - # blank_row = get_first_blank_df_row(summary_df) + 1 - # # logger.debug(f"Blank row index = {blank_row}") - # for col in range(3,6): - # col_letter = row_map[col] - # worksheet.cell(row=blank_row, column=col, value=f"=SUM({col_letter}2:{col_letter}{str(blank_row-1)})") - # for cell in worksheet['D']: - # if cell.row > 1: - # cell.style = 'Currency' - # writer.close() rp = ReportMaker(start_date=info['start_date'], end_date=info['end_date']) rp.write_report(filename=fname, obj=self) self.report.add_result(report) diff --git a/src/submissions/frontend/widgets/submission_widget.py b/src/submissions/frontend/widgets/submission_widget.py index c85fdb7..544ff98 100644 --- a/src/submissions/frontend/widgets/submission_widget.py +++ b/src/submissions/frontend/widgets/submission_widget.py @@ -11,7 +11,7 @@ from pathlib import Path from . import select_open_file, select_save_file import logging, difflib, inspect from pathlib import Path -from tools import Report, Result, check_not_nan, workbook_2_csv, main_form_style +from tools import Report, Result, check_not_nan, workbook_2_csv, main_form_style, report_result from backend.excel.parser import SheetParser from backend.validators import PydSubmission, PydReagent from backend.db import ( @@ -59,17 +59,16 @@ class SubmissionFormContainer(QWidget): self.app.last_dir = fname.parent self.import_drag.emit(fname) + @report_result def importSubmission(self, fname: Path | None = None): """ import submission from excel sheet into form """ self.app.raise_() self.app.activateWindow() - self.import_submission_function(fname) - # logger.debug(f"Result from result reporter: {self.report.results}") - self.app.report.add_result(self.report) self.report = Report() - self.app.result_reporter() + self.import_submission_function(fname) + return self.report def import_submission_function(self, fname: Path | None = None): """ @@ -115,8 +114,9 @@ class SubmissionFormContainer(QWidget): # logger.debug(f"Outgoing report: {self.report.results}") # logger.debug(f"All attributes of submission container:\n{pformat(self.__dict__)}") + @report_result def add_reagent(self, reagent_lot: str | None = None, reagent_role: str | None = None, expiry: date | None = None, - name: str | None = None): + name: str | None = None) -> Tuple[PydReagent, Report]: """ Action to create new reagent in DB. @@ -144,16 +144,18 @@ class SubmissionFormContainer(QWidget): sqlobj, assoc, result = reagent.toSQL() sqlobj.save() report.add_result(result) - self.app.report.add_result(report) - self.app.result_reporter() - return reagent + # logger.debug(f"Reagent: {reagent}, Report: {report}") + return reagent, report class SubmissionFormWidget(QWidget): - def __init__(self, parent: QWidget, submission: PydSubmission) -> None: + def __init__(self, parent: QWidget, submission: PydSubmission, disable: list | None = None) -> None: super().__init__(parent) # self.report = Report() + # logger.debug(f"Disable: {disable}") + if disable is None: + disable = [] self.app = parent.app self.pyd = submission self.missing_info = [] @@ -166,12 +168,19 @@ class SubmissionFormWidget(QWidget): for k in list(self.pyd.model_fields.keys()) + list(self.pyd.model_extra.keys()): if k in self.ignore: continue + try: + # logger.debug(f"Key: {k}, Disable: {disable}") + check = k in disable + # logger.debug(f"Check: {check}") + except TypeError: + check = False try: value = self.pyd.__getattribute__(k) except AttributeError: logger.error(f"Couldn't get attribute from pyd: {k}") value = dict(value=None, missing=True) - add_widget = self.create_widget(key=k, value=value, submission_type=self.pyd.submission_type['value'], sub_obj=st) + add_widget = self.create_widget(key=k, value=value, submission_type=self.pyd.submission_type['value'], + sub_obj=st, disable=check) if add_widget is not None: self.layout.addWidget(add_widget) if k == "extraction_kit": @@ -180,11 +189,13 @@ class SubmissionFormWidget(QWidget): self.scrape_reagents(self.pyd.extraction_kit) def create_widget(self, key: str, value: dict | PydReagent, submission_type: str | None = None, - extraction_kit: str | None = None, sub_obj:BasicSubmission|None=None) -> "self.InfoItem": + extraction_kit: str | None = None, sub_obj: BasicSubmission | None = None, + disable: bool = False) -> "self.InfoItem": """ Make an InfoItem widget to hold a field Args: + disable (): key (str): Name of the field value (dict): Value of field submission_type (str | None, optional): Submissiontype as str. Defaults to None. @@ -192,18 +203,25 @@ class SubmissionFormWidget(QWidget): Returns: self.InfoItem: Form widget to hold name:value """ + # logger.debug(f"Key: {key}, Disable: {disable}") if key not in self.ignore: match value: case PydReagent(): if value.name.lower() != "not applicable": widget = self.ReagentFormWidget(self, reagent=value, extraction_kit=extraction_kit) + else: widget = None case _: widget = self.InfoItem(self, key=key, value=value, submission_type=submission_type, sub_obj=sub_obj) + # logger.debug(f"Setting widget enabled to: {not disable}") + if disable: + widget.input.setEnabled(False) + widget.input.setToolTip("Widget disabled to protect database integrity.") return widget return None + @report_result def scrape_reagents(self, *args, **kwargs): #extraction_kit:str, caller:str|None=None): """ Extracted scrape reagents function that will run when @@ -250,8 +268,7 @@ class SubmissionFormWidget(QWidget): self.layout.addWidget(submit_btn) submit_btn.clicked.connect(self.submit_new_sample_function) self.setLayout(self.layout) - self.app.report.add_result(report) - self.app.result_reporter() + return report def clear_form(self): """ @@ -275,7 +292,8 @@ class SubmissionFormWidget(QWidget): query = [widget for widget in query if widget.objectName() == object_name] return query - def submit_new_sample_function(self) -> QWidget: + @report_result + def submit_new_sample_function(self, *args) -> Report: """ Parse forms and add sample to the database. @@ -294,37 +312,40 @@ class SubmissionFormWidget(QWidget): _, result = self.pyd.check_kit_integrity() report.add_result(result) if len(result.results) > 0: - self.app.report.add_result(report) - self.app.result_reporter() + # self.app.report.add_result(report) + # self.app.report_result() return # logger.debug(f"PYD before transformation into SQL:\n\n{self.pyd}\n\n") base_submission, result = self.pyd.to_sql() # logger.debug(f"SQL object: {pformat(base_submission.__dict__)}") # logger.debug(f"Base submission: {base_submission.to_dict()}") # NOTE: check output message for issues - match result.code: + try: + code = report.results[-1].code + except IndexError: + code = 0 + match code: # NOTE: code 0: everything is fine. case 0: - report.add_result(None) + pass # NOTE: code 1: ask for overwrite case 1: dlg = QuestionAsker(title=f"Review {base_submission.rsl_plate_num}?", message=result.msg) if dlg.exec(): # NOTE: Do not add duplicate reagents. - result = None - + pass else: self.app.ctx.database_session.rollback() report.add_result(Result(msg="Overwrite cancelled", status="Information")) - self.app.report.add_result(report) - self.app.result_reporter() - return + # self.app.report.add_result(report) + # self.app.report_result() + return report # NOTE: code 2: No RSL plate number given case 2: report.add_result(result) - self.app.report.add_result(report) - self.app.result_reporter() - return + # self.app.report.add_result(report) + # self.app.report_result() + return report case _: pass # NOTE: add reagents to submission object @@ -338,8 +359,7 @@ class SubmissionFormWidget(QWidget): # NOTE: reset form self.setParent(None) # logger.debug(f"All attributes of obj: {pformat(self.__dict__)}") - self.app.report.add_result(report) - self.app.result_reporter() + return report def export_csv_function(self, fname: Path | None = None): """ @@ -352,7 +372,6 @@ class SubmissionFormWidget(QWidget): fname = select_save_file(obj=self, default_name=self.pyd.construct_filename(), extension="csv") try: self.pyd.export_csv(fname) - # workbook_2_csv(worksheet=self.pyd.csv, filename=fname) except PermissionError: logger.warning(f"Could not get permissions to {fname}. Possibly the request was cancelled.") except AttributeError: @@ -398,11 +417,13 @@ class SubmissionFormWidget(QWidget): class InfoItem(QWidget): - def __init__(self, parent: QWidget, key: str, value: dict, submission_type: str | None = None, sub_obj:BasicSubmission|None=None) -> None: + def __init__(self, parent: QWidget, key: str, value: dict, submission_type: str | None = None, + sub_obj: BasicSubmission | None = None) -> None: super().__init__(parent) layout = QVBoxLayout() self.label = self.ParsedQLabel(key=key, value=value) - self.input: QWidget = self.set_widget(parent=self, key=key, value=value, submission_type=submission_type, sub_obj=sub_obj) + self.input: QWidget = self.set_widget(parent=self, key=key, value=value, submission_type=submission_type, + sub_obj=sub_obj) self.setObjectName(key) try: self.missing: bool = value['missing'] @@ -439,7 +460,8 @@ class SubmissionFormWidget(QWidget): return None, None return self.input.objectName(), dict(value=value, missing=self.missing) - def set_widget(self, parent: QWidget, key: str, value: dict, submission_type: str | None = None, sub_obj:BasicSubmission|None=None) -> QWidget: + def set_widget(self, parent: QWidget, key: str, value: dict, submission_type: str | None = None, + sub_obj: BasicSubmission | None = None) -> QWidget: """ Creates form widget @@ -472,6 +494,7 @@ class SubmissionFormWidget(QWidget): pass # set combobox values to lookedup values add_widget.addItems(labs) + add_widget.setToolTip("Select submitting lab.") case 'extraction_kit': # if extraction kit not available, all other values fail if not check_not_nan(value): @@ -493,15 +516,7 @@ class SubmissionFormWidget(QWidget): logger.error(f"Couldn't find {obj.prsr.sub['extraction_kit']}") obj.ext_kit = uses[0] add_widget.addItems(uses) - # case 'submitted_date': - # # NOTE: uses base calendar - # add_widget = QDateEdit(calendarPopup=True) - # # NOTE: sets submitted date based on date found in excel sheet - # try: - # add_widget.setDate(value) - # # NOTE: if not found, use today - # except: - # add_widget.setDate(date.today()) + add_widget.setToolTip("Select extraction kit.") case 'submission_category': add_widget = QComboBox() cats = ['Diagnostic', "Surveillance", "Research"] @@ -511,6 +526,7 @@ class SubmissionFormWidget(QWidget): except ValueError: cats.insert(0, cats.pop(cats.index(submission_type))) add_widget.addItems(cats) + add_widget.setToolTip("Enter submission category or select from list.") case _: if key in sub_obj.timestamps(): add_widget = QDateEdit(calendarPopup=True) @@ -520,11 +536,13 @@ class SubmissionFormWidget(QWidget): # NOTE: if not found, use today except: add_widget.setDate(date.today()) + add_widget.setToolTip(f"Select date for {key}") else: # NOTE: anything else gets added in as a line edit add_widget = QLineEdit() # logger.debug(f"Setting widget text to {str(value).replace('_', ' ')}") add_widget.setText(str(value).replace("_", " ")) + add_widget.setToolTip(f"Enter value for {key}") if add_widget is not None: add_widget.setObjectName(key) add_widget.setParent(parent) @@ -594,13 +612,14 @@ class SubmissionFormWidget(QWidget): # NOTE: If changed set self.missing to True and update self.label self.lot.currentTextChanged.connect(self.updated) - def parse_form(self) -> Tuple[PydReagent, dict]: + def parse_form(self) -> Tuple[PydReagent | None, Report]: """ Pulls form info into PydReagent Returns: Tuple[PydReagent, dict]: PydReagent and Report(?) """ + report = Report() lot = self.lot.currentText() # logger.debug(f"Using this lot for the reagent {self.reagent}: {lot}") wanted_reagent = Reagent.query(lot_number=lot, reagent_role=self.reagent.role) @@ -609,14 +628,16 @@ class SubmissionFormWidget(QWidget): dlg = QuestionAsker(title=f"Add {lot}?", message=f"Couldn't find reagent type {self.reagent.role}: {lot} in the database.\n\nWould you like to add it?") if dlg.exec(): - wanted_reagent = self.parent().parent().add_reagent(reagent_lot=lot, reagent_role=self.reagent.role, - expiry=self.reagent.expiry, - name=self.reagent.name) - return wanted_reagent, None + wanted_reagent, _ = self.parent().parent().add_reagent(reagent_lot=lot, + reagent_role=self.reagent.role, + expiry=self.reagent.expiry, + name=self.reagent.name) + return wanted_reagent, report else: # NOTE: In this case we will have an empty reagent and the submission will fail kit integrity check # logger.debug("Will not add reagent.") - return None, Result(msg="Failed integrity check", status="Critical") + report.add_result(Result(msg="Failed integrity check", status="Critical")) + return None, report else: # NOTE: Since this now gets passed in directly from the parser -> pyd -> form and the parser gets the name # from the db, it should no longer be necessary to query the db with reagent/kit, but with rt name directly. @@ -624,7 +645,7 @@ class SubmissionFormWidget(QWidget): if rt is None: rt = ReagentRole.query(kit_type=self.extraction_kit, reagent=wanted_reagent) return PydReagent(name=wanted_reagent.name, lot=wanted_reagent.lot, role=rt.name, - expiry=wanted_reagent.expiry, missing=False), None + expiry=wanted_reagent.expiry, missing=False), report def updated(self): """ @@ -708,4 +729,5 @@ class SubmissionFormWidget(QWidget): # logger.debug(f"New relevant reagents: {relevant_reagents}") self.setObjectName(f"lot_{reagent.role}") self.addItems(relevant_reagents) + self.setToolTip(f"Enter lot number for the reagent used for {reagent.role}") # self.setStyleSheet(main_form_style) diff --git a/src/submissions/tools.py b/src/submissions/tools.py index 6e50aaf..9de01be 100644 --- a/src/submissions/tools.py +++ b/src/submissions/tools.py @@ -4,6 +4,8 @@ Contains miscellaenous functions used by both frontend and backend. from __future__ import annotations import json +from json import JSONDecodeError +import jinja2 import numpy as np import logging, re, yaml, sys, os, stat, platform, getpass, inspect, csv import pandas as pd @@ -18,7 +20,6 @@ from typing import Any, Tuple, Literal, List from PyQt6.QtGui import QPageSize from PyQt6.QtWebEngineWidgets import QWebEngineView from openpyxl.worksheet.worksheet import Worksheet -# from PyQt6 import QtPrintSupport, QtCore, QtWebEngineWidgets from PyQt6.QtPrintSupport import QPrinter logger = logging.getLogger(f"submissions.{__name__}") @@ -74,7 +75,7 @@ def check_key_or_attr(key: str, interest: dict | object, check_none: bool = Fals Returns: bool: True if exists, else False - """ + """ match interest: case dict(): if key in interest.keys(): @@ -175,7 +176,7 @@ def is_missing(value: Any) -> Tuple[Any, bool]: Returns: Tuple[Any, bool]: Value, True if nan, else False - """ + """ if check_not_nan(value): return value, False else: @@ -222,7 +223,11 @@ class Settings(BaseSettings, extra="allow"): FileNotFoundError: Error if database not found. """ + database_schema: str directory_path: Path + database_user: str | None = None + database_password: str | None = None + database_name: str database_path: Path | str | None = None backup_path: Path | str | None = None # super_users: list|None = None @@ -260,17 +265,26 @@ class Settings(BaseSettings, extra="allow"): @field_validator('database_path', mode="before") @classmethod def ensure_database_exists(cls, value, values): - if value == ":memory:": - return value - match value: - case str(): - value = Path(value) - case None: - value = values.data['directory_path'].joinpath("submissions.db") - if value.exists(): - return value - else: - raise FileNotFoundError(f"Couldn't find database at {value}") + # if value == ":memory:": + # return value + match values.data['database_schema']: + case "sqlite": + value = f"/{Path(value).absolute().__str__()}/{values.data['database_name']}.db" + # db_name = f"{values.data['database_name']}.db" + case _: + value = f"@{value}/{values.data['database_name']}" + # db_name = values.data['database_name'] + # match value: + # case str(): + # value = Path(value) + # case None: + # value = values.data['directory_path'].joinpath("submissions.db") + # if value.exists(): + # return value + # else: + # raise FileNotFoundError(f"Couldn't find database at {value}") + + return value @field_validator('database_session', mode="before") @classmethod @@ -278,27 +292,33 @@ class Settings(BaseSettings, extra="allow"): if value is not None: return value else: - database_path = values.data['database_path'] - if database_path is None: - # NOTE: check in user's .submissions directory for submissions.db - if Path.home().joinpath(".submissions", "submissions.db").exists(): - database_path = Path.home().joinpath(".submissions", "submissions.db") - # NOTE: finally, look in the local dir - else: - database_path = package_dir.joinpath("submissions.db") - else: - if database_path == ":memory:": - pass - # NOTE: check if user defined path is directory - elif database_path.is_dir(): - database_path = database_path.joinpath("submissions.db") - # NOTE: check if user defined path is a file - elif database_path.is_file(): - database_path = database_path - else: - raise FileNotFoundError("No database file found. Exiting program.") + template = jinja_template_loading().from_string( + "{{ values['database_schema'] }}://{% if values['database_user'] %}{{ values['database_user'] }}{% if values['database_password'] %}:{{ values['database_password'] }}{% endif %}{% endif %}{{ values['database_path'] }}") + database_path = template.render(values=values.data) + # print(f"Using {database_path} for database path") + # database_path = values.data['database_path'] + # if database_path is None: + # # NOTE: check in user's .submissions directory for submissions.db + # if Path.home().joinpath(".submissions", "submissions.db").exists(): + # database_path = Path.home().joinpath(".submissions", "submissions.db") + # # NOTE: finally, look in the local dir + # else: + # database_path = package_dir.joinpath("submissions.db") + # else: + # if database_path == ":memory:": + # pass + # # NOTE: check if user defined path is directory + # elif database_path.is_dir(): + # database_path = database_path.joinpath("submissions.db") + # # NOTE: check if user defined path is a file + # elif database_path.is_file(): + # database_path = database_path + # else: + # raise FileNotFoundError("No database file found. Exiting program.") logger.info(f"Using {database_path} for database file.") - engine = create_engine(f"sqlite:///{database_path}") #, echo=True, future=True) + # engine = create_engine(f"sqlite:///{database_path}") #, echo=True, future=True) + # engine = create_engine("postgresql+psycopg2://postgres:RE,4321q@localhost:5432/submissions") + engine = create_engine(database_path) session = Session(engine) return session @@ -316,13 +336,21 @@ class Settings(BaseSettings, extra="allow"): def set_from_db(self, db_path: Path): if 'pytest' in sys.modules: - config_items = dict(power_users=['lwark', 'styson', 'ruwang']) + output = dict(power_users=['lwark', 'styson', 'ruwang']) else: - session = Session(create_engine(f"sqlite:///{db_path}")) + # session = Session(create_engine(f"sqlite:///{db_path}")) + session = self.database_session config_items = session.execute(text("SELECT * FROM _configitem")).all() session.close() - config_items = {item[1]: json.loads(item[2]) for item in config_items} - for k, v in config_items.items(): + # print(config_items) + output = {} + for item in config_items: + try: + output[item[1]] = json.loads(item[2]) + except (JSONDecodeError, TypeError): + output[item[1]] = item[2] + # config_items = {item[1]: json.loads(item[2]) for item in config_items} + for k, v in output.items(): if not hasattr(self, k): self.__setattr__(k, v) @@ -355,7 +383,6 @@ def get_config(settings_path: Path | str | None = None) -> Settings: CONFIGDIR.mkdir(parents=True) except FileExistsError: logger.warning(f"Config directory {CONFIGDIR} already exists.") - try: LOGDIR.mkdir(parents=True) except FileExistsError: @@ -373,7 +400,7 @@ def get_config(settings_path: Path | str | None = None) -> Settings: if check_if_app(): settings_path = Path(sys._MEIPASS).joinpath("files", "config.yml") else: - settings_path = package_dir.joinpath('config.yml') + settings_path = package_dir.joinpath('src', 'config.yml') with open(settings_path, "r") as dset: default_settings = yaml.load(dset, Loader=yaml.Loader) # NOTE: Tell program we need to copy the config.yml to the user directory @@ -502,7 +529,7 @@ def setup_logger(verbosity: int = 3): # NOTE: create console handler with a higher log level # NOTE: create custom logger with STERR -> log ch = logging.StreamHandler(stream=sys.stdout) - # NOTE: set looging level based on verbosity + # NOTE: set logging level based on verbosity match verbosity: case 3: ch.setLevel(logging.DEBUG) @@ -542,10 +569,10 @@ def copy_settings(settings_path: Path, settings: dict) -> dict: dict: output dictionary for use in first run """ # NOTE: if the current user is not a superuser remove the superusers entry - if not getpass.getuser() in settings['super_users']: - del settings['super_users'] - if not getpass.getuser() in settings['power_users']: - del settings['power_users'] + # if not getpass.getuser() in settings['super_users']: + # del settings['super_users'] + # if not getpass.getuser() in settings['power_users']: + # del settings['power_users'] if not settings_path.exists(): with open(settings_path, 'w') as f: yaml.dump(settings, f) @@ -651,7 +678,7 @@ class Report(BaseModel): Args: result (Result | Report | None): Results to be added. - """ + """ match result: case Result(): logger.info(f"Adding {result} to results.") @@ -668,7 +695,7 @@ class Report(BaseModel): logger.error(f"Unknown variable type: {type(result)} for entry into ") -def rreplace(s:str, old:str, new:str) -> str: +def rreplace(s: str, old: str, new: str) -> str: """ Removes rightmost occurence of a substring @@ -679,18 +706,18 @@ def rreplace(s:str, old:str, new:str) -> str: Returns: str: updated string - """ + """ return (s[::-1].replace(old[::-1], new[::-1], 1))[::-1] -def html_to_pdf(html:str, output_file: Path | str): +def html_to_pdf(html: str, output_file: Path | str): """ Attempts to print an html string as a PDF. (currently not working) Args: html (str): Input html string. output_file (Path | str): Output PDF file path. - """ + """ if isinstance(output_file, str): output_file = Path(output_file) logger.debug(f"Printing PDF to {output_file}") @@ -732,7 +759,7 @@ def workbook_2_csv(worksheet: Worksheet, filename: Path): Args: worksheet (Worksheet): Incoming worksheet filename (Path): Output csv filepath. - """ + """ with open(filename, 'w', newline="") as f: c = csv.writer(f) for r in worksheet.rows: @@ -748,7 +775,7 @@ def is_power_user() -> bool: Returns: bool: True if yes, False if no. - """ + """ try: check = getpass.getuser() in ctx.power_users except: @@ -773,3 +800,32 @@ def check_authorization(func): return dict(code=1, message="This user does not have permission for this function.", status="warning") return wrapper + + +def report_result(func): + def wrapper(*args, **kwargs): + logger.debug(f"Arguments: {args}") + logger.debug(f"Keyword arguments: {kwargs}") + output = func(*args, **kwargs) + if isinstance(output, tuple): + report = [item for item in output if isinstance(item, Report)][0] + else: + report = None + logger.debug(f"Got report: {report}") + try: + results = report.results + except AttributeError: + logger.error("No results available") + results = [] + for iii, result in enumerate(results): + logger.debug(f"Result {iii}: {result}") + try: + dlg = result.report() + dlg.exec() + except Exception as e: + logger.error(f"Problem reporting due to {e}") + logger.error(result.msg) + logger.debug(f"Returning: {output}") + return output + + return wrapper