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