diff --git a/openpype/modules/sync_server/tray/lib.py b/openpype/modules/sync_server/tray/lib.py index 0282d79ea1..051567ed6c 100644 --- a/openpype/modules/sync_server/tray/lib.py +++ b/openpype/modules/sync_server/tray/lib.py @@ -1,4 +1,5 @@ from Qt import QtCore +import attr from openpype.lib import PypeLogger @@ -20,8 +21,14 @@ ProviderRole = QtCore.Qt.UserRole + 2 ProgressRole = QtCore.Qt.UserRole + 4 DateRole = QtCore.Qt.UserRole + 6 FailedRole = QtCore.Qt.UserRole + 8 +HeaderNameRole = QtCore.Qt.UserRole + 10 +@attr.s +class FilterDefinition: + type = attr.ib() + values = attr.ib(factory=list) + def pretty_size(value, suffix='B'): for unit in ['', 'Ki', 'Mi', 'Gi', 'Ti', 'Pi', 'Ei', 'Zi']: if abs(value) < 1024.0: diff --git a/openpype/modules/sync_server/tray/models.py b/openpype/modules/sync_server/tray/models.py index 3cc53c6ec4..444422c56a 100644 --- a/openpype/modules/sync_server/tray/models.py +++ b/openpype/modules/sync_server/tray/models.py @@ -56,6 +56,10 @@ class _SyncRepresentationModel(QtCore.QAbstractTableModel): """Returns project""" return self._project + @property + def column_filtering(self): + return self._column_filtering + def rowCount(self, _index): return len(self._data) @@ -65,7 +69,15 @@ class _SyncRepresentationModel(QtCore.QAbstractTableModel): def headerData(self, section, orientation, role): if role == Qt.DisplayRole: if orientation == Qt.Horizontal: - return self.COLUMN_LABELS[section][1] + name = self.COLUMN_LABELS[section][0] + txt = "" + if name in self.column_filtering.keys(): + txt = "(F)" + return self.COLUMN_LABELS[section][1] + txt # return label + + if role == lib.HeaderNameRole: + if orientation == Qt.Horizontal: + return self.COLUMN_LABELS[section][0] # return name def get_header_index(self, value): """ @@ -190,6 +202,35 @@ class _SyncRepresentationModel(QtCore.QAbstractTableModel): self.word_filter = word_filter self.refresh() + def get_column_filter_values(self, index): + """ + Returns list of available values for filtering in the column + + Args: + index(int): index of column in header + + Returns: + (dict) of value: label shown in filtering menu + 'value' is used in MongoDB query, 'label' is human readable for + menu + for some columns ('subset') might be 'value' and 'label' same + """ + column_name = self._header[index] + + filter_def = self.COLUMN_FILTERS.get(column_name) + if not filter_def: + return {} + + if filter_def['type'] == 'predefined_set': + return dict(filter_def['values']) + elif filter_def['type'] == 'available_values': + recs = self.dbcon.find({'type': column_name}, {"name": 1, + "_id": -1}) + values = {} + for item in recs: + values[item["name"]] = item["name"] + return dict(sorted(values.items(), key=lambda item: item[1])) + def set_project(self, project): """ Changes project, called after project selection is changed @@ -251,7 +292,7 @@ class SyncRepresentationSummaryModel(_SyncRepresentationModel): ("files_count", "Files"), ("files_size", "Size"), ("priority", "Priority"), - ("state", "Status") + ("status", "Status") ] DEFAULT_SORT = { @@ -259,18 +300,26 @@ class SyncRepresentationSummaryModel(_SyncRepresentationModel): "_id": 1 } SORT_BY_COLUMN = [ - "context.asset", # asset - "context.subset", # subset - "context.version", # version - "context.representation", # representation + "asset", # asset + "subset", # subset + "version", # version + "representation", # representation "updated_dt_local", # local created_dt "updated_dt_remote", # remote created_dt "files_count", # count of files "files_size", # file size of all files "context.asset", # priority TODO - "status" # state + "status" # status ] + COLUMN_FILTERS = { + 'status': {'type': 'predefined_set', + 'values': {k: v for k, v in lib.STATUS.items()}}, + 'subset': {'type': 'available_values'}, + 'asset': {'type': 'available_values'}, + 'representation': {'type': 'available_values'} + } + refresh_started = QtCore.Signal() refresh_finished = QtCore.Signal() @@ -297,7 +346,7 @@ class SyncRepresentationSummaryModel(_SyncRepresentationModel): files_count = attr.ib(default=None) files_size = attr.ib(default=None) priority = attr.ib(default=None) - state = attr.ib(default=None) + status = attr.ib(default=None) path = attr.ib(default=None) def __init__(self, sync_server, header, project=None): @@ -308,6 +357,7 @@ class SyncRepresentationSummaryModel(_SyncRepresentationModel): self._rec_loaded = 0 self._total_records = 0 # how many documents query actually found self.word_filter = None + self._column_filtering = {} self._initialized = False if not self._project or self._project == lib.DUMMY_PROJECT: @@ -359,9 +409,9 @@ class SyncRepresentationSummaryModel(_SyncRepresentationModel): if role == lib.FailedRole: if header_value == 'local_site': - return item.state == lib.STATUS[2] and item.local_progress < 1 + return item.status == lib.STATUS[2] and item.local_progress < 1 if header_value == 'remote_site': - return item.state == lib.STATUS[2] and item.remote_progress < 1 + return item.status == lib.STATUS[2] and item.remote_progress < 1 if role == Qt.DisplayRole: # because of ImageDelegate @@ -397,7 +447,6 @@ class SyncRepresentationSummaryModel(_SyncRepresentationModel): remote_site) for repre in result.get("paginatedResults"): - context = repre.get("context").pop() files = repre.get("files", []) if isinstance(files, dict): # aggregate returns dictionary files = [files] @@ -420,17 +469,17 @@ class SyncRepresentationSummaryModel(_SyncRepresentationModel): avg_progress_local = lib.convert_progress( repre.get('avg_progress_local', '0')) - if context.get("version"): - version = "v{:0>3d}".format(context.get("version")) + if repre.get("version"): + version = "v{:0>3d}".format(repre.get("version")) else: version = "master" item = self.SyncRepresentation( repre.get("_id"), - context.get("asset"), - context.get("subset"), + repre.get("asset"), + repre.get("subset"), version, - context.get("representation"), + repre.get("representation"), local_updated, remote_updated, local_site, @@ -461,7 +510,7 @@ class SyncRepresentationSummaryModel(_SyncRepresentationModel): 'sync_dt' - same for remote side 'local_site' - progress of repr on local side, 1 = finished 'remote_site' - progress on remote side, calculates from files - 'state' - + 'status' - 0 - in progress 1 - failed 2 - queued @@ -481,7 +530,7 @@ class SyncRepresentationSummaryModel(_SyncRepresentationModel): if limit == 0: limit = SyncRepresentationSummaryModel.PAGE_SIZE - return [ + aggr = [ {"$match": self.get_match_part()}, {'$unwind': '$files'}, # merge potentially unwinded records back to single per repre @@ -584,16 +633,43 @@ class SyncRepresentationSummaryModel(_SyncRepresentationModel): 'paused_local': {'$sum': '$paused_local'}, 'updated_dt_local': {'$max': "$updated_dt_local"} }}, - {"$project": self.projection}, - {"$sort": self.sort}, - { + {"$project": self.projection} + ] + + if self.column_filtering: + aggr.append( + {"$match": self.column_filtering} + ) + print(self.column_filtering) + + aggr.extend( + [{"$sort": self.sort}, + { '$facet': { 'paginatedResults': [{'$skip': self._rec_loaded}, {'$limit': limit}], 'totalCount': [{'$count': 'count'}] } - } - ] + }] + ) + + return aggr + + def set_column_filtering(self, checked_values): + """ + Sets dictionary used in '$match' part of MongoDB aggregate + + Args: + checked_values(dict): key:values ({'status':{1:"Foo",3:"Bar"}} + + Modifies: + self._column_filtering : {'status': {'$in': [1, 2, 3]}} + """ + filtering = {} + for key, dict_value in checked_values.items(): + filtering[key] = {'$in': list(dict_value.keys())} + + self._column_filtering = filtering def get_match_part(self): """ @@ -639,10 +715,10 @@ class SyncRepresentationSummaryModel(_SyncRepresentationModel): (dict) """ return { - "context.subset": 1, - "context.asset": 1, - "context.version": 1, - "context.representation": 1, + "subset": {"$first": "$context.subset"}, + "asset": {"$first": "$context.asset"}, + "version": {"$first": "$context.version"}, + "representation": {"$first": "$context.representation"}, "data.path": 1, "files": 1, 'files_count': 1, @@ -721,7 +797,7 @@ class SyncRepresentationDetailModel(_SyncRepresentationModel): ("remote_site", "Remote site"), ("files_size", "Size"), ("priority", "Priority"), - ("state", "Status") + ("status", "Status") ] PAGE_SIZE = 30 @@ -734,7 +810,7 @@ class SyncRepresentationDetailModel(_SyncRepresentationModel): "updated_dt_remote", # remote created_dt "size", # remote progress "context.asset", # priority TODO - "status" # state + "status" # status ] refresh_started = QtCore.Signal() @@ -759,7 +835,7 @@ class SyncRepresentationDetailModel(_SyncRepresentationModel): remote_progress = attr.ib(default=None) size = attr.ib(default=None) priority = attr.ib(default=None) - state = attr.ib(default=None) + status = attr.ib(default=None) tries = attr.ib(default=None) error = attr.ib(default=None) path = attr.ib(default=None) @@ -821,9 +897,9 @@ class SyncRepresentationDetailModel(_SyncRepresentationModel): if role == lib.FailedRole: if header_value == 'local_site': - return item.state == lib.STATUS[2] and item.local_progress < 1 + return item.status == lib.STATUS[2] and item.local_progress < 1 if header_value == 'remote_site': - return item.state == lib.STATUS[2] and item.remote_progress < 1 + return item.status == lib.STATUS[2] and item.remote_progress < 1 if role == Qt.DisplayRole: # because of ImageDelegate diff --git a/openpype/modules/sync_server/tray/widgets.py b/openpype/modules/sync_server/tray/widgets.py index 5071ffa2b0..5719d13716 100644 --- a/openpype/modules/sync_server/tray/widgets.py +++ b/openpype/modules/sync_server/tray/widgets.py @@ -1,6 +1,7 @@ import os import subprocess import sys +from functools import partial from Qt import QtWidgets, QtCore, QtGui from Qt.QtCore import Qt @@ -91,7 +92,7 @@ class SyncProjectListWidget(ProjectListWidget): self.project_name = point_index.data(QtCore.Qt.DisplayRole) menu = QtWidgets.QMenu() - menu.setStyleSheet(style.load_stylesheet()) + #menu.setStyleSheet(style.load_stylesheet()) actions_mapping = {} if self.sync_server.is_project_paused(self.project_name): @@ -150,7 +151,7 @@ class SyncRepresentationWidget(QtWidgets.QWidget): ("files_count", 50), ("files_size", 60), ("priority", 50), - ("state", 110) + ("status", 110) ) def __init__(self, sync_server, project=None, parent=None): @@ -217,6 +218,14 @@ class SyncRepresentationWidget(QtWidgets.QWidget): self.selection_model = self.table_view.selectionModel() self.selection_model.selectionChanged.connect(self._selection_changed) + self.checked_values = {} + + self.horizontal_header = self.table_view.horizontalHeader() + self.horizontal_header.setContextMenuPolicy( + QtCore.Qt.CustomContextMenu) + self.horizontal_header.customContextMenuRequested.connect( + self._on_section_clicked) + def _selection_changed(self, _new_selection): index = self.selection_model.currentIndex() self._selected_id = \ @@ -246,6 +255,136 @@ class SyncRepresentationWidget(QtWidgets.QWidget): self.sync_server, _id, self.table_view.model().project) detail_window.exec() + def _on_section_clicked(self, point): + + logical_index = self.horizontal_header.logicalIndexAt(point) + + model = self.table_view.model() + column_name = model.headerData(logical_index, + Qt.Horizontal, lib.HeaderNameRole) + items_dict = model.get_column_filter_values(logical_index) + + if not items_dict: + return + + menu = QtWidgets.QMenu(self) + + # text filtering only if labels same as values, not if codes are used + if list(items_dict.keys())[0] == list(items_dict.values())[0]: + self.line_edit = QtWidgets.QLineEdit(self) + self.line_edit.setPlaceholderText("Type and enter...") + action_le = QtWidgets.QWidgetAction(menu) + action_le.setDefaultWidget(self.line_edit) + self.line_edit.returnPressed.connect( + partial(self._apply_text_filter, column_name, items_dict)) + menu.addAction(action_le) + menu.addSeparator() + + action_all = QtWidgets.QAction("All", self) + state_checked = 2 + # action_all.triggered.connect(partial(self._apply_filter, column_name, + # items_dict, state_checked)) + action_all.triggered.connect(partial(self._reset_filter, column_name)) + menu.addAction(action_all) + + action_none = QtWidgets.QAction("Unselect all", self) + state_unchecked = 0 + action_none.triggered.connect(partial(self._apply_filter, column_name, + items_dict, state_unchecked)) + menu.addAction(action_none) + menu.addSeparator() + + # nothing explicitly >> ALL implicitly >> first time + if self.checked_values.get(column_name) is None: + checked_keys = items_dict.keys() + else: + checked_keys = self.checked_values[column_name] + + for value, label in items_dict.items(): + checkbox = QtWidgets.QCheckBox(str(label), menu) + if value in checked_keys: + checkbox.setChecked(True) + + action = QtWidgets.QWidgetAction(menu) + action.setDefaultWidget(checkbox) + + checkbox.stateChanged.connect(partial(self._apply_filter, + column_name, {value: label})) + menu.addAction(action) + + self.menu = menu + self.menu_items_dict = items_dict # all available items + self.menu.exec_(QtGui.QCursor.pos()) + + def _reset_filter(self, column_name): + """ + Remove whole column from filter >> not in $match at all (faster) + """ + if self.checked_values.get(column_name) is not None: + self.checked_values.pop(column_name) + self._refresh_model_and_menu(column_name, True, True) + + def _apply_filter(self, column_name, values, state): + """ + Sets 'values' to specific 'state' (checked/unchecked), + sends to model. + """ + self._update_checked_values(column_name, values, state) + self._refresh_model_and_menu(column_name, True, False) + + def _apply_text_filter(self, column_name, items): + """ + Resets all checkboxes, prefers inserted text. + """ + self._update_checked_values(column_name, items, 0) # reset other + text_item = {self.line_edit.text(): self.line_edit.text()} + self._update_checked_values(column_name, text_item, 2) + self._refresh_model_and_menu(column_name, True, True) + + def _refresh_model_and_menu(self, column_name, model=True, menu=True): + """ + Refresh model and its content and possibly menu for big changes. + """ + if model: + self.table_view.model().set_column_filtering(self.checked_values) + self.table_view.model().refresh() + if menu: + self._menu_refresh(column_name) + + def _menu_refresh(self, column_name): + """ + Reset boxes after big change - word filtering or reset + """ + for action in self.menu.actions(): + if not isinstance(action, QtWidgets.QWidgetAction): + continue + + widget = action.defaultWidget() + if not isinstance(widget, QtWidgets.QCheckBox): + continue + + if not self.checked_values.get(column_name) or \ + widget.text() in self.checked_values[column_name].values(): + widget.setChecked(True) + else: + widget.setChecked(False) + + def _update_checked_values(self, column_name, values, state): + """ + Modify dictionary of set values in columns for filtering. + + Modifies 'self.checked_values' + """ + checked = self.checked_values.get(column_name, self.menu_items_dict) + set_items = dict(values.items()) # prevent dictionary change during iter + for value, label in set_items.items(): + if state == 2: # checked + checked[value] = label + elif state == 0 and checked.get(value): + checked.pop(value) + + self.checked_values[column_name] = checked + def _on_context_menu(self, point): """ Shows menu with loader actions on Right-click. @@ -291,17 +430,17 @@ class SyncRepresentationWidget(QtWidgets.QWidget): else: self.site_name = remote_site - if self.item.state in [lib.STATUS[0], lib.STATUS[1]]: + if self.item.status in [lib.STATUS[0], lib.STATUS[1]]: action = QtWidgets.QAction("Pause") actions_mapping[action] = self._pause menu.addAction(action) - if self.item.state == lib.STATUS[3]: + if self.item.status == lib.STATUS[3]: action = QtWidgets.QAction("Unpause") actions_mapping[action] = self._unpause menu.addAction(action) - # if self.item.state == lib.STATUS[1]: + # if self.item.status == lib.STATUS[1]: # action = QtWidgets.QAction("Open error detail") # actions_mapping[action] = self._show_detail # menu.addAction(action) @@ -467,7 +606,7 @@ class SyncRepresentationDetailWidget(QtWidgets.QWidget): ("remote_site", 185), ("size", 60), ("priority", 25), - ("state", 110) + ("status", 110) ) def __init__(self, sync_server, _id=None, project=None, parent=None): @@ -579,7 +718,7 @@ class SyncRepresentationDetailWidget(QtWidgets.QWidget): self.item = self.table_view.model()._data[point_index.row()] menu = QtWidgets.QMenu() - menu.setStyleSheet(style.load_stylesheet()) + #menu.setStyleSheet(style.load_stylesheet()) actions_mapping = {} actions_kwargs_mapping = {} @@ -604,7 +743,7 @@ class SyncRepresentationDetailWidget(QtWidgets.QWidget): actions_kwargs_mapping[action] = {'site': site} menu.addAction(action) - if self.item.state == lib.STATUS[2]: + if self.item.status == lib.STATUS[2]: action = QtWidgets.QAction("Open error detail") actions_mapping[action] = self._show_detail menu.addAction(action)