Addition of turnaround time tracking.
This commit is contained in:
@@ -69,6 +69,6 @@ def update_log(mapper, connection, target):
|
||||
else:
|
||||
logger.info(f"No changes detected, not updating logs.")
|
||||
|
||||
if ctx.database_schema == "sqlite":
|
||||
event.listen(LogMixin, 'after_update', update_log, propagate=True)
|
||||
event.listen(LogMixin, 'after_insert', update_log, propagate=True)
|
||||
# if ctx.database_schema == "sqlite":
|
||||
event.listen(LogMixin, 'after_update', update_log, propagate=True)
|
||||
event.listen(LogMixin, 'after_insert', update_log, propagate=True)
|
||||
|
||||
@@ -279,7 +279,7 @@ class Control(BaseClass):
|
||||
@classmethod
|
||||
def make_parent_buttons(cls, parent: QWidget) -> None:
|
||||
"""
|
||||
Super that will make buttons in a CustomFigure. Made to be overrided.
|
||||
Super that will make buttons in a CustomFigure. Made to be overridden.
|
||||
|
||||
Args:
|
||||
parent (QWidget): chart holding widget to add buttons to.
|
||||
@@ -299,6 +299,10 @@ class Control(BaseClass):
|
||||
|
||||
|
||||
class PCRControl(Control):
|
||||
"""
|
||||
Class made to hold info from Design & Analysis software.
|
||||
"""
|
||||
|
||||
id = Column(INTEGER, ForeignKey('_control.id'), primary_key=True)
|
||||
subtype = Column(String(16)) #: PC or NC
|
||||
target = Column(String(16)) #: N1, N2, etc.
|
||||
@@ -348,7 +352,7 @@ class PCRControl(Control):
|
||||
df = df[df.ct > 0.0]
|
||||
except AttributeError:
|
||||
df = df
|
||||
fig = PCRFigure(df=df, modes=[])
|
||||
fig = PCRFigure(df=df, modes=[], settings=chart_settings)
|
||||
return report, fig
|
||||
|
||||
def to_pydantic(self):
|
||||
@@ -433,12 +437,12 @@ class IridaControl(Control):
|
||||
def convert_by_mode(self, control_sub_type: str, mode: Literal['kraken', 'matches', 'contains'],
|
||||
consolidate: bool = False) -> Generator[dict, None, None]:
|
||||
"""
|
||||
split this instance into analysis types for controls graphs
|
||||
split this instance into analysis types ('kraken', 'matches', 'contains') for controls graphs
|
||||
|
||||
Args:
|
||||
consolidate (bool): whether to merge all off-target genera. Defaults to False
|
||||
control_sub_type (str): control subtype, 'MCS-NOS', etc.
|
||||
mode (str): analysis type, 'contains', etc.
|
||||
mode (Literal['kraken', 'matches', 'contains']): analysis type, 'contains', etc.
|
||||
|
||||
Returns:
|
||||
List[dict]: list of records
|
||||
@@ -562,7 +566,7 @@ class IridaControl(Control):
|
||||
df, modes = cls.prep_df(ctx=ctx, df=df)
|
||||
# logger.debug(f"prepped df: \n {df}")
|
||||
fig = IridaFigure(df=df, ytitle=title, modes=modes, parent=parent,
|
||||
months=chart_settings['months'])
|
||||
settings=chart_settings)
|
||||
return report, fig
|
||||
|
||||
@classmethod
|
||||
@@ -571,9 +575,8 @@ class IridaControl(Control):
|
||||
Convert list of control records to dataframe
|
||||
|
||||
Args:
|
||||
ctx (dict): settings passed from gui
|
||||
input_df (list[dict]): list of dictionaries containing records
|
||||
sub_type (str | None, optional): sub_type of submission type. Defaults to None.
|
||||
sub_mode (str | None, optional): sub_type of submission type. Defaults to None.
|
||||
|
||||
Returns:
|
||||
DataFrame: dataframe of controls
|
||||
|
||||
@@ -168,7 +168,6 @@ class KitType(BaseClass):
|
||||
else:
|
||||
return (item.reagent_role for item in relevant_associations)
|
||||
|
||||
# TODO: Move to BasicSubmission?
|
||||
def construct_xl_map_for_use(self, submission_type: str | SubmissionType) -> Generator[(str, str), None, None]:
|
||||
"""
|
||||
Creates map of locations in Excel workbook for a SubmissionType
|
||||
@@ -274,8 +273,7 @@ class KitType(BaseClass):
|
||||
for kk, vv in assoc.to_export_dict().items():
|
||||
v[kk] = vv
|
||||
base_dict['reagent roles'].append(v)
|
||||
# for k, v in submission_type.construct_equipment_map():
|
||||
for k, v in submission_type.contstruct_field_map("equipment"):
|
||||
for k, v in submission_type.construct_field_map("equipment"):
|
||||
try:
|
||||
assoc = next(item for item in submission_type.submissiontype_equipmentrole_associations if
|
||||
item.equipment_role.name == k)
|
||||
@@ -428,7 +426,7 @@ class Reagent(BaseClass, LogMixin):
|
||||
submission=sub)) #: Association proxy to SubmissionSampleAssociation.samples
|
||||
|
||||
def __repr__(self):
|
||||
if self.name is not None:
|
||||
if self.name:
|
||||
return f"<Reagent({self.name}-{self.lot})>"
|
||||
else:
|
||||
return f"<Reagent({self.role.name}-{self.lot})>"
|
||||
@@ -447,11 +445,12 @@ class Reagent(BaseClass, LogMixin):
|
||||
|
||||
if extraction_kit is not None:
|
||||
# NOTE: Get the intersection of this reagent's ReagentType and all ReagentTypes in KitType
|
||||
try:
|
||||
reagent_role = list(set(self.role).intersection(extraction_kit.reagent_roles))[0]
|
||||
# NOTE: Most will be able to fall back to first ReagentType in itself because most will only have 1.
|
||||
except:
|
||||
reagent_role = self.role[0]
|
||||
reagent_role = next((item for item in set(self.role).intersection(extraction_kit.reagent_roles)), self.role[0])
|
||||
# try:
|
||||
# reagent_role = list(set(self.role).intersection(extraction_kit.reagent_roles))[0]
|
||||
# # NOTE: Most will be able to fall back to first ReagentType in itself because most will only have 1.
|
||||
# except:
|
||||
# reagent_role = self.role[0]
|
||||
else:
|
||||
try:
|
||||
reagent_role = self.role[0]
|
||||
|
||||
@@ -24,7 +24,7 @@ from sqlite3 import OperationalError as SQLOperationalError, IntegrityError as S
|
||||
from openpyxl import Workbook
|
||||
from openpyxl.drawing.image import Image as OpenpyxlImage
|
||||
from tools import row_map, setup_lookup, jinja_template_loading, rreplace, row_keys, check_key_or_attr, Result, Report, \
|
||||
report_result, create_holidays_for_year
|
||||
report_result, create_holidays_for_year, ctx
|
||||
from datetime import datetime, date, timedelta
|
||||
from typing import List, Any, Tuple, Literal, Generator
|
||||
from dateutil.parser import parse
|
||||
@@ -127,7 +127,7 @@ class BasicSubmission(BaseClass, LogMixin):
|
||||
|
||||
def __repr__(self) -> str:
|
||||
submission_type = self.submission_type or "Basic"
|
||||
return f"<{submission_type}Submission({self.rsl_plate_num})>"
|
||||
return f"<Submission({self.rsl_plate_num})>"
|
||||
|
||||
@classmethod
|
||||
def jsons(cls) -> List[str]:
|
||||
@@ -1380,17 +1380,22 @@ class BasicSubmission(BaseClass, LogMixin):
|
||||
writer = pyd.to_writer()
|
||||
writer.xl.save(filename=fname.with_suffix(".xlsx"))
|
||||
|
||||
def get_turnaround_time(self):
|
||||
completed = self.completed_date or datetime.now()
|
||||
return self.calculate_turnaround(start_date=self.submitted_date.date(), end_date=completed.date())
|
||||
def get_turnaround_time(self) -> Tuple[int|None, bool|None]:
|
||||
try:
|
||||
completed = self.completed_date.date()
|
||||
except AttributeError:
|
||||
completed = None
|
||||
return self.calculate_turnaround(start_date=self.submitted_date.date(), end_date=completed)
|
||||
|
||||
@classmethod
|
||||
def calculate_turnaround(cls, start_date:date|None=None, end_date:date|None=None) -> int|None:
|
||||
def calculate_turnaround(cls, start_date:date|None=None, end_date:date|None=None) -> Tuple[int|None, bool|None]:
|
||||
if not end_date:
|
||||
return None, None
|
||||
try:
|
||||
delta = np.busday_count(start_date, end_date, holidays=create_holidays_for_year(start_date.year))
|
||||
delta = np.busday_count(start_date, end_date, holidays=create_holidays_for_year(start_date.year)) + 1
|
||||
except ValueError:
|
||||
return None
|
||||
return delta + 1
|
||||
return None, None
|
||||
return delta, delta <= ctx.TaT_threshold
|
||||
|
||||
|
||||
# Below are the custom submission types
|
||||
|
||||
@@ -293,20 +293,24 @@ class ReagentParser(object):
|
||||
report = Report()
|
||||
if isinstance(submission_type, dict):
|
||||
submission_type = submission_type['value']
|
||||
if isinstance(submission_type, str):
|
||||
submission_type = SubmissionType.query(name=submission_type)
|
||||
reagent_map = {k: v for k, v in self.kit_object.construct_xl_map_for_use(submission_type)}
|
||||
try:
|
||||
del reagent_map['info']
|
||||
except KeyError:
|
||||
pass
|
||||
# logger.debug(f"Reagent map: {pformat(reagent_map)}")
|
||||
logger.debug(f"Reagent map: {pformat(reagent_map)}")
|
||||
# NOTE: If reagent map is empty, maybe the wrong kit was given, check if there's only one kit for that submission type and use it if so.
|
||||
if not reagent_map.keys():
|
||||
if not reagent_map:
|
||||
temp_kit_object = self.submission_type_obj.get_default_kit()
|
||||
logger.debug(f"Temp kit: {temp_kit_object}")
|
||||
if temp_kit_object:
|
||||
self.kit_object = temp_kit_object
|
||||
reagent_map = {k: v for k, v in self.kit_object.construct_xl_map_for_use(submission_type)}
|
||||
logger.warning(f"Attempting to salvage {self.kit_object} with default kit map: {reagent_map}")
|
||||
if not reagent_map.keys():
|
||||
# reagent_map = {k: v for k, v in self.kit_object.construct_xl_map_for_use(submission_type)}
|
||||
logger.warning(f"Attempting to salvage with default kit {self.kit_object} and submission_type: {self.submission_type_obj}")
|
||||
return self.fetch_kit_info_map(submission_type=self.submission_type_obj)
|
||||
else:
|
||||
logger.error(f"Still no reagent map, displaying error.")
|
||||
try:
|
||||
ext_kit_loc = self.submission_type_obj.info_map['extraction_kit']['read'][0]
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
'''
|
||||
Contains functions for generating summary reports
|
||||
'''
|
||||
from pprint import pformat
|
||||
|
||||
from pandas import DataFrame, ExcelWriter
|
||||
import logging
|
||||
from pathlib import Path
|
||||
@@ -137,3 +139,35 @@ class ReportMaker(object):
|
||||
for cell in worksheet['D']:
|
||||
if cell.row > 1:
|
||||
cell.style = 'Currency'
|
||||
|
||||
class TurnaroundMaker(object):
|
||||
|
||||
def __init__(self, start_date: date, end_date: date):
|
||||
self.start_date = start_date
|
||||
self.end_date = end_date
|
||||
# NOTE: Set page size to zero to override limiting query size.
|
||||
self.subs = BasicSubmission.query(start_date=start_date, end_date=end_date, page_size=0)
|
||||
records = [self.build_record(sub) for sub in self.subs]
|
||||
self.df = DataFrame.from_records(records)
|
||||
|
||||
@classmethod
|
||||
def build_record(cls, sub):
|
||||
days, tat_ok = sub.get_turnaround_time()
|
||||
return dict(name=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()
|
||||
@@ -1,6 +1,8 @@
|
||||
'''
|
||||
Contains all operations for creating charts, graphs and visual effects.
|
||||
'''
|
||||
from datetime import timedelta
|
||||
|
||||
from PyQt6.QtWidgets import QWidget
|
||||
import plotly, logging
|
||||
from plotly.graph_objects import Figure
|
||||
@@ -14,10 +16,15 @@ class CustomFigure(Figure):
|
||||
|
||||
df = None
|
||||
|
||||
def __init__(self, df: pd.DataFrame, modes: list, ytitle: str | None = None, parent: QWidget | None = None,
|
||||
months: int = 6):
|
||||
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']])
|
||||
|
||||
def save_figure(self, group_name: str = "plotly_output", parent: QWidget | None = None):
|
||||
"""
|
||||
|
||||
@@ -16,19 +16,23 @@ logger = logging.getLogger(f"submissions.{__name__}")
|
||||
|
||||
class IridaFigure(CustomFigure):
|
||||
|
||||
def __init__(self, df: pd.DataFrame, modes: list, ytitle: str | None = None, parent: QWidget | None = None,
|
||||
months: int = 6):
|
||||
def __init__(self, df: pd.DataFrame, modes: list, settings: dict, ytitle: str | None = None, parent: QWidget | None = None):
|
||||
|
||||
super().__init__(df=df, modes=modes)
|
||||
|
||||
self.construct_chart(df=df, modes=modes)
|
||||
super().__init__(df=df, modes=modes, settings=settings)
|
||||
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):
|
||||
def construct_chart(self, df: pd.DataFrame, modes: list, start_date: date, end_date:date):
|
||||
"""
|
||||
Creates a plotly chart for controls from a pandas dataframe
|
||||
|
||||
Args:
|
||||
end_date ():
|
||||
start_date ():
|
||||
df (pd.DataFrame): input dataframe of controls
|
||||
modes (list): analysis modes to construct charts for
|
||||
ytitle (str | None, optional): title on the y-axis. Defaults to None.
|
||||
|
||||
@@ -13,9 +13,13 @@ logger = logging.getLogger(f"submissions.{__name__}")
|
||||
|
||||
class PCRFigure(CustomFigure):
|
||||
|
||||
def __init__(self, df: pd.DataFrame, modes: list, ytitle: str | None = None, parent: QWidget | None = None,
|
||||
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)
|
||||
super().__init__(df=df, modes=modes, settings=settings)
|
||||
try:
|
||||
months = int(settings['months'])
|
||||
except KeyError:
|
||||
months = 6
|
||||
# logger.debug(f"DF: {self.df}")
|
||||
self.construct_chart(df=df)
|
||||
|
||||
|
||||
@@ -11,4 +11,6 @@ from .controls_chart import *
|
||||
from .submission_details import *
|
||||
from .equipment_usage import *
|
||||
from .gel_checker import *
|
||||
from .summary import Summary
|
||||
from .turnaround import TurnaroundTime
|
||||
from .app import App
|
||||
|
||||
@@ -25,6 +25,7 @@ from .submission_widget import SubmissionFormContainer
|
||||
from .controls_chart import ControlsViewer
|
||||
# from .sample_search import SampleSearchBox
|
||||
from .summary import Summary
|
||||
from .turnaround import TurnaroundTime
|
||||
from .omni_search import SearchBox
|
||||
|
||||
logger = logging.getLogger(f'submissions.{__name__}')
|
||||
@@ -269,12 +270,14 @@ class AddSubForm(QWidget):
|
||||
self.tab2 = QWidget()
|
||||
self.tab3 = QWidget()
|
||||
self.tab4 = QWidget()
|
||||
self.tab5 = QWidget()
|
||||
self.tabs.resize(300, 200)
|
||||
# NOTE: Add tabs
|
||||
self.tabs.addTab(self.tab1, "Submissions")
|
||||
self.tabs.addTab(self.tab2, "Irida Controls")
|
||||
self.tabs.addTab(self.tab3, "PCR Controls")
|
||||
self.tabs.addTab(self.tab4, "Cost Report")
|
||||
self.tabs.addTab(self.tab5, "Turnaround Times")
|
||||
# NOTE: Create submission adder form
|
||||
self.formwidget = SubmissionFormContainer(self)
|
||||
self.formlayout = QVBoxLayout(self)
|
||||
@@ -310,6 +313,10 @@ class AddSubForm(QWidget):
|
||||
self.tab4.layout = QVBoxLayout(self)
|
||||
self.tab4.layout.addWidget(summary_report)
|
||||
self.tab4.setLayout(self.tab4.layout)
|
||||
turnaround = TurnaroundTime(self)
|
||||
self.tab5.layout = QVBoxLayout(self)
|
||||
self.tab5.layout.addWidget(turnaround)
|
||||
self.tab5.setLayout(self.tab5.layout)
|
||||
# NOTE: add tabs to main widget
|
||||
self.layout.addWidget(self.tabs)
|
||||
self.setLayout(self.layout)
|
||||
|
||||
@@ -172,11 +172,11 @@ class SubmissionDetails(QDialog):
|
||||
|
||||
@pyqtSlot(str)
|
||||
def sign_off(self, submission: str | BasicSubmission):
|
||||
# logger.debug(f"Signing off on {submission} - ({getuser()})")
|
||||
logger.debug(f"Signing off on {submission} - ({getuser()})")
|
||||
if isinstance(submission, str):
|
||||
submission = BasicSubmission.query(rsl_plate_num=submission)
|
||||
submission.signed_by = getuser()
|
||||
submission.completed = datetime.now().date()
|
||||
submission.completed_date = datetime.now().date()
|
||||
submission.save()
|
||||
self.submission_details(submission=self.rsl_plate_num)
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from PyQt6.QtCore import QSignalBlocker
|
||||
from PyQt6.QtWebEngineWidgets import QWebEngineView
|
||||
from .info_tab import InfoPane
|
||||
from PyQt6.QtWidgets import QWidget, QGridLayout, QPushButton, QLabel
|
||||
from backend.db import Organization
|
||||
from backend.excel import ReportMaker
|
||||
@@ -11,38 +12,21 @@ import logging
|
||||
logger = logging.getLogger(f"submissions.{__name__}")
|
||||
|
||||
|
||||
class Summary(QWidget):
|
||||
class Summary(InfoPane):
|
||||
|
||||
def __init__(self, parent: QWidget) -> None:
|
||||
super().__init__(parent)
|
||||
self.app = self.parent().parent()
|
||||
# logger.debug(f"\n\n{self.app}\n\n")
|
||||
self.report = Report()
|
||||
self.datepicker = StartEndDatePicker(default_start=-31)
|
||||
self.webview = QWebEngineView()
|
||||
self.datepicker.start_date.dateChanged.connect(self.get_report)
|
||||
self.datepicker.end_date.dateChanged.connect(self.get_report)
|
||||
self.layout = QGridLayout(self)
|
||||
self.layout.addWidget(self.datepicker, 0, 0, 1, 2)
|
||||
self.save_excel_button = QPushButton("Save Excel", parent=self)
|
||||
self.save_excel_button.pressed.connect(self.save_excel)
|
||||
self.save_pdf_button = QPushButton("Save PDF", parent=self)
|
||||
self.save_pdf_button.pressed.connect(self.save_pdf)
|
||||
self.org_select = CheckableComboBox()
|
||||
self.org_select.setEditable(False)
|
||||
self.org_select.addItem("Select", header=True)
|
||||
for org in [org.name for org in Organization.query()]:
|
||||
self.org_select.addItem(org)
|
||||
self.org_select.model().itemChanged.connect(self.get_report)
|
||||
self.layout.addWidget(self.save_excel_button, 0, 2, 1, 1)
|
||||
self.layout.addWidget(self.save_pdf_button, 0, 3, 1, 1)
|
||||
self.layout.addWidget(self.webview, 2, 0, 1, 4)
|
||||
self.org_select.model().itemChanged.connect(self.date_changed)
|
||||
self.layout.addWidget(QLabel("Client"), 1, 0, 1, 1)
|
||||
self.layout.addWidget(self.org_select, 1, 1, 1, 3)
|
||||
self.setLayout(self.layout)
|
||||
self.get_report()
|
||||
self.date_changed()
|
||||
|
||||
def get_report(self):
|
||||
def date_changed(self):
|
||||
orgs = [self.org_select.itemText(i) for i in range(self.org_select.count()) if self.org_select.itemChecked(i)]
|
||||
if self.datepicker.start_date.date() > self.datepicker.end_date.date():
|
||||
logger.warning("Start date after end date is not allowed!")
|
||||
@@ -51,11 +35,12 @@ class Summary(QWidget):
|
||||
# Without triggering this function again
|
||||
with QSignalBlocker(self.datepicker.start_date) as blocker:
|
||||
self.datepicker.start_date.setDate(lastmonth)
|
||||
self.get_report()
|
||||
self.date_changed()
|
||||
return
|
||||
# NOTE: convert to python useable date objects
|
||||
self.start_date = self.datepicker.start_date.date().toPyDate()
|
||||
self.end_date = self.datepicker.end_date.date().toPyDate()
|
||||
# self.start_date = self.datepicker.start_date.date().toPyDate()
|
||||
# self.end_date = self.datepicker.end_date.date().toPyDate()
|
||||
super().date_changed()
|
||||
logger.debug(f"Getting report from {self.start_date} to {self.end_date} using {orgs}")
|
||||
self.report_obj = ReportMaker(start_date=self.start_date, end_date=self.end_date, organizations=orgs)
|
||||
self.webview.setHtml(self.report_obj.html)
|
||||
@@ -66,12 +51,12 @@ class Summary(QWidget):
|
||||
self.save_pdf_button.setEnabled(False)
|
||||
self.save_excel_button.setEnabled(False)
|
||||
|
||||
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)
|
||||
|
||||
def save_pdf(self):
|
||||
fname = select_save_file(obj=self,
|
||||
default_name=f"Report {self.start_date.strftime('%Y%m%d')} - {self.end_date.strftime('%Y%m%d')}",
|
||||
extension="pdf")
|
||||
save_pdf(obj=self.webview, filename=fname)
|
||||
# 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)
|
||||
#
|
||||
# def save_pdf(self):
|
||||
# fname = select_save_file(obj=self,
|
||||
# default_name=f"Report {self.start_date.strftime('%Y%m%d')} - {self.end_date.strftime('%Y%m%d')}",
|
||||
# extension="pdf")
|
||||
# save_pdf(obj=self.webview, filename=fname)
|
||||
|
||||
Reference in New Issue
Block a user