diff --git a/pype/logging/gui/app.py b/pype/logging/gui/app.py new file mode 100644 index 0000000000..7cee280158 --- /dev/null +++ b/pype/logging/gui/app.py @@ -0,0 +1,37 @@ +from Qt import QtWidgets, QtCore +from .widgets import LogsWidget, LogDetailWidget +from pypeapp import style + + +class LogsWindow(QtWidgets.QWidget): + def __init__(self, parent=None): + super(LogsWindow, self).__init__(parent) + + self.setStyleSheet(style.load_stylesheet()) + self.resize(1200, 800) + logs_widget = LogsWidget(parent=self) + log_detail = LogDetailWidget(parent=self) + + main_layout = QtWidgets.QHBoxLayout() + + log_splitter = QtWidgets.QSplitter() + log_splitter.setOrientation(QtCore.Qt.Horizontal) + log_splitter.addWidget(logs_widget) + log_splitter.addWidget(log_detail) + log_splitter.setStretchFactor(0, 65) + log_splitter.setStretchFactor(1, 35) + + main_layout.addWidget(log_splitter) + + self.logs_widget = logs_widget + self.log_detail = log_detail + + self.setLayout(main_layout) + self.setWindowTitle("Logs") + + self.logs_widget.active_changed.connect(self.on_selection_changed) + + def on_selection_changed(self): + index = self.logs_widget.selected_log() + node = index.data(self.logs_widget.model.NodeRole) + self.log_detail.set_detail(node) diff --git a/pype/logging/gui/lib.py b/pype/logging/gui/lib.py new file mode 100644 index 0000000000..85782e071e --- /dev/null +++ b/pype/logging/gui/lib.py @@ -0,0 +1,94 @@ +import contextlib +from Qt import QtCore + + +def _iter_model_rows( + model, column, include_root=False +): + """Iterate over all row indices in a model""" + indices = [QtCore.QModelIndex()] # start iteration at root + + for index in indices: + # Add children to the iterations + child_rows = model.rowCount(index) + for child_row in range(child_rows): + child_index = model.index(child_row, column, index) + indices.append(child_index) + + if not include_root and not index.isValid(): + continue + + yield index + + +@contextlib.contextmanager +def preserve_states( + tree_view, column=0, role=None, + preserve_expanded=True, preserve_selection=True, + expanded_role=QtCore.Qt.DisplayRole, selection_role=QtCore.Qt.DisplayRole + +): + """Preserves row selection in QTreeView by column's data role. + + This function is created to maintain the selection status of + the model items. When refresh is triggered the items which are expanded + will stay expanded and vise versa. + + tree_view (QWidgets.QTreeView): the tree view nested in the application + column (int): the column to retrieve the data from + role (int): the role which dictates what will be returned + + Returns: + None + + """ + # When `role` is set then override both expanded and selection roles + if role: + expanded_role = role + selection_role = role + + model = tree_view.model() + selection_model = tree_view.selectionModel() + flags = selection_model.Select | selection_model.Rows + + expanded = set() + + if preserve_expanded: + for index in _iter_model_rows( + model, column=column, include_root=False + ): + if tree_view.isExpanded(index): + value = index.data(expanded_role) + expanded.add(value) + + selected = None + + if preserve_selection: + selected_rows = selection_model.selectedRows() + if selected_rows: + selected = set(row.data(selection_role) for row in selected_rows) + + try: + yield + finally: + if expanded: + for index in _iter_model_rows( + model, column=0, include_root=False + ): + value = index.data(expanded_role) + is_expanded = value in expanded + # skip if new index was created meanwhile + if is_expanded is None: + continue + tree_view.setExpanded(index, is_expanded) + + if selected: + # Go through all indices, select the ones with similar data + for index in _iter_model_rows( + model, column=column, include_root=False + ): + value = index.data(selection_role) + state = value in selected + if state: + tree_view.scrollTo(index) # Ensure item is visible + selection_model.select(index, flags) diff --git a/pype/logging/gui/models.py b/pype/logging/gui/models.py new file mode 100644 index 0000000000..6722ed0fe0 --- /dev/null +++ b/pype/logging/gui/models.py @@ -0,0 +1,169 @@ +import os +from Qt import QtCore +from pypeapp import Logger +from pypeapp.lib.log import _bootstrap_mongo_log + +log = Logger().get_logger("LogModel", "LoggingModule") + + +class LogModel(QtCore.QAbstractItemModel): + COLUMNS = [ + "user", + "host", + "lineNumber", + "method", + "module", + "fileName", + "loggerName", + "message", + "level", + "timestamp", + ] + + colums_mapping = { + "user": "User", + "host": "Host", + "lineNumber": "Line n.", + "method": "Method", + "module": "Module", + "fileName": "File name", + "loggerName": "Logger name", + "message": "Message", + "level": "Level", + "timestamp": "Timestamp", + } + + NodeRole = QtCore.Qt.UserRole + 1 + + def __init__(self, parent=None): + super(LogModel, self).__init__(parent) + self._root_node = Node() + + collection = os.environ.get('PYPE_LOG_MONGO_COL') + database = _bootstrap_mongo_log() + self.dbcon = None + if collection in database.list_collection_names(): + self.dbcon = database[collection] + + def add_log(self, log): + node = Node(log) + self._root_node.add_child(node) + + def refresh(self): + self.clear() + self.beginResetModel() + if self.dbcon: + result = self.dbcon.find({}) + for item in result: + self.add_log(item) + self.endResetModel() + + + def data(self, index, role): + if not index.isValid(): + return None + + if role == QtCore.Qt.DisplayRole or role == QtCore.Qt.EditRole: + node = index.internalPointer() + column = index.column() + + key = self.COLUMNS[column] + if key == "timestamp": + return str(node.get(key, None)) + return node.get(key, None) + + if role == self.NodeRole: + return index.internalPointer() + + def index(self, row, column, parent): + """Return index for row/column under parent""" + + if not parent.isValid(): + parent_node = self._root_node + else: + parent_node = parent.internalPointer() + + child_item = parent_node.child(row) + if child_item: + return self.createIndex(row, column, child_item) + else: + return QtCore.QModelIndex() + + def rowCount(self, parent): + node = self._root_node + if parent.isValid(): + node = parent.internalPointer() + return node.childCount() + + def columnCount(self, parent): + return len(self.COLUMNS) + + def parent(self, index): + return QtCore.QModelIndex() + + def headerData(self, section, orientation, role): + if role == QtCore.Qt.DisplayRole: + if section < len(self.COLUMNS): + key = self.COLUMNS[section] + return self.colums_mapping.get(key, key) + + super(LogModel, self).headerData(section, orientation, role) + + def flags(self, index): + return (QtCore.Qt.ItemIsEnabled | QtCore.Qt.ItemIsSelectable) + + def clear(self): + self.beginResetModel() + self._root_node = Node() + self.endResetModel() + + +class Node(dict): + """A node that can be represented in a tree view. + + The node can store data just like a dictionary. + + >>> data = {"name": "John", "score": 10} + >>> node = Node(data) + >>> assert node["name"] == "John" + + """ + + def __init__(self, data=None): + super(Node, self).__init__() + + self._children = list() + self._parent = None + + if data is not None: + assert isinstance(data, dict) + self.update(data) + + def childCount(self): + return len(self._children) + + def child(self, row): + if row >= len(self._children): + log.warning("Invalid row as child: {0}".format(row)) + return + + return self._children[row] + + def children(self): + return self._children + + def parent(self): + return self._parent + + def row(self): + """ + Returns: + int: Index of this node under parent""" + if self._parent is not None: + siblings = self.parent().children() + return siblings.index(self) + + def add_child(self, child): + """Add a child to this node""" + child._parent = self + self._children.append(child) diff --git a/pype/logging/gui/widgets.py b/pype/logging/gui/widgets.py new file mode 100644 index 0000000000..66692c2c65 --- /dev/null +++ b/pype/logging/gui/widgets.py @@ -0,0 +1,426 @@ +import datetime +import inspect +from Qt import QtCore, QtWidgets, QtGui +from PyQt5.QtCore import QVariant +from .models import LogModel + +from .lib import preserve_states + + +class SearchComboBox(QtWidgets.QComboBox): + """Searchable ComboBox with empty placeholder value as first value""" + + def __init__(self, parent=None, placeholder=""): + super(SearchComboBox, self).__init__(parent) + + self.setEditable(True) + self.setInsertPolicy(self.NoInsert) + self.lineEdit().setPlaceholderText(placeholder) + + # Apply completer settings + completer = self.completer() + completer.setCompletionMode(completer.PopupCompletion) + completer.setCaseSensitivity(QtCore.Qt.CaseInsensitive) + + # Force style sheet on popup menu + # It won't take the parent stylesheet for some reason + # todo: better fix for completer popup stylesheet + if parent: + popup = completer.popup() + popup.setStyleSheet(parent.styleSheet()) + + self.currentIndexChanged.connect(self.onIndexChange) + + def onIndexChange(self, index): + print(index) + + def populate(self, items): + self.clear() + self.addItems([""]) # ensure first item is placeholder + self.addItems(items) + + def get_valid_value(self): + """Return the current text if it's a valid value else None + + Note: The empty placeholder value is valid and returns as "" + + """ + + text = self.currentText() + lookup = set(self.itemText(i) for i in range(self.count())) + if text not in lookup: + return None + + return text + +class CheckableComboBox2(QtWidgets.QComboBox): + def __init__(self, parent=None): + super(CheckableComboBox, self).__init__(parent) + self.view().pressed.connect(self.handleItemPressed) + self._changed = False + + def handleItemPressed(self, index): + item = self.model().itemFromIndex(index) + if item.checkState() == QtCore.Qt.Checked: + item.setCheckState(QtCore.Qt.Unchecked) + else: + item.setCheckState(QtCore.Qt.Checked) + self._changed = True + + def hidePopup(self): + if not self._changed: + super(CheckableComboBox, self).hidePopup() + self._changed = False + + def itemChecked(self, index): + item = self.model().item(index, self.modelColumn()) + return item.checkState() == QtCore.Qt.Checked + + def setItemChecked(self, index, checked=True): + item = self.model().item(index, self.modelColumn()) + if checked: + item.setCheckState(QtCore.Qt.Checked) + else: + item.setCheckState(QtCore.Qt.Unchecked) + + +class SelectableMenu(QtWidgets.QMenu): + + selection_changed = QtCore.Signal() + + def mouseReleaseEvent(self, event): + action = self.activeAction() + if action and action.isEnabled(): + action.trigger() + self.selection_changed.emit() + else: + super(SelectableMenu, self).mouseReleaseEvent(event) + +class CustomCombo(QtWidgets.QWidget): + + selection_changed = QtCore.Signal() + + def __init__(self, title, parent=None): + super(CustomCombo, self).__init__(parent) + toolbutton = QtWidgets.QToolButton(self) + toolbutton.setText(title) + + toolmenu = SelectableMenu(self) + + toolbutton.setMenu(toolmenu) + toolbutton.setPopupMode(QtWidgets.QToolButton.MenuButtonPopup) + + layout = QtWidgets.QHBoxLayout() + layout.setContentsMargins(0, 0, 0, 0) + layout.addWidget(toolbutton) + + self.setLayout(layout) + + # toolmenu.selection_changed.connect(self.on_selection_changed) + toolmenu.selection_changed.connect(self.selection_changed) + + self.toolbutton = toolbutton + self.toolmenu = toolmenu + self.main_layout = layout + + def populate(self, items): + self.toolmenu.clear() + self.addItems(items) + + def addItems(self, items): + for item in items: + action = self.toolmenu.addAction(item) + action.setCheckable(True) + action.setChecked(True) + self.toolmenu.addAction(action) + + def items(self): + for action in self.toolmenu.actions(): + yield action + + +class CheckableComboBox(QtWidgets.QComboBox): + def __init__(self, parent=None): + super(CheckableComboBox, self).__init__(parent) + + view = QtWidgets.QTreeView() + view.header().hide() + view.setRootIsDecorated(False) + + model = QtGui.QStandardItemModel() + + view.pressed.connect(self.handleItemPressed) + self._changed = False + + self.setView(view) + self.setModel(model) + + self.view = view + self.model = model + + def handleItemPressed(self, index): + item = self.model.itemFromIndex(index) + if item.checkState() == QtCore.Qt.Checked: + item.setCheckState(QtCore.Qt.Unchecked) + else: + item.setCheckState(QtCore.Qt.Checked) + self._changed = True + + def hidePopup(self): + if not self._changed: + super(CheckableComboBox, self).hidePopup() + self._changed = False + + def itemChecked(self, index): + item = self.model.item(index, self.modelColumn()) + return item.checkState() == QtCore.Qt.Checked + + def setItemChecked(self, index, checked=True): + item = self.model.item(index, self.modelColumn()) + if checked: + item.setCheckState(QtCore.Qt.Checked) + else: + item.setCheckState(QtCore.Qt.Unchecked) + + def addItems(self, items): + for text, checked in items: + text_item = QtGui.QStandardItem(text) + checked_item = QtGui.QStandardItem() + checked_item.setData(QVariant(checked), QtCore.Qt.CheckStateRole) + self.model.appendRow([text_item, checked_item]) + + +class LogsWidget(QtWidgets.QWidget): + """A widget that lists the published subsets for an asset""" + + active_changed = QtCore.Signal() + + def __init__(self, parent=None): + super(LogsWidget, self).__init__(parent=parent) + + model = LogModel() + + filter_layout = QtWidgets.QHBoxLayout() + + # user_filter = SearchComboBox(self, "Users") + user_filter = CustomCombo("Users", self) + users = model.dbcon.distinct("user") + user_filter.populate(users) + user_filter.selection_changed.connect(self.user_changed) + + level_filter = CustomCombo("Levels", self) + # levels = [(level, True) for level in model.dbcon.distinct("level")] + levels = model.dbcon.distinct("level") + level_filter.addItems(levels) + + date_from_label = QtWidgets.QLabel("From:") + date_filter_from = QtWidgets.QDateTimeEdit() + + date_from_layout = QtWidgets.QVBoxLayout() + date_from_layout.addWidget(date_from_label) + date_from_layout.addWidget(date_filter_from) + + # now = datetime.datetime.now() + # QtCore.QDateTime(now.year, now.month, now.day, now.hour, now.minute, second = 0, msec = 0, timeSpec = 0) + date_to_label = QtWidgets.QLabel("To:") + date_filter_to = QtWidgets.QDateTimeEdit() + + date_to_layout = QtWidgets.QVBoxLayout() + date_to_layout.addWidget(date_to_label) + date_to_layout.addWidget(date_filter_to) + + filter_layout.addWidget(user_filter) + filter_layout.addWidget(level_filter) + + filter_layout.addLayout(date_from_layout) + filter_layout.addLayout(date_to_layout) + + view = QtWidgets.QTreeView(self) + view.setAllColumnsShowFocus(True) + + # # Set view delegates + # time_delegate = PrettyTimeDelegate() + # column = model.COLUMNS.index("time") + # view.setItemDelegateForColumn(column, time_delegate) + + layout = QtWidgets.QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.addLayout(filter_layout) + layout.addWidget(view) + + view.setContextMenuPolicy(QtCore.Qt.CustomContextMenu) + view.setSortingEnabled(True) + view.sortByColumn( + model.COLUMNS.index("timestamp"), + QtCore.Qt.AscendingOrder + ) + + view.setModel(model) + + view.customContextMenuRequested.connect(self.on_context_menu) + view.selectionModel().selectionChanged.connect(self.active_changed) + # user_filter.connect() + + # TODO remove if nothing will affect... + # header = self.view.header() + # # Enforce the columns to fit the data (purely cosmetic) + # if Qt.__binding__ in ("PySide2", "PyQt5"): + # header.setSectionResizeMode(QtWidgets.QHeaderView.ResizeToContents) + # else: + # header.setResizeMode(QtWidgets.QHeaderView.ResizeToContents) + + # Set signals + + # prepare + model.refresh() + + # Store to memory + self.model = model + self.view = view + + self.user_filter = user_filter + + def user_changed(self): + for action in self.user_filter.items(): + print(action) + + def on_context_menu(self, point): + # TODO will be any actions? it's ready + return + + point_index = self.view.indexAt(point) + if not point_index.isValid(): + return + + # Get selected subsets without groups + selection = self.view.selectionModel() + rows = selection.selectedRows(column=0) + + def selected_log(self): + selection = self.view.selectionModel() + rows = selection.selectedRows(column=0) + if len(rows) == 1: + return rows[0] + + return None + + +class LogDetailWidget(QtWidgets.QWidget): + """A Widget that display information about a specific version""" + data_rows = [ + "user", + "message", + "level", + "logname", + "method", + "module", + "fileName", + "lineNumber", + "host", + "timestamp" + ] + + html_text = u""" +