diff --git a/src/submissions/backend/db/models/controls.py b/src/submissions/backend/db/models/controls.py index 23fdcf2..a60b6a5 100644 --- a/src/submissions/backend/db/models/controls.py +++ b/src/submissions/backend/db/models/controls.py @@ -177,6 +177,8 @@ class Control(BaseClass): data = self.__getattribute__(mode) except TypeError: data = {} + if data is None: + data = {} # logger.debug(f"Length of data: {len(data)}") # logger.debug("dict keys are genera of bacteria, e.g. 'Streptococcus'") for genus in data: diff --git a/src/submissions/backend/db/models/submissions.py b/src/submissions/backend/db/models/submissions.py index bbab71e..e914ffb 100644 --- a/src/submissions/backend/db/models/submissions.py +++ b/src/submissions/backend/db/models/submissions.py @@ -2,6 +2,8 @@ Models for the main submission and sample types. """ from __future__ import annotations + +import sys from getpass import getuser import logging, uuid, tempfile, re, yaml, base64 from zipfile import ZipFile @@ -13,7 +15,8 @@ from sqlalchemy import Column, String, TIMESTAMP, INTEGER, ForeignKey, JSON, FLO from sqlalchemy.orm import relationship, validates, Query from sqlalchemy.orm.attributes import flag_modified from sqlalchemy.ext.associationproxy import association_proxy -from sqlalchemy.exc import OperationalError as AlcOperationalError, IntegrityError as AlcIntegrityError, StatementError +from sqlalchemy.exc import OperationalError as AlcOperationalError, IntegrityError as AlcIntegrityError, StatementError, \ + ArgumentError from sqlite3 import OperationalError as SQLOperationalError, IntegrityError as SQLIntegrityError import pandas as pd from openpyxl import Workbook, load_workbook @@ -244,14 +247,22 @@ class BasicSubmission(BaseClass): ext_info = None output = { "id": self.id, - "Plate Number": self.rsl_plate_num, - "Submission Type": self.submission_type_name, - "Submitter Plate Number": self.submitter_plate_num, - "Submitted Date": self.submitted_date.strftime("%Y-%m-%d"), - "Submitting Lab": sub_lab, - "Sample Count": self.sample_count, - "Extraction Kit": ext_kit, - "Cost": self.run_cost, + # "Plate Number": self.rsl_plate_num, + # "Submission Type": self.submission_type_name, + # "Submitter Plate Number": self.submitter_plate_num, + # "Submitted Date": self.submitted_date.strftime("%Y-%m-%d"), + # "Submitting Lab": sub_lab, + # "Sample Count": self.sample_count, + # "Extraction Kit": ext_kit, + # "Cost": self.run_cost, + "plate_number": self.rsl_plate_num, + "submission_type": self.submission_type_name, + "submitter_plate_number": self.submitter_plate_num, + "submitted_date": self.submitted_date.strftime("%Y-%m-%d"), + "submitting_lab": sub_lab, + "sample_count": self.sample_count, + "extraction_kit": ext_kit, + "cost": self.run_cost, } if report: return output @@ -290,17 +301,26 @@ class BasicSubmission(BaseClass): try: comments = self.comment except Exception as e: - logger.error(f"Error setting comment: {self.comment}") + logger.error(f"Error setting comment: {self.comment}, {e}") comments = None - output["Submission Category"] = self.submission_category - output["Technician"] = self.technician + # output["Submission Category"] = self.submission_category + # output["Technician"] = self.technician + # output["reagents"] = reagents + # output["samples"] = samples + # output["extraction_info"] = ext_info + # output["comment"] = comments + # output["equipment"] = equipment + # output["Cost Centre"] = cost_centre + # output["Signed By"] = self.signed_by + output["submission_category"] = self.submission_category + output["technician"] = self.technician output["reagents"] = reagents output["samples"] = samples output["extraction_info"] = ext_info output["comment"] = comments output["equipment"] = equipment - output["Cost Centre"] = cost_centre - output["Signed By"] = self.signed_by + output["cost_centre"] = cost_centre + output["signed_by"] = self.signed_by return output def calculate_column_count(self) -> int: @@ -380,7 +400,7 @@ class BasicSubmission(BaseClass): for column in range(1, plate_columns + 1): for row in range(1, plate_rows + 1): try: - well = [item for item in sample_list if item['Row'] == row and item['Column'] == column][0] + well = [item for item in sample_list if item['row'] == row and item['column'] == column][0] except IndexError: well = dict(name="", row=row, column=column, background_color="#ffffff") output_samples.append(well) @@ -416,15 +436,21 @@ class BasicSubmission(BaseClass): subs = [item.to_dict() for item in cls.query(submission_type=submission_type, limit=limit, chronologic=chronologic)] # logger.debug(f"Got {len(subs)} submissions.") df = pd.DataFrame.from_records(subs) + # logger.debug(f"Column names: {df.columns}") # NOTE: Exclude sub information - for item in ['controls', 'extraction_info', 'pcr_info', 'comment', 'comments', 'samples', 'reagents', - 'equipment', 'gel_info', 'gel_image', 'dna_core_submission_number', 'gel_controls']: + excluded = ['controls', 'extraction_info', 'pcr_info', 'comment', 'comments', 'samples', 'reagents', + 'equipment', 'gel_info', 'gel_image', 'dna_core_submission_number', 'gel_controls', + 'source_plates', 'pcr_technician', 'ext_technician', 'artic_technician', 'cost_centre', + 'signed_by'] + for item in excluded: try: df = df.drop(item, axis=1) except: logger.warning(f"Couldn't drop '{item}' column from submissionsheet df.") if chronologic: - df.sort_values(by="Submitted Date", axis=0, inplace=True, ascending=False) + df.sort_values(by="id", axis=0, inplace=True, ascending=False) + # NOTE: Human friendly column labels + df.columns = [item.replace("_", " ").title() for item in df.columns] return df def set_attribute(self, key: str, value): @@ -540,9 +566,9 @@ class BasicSubmission(BaseClass): new_dict[key] = [PydEquipment(**equipment) for equipment in dicto['equipment']] except TypeError as e: logger.error(f"Possible no equipment error: {e}") - case "Plate Number": + case "plate_number": new_dict['rsl_plate_num'] = dict(value=value, missing=missing) - case "Submitter Plate Number": + case "submitter_plate_number": new_dict['submitter_plate_num'] = dict(value=value, missing=missing) case "id": pass @@ -566,6 +592,10 @@ class BasicSubmission(BaseClass): self.uploaded_by = getuser() super().save() + @classmethod + def get_regex(cls): + return cls.construct_regex() + # Polymorphic functions @classmethod @@ -598,7 +628,6 @@ class BasicSubmission(BaseClass): polymorphic_identity = polymorphic_identity['value'] if isinstance(polymorphic_identity, SubmissionType): polymorphic_identity = polymorphic_identity.name - # if polymorphic_identity != None: model = cls match polymorphic_identity: case str(): @@ -624,22 +653,6 @@ class BasicSubmission(BaseClass): return model # Child class custom functions - - # @classmethod - # def custom_platemap(cls, xl: pd.ExcelFile, plate_map: pd.DataFrame) -> pd.DataFrame: - # """ - # Stupid stopgap solution to there being an issue with the Bacterial Culture plate map - # - # Args: - # xl (pd.ExcelFile): original xl workbook, used for child classes mostly - # plate_map (pd.DataFrame): original plate map - # - # Returns: - # pd.DataFrame: updated plate map. - # """ - # logger.info(f"Calling {cls.__mapper_args__['polymorphic_identity']} plate mapper.") - # return plate_map - @classmethod def custom_info_parser(cls, input_dict: dict, xl: Workbook | None = None) -> dict: """ @@ -723,7 +736,6 @@ class BasicSubmission(BaseClass): data['abbreviation'] = defaults['abbreviation'] if 'submission_type' not in data.keys() or data['submission_type'] in [None, ""]: data['submission_type'] = defaults['submission_type'] - # outstr = super().enforce_name(instr=instr, data=data) if instr in [None, ""]: # logger.debug("Sending to RSLNamer to make new plate name.") outstr = RSLNamer.construct_new_plate_name(data=data) @@ -754,22 +766,7 @@ class BasicSubmission(BaseClass): outstr = re.sub(r"(-\dR)\d?", rf"\1 {repeat}", outstr).replace(" ", "") abb = cls.get_default_info('abbreviation') return re.sub(rf"{abb}(\d)", rf"{abb}-\1", outstr) - # return outstr - # @classmethod - # def parse_pcr(cls, xl: pd.DataFrame, rsl_number: str) -> list: - # """ - # Perform custom parsing of pcr info. - # - # Args: - # xl (pd.DataFrame): pcr info form - # rsl_number (str): rsl plate num of interest - # - # Returns: - # list: _description_ - # """ - # logger.debug(f"Hello from {cls.__mapper_args__['polymorphic_identity']} PCR parser!") - # return [] @classmethod def parse_pcr(cls, xl: Workbook, rsl_plate_num: str) -> list: """ @@ -896,15 +893,10 @@ class BasicSubmission(BaseClass): # logger.debug(f"Incoming kwargs: {kwargs}") # NOTE: if you go back to using 'model' change the appropriate cls to model in the query filters if submission_type is not None: - # if isinstance(submission_type, SubmissionType): - # model = cls.find_subclasses(submission_type=submission_type.name) model = cls.find_polymorphic_subclass(polymorphic_identity=submission_type) - # else: - # model = cls.find_subclasses(submission_type=submission_type) elif len(kwargs) > 0: # find the subclass containing the relevant attributes # logger.debug(f"Attributes for search: {kwargs}") - # model = cls.find_subclasses(attrs=kwargs) model = cls.find_polymorphic_subclass(attrs=kwargs) else: model = cls @@ -979,17 +971,6 @@ class BasicSubmission(BaseClass): limit = 1 case _: pass - # for k, v in kwargs.items(): - # logger.debug(f"Looking up attribute: {k}") - # attr = getattr(model, k) - # logger.debug(f"Got attr: {attr}") - # query = query.filter(attr==v) - # if len(kwargs) > 0: - # limit = 1 - # query = cls.query_by_keywords(query=query, model=model, **kwargs) - # if any(x in kwargs.keys() for x in cls.get_default_info('singles')): - # logger.debug(f"There's a singled out item in kwargs") - # limit = 1 if chronologic: query.order_by(cls.submitted_date) return cls.execute_query(query=query, model=model, limit=limit, **kwargs) @@ -1150,7 +1131,6 @@ class BasicSubmission(BaseClass): if fname.name == "": # logger.debug(f"export cancelled.") return - # pyd.filepath = fname if full_backup: backup = self.to_dict(full_data=True) try: @@ -1158,11 +1138,7 @@ class BasicSubmission(BaseClass): yaml.dump(backup, f) except KeyError as e: logger.error(f"Problem saving yml backup file: {e}") - # wb = pyd.autofill_excel() - # wb = pyd.autofill_samples(wb) - # wb = pyd.autofill_equipment(wb) writer = pyd.to_writer() - # wb.save(filename=fname.with_suffix(".xlsx")) writer.xl.save(filename=fname.with_suffix(".xlsx")) # Below are the custom submission types @@ -1192,49 +1168,6 @@ class BacterialCulture(BasicSubmission): output['controls'] = [item.to_sub_dict() for item in self.controls] return output - # @classmethod - # def custom_platemap(cls, xl: pd.ExcelFile, plate_map: pd.DataFrame) -> pd.DataFrame: - # """ - # Stupid stopgap solution to there being an issue with the Bacterial Culture plate map. Extends parent. - # - # Args: - # xl (pd.ExcelFile): original xl workbook - # plate_map (pd.DataFrame): original plate map - # - # Returns: - # pd.DataFrame: updated plate map. - # """ - # plate_map = super().custom_platemap(xl, plate_map) - # num1 = xl.parse("Sample List").iloc[40, 1] - # num2 = xl.parse("Sample List").iloc[41, 1] - # # logger.debug(f"Broken: {plate_map.iloc[5, 0]}, {plate_map.iloc[6, 0]}") - # # logger.debug(f"Replace: {num1}, {num2}") - # if not check_not_nan(plate_map.iloc[5, 0]): - # plate_map.iloc[5, 0] = num1 - # if not check_not_nan(plate_map.iloc[6, 0]): - # plate_map.iloc[6, 0] = num2 - # return plate_map - - # @classmethod - # def custom_writer(cls, input_excel: Workbook, info: dict | None = None, backup: bool = False) -> Workbook: - # """ - # Stupid stopgap solution to there being an issue with the Bacterial Culture plate map. Extends parent. - # - # Args: - # input_excel (Workbook): Input openpyxl workbook - # - # Returns: - # Workbook: Updated openpyxl workbook - # """ - # input_excel = super().custom_writer(input_excel) - # sheet = input_excel['Plate Map'] - # if sheet.cell(12, 2).value == None: - # sheet.cell(row=12, column=2, value="=IF(ISBLANK('Sample List'!$B42),\"\",'Sample List'!$B42)") - # if sheet.cell(13, 2).value == None: - # sheet.cell(row=13, column=2, value="=IF(ISBLANK('Sample List'!$B43),\"\",'Sample List'!$B43)") - # input_excel["Sample List"].cell(row=15, column=2, value=getuser()) - # return input_excel - @classmethod def get_regex(cls) -> str: """ @@ -1336,13 +1269,13 @@ class Wastewater(BasicSubmission): except TypeError as e: pass if self.ext_technician is None or self.ext_technician == "None": - output['Ext Technician'] = self.technician + output['ext_technician'] = self.technician else: - output["Ext Technician"] = self.ext_technician + output["ext_technician"] = self.ext_technician if self.pcr_technician is None or self.pcr_technician == "None": - output["PCR Technician"] = self.technician + output["pcr_technician"] = self.technician else: - output['PCR Technician'] = self.pcr_technician + output['pcr_technician'] = self.pcr_technician return output @classmethod @@ -1361,46 +1294,6 @@ class Wastewater(BasicSubmission): input_dict['csv'] = xl["Copy to import file"] return input_dict - # @classmethod - # def parse_pcr(cls, xl: pd.ExcelFile, rsl_number: str) -> list: - # """ - # Parse specific to wastewater samples. - # """ - # samples = super().parse_pcr(xl=xl, rsl_number=rsl_number) - # df = xl.parse(sheet_name="Results", dtype=object).fillna("") - # column_names = ["Well", "Well Position", "Omit", "Sample", "Target", "Task", " Reporter", "Quencher", - # "Amp Status", "Amp Score", "Curve Quality", "Result Quality Issues", "Cq", "Cq Confidence", - # "Cq Mean", "Cq SD", "Auto Threshold", "Threshold", "Auto Baseline", "Baseline Start", - # "Baseline End"] - # samples_df = df.iloc[23:][0:] - # logger.debug(f"Dataframe of PCR results:\n\t{samples_df}") - # samples_df.columns = column_names - # logger.debug(f"Samples columns: {samples_df.columns}") - # well_call_df = xl.parse(sheet_name="Well Call").iloc[24:][0:].iloc[:, -1:] - # try: - # samples_df['Assessment'] = well_call_df.values - # except ValueError: - # logger.error("Well call number doesn't match sample number") - # logger.debug(f"Well call df: {well_call_df}") - # for _, row in samples_df.iterrows(): - # try: - # sample_obj = [sample for sample in samples if sample['sample'] == row[3]][0] - # except IndexError: - # sample_obj = dict( - # sample=row['Sample'], - # plate_rsl=rsl_number, - # ) - # logger.debug(f"Got sample obj: {sample_obj}") - # if isinstance(row['Cq'], float): - # sample_obj[f"ct_{row['Target'].lower()}"] = row['Cq'] - # else: - # sample_obj[f"ct_{row['Target'].lower()}"] = 0.0 - # try: - # sample_obj[f"{row['Target'].lower()}_status"] = row['Assessment'] - # except KeyError: - # logger.error(f"No assessment for {sample_obj['sample']}") - # samples.append(sample_obj) - # return samples @classmethod def parse_pcr(cls, xl: Workbook, rsl_plate_num: str) -> list: """ @@ -1531,6 +1424,8 @@ class WastewaterArtic(BasicSubmission): dict: dictionary used in submissions summary """ output = super().to_dict(full_data=full_data, backup=backup, report=report) + if self.artic_technician in [None, "None"]: + output['artic_technician'] = self.technician if report: return output output['gel_info'] = self.gel_info @@ -1770,16 +1665,13 @@ class WastewaterArtic(BasicSubmission): Workbook: Updated workbook """ input_excel = super().custom_info_writer(input_excel, info, backup) - # worksheet = input_excel["First Strand List"] - # samples = cls.query(rsl_number=info['rsl_plate_num']['value']).submission_sample_associations - # samples = sorted(samples, key=attrgetter('column', 'row')) - # logger.debug(f"Info:\n{pformat(info)}") + logger.debug(f"Info:\n{pformat(info)}") check = 'source_plates' in info.keys() and info['source_plates'] is not None if check: worksheet = input_excel['First Strand List'] start_row = 8 for iii, plate in enumerate(info['source_plates']['value']): - # logger.debug(f"Plate: {plate}") + logger.debug(f"Plate: {plate}") row = start_row + iii try: worksheet.cell(row=row, column=3, value=plate['plate']) @@ -1868,12 +1760,23 @@ class WastewaterArtic(BasicSubmission): set_plate = None for assoc in self.submission_sample_associations: dicto = assoc.to_sub_dict() + if self.source_plates is None: + output.append(dicto) + continue for item in self.source_plates: old_plate = WastewaterAssociation.query(submission=item['plate'], sample=assoc.sample, limit=1) if old_plate is not None: set_plate = old_plate.submission.rsl_plate_num + logger.debug(dicto['WW Processing Num']) + if dicto['WW Processing Num'].startswith("NTC"): + dicto['Well'] = dicto['WW Processing Num'] + else: + dicto['Well'] = f"{row_map[old_plate.row]}{old_plate.column}" break + elif dicto['WW Processing Num'].startswith("NTC"): + dicto['Well'] = dicto['WW Processing Num'] dicto['plate_name'] = set_plate + logger.debug(f"Here is our raw sample: {pformat(dicto)}") output.append(dicto) return output @@ -1997,8 +1900,8 @@ class BasicSample(BaseClass): # logger.debug(f"Converting {self} to dict.") # start = time() sample = {} - sample['Submitter ID'] = self.submitter_id - sample['Sample Type'] = self.sample_type + sample['submitter_id'] = self.submitter_id + sample['sample_type'] = self.sample_type if full_data: sample['submissions'] = sorted([item.to_sub_dict() for item in self.sample_submission_associations], key=itemgetter('submitted_date')) @@ -2099,7 +2002,7 @@ class BasicSample(BaseClass): @setup_lookup def query(cls, submitter_id: str | None = None, - sample_type: str | None = None, + sample_type: str | BasicSample | None = None, limit: int = 0, **kwargs ) -> BasicSample | List[BasicSample]: @@ -2114,14 +2017,14 @@ class BasicSample(BaseClass): Returns: models.BasicSample|List[models.BasicSample]: Sample(s) of interest. """ - if sample_type is None: - # model = cls.find_subclasses(attrs=kwargs) - model = cls.find_polymorphic_subclass(attrs=kwargs) - else: - model = cls.find_polymorphic_subclass(polymorphic_identity=sample_type) + match sample_type: + case str(): + model = cls.find_polymorphic_subclass(polymorphic_identity=sample_type) + case BasicSample(): + model = sample_type + case _: + model = cls.find_polymorphic_subclass(attrs=kwargs) # logger.debug(f"Length of kwargs: {len(kwargs)}") - # model = models.BasicSample.find_subclasses(ctx=ctx, attrs=kwargs) - # query: Query = setup_lookup(ctx=ctx, locals=locals()).query(model) query: Query = cls.__database_session__.query(model) match submitter_id: case str(): @@ -2130,20 +2033,7 @@ class BasicSample(BaseClass): limit = 1 case _: pass - # match sample_type: - # case str(): - # logger.warning(f"Looking up samples with sample_type is disabled.") - # # query = query.filter(models.BasicSample.sample_type==sample_type) - # case _: - # pass - # for k, v in kwargs.items(): - # attr = getattr(model, k) - # # logger.debug(f"Got attr: {attr}") - # query = query.filter(attr==v) - # if len(kwargs) > 0: - # limit = 1 return cls.execute_query(query=query, model=model, limit=limit, **kwargs) - # return cls.execute_query(query=query, limit=limit) @classmethod def query_or_create(cls, sample_type: str | None = None, **kwargs) -> BasicSample: @@ -2163,10 +2053,6 @@ class BasicSample(BaseClass): 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.") sanitized_kwargs = {k:v for k,v in kwargs.items() if k not in disallowed} instance = cls.query(sample_type=sample_type, limit=1, **kwargs) # logger.debug(f"Retrieved instance: {instance}") @@ -2177,9 +2063,85 @@ class BasicSample(BaseClass): logger.debug(f"Creating instance: {instance}") return instance + @classmethod + def fuzzy_search(cls, + # submitter_id: str | None = None, + sample_type: str | BasicSample | None = None, + # limit: int = 0, + **kwargs + ) -> List[BasicSample]: + match sample_type: + case str(): + model = cls.find_polymorphic_subclass(polymorphic_identity=sample_type) + case BasicSample(): + model = sample_type + case _: + model = cls.find_polymorphic_subclass(attrs=kwargs) + # logger.debug(f"Length of kwargs: {len(kwargs)}") + query: Query = cls.__database_session__.query(model) + for k, v in kwargs.items(): + search = f"%{v}%" + try: + attr = getattr(model, k) + query = query.filter(attr.like(search)) + except (ArgumentError, AttributeError) as e: + logger.error(f"Attribute {k} unavailable due to:\n\t{e}\nSkipping.") + return query.all() + def delete(self): raise AttributeError(f"Delete not implemented for {self.__class__}") + @classmethod + def get_searchables(cls): + return [dict(label="Submitter ID", field="submitter_id")] + + + @classmethod + def samples_to_df(cls, sample_type: str | None | BasicSample = None, **kwargs): + # def samples_to_df(cls, sample_type:str|None|BasicSample=None, searchables:dict={}): + logger.debug(f"Checking {sample_type} with type {type(sample_type)}") + match sample_type: + case str(): + model = BasicSample.find_polymorphic_subclass(polymorphic_identity=sample_type) + case _: + try: + check = issubclass(sample_type, BasicSample) + except TypeError: + check = False + if check: + model = sample_type + else: + model = cls + q_out = model.fuzzy_search(sample_type=sample_type, **kwargs) + if not isinstance(q_out, list): + q_out = [q_out] + try: + samples = [sample.to_sub_dict() for sample in q_out] + except TypeError as e: + logger.error(f"Couldn't find any samples with data: {kwargs}\nDue to {e}") + return None + df = pd.DataFrame.from_records(samples) + # NOTE: Exclude sub information + for item in ['concentration', 'organism', 'colour', 'tooltip', 'comments', 'samples', 'reagents', + 'equipment', 'gel_info', 'gel_image', 'dna_core_submission_number', 'gel_controls']: + try: + df = df.drop(item, axis=1) + except: + logger.warning(f"Couldn't drop '{item}' column from submissionsheet df.") + return df + + def show_details(self, obj): + """ + Creates Widget for showing submission details. + + Args: + obj (_type_): parent widget + """ + # logger.debug("Hello from details") + from frontend.widgets.submission_details import SubmissionDetails + dlg = SubmissionDetails(parent=obj, sub=self) + if dlg.exec(): + pass #Below are the custom sample types @@ -2229,10 +2191,10 @@ class WastewaterSample(BasicSample): dict: well location and name (sample id, organism) NOTE: keys must sync with WWSample to_sub_dict above """ sample = super().to_sub_dict(full_data=full_data) - sample['WW Processing Num'] = self.ww_processing_num - sample['Sample Location'] = self.sample_location - sample['Received Date'] = self.received_date - sample['Collection Date'] = self.collection_date + sample['ww_processing_num'] = self.ww_processing_num + sample['sample_location'] = self.sample_location + sample['received_date'] = self.received_date + sample['collection_date'] = self.collection_date return sample @classmethod @@ -2257,28 +2219,9 @@ class WastewaterSample(BasicSample): output_dict['rsl_number'] = "RSL-WW-" + output_dict['ww_processing_num'] if output_dict['ww_full_sample_id'] is not None and output_dict["submitter_id"] in disallowed: output_dict["submitter_id"] = output_dict['ww_full_sample_id'] - # if re.search(r"^NTC", output_dict['submitter_id']): - # output_dict['submitter_id'] = "Artic-" + output_dict['submitter_id'] - # Ad hoc repair method for WW (or possibly upstream) not formatting some dates properly. - # NOTE: Should be handled by validator. - # match output_dict['collection_date']: - # case str(): - # try: - # output_dict['collection_date'] = parse(output_dict['collection_date']).date() - # except ParserError: - # logger.error(f"Problem parsing collection_date: {output_dict['collection_date']}") - # output_dict['collection_date'] = date(1970, 1, 1) - # case datetime(): - # output_dict['collection_date'] = output_dict['collection_date'].date() - # case date(): - # pass - # case _: - # del output_dict['collection_date'] return output_dict def get_previous_ww_submission(self, current_artic_submission: WastewaterArtic): - # assocs = [assoc for assoc in self.sample_submission_associations if assoc.submission.submission_type_name=="Wastewater"] - # subs = self.submissions[:self.submissions.index(current_artic_submission)] try: plates = [item['plate'] for item in current_artic_submission.source_plates] except TypeError as e: @@ -2292,6 +2235,13 @@ class WastewaterSample(BasicSample): except IndexError: return None + @classmethod + def get_searchables(cls): + searchables = super().get_searchables() + for item in ["ww_processing_num", "ww_full_sample_id", "rsl_number"]: + label = item.strip("ww_").replace("_", " ").replace("rsl", "RSL").title() + searchables.append(dict(label=label, field=item)) + return searchables class BacterialCultureSample(BasicSample): """ @@ -2314,16 +2264,15 @@ class BacterialCultureSample(BasicSample): """ # start = time() sample = super().to_sub_dict(full_data=full_data) - sample['Name'] = self.submitter_id - sample['Organism'] = self.organism - sample['Concentration'] = self.concentration + sample['name'] = self.submitter_id + sample['organism'] = self.organism + sample['concentration'] = self.concentration if self.control != None: sample['colour'] = [0, 128, 0] sample['tooltip'] = f"Control: {self.control.controltype.name} - {self.control.controltype.targets}" # logger.debug(f"Done converting to {self} to dict after {time()-start}") return sample - # Submission to Sample Associations class SubmissionSampleAssociation(BaseClass): @@ -2387,15 +2336,15 @@ class SubmissionSampleAssociation(BaseClass): # logger.debug(f"Running {self.__repr__()}") sample = self.sample.to_sub_dict() # logger.debug("Sample conversion complete.") - sample['Name'] = self.sample.submitter_id - sample['Row'] = self.row - sample['Column'] = self.column + sample['name'] = self.sample.submitter_id + sample['row'] = self.row + sample['column'] = self.column try: - sample['Well'] = f"{row_map[self.row]}{self.column}" + sample['well'] = f"{row_map[self.row]}{self.column}" except KeyError as e: logger.error(f"Unable to find row {self.row} in row_map.") sample['Well'] = None - sample['Plate Name'] = self.submission.rsl_plate_num + sample['plate_name'] = self.submission.rsl_plate_num sample['positive'] = False sample['submitted_date'] = self.submission.submitted_date sample['submission_rank'] = self.submission_rank diff --git a/src/submissions/backend/excel/parser.py b/src/submissions/backend/excel/parser.py index fca7e8e..acfa67f 100644 --- a/src/submissions/backend/excel/parser.py +++ b/src/submissions/backend/excel/parser.py @@ -35,7 +35,7 @@ class SheetParser(object): Args: filepath (Path | None, optional): file path to excel sheet. Defaults to None. """ - logger.debug(f"\n\nParsing {filepath.__str__()}\n\n") + logger.info(f"\n\nParsing {filepath.__str__()}\n\n") match filepath: case Path(): self.filepath = filepath @@ -651,6 +651,15 @@ class PCRParser(object): info_map = self.submission_obj.get_submission_type().sample_map['pcr_general_info'] sheet = self.xl[info_map['sheet']] iter_rows = sheet.iter_rows(min_row=info_map['start_row'], max_row=info_map['end_row']) - pcr = {row[0].value.lower().replace(' ', '_'): row[1].value for row in iter_rows} + pcr = {} + for row in iter_rows: + try: + key = row[0].value.lower().replace(' ', '_') + except AttributeError as e: + logger.error(f"No key: {row[0].value} due to {e}") + continue + value = row[1].value or "" + pcr[key] = value pcr['imported_by'] = getuser() + # logger.debug(f"PCR: {pformat(pcr)}") return pcr diff --git a/src/submissions/backend/excel/reports.py b/src/submissions/backend/excel/reports.py index 2f9497c..a8f25d2 100644 --- a/src/submissions/backend/excel/reports.py +++ b/src/submissions/backend/excel/reports.py @@ -23,13 +23,13 @@ def make_report_xlsx(records:list[dict]) -> Tuple[DataFrame, DataFrame]: """ df = DataFrame.from_records(records) # put submissions with the same lab together - df = df.sort_values("Submitting Lab") + df = df.sort_values("submitting_lab") # aggregate cost and sample count columns - df2 = df.groupby(["Submitting Lab", "Extraction Kit"]).agg({'Extraction Kit':'count', 'Cost': 'sum', 'Sample Count':'sum'}) - df2 = df2.rename(columns={"Extraction Kit": 'Run Count'}) + df2 = df.groupby(["submitting_lab", "extraction_kit"]).agg({'extraction_kit':'count', 'cost': 'sum', 'sample_count':'sum'}) + df2 = df2.rename(columns={"extraction_kit": 'run_count'}) # logger.debug(f"Output daftaframe for xlsx: {df2.columns}") df = df.drop('id', axis=1) - df = df.sort_values(['Submitting Lab', "Submitted Date"]) + df = df.sort_values(['submitting_lab', "submitted_date"]) return df, df2 def make_report_html(df:DataFrame, start_date:date, end_date:date) -> str: diff --git a/src/submissions/backend/excel/writer.py b/src/submissions/backend/excel/writer.py index 4a6568f..bd5c483 100644 --- a/src/submissions/backend/excel/writer.py +++ b/src/submissions/backend/excel/writer.py @@ -85,6 +85,7 @@ class SheetWriter(object): class InfoWriter(object): def __init__(self, xl: Workbook, submission_type: SubmissionType | str, info_dict: dict, sub_object:BasicSubmission|None=None): + logger.debug(f"Info_dict coming into InfoWriter: {pformat(info_dict)}") if isinstance(submission_type, str): submission_type = SubmissionType.query(name=submission_type) if sub_object is None: @@ -110,10 +111,15 @@ class InfoWriter(object): dicto['value'] = v if len(dicto) > 0: output[k] = dicto + # logger.debug(f"Reconciled info: {pformat(output)}") return output def write_info(self): for k, v in self.info.items(): + # NOTE: merge all comments to fit in single cell. + if k == "comment" and isinstance(v['value'], list): + json_join = [item['text'] for item in v['value'] if 'text' in item.keys()] + v['value'] = "\n".join(json_join) try: locations = v['locations'] except KeyError: @@ -183,6 +189,7 @@ class SampleWriter(object): output = [] multiples = ['row', 'column', 'assoc_id', 'submission_rank'] for sample in sample_list: + # logger.debug(f"Writing sample: {sample}") for assoc in zip(sample['row'], sample['column'], sample['submission_rank']): new = dict(row=assoc[0], column=assoc[1], submission_rank=assoc[2]) for k, v in sample.items(): diff --git a/src/submissions/backend/validators/__init__.py b/src/submissions/backend/validators/__init__.py index de2590b..b4c536c 100644 --- a/src/submissions/backend/validators/__init__.py +++ b/src/submissions/backend/validators/__init__.py @@ -100,11 +100,15 @@ class RSLNamer(object): regex (str): string to construct pattern filename (str): string to be parsed """ - # logger.debug(f"Input string to be parsed: {filename}") + logger.debug(f"Input string to be parsed: {filename}") if regex is None: regex = BasicSubmission.construct_regex() else: - regex = re.compile(rf'{regex}', re.IGNORECASE | re.VERBOSE) + logger.debug(f"Incoming regex: {regex}") + try: + regex = re.compile(rf'{regex}', re.IGNORECASE | re.VERBOSE) + except re.error as e: + regex = BasicSubmission.construct_regex() # logger.debug(f"Using regex: {regex}") match filename: case Path(): diff --git a/src/submissions/backend/validators/pydant.py b/src/submissions/backend/validators/pydant.py index bf0236a..0a75902 100644 --- a/src/submissions/backend/validators/pydant.py +++ b/src/submissions/backend/validators/pydant.py @@ -194,7 +194,7 @@ class PydSample(BaseModel, extra='allow'): # logger.debug(f"Data for pydsample: {data}") model = BasicSample.find_polymorphic_subclass(polymorphic_identity=data.sample_type) for k, v in data.model_extra.items(): - print(k, v) + # print(k, v) if k in model.timestamps(): if isinstance(v, str): v = datetime.strptime(v, "%Y-%m-%d") @@ -463,7 +463,10 @@ class PydSubmission(BaseModel, extra='allow'): return value else: # logger.debug("Constructing plate name.") - output = RSLNamer(filename=values.data['filepath'].__str__(), sub_type=sub_type, + if "pytest" in sys.modules and sub_type.replace(" ", "") == "BasicSubmission": + output = "RSL-BS-Test001" + else: + output = RSLNamer(filename=values.data['filepath'].__str__(), sub_type=sub_type, data=values.data).parsed_name return dict(value=output, missing=True) diff --git a/src/submissions/frontend/widgets/app.py b/src/submissions/frontend/widgets/app.py index 1f4e5a3..d376abb 100644 --- a/src/submissions/frontend/widgets/app.py +++ b/src/submissions/frontend/widgets/app.py @@ -18,6 +18,7 @@ from .submission_widget import SubmissionFormContainer from .controls_chart import ControlsViewer from .kit_creator import KitAdder from .submission_type_creator import SubmissionTypeAdder +from .sample_search import SearchBox logger = logging.getLogger(f'submissions.{__name__}') logger.info("Hello, I am a logger") @@ -71,6 +72,7 @@ class App(QMainWindow): fileMenu.addAction(self.importAction) # fileMenu.addAction(self.importPCRAction) methodsMenu.addAction(self.searchLog) + methodsMenu.addAction(self.searchSample) reportMenu.addAction(self.generateReportAction) maintenanceMenu.addAction(self.joinExtractionAction) maintenanceMenu.addAction(self.joinPCRAction) @@ -102,6 +104,7 @@ class App(QMainWindow): self.helpAction = QAction("&About", self) self.docsAction = QAction("&Docs", self) self.searchLog = QAction("Search Log", self) + self.searchSample = QAction("Search Sample", self) def _connectActions(self): """ @@ -117,6 +120,7 @@ class App(QMainWindow): self.helpAction.triggered.connect(self.showAbout) self.docsAction.triggered.connect(self.openDocs) self.searchLog.triggered.connect(self.runSearch) + self.searchSample.triggered.connect(self.runSampleSearch) def showAbout(self): """ @@ -161,6 +165,10 @@ class App(QMainWindow): dlg = LogParser(self) dlg.exec() + def runSampleSearch(self): + dlg = SearchBox(self) + dlg.exec() + def backup_database(self): month = date.today().strftime("%Y-%m") # day = date.today().strftime("%Y-%m-%d") @@ -171,6 +179,7 @@ class App(QMainWindow): logger.info("No backup found for this month, backing up database.") shutil.copyfile(self.ctx.database_path, current_month_bak) + class AddSubForm(QWidget): def __init__(self, parent:QWidget): diff --git a/src/submissions/frontend/widgets/controls_chart.py b/src/submissions/frontend/widgets/controls_chart.py index a73bd55..babb7ff 100644 --- a/src/submissions/frontend/widgets/controls_chart.py +++ b/src/submissions/frontend/widgets/controls_chart.py @@ -18,7 +18,7 @@ class ControlsViewer(QWidget): def __init__(self, parent: QWidget) -> None: super().__init__(parent) self.app = self.parent().parent() - print(f"\n\n{self.app}\n\n") + # logger.debug(f"\n\n{self.app}\n\n") self.report = Report() self.datepicker = ControlsDatePicker() self.webengineview = QWebEngineView() diff --git a/src/submissions/frontend/widgets/sample_search.py b/src/submissions/frontend/widgets/sample_search.py new file mode 100644 index 0000000..234712f --- /dev/null +++ b/src/submissions/frontend/widgets/sample_search.py @@ -0,0 +1,101 @@ +from pprint import pformat +from typing import Tuple +from pandas import DataFrame +from PyQt6.QtCore import QAbstractTableModel, Qt, QEvent, QSortFilterProxyModel +from PyQt6.QtWidgets import ( + QLabel, QVBoxLayout, QDialog, + QDialogButtonBox, QMessageBox, QComboBox, QTableView, QWidget, QLineEdit, QGridLayout +) +from backend.db.models import BasicSample +from .submission_table import pandasModel +from .submission_details import SubmissionDetails +import logging + +logger = logging.getLogger(f"submissions.{__name__}") + + +class SearchBox(QDialog): + + def __init__(self, parent): + super().__init__(parent) + self.layout = QGridLayout(self) + self.sample_type = QComboBox(self) + self.sample_type.setObjectName("sample_type") + self.sample_type.currentTextChanged.connect(self.update_widgets) + options = [cls.__mapper_args__['polymorphic_identity'] for cls in BasicSample.__subclasses__()] + self.sample_type.addItems(options) + self.sample_type.setEditable(False) + self.setMinimumSize(600, 600) + self.sample_type.setMinimumWidth(self.minimumWidth()) + self.layout.addWidget(self.sample_type, 0, 0) + self.results = SearchResults() + self.layout.addWidget(self.results, 5, 0) + self.setLayout(self.layout) + self.update_widgets() + + def update_widgets(self): + deletes = [item for item in self.findChildren(FieldSearch)] + # logger.debug(deletes) + for item in deletes: + item.setParent(None) + self.type = BasicSample.find_polymorphic_subclass(self.sample_type.currentText()) + # logger.debug(f"Sample type: {self.type}") + searchables = self.type.get_searchables() + start_row = 1 + for iii, item in enumerate(searchables): + widget = FieldSearch(parent=self, label=item['label'], field_name=item['field']) + self.layout.addWidget(widget, start_row+iii, 0) + + def parse_form(self): + fields = [item.parse_form() for item in self.findChildren(FieldSearch)] + return {item[0]:item[1] for item in fields if item[1] is not None} + + def update_data(self): + fields = self.parse_form() + data = self.type.samples_to_df(sample_type=self.type, **fields) + # logger.debug(f"Data: {data}") + self.results.setData(df=data) + + +class FieldSearch(QWidget): + + def __init__(self, parent, label, field_name): + super().__init__(parent) + self.layout = QVBoxLayout(self) + label_widget = QLabel(label) + self.layout.addWidget(label_widget) + self.search_widget = QLineEdit() + self.search_widget.setObjectName(field_name) + self.layout.addWidget(self.search_widget) + self.setLayout(self.layout) + self.search_widget.returnPressed.connect(self.enter_pressed) + + def enter_pressed(self): + self.parent().update_data() + + def parse_form(self) -> Tuple: + field_value = self.search_widget.text() + if field_value == "": + field_value = None + return self.search_widget.objectName(), field_value + + +class SearchResults(QTableView): + + def __init__(self): + super().__init__() + self.doubleClicked.connect(lambda x: BasicSample.query(submitter_id=x.sibling(x.row(), 0).data()).show_details(self)) + + def setData(self, df:DataFrame) -> None: + """ + sets data in model + """ + self.data = df + try: + self.data['id'] = self.data['id'].apply(str) + self.data['id'] = self.data['id'].str.zfill(3) + except (TypeError, KeyError): + logger.error("Couldn't format id string.") + proxy_model = QSortFilterProxyModel() + proxy_model.setSourceModel(pandasModel(self.data)) + self.setModel(proxy_model) \ No newline at end of file diff --git a/src/submissions/frontend/widgets/submission_details.py b/src/submissions/frontend/widgets/submission_details.py index c708ea2..0d39dbc 100644 --- a/src/submissions/frontend/widgets/submission_details.py +++ b/src/submissions/frontend/widgets/submission_details.py @@ -25,7 +25,7 @@ class SubmissionDetails(QDialog): """ a window showing text details of submission """ - def __init__(self, parent, sub:BasicSubmission) -> None: + def __init__(self, parent, sub:BasicSubmission|BasicSample) -> None: super().__init__(parent) try: @@ -47,19 +47,24 @@ class SubmissionDetails(QDialog): # NOTE: setup channel self.channel = QWebChannel() self.channel.registerObject('backend', self) - self.submission_details(submission=sub) - self.rsl_plate_num = sub.rsl_plate_num + match sub: + case BasicSubmission(): + self.submission_details(submission=sub) + self.rsl_plate_num = sub.rsl_plate_num + case BasicSample(): + self.sample_details(sample=sub) self.webview.page().setWebChannel(self.channel) @pyqtSlot(str) - def sample_details(self, sample:str): + def sample_details(self, sample:str|BasicSample): """ Changes details view to summary of Sample Args: sample (str): Submitter Id of the sample. - """ - sample = BasicSample.query(submitter_id=sample) + """ + if isinstance(sample, str): + sample = BasicSample.query(submitter_id=sample) base_dict = sample.to_sub_dict(full_data=True) base_dict, template = sample.get_details_template(base_dict=base_dict) html = template.render(sample=base_dict) @@ -87,6 +92,8 @@ class SubmissionDetails(QDialog): self.base_dict, self.template = submission.get_details_template(base_dict=self.base_dict) self.html = self.template.render(sub=self.base_dict, signing_permission=is_power_user()) self.webview.setHtml(self.html) + with open("test.html", "w") as f: + f.write(self.html) self.setWindowTitle(f"Submission Details - {submission.rsl_plate_num}") @pyqtSlot(str) @@ -138,7 +145,7 @@ class SubmissionComment(QDialog): super().__init__(parent) try: self.app = parent.parent().parent().parent().parent().parent().parent - print(f"App: {self.app}") + # logger.debug(f"App: {self.app}") except AttributeError: pass self.submission = submission diff --git a/src/submissions/frontend/widgets/submission_table.py b/src/submissions/frontend/widgets/submission_table.py index 084f989..22189a6 100644 --- a/src/submissions/frontend/widgets/submission_table.py +++ b/src/submissions/frontend/widgets/submission_table.py @@ -87,11 +87,10 @@ class SubmissionsSheet(QTableView): """ self.data = BasicSubmission.submissions_to_df() try: - self.data['id'] = self.data['id'].apply(str) - self.data['id'] = self.data['id'].str.zfill(3) - except KeyError: - pass - + self.data['Id'] = self.data['Id'].apply(str) + self.data['Id'] = self.data['Id'].str.zfill(3) + except KeyError as e: + logger.error(f"Could not alter id to string due to {e}") proxyModel = QSortFilterProxyModel() proxyModel.setSourceModel(pandasModel(self.data)) self.setModel(proxyModel) diff --git a/src/submissions/frontend/widgets/submission_widget.py b/src/submissions/frontend/widgets/submission_widget.py index ef77964..a5ba527 100644 --- a/src/submissions/frontend/widgets/submission_widget.py +++ b/src/submissions/frontend/widgets/submission_widget.py @@ -13,7 +13,7 @@ from tools import Report, Result, check_not_nan, workbook_2_csv from backend.excel.parser import SheetParser from backend.validators import PydSubmission, PydReagent from backend.db import ( - KitType, Organization, SubmissionType, Reagent, + KitType, Organization, SubmissionType, Reagent, ReagentType, KitTypeReagentTypeAssociation ) from pprint import pformat @@ -24,9 +24,9 @@ from datetime import date logger = logging.getLogger(f"submissions.{__name__}") -class SubmissionFormContainer(QWidget): - # A signal carrying a path +class SubmissionFormContainer(QWidget): + # A signal carrying a path import_drag = pyqtSignal(Path) def __init__(self, parent: QWidget) -> None: @@ -41,7 +41,7 @@ class SubmissionFormContainer(QWidget): def dragEnterEvent(self, event): """ Allow drag if file. - """ + """ if event.mimeData().hasUrls(): event.accept() else: @@ -50,16 +50,16 @@ class SubmissionFormContainer(QWidget): def dropEvent(self, event): """ Sets filename when file dropped - """ + """ fname = Path([u.toLocalFile() for u in event.mimeData().urls()][0]) # logger.debug(f"App: {self.app}") self.app.last_dir = fname.parent self.import_drag.emit(fname) - def importSubmission(self, fname:Path|None=None): + 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) @@ -68,7 +68,7 @@ class SubmissionFormContainer(QWidget): self.report = Report() self.app.result_reporter() - def import_submission_function(self, fname:Path|None=None): + def import_submission_function(self, fname: Path | None = None): """ Import a new submission to the app window @@ -77,17 +77,17 @@ class SubmissionFormContainer(QWidget): Returns: Tuple[QMainWindow, dict|None]: Collection of new main app window and result dict - """ + """ logger.info(f"\n\nStarting Import...\n\n") report = Report() try: self.form.setParent(None) except AttributeError: pass - # initialize samples + # NOTE: initialize samples self.samples = [] self.missing_info = [] - # set file dialog + # NOTE: set file dialog if isinstance(fname, bool) or fname == None: fname = select_open_file(self, file_extension="xlsx") # logger.debug(f"Attempting to parse file: {fname}") @@ -95,7 +95,7 @@ class SubmissionFormContainer(QWidget): report.add_result(Result(msg=f"File {fname.__str__()} not found.", status="critical")) self.report.add_result(report) return - # create sheetparser using excel sheet and context from gui + # NOTE: create sheetparser using excel sheet and context from gui try: self.prsr = SheetParser(filepath=fname) except PermissionError: @@ -108,13 +108,12 @@ class SubmissionFormContainer(QWidget): # logger.debug(f"Pydantic result: \n\n{pformat(self.pyd)}\n\n") self.form = self.pyd.to_form(parent=self) self.layout().addWidget(self.form) - # if self.prsr.sample_result != None: - # report.add_result(msg=self.prsr.sample_result, status="Warning") self.report.add_result(report) # logger.debug(f"Outgoing report: {self.report.results}") # logger.debug(f"All attributes of submission container:\n{pformat(self.__dict__)}") - def add_reagent(self, reagent_lot:str|None=None, reagent_type:str|None=None, expiry:date|None=None, name:str|None=None): + def add_reagent(self, reagent_lot: str | None = None, reagent_type: str | None = None, expiry: date | None = None, + name: str | None = None): """ Action to create new reagent in DB. @@ -126,28 +125,29 @@ class SubmissionFormContainer(QWidget): Returns: models.Reagent: the constructed reagent object to add to submission - """ + """ report = Report() if isinstance(reagent_lot, bool): reagent_lot = "" - # create form + # NOTE: create form dlg = AddReagentForm(reagent_lot=reagent_lot, reagent_type=reagent_type, expiry=expiry, reagent_name=name) if dlg.exec(): # extract form info info = dlg.parse_form() # logger.debug(f"Reagent info: {info}") - # create reagent object + # NOTE: create reagent object reagent = PydReagent(ctx=self.app.ctx, **info, missing=False) - # send reagent to db + # NOTE: send reagent to db sqlobj, result = reagent.toSQL() sqlobj.save() report.add_result(result) self.app.result_reporter() return reagent + class SubmissionFormWidget(QWidget): - def __init__(self, parent: QWidget, submission:PydSubmission) -> None: + def __init__(self, parent: QWidget, submission: PydSubmission) -> None: super().__init__(parent) # self.report = Report() self.app = parent.app @@ -157,10 +157,8 @@ class SubmissionFormWidget(QWidget): defaults = st.get_default_info("form_recover", "form_ignore") self.recover = defaults['form_recover'] self.ignore = defaults['form_ignore'] - # self.ignore += self.recover # logger.debug(f"Attempting to extend ignore list with {self.pyd.submission_type['value']}") self.layout = QVBoxLayout() - # for k, v in kwargs.items(): for k in list(self.pyd.model_fields.keys()) + list(self.pyd.model_extra.keys()): if k in self.ignore: continue @@ -176,7 +174,8 @@ class SubmissionFormWidget(QWidget): add_widget.input.currentTextChanged.connect(self.scrape_reagents) 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) -> "self.InfoItem": + def create_widget(self, key: str, value: dict | PydReagent, submission_type: str | None = None, + extraction_kit: str | None = None) -> "self.InfoItem": """ Make an InfoItem widget to hold a field @@ -187,7 +186,7 @@ class SubmissionFormWidget(QWidget): Returns: self.InfoItem: Form widget to hold name:value - """ + """ if key not in self.ignore: match value: case PydReagent(): @@ -199,8 +198,8 @@ class SubmissionFormWidget(QWidget): widget = self.InfoItem(self, key=key, value=value, submission_type=submission_type) return widget return None - - def scrape_reagents(self, *args, **kwargs):#extraction_kit:str, caller:str|None=None): + + def scrape_reagents(self, *args, **kwargs): #extraction_kit:str, caller:str|None=None): """ Extracted scrape reagents function that will run when form 'extraction_kit' widget is updated. @@ -211,16 +210,15 @@ class SubmissionFormWidget(QWidget): Returns: Tuple[QMainWindow, dict]: Updated application and result - """ + """ extraction_kit = args[0] caller = inspect.stack()[1].function.__repr__().replace("'", "") - # self.reagents = [] # logger.debug(f"Self.reagents: {self.reagents}") # logger.debug(f"\n\n{pformat(caller)}\n\n") # logger.debug(f"SubmissionType: {self.submission_type}") report = Report() # logger.debug(f"Extraction kit: {extraction_kit}") - # Remove previous reagent widgets + # NOTE: Remove previous reagent widgets try: old_reagents = self.find_widgets() except AttributeError: @@ -230,19 +228,6 @@ class SubmissionFormWidget(QWidget): for reagent in old_reagents: if isinstance(reagent, self.ReagentFormWidget) or isinstance(reagent, QPushButton): reagent.setParent(None) - # match caller: - # case "import_submission_function": - # self.reagents = self.prsr.sub['reagents'] - # case _: - # already_have = [reagent for reagent in self.prsr.sub['reagents'] if not reagent.missing] - # already_have = [reagent for reagent in self.pyd.reagents if not reagent.missing] - # names = list(set([item.type for item in already_have])) - # # logger.debug(f"Already have: {already_have}") - # reagents = [item.to_pydantic() for item in KitType.query(name=extraction_kit).get_reagents(submission_type=self.pyd.submission_type) if item.name not in names] - # # logger.debug(f"Missing: {reagents}") - # self.pyd.reagents = already_have + reagents - # logger.debug(f"Reagents: {self.reagents}") - # self.kit_integrity_completion_function(extraction_kit=extraction_kit) reagents, integrity_report = self.pyd.check_kit_integrity(extraction_kit=extraction_kit) # logger.debug(f"Missing reagents: {obj.missing_reagents}") for reagent in reagents: @@ -266,11 +251,11 @@ class SubmissionFormWidget(QWidget): def clear_form(self): """ Removes all form widgets - """ + """ for item in self.findChildren(QWidget): item.setParent(None) - def find_widgets(self, object_name:str|None=None) -> List[QWidget]: + def find_widgets(self, object_name: str | None = None) -> List[QWidget]: """ Gets all widgets filtered by object name @@ -279,12 +264,12 @@ class SubmissionFormWidget(QWidget): Returns: List[QWidget]: Widgets matching filter - """ + """ query = self.findChildren(QWidget) - if object_name != None: - query = [widget for widget in query if widget.objectName()==object_name] + if object_name is not None: + query = [widget for widget in query if widget.objectName() == object_name] return query - + def submit_new_sample_function(self) -> QWidget: """ Parse forms and add sample to the database. @@ -294,7 +279,7 @@ class SubmissionFormWidget(QWidget): Returns: Tuple[QMainWindow, dict]: Collection of new main app window and result dict - """ + """ logger.info(f"\n\nBeginning Submission\n\n") report = Report() result = self.parse_form() @@ -320,8 +305,7 @@ class SubmissionFormWidget(QWidget): case 1: dlg = QuestionAsker(title=f"Review {base_submission.rsl_plate_num}?", message=result.msg) if dlg.exec(): - # Do not add duplicate reagents. - # base_submission.reagents = [] + # NOTE: Do not add duplicate reagents. result = None else: self.app.ctx.database_session.rollback() @@ -347,13 +331,13 @@ class SubmissionFormWidget(QWidget): self.app.report.add_result(report) self.app.result_reporter() - def export_csv_function(self, fname:Path|None=None): + def export_csv_function(self, fname: Path | None = None): """ Save the submission's csv file. Args: fname (Path | None, optional): Input filename. Defaults to None. - """ + """ if isinstance(fname, bool) or fname == None: fname = select_save_file(obj=self, default_name=self.pyd.construct_filename(), extension="csv") try: @@ -371,7 +355,7 @@ class SubmissionFormWidget(QWidget): Returns: Report: Report on status of parse. - """ + """ report = Report() logger.info(f"Hello from form parser!") info = {} @@ -397,28 +381,28 @@ class SubmissionFormWidget(QWidget): value = getattr(self, item) # logger.debug(f"Setting {item}") info[item] = value - for k,v in info.items(): + for k, v in info.items(): self.pyd.set_attribute(key=k, value=v) # NOTE: return submission report.add_result(report) return report - + class InfoItem(QWidget): - def __init__(self, parent: QWidget, key:str, value:dict, submission_type:str|None=None) -> None: + def __init__(self, parent: QWidget, key: str, value: dict, submission_type: str | 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) self.setObjectName(key) try: - self.missing:bool = value['missing'] + self.missing: bool = value['missing'] except (TypeError, KeyError): - self.missing:bool = True - if self.input != None: + self.missing: bool = True + if self.input is not None: layout.addWidget(self.label) layout.addWidget(self.input) - layout.setContentsMargins(0,0,0,0) + layout.setContentsMargins(0, 0, 0, 0) self.setLayout(layout) match self.input: case QComboBox(): @@ -427,7 +411,7 @@ class SubmissionFormWidget(QWidget): self.input.dateChanged.connect(self.update_missing) case QLineEdit(): self.input.textChanged.connect(self.update_missing) - + def parse_form(self) -> Tuple[str, dict]: """ Pulls info from widget into dict @@ -445,8 +429,8 @@ class SubmissionFormWidget(QWidget): case _: 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) -> QWidget: + + def set_widget(self, parent: QWidget, key: str, value: dict, submission_type: str | None = None) -> QWidget: """ Creates form widget @@ -458,7 +442,7 @@ class SubmissionFormWidget(QWidget): Returns: QWidget: Form object - """ + """ try: value = value['value'] except (TypeError, KeyError): @@ -480,11 +464,12 @@ class SubmissionFormWidget(QWidget): case 'extraction_kit': # if extraction kit not available, all other values fail if not check_not_nan(value): - msg = AlertPop(message="Make sure to check your extraction kit in the excel sheet!", status="warning") + msg = AlertPop(message="Make sure to check your extraction kit in the excel sheet!", + status="warning") msg.exec() - # create combobox to hold looked up kits + # NOTE: create combobox to hold looked up kits add_widget = QComboBox() - # lookup existing kits by 'submission_type' decided on by sheetparser + # NOTE: lookup existing kits by 'submission_type' decided on by sheetparser # logger.debug(f"Looking up kits used for {submission_type}") uses = [item.name for item in KitType.query(used_for=submission_type)] obj.uses = uses @@ -497,14 +482,13 @@ 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': - # uses base calendar + # NOTE: uses base calendar add_widget = QDateEdit(calendarPopup=True) - # sets submitted date based on date found in excel sheet + # NOTE: sets submitted date based on date found in excel sheet try: add_widget.setDate(value) - # if not found, use today + # NOTE: if not found, use today except: add_widget.setDate(date.today()) case 'submission_category': @@ -517,25 +501,25 @@ class SubmissionFormWidget(QWidget): cats.insert(0, cats.pop(cats.index(submission_type))) add_widget.addItems(cats) case _: - # anything else gets added in as a line edit + # 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("_", " ")) - if add_widget != None: + if add_widget is not None: add_widget.setObjectName(key) add_widget.setParent(parent) return add_widget - + def update_missing(self): """ Set widget status to updated - """ + """ self.missing = True self.label.updated(self.objectName()) class ParsedQLabel(QLabel): - def __init__(self, key:str, value:dict, title:bool=True, label_name:str|None=None): + def __init__(self, key: str, value: dict, title: bool = True, label_name: str | None = None): super().__init__() try: check = not value['missing'] @@ -546,7 +530,7 @@ class SubmissionFormWidget(QWidget): else: self.setObjectName(f"{key}_label") if title: - output = key.replace('_', ' ').title() + output = key.replace('_', ' ').title().replace("Rsl", "RSL").replace("Pcr", "PCR") else: output = key.replace('_', ' ') if check: @@ -554,43 +538,38 @@ class SubmissionFormWidget(QWidget): else: self.setText(f"MISSING {output}") - def updated(self, key:str, title:bool=True): + def updated(self, key: str, title: bool = True): """ Mark widget as updated Args: key (str): Name of the field title (bool, optional): Use title case. Defaults to True. - """ + """ if title: - output = key.replace('_', ' ').title() + output = key.replace('_', ' ').title().replace("Rsl", "RSL").replace("Pcr", "PCR") else: output = key.replace('_', ' ') self.setText(f"UPDATED {output}") class ReagentFormWidget(QWidget): - def __init__(self, parent:QWidget, reagent:PydReagent, extraction_kit:str): + def __init__(self, parent: QWidget, reagent: PydReagent, extraction_kit: str): super().__init__(parent) self.app = self.parent().parent().parent().parent().parent().parent().parent().parent() self.reagent = reagent self.extraction_kit = extraction_kit layout = QVBoxLayout() - # layout = QGridLayout() - # self.check_box = QCheckBox(self) - # self.check_box.setChecked(True) - # self.check_box.stateChanged.connect(self.check_uncheck) - # layout.addWidget(self.check_box, 0,0) self.label = self.ReagentParsedLabel(reagent=reagent) layout.addWidget(self.label) self.lot = self.ReagentLot(reagent=reagent, extraction_kit=extraction_kit) layout.addWidget(self.lot) - # Remove spacing between reagents - layout.setContentsMargins(0,0,0,0) + # NOTE: Remove spacing between reagents + layout.setContentsMargins(0, 0, 0, 0) self.setLayout(layout) self.setObjectName(reagent.name) self.missing = reagent.missing - # If changed set self.missing to True and update self.label + # 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]: @@ -599,40 +578,42 @@ class SubmissionFormWidget(QWidget): Returns: Tuple[PydReagent, dict]: PydReagent and Report(?) - """ - # if not self.check_box.isChecked(): - # return None, None + """ lot = self.lot.currentText() # logger.debug(f"Using this lot for the reagent {self.reagent}: {lot}") wanted_reagent = Reagent.query(lot_number=lot, reagent_type=self.reagent.type) # NOTE: if reagent doesn't exist in database, offer to add it (uses App.add_reagent) if wanted_reagent == None: - dlg = QuestionAsker(title=f"Add {lot}?", message=f"Couldn't find reagent type {self.reagent.type}: {lot} in the database.\n\nWould you like to add it?") + dlg = QuestionAsker(title=f"Add {lot}?", + message=f"Couldn't find reagent type {self.reagent.type}: {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_type=self.reagent.type, expiry=self.reagent.expiry, name=self.reagent.name) + wanted_reagent = self.parent().parent().add_reagent(reagent_lot=lot, reagent_type=self.reagent.type, + expiry=self.reagent.expiry, + name=self.reagent.name) return wanted_reagent, None 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") else: - # Since this now gets passed in directly from the parser -> pyd -> form and the parser gets the name + # NOTE: Since this now gets passed in directly from the parser -> pyd -> form and the parser gets the name # from the db, it should no longer be necessary to query the db with reagent/kit, but with rt name directly. rt = ReagentType.query(name=self.reagent.type) if rt == None: rt = ReagentType.query(kit_type=self.extraction_kit, reagent=wanted_reagent) - return PydReagent(name=wanted_reagent.name, lot=wanted_reagent.lot, type=rt.name, expiry=wanted_reagent.expiry, missing=False), None + return PydReagent(name=wanted_reagent.name, lot=wanted_reagent.lot, type=rt.name, + expiry=wanted_reagent.expiry, missing=False), None def updated(self): """ Set widget status to updated - """ + """ self.missing = True self.label.updated(self.reagent.type) class ReagentParsedLabel(QLabel): - - def __init__(self, reagent:PydReagent): + + def __init__(self, reagent: PydReagent): super().__init__() try: check = not reagent.missing @@ -643,19 +624,19 @@ class SubmissionFormWidget(QWidget): self.setText(f"Parsed {reagent.type}") else: self.setText(f"MISSING {reagent.type}") - - def updated(self, reagent_type:str): + + def updated(self, reagent_type: str): """ Marks widget as updated Args: reagent_type (str): _description_ - """ + """ self.setText(f"UPDATED {reagent_type}") class ReagentLot(QComboBox): - def __init__(self, reagent, extraction_kit:str) -> None: + def __init__(self, reagent, extraction_kit: str) -> None: super().__init__() self.setEditable(True) # logger.debug(f"Attempting lookup of reagents by type: {reagent.type}") @@ -664,7 +645,7 @@ class SubmissionFormWidget(QWidget): relevant_reagents = [str(item.lot) for item in lookup] output_reg = [] for rel_reagent in relevant_reagents: - # extract strings from any sets. + # NOTE: extract strings from any sets. if isinstance(rel_reagent, set): for thing in rel_reagent: output_reg.append(thing) @@ -677,7 +658,8 @@ class SubmissionFormWidget(QWidget): if check_not_nan(reagent.lot): relevant_reagents.insert(0, str(reagent.lot)) else: - looked_up_rt = KitTypeReagentTypeAssociation.query(reagent_type=reagent.type, kit_type=extraction_kit) + looked_up_rt = KitTypeReagentTypeAssociation.query(reagent_type=reagent.type, + kit_type=extraction_kit) try: looked_up_reg = Reagent.query(lot_number=looked_up_rt.last_used) except AttributeError: diff --git a/src/submissions/templates/basicsample_details.html b/src/submissions/templates/basicsample_details.html index a917286..4fb0a45 100644 --- a/src/submissions/templates/basicsample_details.html +++ b/src/submissions/templates/basicsample_details.html @@ -42,11 +42,11 @@ {% block body %}
{% for key, value in sample.items() if key not in sample['excluded'] %}
- {{ key }}: {{ value }}
+ {{ key | replace("_", " ") | title }}: {{ value }}
{% endfor %}
{{ submission['Plate Name'] }}: {{ submission['Well'] }}
+{{ submission['plate_name'] }}: {{ submission['well'] }}
{% endfor %} {% endif %} {% endblock %} @@ -57,8 +57,8 @@ backend = channel.objects.backend; }); {% for submission in sample['submissions'] %} - document.getElementById("{{ submission['Plate Name'] }}").addEventListener("dblclick", function(){ - backend.submission_details("{{ submission['Plate Name'] }}"); + document.getElementById("{{ submission['plate_name'] }}").addEventListener("dblclick", function(){ + backend.submission_details("{{ submission['plate_name'] }}"); }); {% endfor %} diff --git a/src/submissions/templates/basicsubmission_details.html b/src/submissions/templates/basicsubmission_details.html index 1275457..1633875 100644 --- a/src/submissions/templates/basicsubmission_details.html +++ b/src/submissions/templates/basicsubmission_details.html @@ -43,7 +43,7 @@{% for key, value in sub.items() if key not in sub['excluded'] %}
- {{ key }}: {% if key=='Cost' %}{% if sub['Cost'] %} {{ "${:,.2f}".format(value) }}{% endif %}{% else %}{{ value }}{% endif %}
+ {{ key | replace("_", " ") | title | replace("Pcr", "PCR") }}: {% if key=='Cost' %}{% if sub['cost'] %} {{ "${:,.2f}".format(value) }}{% endif %}{% else %}{{ value }}{% endif %}
{% endfor %}
{% for item in sub['reagents'] %} @@ -58,7 +58,7 @@ {% if sub['samples'] %}
{% for item in sub['samples'] %}
- {{ item['Well'] }}: {% if item['Organism'] %} {{ item['Name'] }} - ({{ item['Organism']|replace('\n\t', '
') }}){% else %} {{ item['Name']|replace('\n\t', '
') }}{% endif %}
+ {{ item['well'] }}: {% if item['organism'] %} {{ item['name'] }} - ({{ item['organism']|replace('\n\t', '
') }}){% else %} {{ item['name']|replace('\n\t', '
') }}{% endif %}
{% endfor %}