diff --git a/alembic.ini b/alembic.ini index c3d6540..fb9aaf6 100644 --- a/alembic.ini +++ b/alembic.ini @@ -56,6 +56,7 @@ version_path_separator = os # Use os.pathsep. Default configuration used for ne # output_encoding = utf-8 sqlalchemy.url = sqlite:///L:\Robotics Laboratory Support\Submissions\submissions.db +; sqlalchemy.url = sqlite:///C:\Users\lwark\Documents\Archives\DB_backups\submissions-20230130.db [post_write_hooks] diff --git a/alembic/versions/178203610c3b_updating_costs.py b/alembic/versions/178203610c3b_updating_costs.py new file mode 100644 index 0000000..2cfbbe9 --- /dev/null +++ b/alembic/versions/178203610c3b_updating_costs.py @@ -0,0 +1,40 @@ +"""updating costs + +Revision ID: 178203610c3b +Revises: afbdd9e46207 +Create Date: 2023-02-02 09:31:05.748477 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '178203610c3b' +down_revision = 'afbdd9e46207' +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('_kits', schema=None) as batch_op: + batch_op.add_column(sa.Column('mutable_cost', sa.FLOAT(precision=2), nullable=True)) + batch_op.add_column(sa.Column('constant_cost', sa.FLOAT(precision=2), nullable=True)) + + with op.batch_alter_table('_submissions', schema=None) as batch_op: + batch_op.add_column(sa.Column('run_cost', sa.FLOAT(precision=2), nullable=True)) + + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('_submissions', schema=None) as batch_op: + batch_op.drop_column('run_cost') + + with op.batch_alter_table('_kits', schema=None) as batch_op: + batch_op.drop_column('constant_cost') + batch_op.drop_column('mutable_cost') + + # ### end Alembic commands ### diff --git a/alembic/versions/afbdd9e46207_add_extraction_info_to_submissions.py b/alembic/versions/afbdd9e46207_add_extraction_info_to_submissions.py new file mode 100644 index 0000000..49f6ab5 --- /dev/null +++ b/alembic/versions/afbdd9e46207_add_extraction_info_to_submissions.py @@ -0,0 +1,40 @@ +"""add extraction info to submissions + +Revision ID: afbdd9e46207 +Revises: 8753ed70f148 +Create Date: 2023-01-30 13:05:42.858306 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'afbdd9e46207' +down_revision = '8753ed70f148' +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('_contacts', schema=None) as batch_op: + batch_op.add_column(sa.Column('organization_id', sa.INTEGER(), nullable=True)) + batch_op.create_foreign_key('fk_contact_org_id', '_organizations', ['organization_id'], ['id'], ondelete='SET NULL') + + with op.batch_alter_table('_submissions', schema=None) as batch_op: + batch_op.add_column(sa.Column('extraction_info', sa.JSON(), nullable=True)) + + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('_submissions', schema=None) as batch_op: + batch_op.drop_column('extraction_info') + + with op.batch_alter_table('_contacts', schema=None) as batch_op: + batch_op.drop_constraint('fk_contact_org_id', type_='foreignkey') + batch_op.drop_column('organization_id') + + # ### end Alembic commands ### diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..544a26e Binary files /dev/null and b/requirements.txt differ diff --git a/src/submissions/backend/db/__init__.py b/src/submissions/backend/db/__init__.py index a048f8f..d771baf 100644 --- a/src/submissions/backend/db/__init__.py +++ b/src/submissions/backend/db/__init__.py @@ -5,7 +5,7 @@ import sqlite3 # from sqlalchemy.exc import IntegrityError, OperationalError # from sqlite3 import IntegrityError, OperationalError import logging -from datetime import date, datetime +from datetime import date, datetime, timedelta from sqlalchemy import and_ import uuid import base64 @@ -132,6 +132,12 @@ def construct_submission_info(ctx:dict, info_dict:dict) -> models.BasicSubmissio except AttributeError: logger.debug(f"Could not set attribute: {item} to {info_dict[item]}") continue + # calculate cost of the run: immutable cost + mutable times number of columns + try: + instance.run_cost = instance.extraction_kit.immutable_cost + (instance.extraction_kit.mutable_cost * ((instance.sample_count / 8)/12)) + except TypeError: + logger.debug(f"Looks like that kit doesn't have cost breakdown yet, using full plate cost.") + instance.run_cost = instance.extraction_kit.cost_per_run logger.debug(f"Constructed instance: {instance.to_string()}") logger.debug(msg) return instance, {'message':msg} @@ -395,12 +401,13 @@ def create_kit_from_yaml(ctx:dict, exp:dict) -> None: exp (dict): Experiment dictionary created from yaml file """ try: - super_users = ctx['super_users'] + power_users = ctx['power_users'] except KeyError: logger.debug("This user does not have permission to add kits.") return {'code':1,'message':"This user does not have permission to add kits."} - if getuser not in super_users: - logger.debug("This user does not have permission to add kits.") + logger.debug(f"Adding kit for user: {getuser()}") + if getuser() not in power_users: + logger.debug(f"{getuser()} does not have permission to add kits.") return {'code':1, 'message':"This user does not have permission to add kits."} for type in exp: if type == "password": @@ -410,7 +417,7 @@ def create_kit_from_yaml(ctx:dict, exp:dict) -> None: for r in exp[type]['kits'][kt]['reagenttypes']: look_up = ctx['database_session'].query(models.ReagentType).filter(models.ReagentType.name==r).first() if look_up == None: - rt = models.ReagentType(name=r.replace(" ", "_").lower(), eol_ext=datetime.timedelta(30*exp[type]['kits'][kt]['reagenttypes'][r]['eol_ext']), kits=[kit]) + rt = models.ReagentType(name=r.replace(" ", "_").lower(), eol_ext=timedelta(30*exp[type]['kits'][kt]['reagenttypes'][r]['eol_ext']), kits=[kit]) else: rt = look_up rt.kits.append(kit) diff --git a/src/submissions/backend/db/models/kits.py b/src/submissions/backend/db/models/kits.py index dfc2099..d7d9fa5 100644 --- a/src/submissions/backend/db/models/kits.py +++ b/src/submissions/backend/db/models/kits.py @@ -18,6 +18,8 @@ class KitType(Base): submissions = relationship("BasicSubmission", back_populates="extraction_kit") #: submissions this kit was used for used_for = Column(JSON) #: list of names of sample types this kit can process cost_per_run = Column(FLOAT(2)) #: dollar amount for each full run of this kit + mutable_cost = Column(FLOAT(2)) #: dollar amount that can change with number of columns (reagents, tips, etc) + constant_cost = Column(FLOAT(2)) #: dollar amount that will remain constant (plates, man hours, etc) reagent_types = relationship("ReagentType", back_populates="kits", uselist=True, secondary=reagenttypes_kittypes) #: reagent types this kit contains reagent_types_id = Column(INTEGER, ForeignKey("_reagent_types.id", ondelete='SET NULL', use_alter=True, name="fk_KT_reagentstype_id")) #: joined reagent type id diff --git a/src/submissions/backend/db/models/submissions.py b/src/submissions/backend/db/models/submissions.py index 00512cc..7729711 100644 --- a/src/submissions/backend/db/models/submissions.py +++ b/src/submissions/backend/db/models/submissions.py @@ -1,5 +1,5 @@ from . import Base -from sqlalchemy import Column, String, TIMESTAMP, INTEGER, ForeignKey, Table +from sqlalchemy import Column, String, TIMESTAMP, INTEGER, ForeignKey, Table, JSON, FLOAT from sqlalchemy.orm import relationship from datetime import datetime as dt @@ -26,6 +26,8 @@ class BasicSubmission(Base): # Move this into custom types? reagents = relationship("Reagent", back_populates="submissions", secondary=reagents_submissions) #: relationship to reagents reagents_id = Column(String, ForeignKey("_reagents.id", ondelete="SET NULL", name="fk_BS_reagents_id")) #: id of used reagents + extraction_info = Column(JSON) #: unstructured output from the extraction table logger. + run_cost = Column(FLOAT(2)) __mapper_args__ = { "polymorphic_identity": "basic_submission", @@ -73,6 +75,7 @@ class BasicSubmission(Base): "Sample Count": self.sample_count, "Extraction Kit": ext_kit, "Technician": self.technician, + "Cost": self.run_cost, } return output @@ -99,10 +102,11 @@ class BasicSubmission(Base): except AttributeError: ext_kit = None # get extraction kit cost from nested kittype object - try: - cost = self.extraction_kit.cost_per_run - except AttributeError: - cost = None + # depreciated as it will change kit cost overtime + # try: + # cost = self.extraction_kit.cost_per_run + # except AttributeError: + # cost = None output = { "id": self.id, "Plate Number": self.rsl_plate_num, @@ -112,7 +116,7 @@ class BasicSubmission(Base): "Submitting Lab": sub_lab, "Sample Count": self.sample_count, "Extraction Kit": ext_kit, - "Cost": cost + "Cost": self.run_cost } return output diff --git a/src/submissions/backend/excel/parser.py b/src/submissions/backend/excel/parser.py index e5fae9f..e8ad0bb 100644 --- a/src/submissions/backend/excel/parser.py +++ b/src/submissions/backend/excel/parser.py @@ -88,7 +88,31 @@ class SheetParser(object): def _parse_bacterial_culture(self) -> None: """ pulls info specific to bacterial culture sample type - """ + """ + + def _parse_reagents(df:pd.DataFrame) -> None: + for ii, row in df.iterrows(): + logger.debug(f"Running reagent parse for {row[1]} with type {type(row[1])} and value: {row[2]} with type {type(row[2])}") + try: + check = not np.isnan(row[1]) + except TypeError: + check = True + if not isinstance(row[2], float) and check: + # must be prefixed with 'lot_' to be recognized by gui + try: + reagent_type = row[1].replace(' ', '_').lower().strip() + except AttributeError: + pass + if reagent_type == "//": + reagent_type = row[0].replace(' ', '_').lower().strip() + try: + output_var = row[2].upper() + except AttributeError: + logger.debug(f"Couldn't upperize {row[2]}, must be a number") + output_var = row[2] + logger.debug(f"Output variable is {output_var}") + self.sub[f"lot_{reagent_type}"] = output_var + submission_info = self._parse_generic("Sample List") # iloc is [row][column] and the first row is set as header row so -2 tech = str(submission_info.iloc[11][1]) @@ -100,54 +124,91 @@ class SheetParser(object): self.sub['technician'] = tech # reagents # must be prefixed with 'lot_' to be recognized by gui - self.sub['lot_wash_1'] = submission_info.iloc[1][6] #if pd.isnull(submission_info.iloc[1][6]) else string_formatter(submission_info.iloc[1][6]) - self.sub['lot_wash_2'] = submission_info.iloc[2][6] #if pd.isnull(submission_info.iloc[2][6]) else string_formatter(submission_info.iloc[2][6]) - self.sub['lot_binding_buffer'] = submission_info.iloc[3][6] #if pd.isnull(submission_info.iloc[3][6]) else string_formatter(submission_info.iloc[3][6]) - self.sub['lot_magnetic_beads'] = submission_info.iloc[4][6] #if pd.isnull(submission_info.iloc[4][6]) else string_formatter(submission_info.iloc[4][6]) - self.sub['lot_lysis_buffer'] = submission_info.iloc[5][6] #if np.nan(submission_info.iloc[5][6]) else string_formatter(submission_info.iloc[5][6]) - self.sub['lot_elution_buffer'] = submission_info.iloc[6][6] #if pd.isnull(submission_info.iloc[6][6]) else string_formatter(submission_info.iloc[6][6]) - self.sub['lot_isopropanol'] = submission_info.iloc[9][6] #if pd.isnull(submission_info.iloc[9][6]) else string_formatter(submission_info.iloc[9][6]) - self.sub['lot_ethanol'] = submission_info.iloc[10][6] #if pd.isnull(submission_info.iloc[10][6]) else string_formatter(submission_info.iloc[10][6]) - self.sub['lot_positive_control'] = submission_info.iloc[103][1] #if pd.isnull(submission_info.iloc[103][1]) else string_formatter(submission_info.iloc[103][1]) - self.sub['lot_plate'] = submission_info.iloc[12][6] #if pd.isnull(submission_info.iloc[12][6]) else string_formatter(submission_info.iloc[12][6]) + # Todo: find a more adaptable way to read reagents. + + reagent_range = submission_info.iloc[1:13, 4:8] + _parse_reagents(reagent_range) + # self.sub['lot_wash_1'] = submission_info.iloc[1][6] #if pd.isnull(submission_info.iloc[1][6]) else string_formatter(submission_info.iloc[1][6]) + # self.sub['lot_wash_2'] = submission_info.iloc[2][6] #if pd.isnull(submission_info.iloc[2][6]) else string_formatter(submission_info.iloc[2][6]) + # self.sub['lot_binding_buffer'] = submission_info.iloc[3][6] #if pd.isnull(submission_info.iloc[3][6]) else string_formatter(submission_info.iloc[3][6]) + # self.sub['lot_magnetic_beads'] = submission_info.iloc[4][6] #if pd.isnull(submission_info.iloc[4][6]) else string_formatter(submission_info.iloc[4][6]) + # self.sub['lot_lysis_buffer'] = submission_info.iloc[5][6] #if np.nan(submission_info.iloc[5][6]) else string_formatter(submission_info.iloc[5][6]) + # self.sub['lot_elution_buffer'] = submission_info.iloc[6][6] #if pd.isnull(submission_info.iloc[6][6]) else string_formatter(submission_info.iloc[6][6]) + # self.sub['lot_isopropanol'] = submission_info.iloc[9][6] #if pd.isnull(submission_info.iloc[9][6]) else string_formatter(submission_info.iloc[9][6]) + # self.sub['lot_ethanol'] = submission_info.iloc[10][6] #if pd.isnull(submission_info.iloc[10][6]) else string_formatter(submission_info.iloc[10][6]) + # self.sub['lot_positive_control'] = submission_info.iloc[103][1] #if pd.isnull(submission_info.iloc[103][1]) else string_formatter(submission_info.iloc[103][1]) + # self.sub['lot_plate'] = submission_info.iloc[12][6] #if pd.isnull(submission_info.iloc[12][6]) else string_formatter(submission_info.iloc[12][6]) # get individual sample info sample_parser = SampleParser(submission_info.iloc[15:111]) sample_parse = getattr(sample_parser, f"parse_{self.sub['submission_type'].lower()}_samples") logger.debug(f"Parser result: {self.sub}") self.sub['samples'] = sample_parse() + + + def _parse_wastewater(self) -> None: """ pulls info specific to wastewater sample type """ + + def _parse_reagents(df:pd.DataFrame) -> None: + logger.debug(df) + for ii, row in df.iterrows(): + try: + check = not np.isnan(row[5]) + except TypeError: + check = True + if not isinstance(row[5], float) and check: + # must be prefixed with 'lot_' to be recognized by gui + output_key = re.sub(r"\d{1,3}%", "", row[0].replace(' ', '_').lower()) + try: + output_var = row[5].upper() + except AttributeError: + logger.debug(f"Couldn't upperize {row[2]}, must be a number") + output_var = row[5] + self.sub[f"lot_{output_key}"] = output_var + # submission_info = self.xl.parse("WW Submissions (ENTER HERE)") submission_info = self._parse_generic("WW Submissions (ENTER HERE)") enrichment_info = self.xl.parse("Enrichment Worksheet", dtype=object) + enr_reagent_range = enrichment_info.iloc[0:4, 9:20] extraction_info = self.xl.parse("Extraction Worksheet", dtype=object) + ext_reagent_range = extraction_info.iloc[0:5, 9:20] qprc_info = self.xl.parse("qPCR Worksheet", dtype=object) + pcr_reagent_range = qprc_info.iloc[0:5, 9:20] self.sub['technician'] = f"Enr: {enrichment_info.columns[2]}, Ext: {extraction_info.columns[2]}, PCR: {qprc_info.columns[2]}" + _parse_reagents(enr_reagent_range) + _parse_reagents(ext_reagent_range) + _parse_reagents(pcr_reagent_range) # reagents - logger.debug(qprc_info) - self.sub['lot_lysis_buffer'] = enrichment_info.iloc[0][14] #if pd.isnull(enrichment_info.iloc[0][14]) else string_formatter(enrichment_info.iloc[0][14]) - self.sub['lot_proteinase_K'] = enrichment_info.iloc[1][14] #if pd.isnull(enrichment_info.iloc[1][14]) else string_formatter(enrichment_info.iloc[1][14]) - self.sub['lot_magnetic_virus_particles'] = enrichment_info.iloc[2][14] #if pd.isnull(enrichment_info.iloc[2][14]) else string_formatter(enrichment_info.iloc[2][14]) - self.sub['lot_enrichment_reagent_1'] = enrichment_info.iloc[3][14] #if pd.isnull(enrichment_info.iloc[3][14]) else string_formatter(enrichment_info.iloc[3][14]) - self.sub['lot_binding_buffer'] = extraction_info.iloc[0][14] #if pd.isnull(extraction_info.iloc[0][14]) else string_formatter(extraction_info.iloc[0][14]) - self.sub['lot_magnetic_beads'] = extraction_info.iloc[1][14] #if pd.isnull(extraction_info.iloc[1][14]) else string_formatter(extraction_info.iloc[1][14]) - self.sub['lot_wash'] = extraction_info.iloc[2][14] #if pd.isnull(extraction_info.iloc[2][14]) else string_formatter(extraction_info.iloc[2][14]) - self.sub['lot_ethanol'] = extraction_info.iloc[3][14] #if pd.isnull(extraction_info.iloc[3][14]) else string_formatter(extraction_info.iloc[3][14]) - self.sub['lot_elution_buffer'] = extraction_info.iloc[4][14] #if pd.isnull(extraction_info.iloc[4][14]) else string_formatter(extraction_info.iloc[4][14]) - self.sub['lot_master_mix'] = qprc_info.iloc[0][14] #if pd.isnull(qprc_info.iloc[0][14]) else string_formatter(qprc_info.iloc[0][14]) - self.sub['lot_pre_mix_1'] = qprc_info.iloc[1][14] #if pd.isnull(qprc_info.iloc[1][14]) else string_formatter(qprc_info.iloc[1][14]) - self.sub['lot_pre_mix_2'] = qprc_info.iloc[2][14] #if pd.isnull(qprc_info.iloc[2][14]) else string_formatter(qprc_info.iloc[2][14]) - self.sub['lot_positive_control'] = qprc_info.iloc[3][14] #if pd.isnull(qprc_info.iloc[3][14]) else string_formatter(qprc_info.iloc[3][14]) - self.sub['lot_ddh2o'] = qprc_info.iloc[4][14] #if pd.isnull(qprc_info.iloc[4][14]) else string_formatter(qprc_info.iloc[4][14]) + # logger.debug(qprc_info) + # self.sub['lot_lysis_buffer'] = enrichment_info.iloc[0][14] #if pd.isnull(enrichment_info.iloc[0][14]) else string_formatter(enrichment_info.iloc[0][14]) + # self.sub['lot_proteinase_K'] = enrichment_info.iloc[1][14] #if pd.isnull(enrichment_info.iloc[1][14]) else string_formatter(enrichment_info.iloc[1][14]) + # self.sub['lot_magnetic_virus_particles'] = enrichment_info.iloc[2][14] #if pd.isnull(enrichment_info.iloc[2][14]) else string_formatter(enrichment_info.iloc[2][14]) + # self.sub['lot_enrichment_reagent_1'] = enrichment_info.iloc[3][14] #if pd.isnull(enrichment_info.iloc[3][14]) else string_formatter(enrichment_info.iloc[3][14]) + # self.sub['lot_binding_buffer'] = extraction_info.iloc[0][14] #if pd.isnull(extraction_info.iloc[0][14]) else string_formatter(extraction_info.iloc[0][14]) + # self.sub['lot_magnetic_beads'] = extraction_info.iloc[1][14] #if pd.isnull(extraction_info.iloc[1][14]) else string_formatter(extraction_info.iloc[1][14]) + # self.sub['lot_wash'] = extraction_info.iloc[2][14] #if pd.isnull(extraction_info.iloc[2][14]) else string_formatter(extraction_info.iloc[2][14]) + # self.sub['lot_ethanol'] = extraction_info.iloc[3][14] #if pd.isnull(extraction_info.iloc[3][14]) else string_formatter(extraction_info.iloc[3][14]) + # self.sub['lot_elution_buffer'] = extraction_info.iloc[4][14] #if pd.isnull(extraction_info.iloc[4][14]) else string_formatter(extraction_info.iloc[4][14]) + # self.sub['lot_master_mix'] = qprc_info.iloc[0][14] #if pd.isnull(qprc_info.iloc[0][14]) else string_formatter(qprc_info.iloc[0][14]) + # self.sub['lot_pre_mix_1'] = qprc_info.iloc[1][14] #if pd.isnull(qprc_info.iloc[1][14]) else string_formatter(qprc_info.iloc[1][14]) + # self.sub['lot_pre_mix_2'] = qprc_info.iloc[2][14] #if pd.isnull(qprc_info.iloc[2][14]) else string_formatter(qprc_info.iloc[2][14]) + # self.sub['lot_positive_control'] = qprc_info.iloc[3][14] #if pd.isnull(qprc_info.iloc[3][14]) else string_formatter(qprc_info.iloc[3][14]) + # self.sub['lot_ddh2o'] = qprc_info.iloc[4][14] #if pd.isnull(qprc_info.iloc[4][14]) else string_formatter(qprc_info.iloc[4][14]) # gt individual sample info sample_parser = SampleParser(submission_info.iloc[16:40]) sample_parse = getattr(sample_parser, f"parse_{self.sub['submission_type'].lower()}_samples") self.sub['samples'] = sample_parse() + + + + + + class SampleParser(object): """ diff --git a/src/submissions/backend/excel/reports.py b/src/submissions/backend/excel/reports.py index 62a0a42..a3bd59d 100644 --- a/src/submissions/backend/excel/reports.py +++ b/src/submissions/backend/excel/reports.py @@ -1,9 +1,21 @@ -import pandas as pd -from pandas import DataFrame -import numpy as np + +from pandas import DataFrame, concat from backend.db import models import json import logging +from jinja2 import Environment, FileSystemLoader +from datetime import date +import sys +from pathlib import Path + +logger = logging.getLogger(f"submissions.{__name__}") + +if getattr(sys, 'frozen', False): + loader_path = Path(sys._MEIPASS).joinpath("files", "templates") +else: + loader_path = Path(__file__).parents[2].joinpath('templates').absolute().__str__() +loader = FileSystemLoader(loader_path) +env = Environment(loader=loader) logger = logging.getLogger(f"submissions.{__name__}") @@ -22,12 +34,50 @@ def make_report_xlsx(records:list[dict]) -> DataFrame: df = df.sort_values("Submitting Lab") # aggregate cost and sample count columns df2 = df.groupby(["Submitting Lab", "Extraction Kit"]).agg({'Cost': ['sum', 'count'], 'Sample Count':['sum']}) - logger.debug(df2.columns) # apply formating to cost column - df2.iloc[:, (df2.columns.get_level_values(1)=='sum') & (df2.columns.get_level_values(0)=='Cost')] = df2.iloc[:, (df2.columns.get_level_values(1)=='sum') & (df2.columns.get_level_values(0)=='Cost')].applymap('${:,.2f}'.format) + # df2.iloc[:, (df2.columns.get_level_values(1)=='sum') & (df2.columns.get_level_values(0)=='Cost')] = df2.iloc[:, (df2.columns.get_level_values(1)=='sum') & (df2.columns.get_level_values(0)=='Cost')].applymap('${:,.2f}'.format) return df2 +def make_report_html(df:DataFrame, start_date:date, end_date:date) -> str: + + """ + generates html from the report dataframe + + Args: + df (DataFrame): input dataframe generated from 'make_report_xlsx' above + start_date (date): starting date of the report period + end_date (date): ending date of the report period + + Returns: + str: html string + """ + old_lab = "" + output = [] + for ii, row in enumerate(df.iterrows()): + row = [item for item in row] + lab = row[0][0] + logger.debug(f"Old lab: {old_lab}, Current lab: {lab}") + kit = dict(name=row[0][1], cost=row[1][('Cost', 'sum')], plate_count=int(row[1][('Cost', 'count')]), sample_count=int(row[1][('Sample Count', 'sum')])) + if lab == old_lab: + output[ii-1]['kits'].append(kit) + output[ii-1]['total_cost'] += kit['cost'] + output[ii-1]['total_samples'] += kit['sample_count'] + output[ii-1]['total_plates'] += kit['plate_count'] + else: + adder = dict(lab=lab, kits=[kit], total_cost=kit['cost'], total_samples=kit['sample_count'], total_plates=kit['plate_count']) + output.append(adder) + old_lab = lab + logger.debug(output) + dicto = {'start_date':start_date, 'end_date':end_date, 'labs':output}#, "table":table} + temp = env.get_template('summary_report.html') + html = temp.render(input=dicto) + return html + + + + + # def split_controls_dictionary(ctx:dict, input_dict) -> list[dict]: # # this will be the date in string form # dict_name = list(input_dict.keys())[0] @@ -126,3 +176,4 @@ def convert_data_list_to_df(ctx:dict, input:list[dict], subtype:str|None=None) - del df[column] # logger.debug(df) return df + diff --git a/src/submissions/configure/__init__.py b/src/submissions/configure/__init__.py index 62cd9a3..cccce2e 100644 --- a/src/submissions/configure/__init__.py +++ b/src/submissions/configure/__init__.py @@ -226,6 +226,8 @@ def copy_settings(settings_path:Path, settings:dict) -> dict: # 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'] with open(settings_path, 'w') as f: yaml.dump(settings, f) return settings diff --git a/src/submissions/frontend/__init__.py b/src/submissions/frontend/__init__.py index ede26d4..8dc1abe 100644 --- a/src/submissions/frontend/__init__.py +++ b/src/submissions/frontend/__init__.py @@ -14,6 +14,8 @@ from PyQt6.QtWebEngineWidgets import QWebEngineView from pathlib import Path import plotly +import pandas as pd +from xhtml2pdf import pisa # import plotly.express as px import yaml @@ -25,7 +27,7 @@ from backend.db import (construct_submission_info, lookup_reagent, get_all_Control_Types_names, create_kit_from_yaml, get_all_available_modes, get_all_controls_by_type, get_control_subtypes ) -from backend.excel.reports import make_report_xlsx +from backend.excel.reports import make_report_xlsx, make_report_html import numpy from frontend.custom_widgets import AddReagentQuestion, AddReagentForm, SubmissionsSheet, ReportDatePicker, KitAdder, ControlsDatePicker, OverwriteSubQuestion import logging @@ -362,17 +364,22 @@ class App(QMainWindow): subs = lookup_submissions_by_date_range(ctx=self.ctx, start_date=info['start_date'], end_date=info['end_date']) # convert each object to dict records = [item.report_dict() for item in subs] - # make dataframe from record dictionaries df = make_report_xlsx(records=records) - # setup filedialog to handle save location of report - home_dir = Path(self.ctx["directory_path"]).joinpath(f"Submissions_Report_{info['start_date']}-{info['end_date']}.xlsx").resolve().__str__() - fname = Path(QFileDialog.getSaveFileName(self, "Save File", home_dir, filter=".xlsx")[0]) - # save file - try: - df.to_excel(fname, engine="openpyxl") - except PermissionError: - pass + html = make_report_html(df=df, start_date=info['start_date'], end_date=info['end_date']) + # make dataframe from record dictionaries + # df = make_report_xlsx(records=records) + # # setup filedialog to handle save location of report + home_dir = Path(self.ctx["directory_path"]).joinpath(f"Submissions_Report_{info['start_date']}-{info['end_date']}.pdf").resolve().__str__() + # fname = Path(QFileDialog.getSaveFileName(self, "Save File", home_dir, filter=".xlsx")[0]) + fname = Path(QFileDialog.getSaveFileName(self, "Save File", home_dir, filter=".pdf")[0]) + # logger.debug(f"report output name: {fname}") + # df.to_excel(fname, engine='openpyxl') + with open(fname, "w+b") as f: + pisa.CreatePDF(html, dest=f) + df.to_excel(fname.with_suffix(".xlsx"), engine='openpyxl') + + def add_kit(self): """ diff --git a/src/submissions/frontend/custom_widgets/__init__.py b/src/submissions/frontend/custom_widgets/__init__.py index ac460fd..bf5d429 100644 --- a/src/submissions/frontend/custom_widgets/__init__.py +++ b/src/submissions/frontend/custom_widgets/__init__.py @@ -302,11 +302,16 @@ class KitAdder(QWidget): used_for.setEditable(True) self.grid.addWidget(used_for,3,1) # set cost per run - self.grid.addWidget(QLabel("Cost per run:"),4,0) + self.grid.addWidget(QLabel("Constant cost per full plate (plates, work hours, etc.):"),4,0) cost = QSpinBox() cost.setMinimum(0) cost.setMaximum(9999) self.grid.addWidget(cost,4,1) + self.grid.addWidget(QLabel("Mutable cost per full plate (tips, reagents, etc.):"),5,0) + cost = QSpinBox() + cost.setMinimum(0) + cost.setMaximum(9999) + self.grid.addWidget(cost,5,1) # button to add additional reagent types self.add_RT_btn = QPushButton("Add Reagent Type") self.grid.addWidget(self.add_RT_btn) @@ -338,7 +343,8 @@ class KitAdder(QWidget): yml_type[used] = {} yml_type[used]['kits'] = {} yml_type[used]['kits'][info['kit_name']] = {} - yml_type[used]['kits'][info['kit_name']]['cost'] = info['cost_per_run'] + yml_type[used]['kits'][info['kit_name']]['constant_cost'] = info["Constant cost per full plate (plates, work hours, etc.)"] + yml_type[used]['kits'][info['kit_name']]['mutable_cost'] = info["Mutable cost per full plate (tips, reagents, etc.)"] yml_type[used]['kits'][info['kit_name']]['reagenttypes'] = reagents logger.debug(yml_type) # send to kit constructor @@ -381,20 +387,19 @@ class KitAdder(QWidget): if not isinstance(prev_item, QDateEdit) and not isinstance(prev_item, QComboBox) and not isinstance(prev_item, QSpinBox) and not isinstance(prev_item, QScrollBar): logger.debug(f"Previous: {prev_item}") logger.debug(f"Item: {item}, {item.text()}") - values.append(item.text()) + values.append(item.text().strip()) case QComboBox(): - values.append(item.currentText()) + values.append(item.currentText().strip()) case QDateEdit(): values.append(item.date().toPyDate()) case QSpinBox(): values.append(item.value()) case ReagentTypeForm(): - re_labels, re_values, _ = self.extract_form_info(item) reagent = {item[0]:item[1] for item in zip(re_labels, re_values)} logger.debug(reagent) # reagent = {reagent['name:']:{'eol':reagent['extension_of_life_(months):']}} - reagents[reagent['name']] = {'eol_ext':int(reagent['extension_of_life_(months)'])} + reagents[reagent["name_(*exactly*_as_it_appears_in_the_excel_submission_form)"].strip()] = {'eol_ext':int(reagent['extension_of_life_(months)'])} prev_item = item return labels, values, reagents @@ -408,7 +413,7 @@ class ReagentTypeForm(QWidget): super().__init__() grid = QGridLayout() self.setLayout(grid) - grid.addWidget(QLabel("Name:"),0,0) + grid.addWidget(QLabel("Name (*Exactly* as it appears in the excel submission form):"),0,0) reagent_getter = QComboBox() # lookup all reagent type names from db reagent_getter.addItems(get_all_reagenttype_names(ctx=parent_ctx)) diff --git a/src/submissions/templates/summary_report.html b/src/submissions/templates/summary_report.html new file mode 100644 index 0000000..7cf63d7 --- /dev/null +++ b/src/submissions/templates/summary_report.html @@ -0,0 +1,22 @@ + + +
+{{ kit['name'] }}
+Plates: {{ kit['plate_count'] }}, Samples: {{ kit['sample_count'] }}, Cost: {{ "${:,.2f}".format(kit['cost']) }}
+ {% endfor %} +Lab total:
+Plates: {{ lab['total_plates'] }}, Samples: {{ lab['total_samples'] }}, Cost: {{ "${:,.2f}".format(lab['total_cost']) }}
+