diff --git a/src/submissions/__init__.py b/src/submissions/__init__.py index 3daf76b..ddb5d95 100644 --- a/src/submissions/__init__.py +++ b/src/submissions/__init__.py @@ -11,6 +11,12 @@ month = date.today().month day = date.today().day def get_week_of_month() -> int: + """ + Gets the current week number of the month. + + Returns: + int: + """ for ii, week in enumerate(calendar.monthcalendar(date.today().year, date.today().month)): if day in week: return ii + 1 diff --git a/src/submissions/backend/db/models/controls.py b/src/submissions/backend/db/models/controls.py index ba47254..ac47238 100644 --- a/src/submissions/backend/db/models/controls.py +++ b/src/submissions/backend/db/models/controls.py @@ -503,7 +503,7 @@ class IridaControl(Control): """ super().make_parent_buttons(parent=parent) - rows = parent.layout.rowCount() + rows = parent.layout.rowCount() - 2 # logger.debug(f"Parent rows: {rows}") checker = QCheckBox(parent) checker.setChecked(True) diff --git a/src/submissions/backend/excel/reports.py b/src/submissions/backend/excel/reports.py index 6e04219..f92d107 100644 --- a/src/submissions/backend/excel/reports.py +++ b/src/submissions/backend/excel/reports.py @@ -17,6 +17,25 @@ logger = logging.getLogger(f"submissions.{__name__}") env = jinja_template_loading() +class ReportArchetype(object): + + def write_report(self, filename: Path | str, obj: QWidget | None = None): + """ + Writes info to files. + + Args: + filename (Path | str): Basename of output file + obj (QWidget | None, optional): Parent object. Defaults to None. + """ + if isinstance(filename, str): + filename = Path(filename) + filename = filename.absolute() + self.writer = ExcelWriter(filename.with_suffix(".xlsx"), engine='openpyxl') + self.df.to_excel(self.writer, sheet_name=self.sheet_name) + # logger.debug(f"Writing report to: {filename}") + self.writer.close() + + class ReportMaker(object): def __init__(self, start_date: date, end_date: date, organizations: list | None = None): @@ -138,7 +157,7 @@ class ReportMaker(object): if cell.row > 1: cell.style = 'Currency' -class TurnaroundMaker(object): +class TurnaroundMaker(ReportArchetype): def __init__(self, start_date: date, end_date: date, submission_type:str): self.start_date = start_date @@ -147,6 +166,7 @@ class TurnaroundMaker(object): self.subs = BasicSubmission.query(start_date=start_date, end_date=end_date, submission_type_name=submission_type, page_size=0) records = [self.build_record(sub) for sub in self.subs] self.df = DataFrame.from_records(records) + self.sheet_name = "Turnaround" @classmethod def build_record(cls, sub: BasicSubmission) -> dict: @@ -163,18 +183,12 @@ class TurnaroundMaker(object): return dict(name=str(sub.rsl_plate_num), days=days, submitted_date=sub.submitted_date, completed_date=sub.completed_date, acceptable=tat_ok) - def write_report(self, filename: Path | str, obj: QWidget | None = None): - """ - Writes info to files. - Args: - filename (Path | str): Basename of output file - obj (QWidget | None, optional): Parent object. Defaults to None. - """ - if isinstance(filename, str): - filename = Path(filename) - filename = filename.absolute() - self.writer = ExcelWriter(filename.with_suffix(".xlsx"), engine='openpyxl') - self.df.to_excel(self.writer, sheet_name="Turnaround") - # logger.debug(f"Writing report to: {filename}") - self.writer.close() +class ChartReportMaker(ReportArchetype): + + def __init__(self, df: DataFrame, sheet_name): + self.df = df + self.sheet_name = sheet_name + + + diff --git a/src/submissions/frontend/__init__.py b/src/submissions/frontend/__init__.py index 960d1ab..79b6f7a 100644 --- a/src/submissions/frontend/__init__.py +++ b/src/submissions/frontend/__init__.py @@ -2,4 +2,4 @@ Constructs main application. ''' from .widgets import * -from .visualizations import * \ No newline at end of file +from .visualizations import * diff --git a/src/submissions/frontend/visualizations/__init__.py b/src/submissions/frontend/visualizations/__init__.py index 25994b7..1dccb7d 100644 --- a/src/submissions/frontend/visualizations/__init__.py +++ b/src/submissions/frontend/visualizations/__init__.py @@ -1,13 +1,14 @@ ''' Contains all operations for creating charts, graphs and visual effects. ''' -from datetime import timedelta - +from datetime import timedelta, date +from typing import Generator from PyQt6.QtWidgets import QWidget import plotly, logging from plotly.graph_objects import Figure import pandas as pd from frontend.widgets.functions import select_save_file +from tools import divide_chunks logger = logging.getLogger(f"submissions.{__name__}") @@ -18,31 +19,121 @@ class CustomFigure(Figure): def __init__(self, df: pd.DataFrame, settings: dict, modes: list, ytitle: str | None = None, parent: QWidget | None = None): super().__init__() - # self.settings = settings try: months = int(settings['months']) except KeyError: months = 6 self.df = df self.update_xaxes(range=[settings['start_date'] - timedelta(days=1), settings['end_date']]) + self.generic_figure_markers(modes=modes, ytitle=ytitle, months=months) - def save_figure(self, group_name: str = "plotly_output", parent: QWidget | None = None): + def generic_figure_markers(self, modes: list = [], ytitle: str | None = None, months: int = 6): """ - Writes plotly figure to html file. + Adds standard layout to figure. Args: - figs (): - settings (dict): settings passed down from click - fig (Figure): input figure object - group_name (str): controltype + fig (Figure): Input figure. + modes (list, optional): List of modes included in figure. Defaults to []. + ytitle (str, optional): Title for the y-axis. Defaults to None. + + Returns: + Figure: Output figure with updated titles, rangeslider, buttons. """ + if modes: + ytitle = modes[0] + # logger.debug("Creating visibles list for each mode.") + self.update_layout( + xaxis_title="Submitted Date (* - Date parsed from fastq file creation date)", + yaxis_title=ytitle, + showlegend=True, + barmode='stack', + updatemenus=[ + dict( + type="buttons", + direction="right", + x=0.7, + y=1.2, + showactive=True, + buttons=[button for button in self.make_pyqt_buttons(modes=modes)], + ) + ] + ) + self.update_xaxes( + rangeslider_visible=True, + rangeselector=dict( + buttons=[button for button in self.make_plotly_buttons(months=months)] + ) + ) + assert isinstance(self, CustomFigure) - output = select_save_file(obj=parent, default_name=group_name, extension="png") - self.write_image(output.absolute().__str__(), engine="kaleido") + def make_plotly_buttons(self, months: int = 6) -> Generator[dict, None, None]: + """ + Creates html buttons to zoom in on date areas - def save_data(self, group_name: str = "plotly_export", parent:QWidget|None=None): - output = select_save_file(obj=parent, default_name=group_name, extension="xlsx") - self.df.to_excel(output.absolute().__str__(), engine="openpyxl", index=False) + Args: + months (int, optional): Number of months of data given. Defaults to 6. + + Yields: + Generator[dict, None, None]: Button details. + """ + rng = [1] + if months > 2: + rng += [iii for iii in range(3, months, 3)] + # logger.debug(f"Making buttons for months: {rng}") + buttons = [dict(count=iii, label=f"{iii}m", step="month", stepmode="backward") for iii in rng] + if months > date.today().month: + buttons += [dict(count=1, label="YTD", step="year", stepmode="todate")] + buttons += [dict(step="all")] + for button in buttons: + yield button + + def make_pyqt_buttons(self, modes: list) -> Generator[dict, None, None]: + """ + Creates list of buttons with one for each mode to be used in showing/hiding mode traces. + + Args: + modes (list): list of modes used by main parser. + fig_len (int): number of traces in the figure + + Returns: + Generator[dict, None, None]: list of buttons. + """ + fig_len = len(self.data) + if len(modes) > 1: + for ii, mode in enumerate(modes): + # NOTE: What I need to do is create a list of bools with the same length as the fig.data + mode_vis = [True] * fig_len + # NOTE: And break it into {len(modes)} chunks + mode_vis = list(divide_chunks(mode_vis, len(modes))) + # NOTE: Then, for each chunk, if the chunk index isn't equal to the index of the current mode, set to false + for jj, sublist in enumerate(mode_vis): + if jj != ii: + mode_vis[jj] = [not elem for elem in mode_vis[jj]] + # NOTE: Finally, flatten list. + mode_vis = [item for sublist in mode_vis for item in sublist] + # NOTE: Now, yield button to add to list + yield dict(label=mode, method="update", args=[ + {"visible": mode_vis}, + {"yaxis.title.text": mode}, + ]) + + # def save_figure(self, group_name: str = "plotly_output", parent: QWidget | None = None): + # """ + # Writes plotly figure to html file. + # + # Args: + # figs (): + # settings (dict): settings passed down from click + # fig (Figure): input figure object + # group_name (str): controltype + # """ + # + # output = select_save_file(obj=parent, default_name=group_name, extension="png") + # self.write_image(output.absolute().__str__(), engine="kaleido") + # + # def save_data(self, group_name: str = "plotly_export", parent:QWidget|None=None): + # output = select_save_file(obj=parent, default_name=group_name, extension="xlsx") + # self.df.to_excel(output.absolute().__str__(), engine="openpyxl", index=False) def to_html(self) -> str: """ diff --git a/src/submissions/frontend/visualizations/irida_charts.py b/src/submissions/frontend/visualizations/irida_charts.py index 42e2172..06e04ac 100644 --- a/src/submissions/frontend/visualizations/irida_charts.py +++ b/src/submissions/frontend/visualizations/irida_charts.py @@ -19,12 +19,13 @@ class IridaFigure(CustomFigure): def __init__(self, df: pd.DataFrame, modes: list, settings: dict, ytitle: str | None = None, parent: QWidget | None = None): super().__init__(df=df, modes=modes, settings=settings) + self.df = df try: months = int(settings['months']) except KeyError: months = 6 self.construct_chart(df=df, modes=modes, start_date=settings['start_date'], end_date=settings['end_date']) - self.generic_figure_markers(modes=modes, ytitle=ytitle, months=months) + def construct_chart(self, df: pd.DataFrame, modes: list, start_date: date, end_date:date): """ @@ -69,94 +70,3 @@ class IridaFigure(CustomFigure): ) bar.update_traces(visible=ii == 0) self.add_traces(bar.data) - - def generic_figure_markers(self, modes: list = [], ytitle: str | None = None, months: int = 6): - """ - Adds standard layout to figure. - - Args: - fig (Figure): Input figure. - modes (list, optional): List of modes included in figure. Defaults to []. - ytitle (str, optional): Title for the y-axis. Defaults to None. - - Returns: - Figure: Output figure with updated titles, rangeslider, buttons. - """ - if modes: - ytitle = modes[0] - # logger.debug("Creating visibles list for each mode.") - self.update_layout( - xaxis_title="Submitted Date (* - Date parsed from fastq file creation date)", - yaxis_title=ytitle, - showlegend=True, - barmode='stack', - updatemenus=[ - dict( - type="buttons", - direction="right", - x=0.7, - y=1.2, - showactive=True, - buttons=[button for button in self.make_pyqt_buttons(modes=modes)], - ) - ] - ) - self.update_xaxes( - rangeslider_visible=True, - rangeselector=dict( - buttons=[button for button in self.make_plotly_buttons(months=months)] - ) - ) - assert isinstance(self, CustomFigure) - - def make_plotly_buttons(self, months: int = 6) -> Generator[dict, None, None]: - """ - Creates html buttons to zoom in on date areas - - Args: - months (int, optional): Number of months of data given. Defaults to 6. - - Yields: - Generator[dict, None, None]: Button details. - """ - rng = [1] - if months > 2: - rng += [iii for iii in range(3, months, 3)] - # logger.debug(f"Making buttons for months: {rng}") - buttons = [dict(count=iii, label=f"{iii}m", step="month", stepmode="backward") for iii in rng] - if months > date.today().month: - buttons += [dict(count=1, label="YTD", step="year", stepmode="todate")] - buttons += [dict(step="all")] - for button in buttons: - yield button - - def make_pyqt_buttons(self, modes: list) -> Generator[dict, None, None]: - """ - Creates list of buttons with one for each mode to be used in showing/hiding mode traces. - - Args: - modes (list): list of modes used by main parser. - fig_len (int): number of traces in the figure - - Returns: - Generator[dict, None, None]: list of buttons. - """ - fig_len = len(self.data) - if len(modes) > 1: - for ii, mode in enumerate(modes): - # NOTE: What I need to do is create a list of bools with the same length as the fig.data - mode_vis = [True] * fig_len - # NOTE: And break it into {len(modes)} chunks - mode_vis = list(divide_chunks(mode_vis, len(modes))) - # NOTE: Then, for each chunk, if the chunk index isn't equal to the index of the current mode, set to false - for jj, sublist in enumerate(mode_vis): - if jj != ii: - mode_vis[jj] = [not elem for elem in mode_vis[jj]] - # NOTE: Finally, flatten list. - mode_vis = [item for sublist in mode_vis for item in sublist] - # NOTE: Now, yield button to add to list - yield dict(label=mode, method="update", args=[ - {"visible": mode_vis}, - {"yaxis.title.text": mode}, - ]) - diff --git a/src/submissions/frontend/visualizations/pcr_charts.py b/src/submissions/frontend/visualizations/pcr_charts.py index 0e0812a..b5b8d44 100644 --- a/src/submissions/frontend/visualizations/pcr_charts.py +++ b/src/submissions/frontend/visualizations/pcr_charts.py @@ -16,6 +16,7 @@ class PCRFigure(CustomFigure): def __init__(self, df: pd.DataFrame, modes: list, settings: dict, ytitle: str | None = None, parent: QWidget | None = None, months: int = 6): super().__init__(df=df, modes=modes, settings=settings) + self.df = df try: months = int(settings['months']) except KeyError: diff --git a/src/submissions/frontend/widgets/controls_chart.py b/src/submissions/frontend/widgets/controls_chart.py index a252d8b..20bd161 100644 --- a/src/submissions/frontend/widgets/controls_chart.py +++ b/src/submissions/frontend/widgets/controls_chart.py @@ -1,18 +1,17 @@ """ Handles display of control charts """ -from datetime import date from pprint import pformat -from PyQt6.QtWebEngineWidgets import QWebEngineView from PyQt6.QtWidgets import ( - QWidget, QComboBox, QPushButton, QGridLayout + QWidget, QComboBox, QPushButton ) from PyQt6.QtCore import QSignalBlocker + +from backend import ChartReportMaker from backend.db import ControlType, IridaControl import logging -from tools import Report, report_result, Result +from tools import Report, report_result from frontend.visualizations import CustomFigure -from .misc import StartEndDatePicker from .info_tab import InfoPane logger = logging.getLogger(f"submissions.{__name__}") @@ -27,13 +26,8 @@ class ControlsViewer(InfoPane): if not self.archetype: return logger.debug(f"Archetype set as: {self.archetype}") - # self.app = self.parent().parent() # logger.debug(f"\n\n{self.app}\n\n") - # self.report = Report() - # self.datepicker = StartEndDatePicker(default_start=-180) - # self.webengineview = QWebEngineView() # NOTE: set tab2 layout - # self.layout = QGridLayout(self) self.control_sub_typer = QComboBox() # NOTE: fetch types of controls con_sub_types = [item for item in self.archetype.targets.keys()] @@ -46,7 +40,6 @@ class ControlsViewer(InfoPane): self.mode_sub_typer = QComboBox() self.mode_sub_typer.setEnabled(False) # NOTE: add widgets to tab2 layout - # self.layout.addWidget(self.datepicker, 0, 0, 1, 2) self.save_button = QPushButton("Save Chart", parent=self) self.layout.addWidget(self.save_button, 0, 2, 1, 1) self.export_button = QPushButton("Save Data", parent=self) @@ -55,21 +48,17 @@ class ControlsViewer(InfoPane): self.layout.addWidget(self.mode_typer, 2, 0, 1, 4) self.layout.addWidget(self.mode_sub_typer, 3, 0, 1, 4) self.archetype.get_instance_class().make_parent_buttons(parent=self) - # self.layout.addWidget(self.webengineview, self.layout.rowCount(), 0, 1, 4) - # self.setLayout(self.layout) self.update_data() self.control_sub_typer.currentIndexChanged.connect(self.update_data) self.mode_typer.currentIndexChanged.connect(self.update_data) - # self.datepicker.start_date.dateChanged.connect(self.update_data) - # self.datepicker.end_date.dateChanged.connect(self.update_data) - self.save_button.pressed.connect(self.save_chart_function) - self.export_button.pressed.connect(self.save_data_function) + self.save_button.pressed.connect(self.save_png) + self.export_button.pressed.connect(self.save_excel) - def save_chart_function(self): - self.fig.save_figure(parent=self) - - def save_data_function(self): - self.fig.save_data(parent=self) + # def save_chart_function(self): + # self.fig.save_figure(parent=self) + # + # def save_data_function(self): + # self.fig.save_data(parent=self) @report_result def update_data(self, *args, **kwargs): @@ -117,20 +106,6 @@ class ControlsViewer(InfoPane): self.chart_maker_function() # return report - @classmethod - def diff_month(self, d1: date, d2: date) -> float: - """ - Gets the number of months difference between two different dates - - Args: - d1 (date): Start date. - d2 (date): End date. - - Returns: - float: Number of months difference - """ - return abs((d1.year - d2.year) * 12 + d1.month - d2.month) - @report_result def chart_maker_function(self, *args, **kwargs): # TODO: Generalize this by moving as much code as possible to IridaControl @@ -158,6 +133,7 @@ class ControlsViewer(InfoPane): mode=self.mode, sub_mode=self.mode_sub_type, parent=self, months=months) self.fig = self.archetype.get_instance_class().make_chart(chart_settings=chart_settings, parent=self, ctx=self.app.ctx) + self.report_obj = ChartReportMaker(df=self.fig.df, sheet_name=self.archetype.name) if issubclass(self.fig.__class__, CustomFigure): self.save_button.setEnabled(True) # logger.debug(f"Updating figure...") diff --git a/src/submissions/frontend/widgets/info_tab.py b/src/submissions/frontend/widgets/info_tab.py index ead8dca..e9010cb 100644 --- a/src/submissions/frontend/widgets/info_tab.py +++ b/src/submissions/frontend/widgets/info_tab.py @@ -1,6 +1,7 @@ """ A pane to show info e.g. cost reports and turnaround times. """ +from datetime import date from PyQt6.QtCore import QSignalBlocker from PyQt6.QtWebEngineWidgets import QWebEngineView from PyQt6.QtWidgets import QWidget, QGridLayout @@ -25,7 +26,8 @@ class InfoPane(QWidget): self.datepicker.end_date.dateChanged.connect(self.update_data) self.layout = QGridLayout(self) self.layout.addWidget(self.datepicker, 0, 0, 1, 2) - self.layout.addWidget(self.webview, 4, 0, 1, 4) + # NOTE: Placed in lower row to allow for addition of custom rows. + self.layout.addWidget(self.webview, 5, 0, 1, 4) self.setLayout(self.layout) @report_result @@ -45,6 +47,20 @@ class InfoPane(QWidget): report.add_result(Result(owner=self.__str__(), msg=msg, status="Warning")) return report + @classmethod + def diff_month(self, d1: date, d2: date) -> float: + """ + Gets the number of months difference between two different dates + + Args: + d1 (date): Start date. + d2 (date): End date. + + Returns: + float: Number of months difference + """ + return abs((d1.year - d2.year) * 12 + d1.month - d2.month) + def save_excel(self): fname = select_save_file(self, default_name=f"Report {self.start_date.strftime('%Y%m%d')} - {self.end_date.strftime('%Y%m%d')}", extension="xlsx") self.report_obj.write_report(fname, obj=self) diff --git a/src/submissions/frontend/widgets/turnaround.py b/src/submissions/frontend/widgets/turnaround.py index 46032d4..19c6a99 100644 --- a/src/submissions/frontend/widgets/turnaround.py +++ b/src/submissions/frontend/widgets/turnaround.py @@ -1,10 +1,10 @@ -from PyQt6.QtCore import QSignalBlocker -from PyQt6.QtWebEngineWidgets import QWebEngineView -from PyQt6.QtWidgets import QWidget, QGridLayout, QPushButton, QLabel, QComboBox +""" +Pane showing turnaround time summary. +""" +from PyQt6.QtWidgets import QWidget, QPushButton, QComboBox, QLabel from .info_tab import InfoPane from backend.excel.reports import TurnaroundMaker -from pandas import DataFrame -from backend.db import BasicSubmission, SubmissionType +from backend.db import SubmissionType from frontend.visualizations.turnaround_chart import TurnaroundChart import logging @@ -26,12 +26,14 @@ class TurnaroundTime(InfoPane): self.submission_typer = QComboBox(self) subs = ["All"] + [item.name for item in SubmissionType.query()] self.submission_typer.addItems(subs) + self.layout.addWidget(QLabel("Submission Type"), 1, 0, 1, 1) self.layout.addWidget(self.submission_typer, 1, 1, 1, 3) self.submission_typer.currentTextChanged.connect(self.update_data) self.update_data() def update_data(self): super().update_data() + months = self.diff_month(self.start_date, self.end_date) chart_settings = dict(start_date=self.start_date, end_date=self.end_date) if self.submission_typer.currentText() == "All": submission_type = None @@ -44,5 +46,5 @@ class TurnaroundTime(InfoPane): threshold = subtype_obj.defaults['turnaround_time'] + 0.5 else: threshold = None - self.fig = TurnaroundChart(df=self.report_obj.df, settings=chart_settings, modes=[], threshold=threshold) + self.fig = TurnaroundChart(df=self.report_obj.df, settings=chart_settings, modes=[], threshold=threshold, months=months) self.webview.setHtml(self.fig.to_html()) diff --git a/src/submissions/tools/__init__.py b/src/submissions/tools/__init__.py index ce990e2..eff21b3 100644 --- a/src/submissions/tools/__init__.py +++ b/src/submissions/tools/__init__.py @@ -2,7 +2,6 @@ Contains miscellaenous functions used by both frontend and backend. ''' from __future__ import annotations - from datetime import date, datetime, timedelta from json import JSONDecodeError from pprint import pprint @@ -13,7 +12,6 @@ from jinja2 import Environment, FileSystemLoader from logging import handlers from pathlib import Path from sqlalchemy.orm import Session -from sqlalchemy.orm.state import InstanceState from sqlalchemy import create_engine, text, MetaData from pydantic import field_validator, BaseModel, Field from pydantic_settings import BaseSettings, SettingsConfigDict @@ -893,11 +891,21 @@ def yaml_regex_creator(loader, node): return f"(?P<{name}>RSL(?:-|_)?{abbr}(?:-|_)?20\d{2}-?\d{2}-?\d{2}(?:(_|-)?\d?([^_0123456789\sA-QS-Z]|$)?R?\d?)?)" -def super_splitter(input:str, ioi:str, idx:int) -> str: +def super_splitter(ins_str:str, substring:str, idx:int) -> str: + """ + + Args: + ins_str (str): input string + substring (str): substring to split on + idx (int): the occurrence of the substring to return + + Returns: + + """ try: - return input.split(ioi)[idx] + return ins_str.split(substring)[idx] except IndexError: - return input + return ins_str ctx = get_config(None) @@ -1021,7 +1029,3 @@ def create_holidays_for_year(year: int|None=None) -> List[date]: holidays.append(easter(year) - timedelta(days=2)) holidays.append(easter(year) + timedelta(days=1)) return sorted(holidays) - - - -