1import os 2import io 3import logging 4import traceback 5import warnings 6import pickle 7from collections import OrderedDict 8from enum import IntEnum 9 10from typing import Optional 11 12import pkg_resources 13 14from AnyQt.QtCore import Qt, QObject, pyqtSlot, QSize 15from AnyQt.QtGui import QIcon, QCursor, QStandardItemModel, QStandardItem 16from AnyQt.QtWidgets import ( 17 QApplication, QDialog, QFileDialog, QTableView, QHeaderView, 18 QMessageBox) 19from AnyQt.QtPrintSupport import QPrinter, QPrintDialog 20 21 22from orangewidget import gui 23from orangewidget.widget import OWBaseWidget 24from orangewidget.settings import Setting 25 26# Importing WebviewWidget can fail if neither QWebKit or QWebEngine 27# are available 28try: 29 from orangewidget.utils.webview import WebviewWidget 30except ImportError: # pragma: no cover 31 WebviewWidget = None 32 HAVE_REPORT = False 33else: 34 HAVE_REPORT = True 35 36 37log = logging.getLogger(__name__) 38 39class Column(IntEnum): 40 item = 0 41 remove = 1 42 scheme = 2 43 44 45class ReportItem(QStandardItem): 46 def __init__(self, name, html, scheme, module, icon_name, comment=""): 47 self.name = name 48 self.html = html 49 self.scheme = scheme 50 self.module = module 51 self.icon_name = icon_name 52 self.comment = comment 53 try: 54 path = pkg_resources.resource_filename(module, icon_name) 55 except ImportError: 56 path = "" 57 except ValueError: 58 path = "" 59 icon = QIcon(path) 60 self.id = id(icon) 61 super().__init__(icon, name) 62 63 def __getnewargs__(self): 64 return (self.name, self.html, self.scheme, self.module, self.icon_name, 65 self.comment) 66 67 68class ReportItemModel(QStandardItemModel): 69 def __init__(self, rows, columns, parent=None): 70 super().__init__(rows, columns, parent) 71 72 def add_item(self, item): 73 row = self.rowCount() 74 self.setItem(row, Column.item, item) 75 self.setItem(row, Column.remove, self._icon_item("Remove")) 76 self.setItem(row, Column.scheme, self._icon_item("Open Scheme")) 77 78 def get_item_by_id(self, item_id): 79 for i in range(self.rowCount()): 80 item = self.item(i) 81 if str(item.id) == item_id: 82 return item 83 return None 84 85 @staticmethod 86 def _icon_item(tooltip): 87 item = QStandardItem() 88 item.setEditable(False) 89 item.setToolTip(tooltip) 90 return item 91 92 93class ReportTable(QTableView): 94 def __init__(self, parent): 95 super().__init__(parent) 96 self._icon_remove = QIcon(pkg_resources.resource_filename( 97 __name__, "icons/delete.svg")) 98 self._icon_scheme = QIcon(pkg_resources.resource_filename( 99 __name__, "icons/scheme.svg")) 100 101 def mouseMoveEvent(self, event): 102 self._clear_icons() 103 self._repaint(self.indexAt(event.pos())) 104 105 def mouseReleaseEvent(self, event): 106 if event.button() == Qt.LeftButton: 107 super().mouseReleaseEvent(event) 108 self._clear_icons() 109 self._repaint(self.indexAt(event.pos())) 110 111 def leaveEvent(self, _): 112 self._clear_icons() 113 114 def _repaint(self, index): 115 row, column = index.row(), index.column() 116 if column in (Column.remove, Column.scheme): 117 self.setCursor(QCursor(Qt.PointingHandCursor)) 118 else: 119 self.setCursor(QCursor(Qt.ArrowCursor)) 120 if row >= 0: 121 self.model().item(row, Column.remove).setIcon(self._icon_remove) 122 self.model().item(row, Column.scheme).setIcon(self._icon_scheme) 123 124 def _clear_icons(self): 125 model = self.model() 126 for i in range(model.rowCount()): 127 model.item(i, Column.remove).setIcon(QIcon()) 128 model.item(i, Column.scheme).setIcon(QIcon()) 129 130 131class OWReport(OWBaseWidget): 132 name = "Report" 133 save_dir = Setting("") 134 open_dir = Setting("") 135 136 def __init__(self): 137 super().__init__() 138 self._setup_ui_() 139 self.report_changed = False 140 self.have_report_warning_shown = False 141 142 index_file = pkg_resources.resource_filename(__name__, "index.html") 143 with open(index_file, "r") as f: 144 self.report_html_template = f.read() 145 146 def _setup_ui_(self): 147 self.table_model = ReportItemModel(0, len(Column.__members__)) 148 self.table = ReportTable(self.controlArea) 149 self.table.setModel(self.table_model) 150 self.table.setShowGrid(False) 151 self.table.setSelectionBehavior(QTableView.SelectRows) 152 self.table.setSelectionMode(QTableView.SingleSelection) 153 self.table.setWordWrap(False) 154 self.table.setMouseTracking(True) 155 self.table.verticalHeader().setSectionResizeMode(QHeaderView.Fixed) 156 self.table.verticalHeader().setDefaultSectionSize(20) 157 self.table.verticalHeader().setVisible(False) 158 self.table.horizontalHeader().setVisible(False) 159 self.table.setFixedWidth(250) 160 self.table.setColumnWidth(Column.item, 200) 161 self.table.setColumnWidth(Column.remove, 23) 162 self.table.setColumnWidth(Column.scheme, 25) 163 self.table.clicked.connect(self._table_clicked) 164 self.table.selectionModel().selectionChanged.connect( 165 self._table_selection_changed) 166 self.controlArea.layout().addWidget(self.table) 167 168 self.last_scheme = None 169 self.scheme_button = gui.button( 170 self.controlArea, self, "Back to Last Scheme", 171 callback=self._show_last_scheme 172 ) 173 box = gui.hBox(self.controlArea) 174 box.setContentsMargins(-6, 0, -6, 0) 175 self.save_button = gui.button( 176 box, self, "Save", callback=self.save_report, disabled=True 177 ) 178 self.print_button = gui.button( 179 box, self, "Print", callback=self._print_report, disabled=True 180 ) 181 182 class PyBridge(QObject): 183 @pyqtSlot(str) 184 def _select_item(myself, item_id): 185 item = self.table_model.get_item_by_id(item_id) 186 self.table.selectRow(self.table_model.indexFromItem(item).row()) 187 self._change_selected_item(item) 188 189 @pyqtSlot(str, str) 190 def _add_comment(myself, item_id, value): 191 item = self.table_model.get_item_by_id(item_id) 192 item.comment = value 193 self.report_changed = True 194 195 if WebviewWidget is not None: 196 self.report_view = WebviewWidget(self.mainArea, bridge=PyBridge(self)) 197 self.mainArea.layout().addWidget(self.report_view) 198 else: 199 self.report_view = None 200 201 def sizeHint(self): 202 return QSize(850, 500) 203 204 def _table_clicked(self, index): 205 if index.column() == Column.remove: 206 self._remove_item(index.row()) 207 indexes = self.table.selectionModel().selectedIndexes() 208 if indexes: 209 item = self.table_model.item(indexes[0].row()) 210 self._scroll_to_item(item) 211 self._change_selected_item(item) 212 if index.column() == Column.scheme: 213 self._show_scheme(index.row()) 214 215 def _table_selection_changed(self, new_selection, _): 216 if new_selection.indexes(): 217 item = self.table_model.item(new_selection.indexes()[0].row()) 218 self._scroll_to_item(item) 219 self._change_selected_item(item) 220 221 def _remove_item(self, row): 222 self.table_model.removeRow(row) 223 self._empty_report() 224 self.report_changed = True 225 self._build_html() 226 227 def clear(self): 228 self.table_model.clear() 229 self._empty_report() 230 self.report_changed = True 231 self._build_html() 232 233 def _add_item(self, widget): 234 name = widget.get_widget_name_extension() 235 name = "{} - {}".format(widget.name, name) if name else widget.name 236 item = ReportItem(name, widget.report_html, self._get_scheme(), 237 widget.__module__, widget.icon) 238 self.table_model.add_item(item) 239 self._empty_report() 240 self.report_changed = True 241 return item 242 243 def _empty_report(self): 244 # disable save and print if no reports 245 self.save_button.setEnabled(self.table_model.rowCount()) 246 self.print_button.setEnabled(self.table_model.rowCount()) 247 248 def _build_html(self, selected_id=None): 249 if not self.report_view: 250 return 251 html = self.report_html_template 252 if selected_id is not None: 253 onload = f"(function (id) {{" \ 254 f" setSelectedId(id); scrollToId(id); " \ 255 f"}}" \ 256 f"(\"{selected_id}\"));" \ 257 f"" 258 html += f"<body onload='{onload}'>" 259 else: 260 html += "<body>" 261 for i in range(self.table_model.rowCount()): 262 item = self.table_model.item(i) 263 html += "<div id='{}' class='normal' " \ 264 "onClick='pybridge._select_item(this.id)'>{}<div " \ 265 "class='textwrapper'><textarea " \ 266 "placeholder='Write a comment...'" \ 267 "onInput='this.innerHTML = this.value;" \ 268 "pybridge._add_comment(this.parentNode.parentNode.id, this.value);'" \ 269 ">{}</textarea></div>" \ 270 "</div>".format(item.id, item.html, item.comment) 271 html += "</body></html>" 272 self.report_view.setHtml(html) 273 274 def _scroll_to_item(self, item): 275 if not self.report_view: 276 return 277 self.report_view.runJavaScript( 278 f"scrollToId('{item.id}')", 279 lambda res: log.debug("scrollToId returned %s", res) 280 ) 281 282 def _change_selected_item(self, item): 283 if not self.report_view: 284 return 285 self.report_view.runJavaScript( 286 f"setSelectedId('{item.id}');", 287 lambda res: log.debug("setSelectedId returned %s", res) 288 ) 289 self.report_changed = True 290 291 def make_report(self, widget): 292 item = self._add_item(widget) 293 self._build_html(item.id) 294 self.table.selectionModel().selectionChanged.disconnect( 295 self._table_selection_changed 296 ) 297 self.table.selectRow(self.table_model.rowCount() - 1) 298 self.table.selectionModel().selectionChanged.connect( 299 self._table_selection_changed 300 ) 301 302 def _get_scheme(self): 303 canvas = self.get_canvas_instance() 304 if canvas is None: 305 return None 306 scheme = canvas.current_document().scheme() 307 return self._get_scheme_str(scheme) 308 309 def _get_scheme_str(self, scheme): 310 buffer = io.BytesIO() 311 scheme.save_to(buffer, pickle_fallback=True) 312 return buffer.getvalue().decode("utf-8") 313 314 def _show_scheme(self, row): 315 scheme = self.table_model.item(row).scheme 316 canvas = self.get_canvas_instance() 317 if canvas is None: 318 return 319 document = canvas.current_document() 320 if document.isModifiedStrict(): 321 self.last_scheme = self._get_scheme_str(document.scheme()) 322 self._load_scheme(scheme) 323 324 def _show_last_scheme(self): 325 if self.last_scheme: 326 self._load_scheme(self.last_scheme) 327 328 def _load_scheme(self, contents): 329 # forcibly load the contents into the associated CanvasMainWindow 330 # instance if one exists. Preserve `self` as the designated report. 331 canvas = self.get_canvas_instance() 332 if canvas is not None: 333 document = canvas.current_document() 334 scheme = document.scheme() 335 # Clear the undo stack as it will no longer apply to the new 336 # workflow. 337 document.undoStack().clear() 338 scheme.clear() 339 scheme.load_from(io.StringIO(contents)) 340 341 def save_report(self): 342 """Save report""" 343 formats = (('HTML (*.html)', '.html'), 344 ('PDF (*.pdf)', '.pdf')) if self.report_view else tuple() 345 formats = formats + (('Report (*.report)', '.report'),) 346 formats = OrderedDict(formats) 347 348 filename, selected_format = QFileDialog.getSaveFileName( 349 self, "Save Report", self.save_dir, ';;'.join(formats.keys())) 350 if not filename: 351 return QDialog.Rejected 352 353 # Set appropriate extension if not set by the user 354 expect_ext = formats[selected_format] 355 if not filename.endswith(expect_ext): 356 filename += expect_ext 357 358 self.save_dir = os.path.dirname(filename) 359 self.saveSettings() 360 _, extension = os.path.splitext(filename) 361 if extension == ".pdf": 362 printer = QPrinter() 363 printer.setPageSize(QPrinter.A4) 364 printer.setOutputFormat(QPrinter.PdfFormat) 365 printer.setOutputFileName(filename) 366 self._print_to_printer(printer) 367 elif extension == ".report": 368 self.save(filename) 369 else: 370 def save_html(contents): 371 try: 372 with open(filename, "w", encoding="utf-8") as f: 373 f.write(contents) 374 except PermissionError: 375 self.permission_error(filename) 376 377 if self.report_view: 378 save_html(self.report_view.html()) 379 self.report_changed = False 380 return QDialog.Accepted 381 382 def _print_to_printer(self, printer): 383 if not self.report_view: 384 return 385 filename = printer.outputFileName() 386 if filename: 387 try: 388 # QtWebEngine 389 return self.report_view.page().printToPdf(filename) 390 except AttributeError: 391 try: 392 # QtWebKit 393 return self.report_view.print_(printer) 394 except AttributeError: 395 # QtWebEngine 5.6 396 pass 397 # Fallback to printing widget as an image 398 self.report_view.render(printer) 399 400 def _print_report(self): 401 printer = QPrinter() 402 print_dialog = QPrintDialog(printer, self) 403 print_dialog.setWindowTitle("Print report") 404 if print_dialog.exec_() != QDialog.Accepted: 405 return 406 self._print_to_printer(printer) 407 408 def save(self, filename): 409 attributes = {} 410 for key in ('last_scheme', 'open_dir'): 411 attributes[key] = getattr(self, key, None) 412 items = [self.table_model.item(i) 413 for i in range(self.table_model.rowCount())] 414 report = dict(__version__=1, 415 attributes=attributes, 416 items=items) 417 418 try: 419 with open(filename, 'wb') as f: 420 pickle.dump(report, f) 421 except PermissionError: 422 self.permission_error(filename) 423 424 @classmethod 425 def load(cls, filename): 426 with open(filename, 'rb') as f: 427 report = pickle.load(f) 428 429 if not isinstance(report, dict): 430 return report 431 432 self = cls() 433 self.__dict__.update(report['attributes']) 434 for item in report['items']: 435 self.table_model.add_item( 436 ReportItem(item.name, item.html, item.scheme, 437 item.module, item.icon_name, item.comment) 438 ) 439 return self 440 441 def permission_error(self, filename): 442 log.error("PermissionError when trying to write report.", exc_info=True) 443 mb = QMessageBox( 444 self, 445 icon=QMessageBox.Critical, 446 windowTitle=self.tr("Error"), 447 text=self.tr("Permission error when trying to write report."), 448 informativeText=self.tr("Permission error occurred " 449 "while saving '{}'.").format(filename), 450 detailedText=traceback.format_exc(limit=20) 451 ) 452 mb.setWindowModality(Qt.WindowModal) 453 mb.setAttribute(Qt.WA_DeleteOnClose) 454 mb.exec_() 455 456 def is_empty(self): 457 return not self.table_model.rowCount() 458 459 def is_changed(self): 460 return self.report_changed 461 462 @staticmethod 463 def set_instance(report): 464 warnings.warn( 465 "OWReport.set_instance is deprecated", 466 DeprecationWarning, stacklevel=2 467 ) 468 app_inst = QApplication.instance() 469 app_inst._report_window = report 470 471 @staticmethod 472 def get_instance(): 473 warnings.warn( 474 "OWReport.get_instance is deprecated", 475 DeprecationWarning, stacklevel=2 476 ) 477 app_inst = QApplication.instance() 478 if not hasattr(app_inst, "_report_window"): 479 report = OWReport() 480 app_inst._report_window = report 481 return app_inst._report_window 482 483 def get_canvas_instance(self): 484 # type: () -> Optional[CanvasMainWindow] 485 """ 486 Return a CanvasMainWindow instance to which this report is attached. 487 488 Return None if not associated with any window. 489 490 Returns 491 ------- 492 window : Optional[CanvasMainWindow] 493 """ 494 try: 495 from orangewidget.workflow.mainwindow import OWCanvasMainWindow 496 except ImportError: 497 return None 498 # Run up the parent/window chain 499 parent = self.parent() 500 if parent is not None: 501 window = parent.window() 502 if isinstance(window, OWCanvasMainWindow): 503 return window 504 505 def copy_to_clipboard(self): 506 if self.report_view: 507 self.report_view.triggerPageAction(self.report_view.page().Copy) 508 509