diff --git a/openpype/modules/sync_server/tray/lib.py b/openpype/modules/sync_server/tray/lib.py index 0282d79ea1..3597213b31 100644 --- a/openpype/modules/sync_server/tray/lib.py +++ b/openpype/modules/sync_server/tray/lib.py @@ -1,4 +1,7 @@ from Qt import QtCore +import attr +import abc +import six from openpype.lib import PypeLogger @@ -20,8 +23,111 @@ 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 +@six.add_metaclass(abc.ABCMeta) +class AbstractColumnFilter: + + def __init__(self, column_name, dbcon=None): + self.column_name = column_name + self.dbcon = dbcon + self._search_variants = [] + + def search_variants(self): + """ + Returns all flavors of search available for this column, + """ + return self._search_variants + + @abc.abstractmethod + def values(self): + """ + Returns dict of available values for filter {'label':'value'} + """ + pass + + @abc.abstractmethod + def prepare_match_part(self, values): + """ + Prepares format valid for $match part from 'values + + Args: + values (dict): {'label': 'value'} + Returns: + (dict): {'COLUMN_NAME': {'$in': ['val1', 'val2']}} + """ + pass + + +class PredefinedSetFilter(AbstractColumnFilter): + + def __init__(self, column_name, values): + super().__init__(column_name) + self._search_variants = ['checkbox'] + self._values = values + if self._values and \ + list(self._values.keys())[0] == list(self._values.values())[0]: + self._search_variants.append('text') + + def values(self): + return {k: v for k, v in self._values.items()} + + def prepare_match_part(self, values): + return {'$in': list(values.keys())} + + +class RegexTextFilter(AbstractColumnFilter): + + def __init__(self, column_name): + super().__init__(column_name) + self._search_variants = ['text'] + + def values(self): + return {} + + def prepare_match_part(self, values): + """ values = {'text1 text2': 'text1 text2'} """ + if not values: + return {} + + regex_strs = set() + text = list(values.keys())[0] # only single key always expected + for word in text.split(): + regex_strs.add('.*{}.*'.format(word)) + + return {"$regex": "|".join(regex_strs), + "$options": 'i'} + + +class MultiSelectFilter(AbstractColumnFilter): + + def __init__(self, column_name, values=None, dbcon=None): + super().__init__(column_name) + self._values = values + self.dbcon = dbcon + self._search_variants = ['checkbox'] + + def values(self): + if self._values: + return {k: v for k, v in self._values.items()} + + recs = self.dbcon.find({'type': self.column_name}, {"name": 1, + "_id": -1}) + values = {} + for item in recs: + values[item["name"]] = item["name"] + return dict(sorted(values.items(), key=lambda it: it[1])) + + def prepare_match_part(self, values): + return {'$in': list(values.keys())} + + +@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..981299c6cf 100644 --- a/openpype/modules/sync_server/tray/models.py +++ b/openpype/modules/sync_server/tray/models.py @@ -56,17 +56,31 @@ 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) - def columnCount(self, _index): + def columnCount(self, _index=None): return len(self._header) - def headerData(self, section, orientation, role): + def headerData(self, section, orientation, role=Qt.DisplayRole): + if section >= len(self.COLUMN_LABELS): + return + if role == Qt.DisplayRole: if orientation == Qt.Horizontal: return self.COLUMN_LABELS[section][1] + if role == lib.HeaderNameRole: + if orientation == Qt.Horizontal: + return self.COLUMN_LABELS[section][0] # return name + + def get_column(self, index): + return self.COLUMN_LABELS[index] + def get_header_index(self, value): """ Returns index of 'value' in headers @@ -103,7 +117,7 @@ class _SyncRepresentationModel(QtCore.QAbstractTableModel): self._rec_loaded = 0 if not representations: - self.query = self.get_default_query(load_records) + self.query = self.get_query(load_records) representations = self.dbcon.aggregate(self.query) self.add_page_records(self.local_site, self.remote_site, @@ -138,7 +152,7 @@ class _SyncRepresentationModel(QtCore.QAbstractTableModel): log.debug("fetchMore") items_to_fetch = min(self._total_records - self._rec_loaded, self.PAGE_SIZE) - self.query = self.get_default_query(self._rec_loaded) + self.query = self.get_query(self._rec_loaded) representations = self.dbcon.aggregate(self.query) self.beginInsertRows(index, self._rec_loaded, @@ -171,7 +185,7 @@ class _SyncRepresentationModel(QtCore.QAbstractTableModel): order = -1 self.sort = {self.SORT_BY_COLUMN[index]: order, '_id': 1} - self.query = self.get_default_query() + self.query = self.get_query() # import json # log.debug(json.dumps(self.query, indent=4).\ # replace('False', 'false').\ @@ -180,16 +194,86 @@ class _SyncRepresentationModel(QtCore.QAbstractTableModel): representations = self.dbcon.aggregate(self.query) self.refresh(representations) - def set_filter(self, word_filter): + def set_word_filter(self, word_filter): """ Adds text value filtering Args: word_filter (str): string inputted by user """ - self.word_filter = word_filter + self._word_filter = word_filter self.refresh() + def get_filters(self): + """ + Returns all available filter editors per column_name keys. + """ + filters = {} + for column_name, _ in self.COLUMN_LABELS: + filter_rec = self.COLUMN_FILTERS.get(column_name) + if filter_rec: + filter_rec.dbcon = self.dbcon + filters[column_name] = filter_rec + + return filters + + def get_column_filter(self, index): + """ + Returns filter object for column 'index + + Args: + index(int): index of column in header + + Returns: + (AbstractColumnFilter) + """ + column_name = self._header[index] + + filter_rec = self.COLUMN_FILTERS.get(column_name) + if filter_rec: + filter_rec.dbcon = self.dbcon # up-to-date db connection + + return filter_rec + + 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 column_name, dict_value in checked_values.items(): + column_f = self.COLUMN_FILTERS.get(column_name) + if not column_f: + continue + column_f.dbcon = self.dbcon + filtering[column_name] = column_f.prepare_match_part(dict_value) + + self._column_filtering = filtering + + 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 + """ + filter_rec = self.get_column_filter(index) + if not filter_rec: + return {} + + return filter_rec.values() + def set_project(self, project): """ Changes project, called after project selection is changed @@ -251,7 +335,7 @@ class SyncRepresentationSummaryModel(_SyncRepresentationModel): ("files_count", "Files"), ("files_size", "Size"), ("priority", "Priority"), - ("state", "Status") + ("status", "Status") ] DEFAULT_SORT = { @@ -259,18 +343,25 @@ 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': lib.PredefinedSetFilter('status', lib.STATUS), + 'subset': lib.RegexTextFilter('subset'), + 'asset': lib.RegexTextFilter('asset'), + 'representation': lib.MultiSelectFilter('representation') + } + refresh_started = QtCore.Signal() refresh_finished = QtCore.Signal() @@ -297,7 +388,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): @@ -307,7 +398,10 @@ class SyncRepresentationSummaryModel(_SyncRepresentationModel): self._project = project self._rec_loaded = 0 self._total_records = 0 # how many documents query actually found - self.word_filter = None + self._word_filter = None + self._column_filtering = {} + + self._word_filter = None self._initialized = False if not self._project or self._project == lib.DUMMY_PROJECT: @@ -319,12 +413,10 @@ class SyncRepresentationSummaryModel(_SyncRepresentationModel): self.local_site = self.sync_server.get_active_site(self.project) self.remote_site = self.sync_server.get_remote_site(self.project) - self.projection = self.get_default_projection() - self.sort = self.DEFAULT_SORT - self.query = self.get_default_query() - self.default_query = list(self.get_default_query()) + self.query = self.get_query() + self.default_query = list(self.get_query()) representations = self.dbcon.aggregate(self.query) self.refresh(representations) @@ -359,9 +451,11 @@ 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 +491,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 +513,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, @@ -449,7 +542,7 @@ class SyncRepresentationSummaryModel(_SyncRepresentationModel): self._data.append(item) self._rec_loaded += 1 - def get_default_query(self, limit=0): + def get_query(self, limit=0): """ Returns basic aggregate query for main table. @@ -461,7 +554,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 +574,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 +677,26 @@ 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} + ) + + aggr.extend( + [{"$sort": self.sort}, + { '$facet': { 'paginatedResults': [{'$skip': self._rec_loaded}, {'$limit': limit}], 'totalCount': [{'$count': 'count'}] } - } - ] + }] + ) + + return aggr def get_match_part(self): """ @@ -614,22 +717,23 @@ class SyncRepresentationSummaryModel(_SyncRepresentationModel): 'files.sites.name': {'$all': [self.local_site, self.remote_site]} } - if not self.word_filter: + if not self._word_filter: return base_match else: - regex_str = '.*{}.*'.format(self.word_filter) + regex_str = '.*{}.*'.format(self._word_filter) base_match['$or'] = [ {'context.subset': {'$regex': regex_str, '$options': 'i'}}, {'context.asset': {'$regex': regex_str, '$options': 'i'}}, {'context.representation': {'$regex': regex_str, '$options': 'i'}}] - if ObjectId.is_valid(self.word_filter): - base_match['$or'] = [{'_id': ObjectId(self.word_filter)}] + if ObjectId.is_valid(self._word_filter): + base_match['$or'] = [{'_id': ObjectId(self._word_filter)}] return base_match - def get_default_projection(self): + @property + def projection(self): """ Projection part for aggregate query. @@ -639,10 +743,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 +825,7 @@ class SyncRepresentationDetailModel(_SyncRepresentationModel): ("remote_site", "Remote site"), ("files_size", "Size"), ("priority", "Priority"), - ("state", "Status") + ("status", "Status") ] PAGE_SIZE = 30 @@ -733,10 +837,15 @@ class SyncRepresentationDetailModel(_SyncRepresentationModel): "updated_dt_local", # local created_dt "updated_dt_remote", # remote created_dt "size", # remote progress - "context.asset", # priority TODO - "status" # state + "size", # priority TODO + "status" # status ] + COLUMN_FILTERS = { + 'status': lib.PredefinedSetFilter('status', lib.STATUS), + 'file': lib.RegexTextFilter('file'), + } + refresh_started = QtCore.Signal() refresh_finished = QtCore.Signal() @@ -759,7 +868,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) @@ -772,9 +881,10 @@ class SyncRepresentationDetailModel(_SyncRepresentationModel): self._project = project self._rec_loaded = 0 self._total_records = 0 # how many documents query actually found - self.word_filter = None + self._word_filter = None self._id = _id self._initialized = False + self._column_filtering = {} self.sync_server = sync_server # TODO think about admin mode @@ -784,10 +894,7 @@ class SyncRepresentationDetailModel(_SyncRepresentationModel): self.sort = self.DEFAULT_SORT - # in case we would like to hide/show some columns - self.projection = self.get_default_projection() - - self.query = self.get_default_query() + self.query = self.get_query() representations = self.dbcon.aggregate(self.query) self.refresh(representations) @@ -821,9 +928,11 @@ 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 @@ -909,7 +1018,7 @@ class SyncRepresentationDetailModel(_SyncRepresentationModel): self._data.append(item) self._rec_loaded += 1 - def get_default_query(self, limit=0): + def get_query(self, limit=0): """ Gets query that gets used when no extra sorting, filtering or projecting is needed. @@ -923,7 +1032,7 @@ class SyncRepresentationDetailModel(_SyncRepresentationModel): if limit == 0: limit = SyncRepresentationSummaryModel.PAGE_SIZE - return [ + aggr = [ {"$match": self.get_match_part()}, {"$unwind": "$files"}, {'$addFields': { @@ -1019,7 +1128,16 @@ class SyncRepresentationDetailModel(_SyncRepresentationModel): ]} ]}} }}, - {"$project": self.projection}, + {"$project": self.projection} + ] + + if self.column_filtering: + aggr.append( + {"$match": self.column_filtering} + ) + print(self.column_filtering) + + aggr.extend([ {"$sort": self.sort}, { '$facet': { @@ -1028,7 +1146,9 @@ class SyncRepresentationDetailModel(_SyncRepresentationModel): 'totalCount': [{'$count': 'count'}] } } - ] + ]) + + return aggr def get_match_part(self): """ @@ -1038,20 +1158,21 @@ class SyncRepresentationDetailModel(_SyncRepresentationModel): Returns: (dict) """ - if not self.word_filter: + if not self._word_filter: return { "type": "representation", "_id": self._id } else: - regex_str = '.*{}.*'.format(self.word_filter) + regex_str = '.*{}.*'.format(self._word_filter) return { "type": "representation", "_id": self._id, '$or': [{'files.path': {'$regex': regex_str, '$options': 'i'}}] } - def get_default_projection(self): + @property + def projection(self): """ Projection part for aggregate query. diff --git a/openpype/modules/sync_server/tray/widgets.py b/openpype/modules/sync_server/tray/widgets.py index 5071ffa2b0..6d8348becb 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 @@ -14,6 +15,7 @@ from openpype.api import get_local_site_id from openpype.lib import PypeLogger from avalon.tools.delegates import pretty_timestamp +from avalon.vendor import qtawesome from openpype.modules.sync_server.tray.models import ( SyncRepresentationSummaryModel, @@ -40,6 +42,8 @@ class SyncProjectListWidget(ProjectListWidget): self.local_site = None self.icons = {} + self.layout().setContentsMargins(0, 0, 0, 0) + def validate_context_change(self): return True @@ -91,7 +95,6 @@ class SyncProjectListWidget(ProjectListWidget): self.project_name = point_index.data(QtCore.Qt.DisplayRole) menu = QtWidgets.QMenu() - menu.setStyleSheet(style.load_stylesheet()) actions_mapping = {} if self.sync_server.is_project_paused(self.project_name): @@ -141,16 +144,16 @@ class SyncRepresentationWidget(QtWidgets.QWidget): message_generated = QtCore.Signal(str) default_widths = ( - ("asset", 220), - ("subset", 190), - ("version", 55), - ("representation", 95), - ("local_site", 170), - ("remote_site", 170), + ("asset", 190), + ("subset", 170), + ("version", 60), + ("representation", 145), + ("local_site", 160), + ("remote_site", 160), ("files_count", 50), ("files_size", 60), - ("priority", 50), - ("state", 110) + ("priority", 70), + ("status", 110) ) def __init__(self, sync_server, project=None, parent=None): @@ -162,13 +165,16 @@ class SyncRepresentationWidget(QtWidgets.QWidget): self.representation_id = None self.site_name = None # to pause/unpause representation - self.filter = QtWidgets.QLineEdit() - self.filter.setPlaceholderText("Filter representations..") + self.txt_filter = QtWidgets.QLineEdit() + self.txt_filter.setPlaceholderText("Quick filter representations..") + self.txt_filter.setClearButtonEnabled(True) + self.txt_filter.addAction(qtawesome.icon("fa.filter", color="gray"), + QtWidgets.QLineEdit.LeadingPosition) self._scrollbar_pos = None top_bar_layout = QtWidgets.QHBoxLayout() - top_bar_layout.addWidget(self.filter) + top_bar_layout.addWidget(self.txt_filter) self.table_view = QtWidgets.QTableView() headers = [item[0] for item in self.default_widths] @@ -182,8 +188,6 @@ class SyncRepresentationWidget(QtWidgets.QWidget): QtWidgets.QAbstractItemView.SelectRows) self.table_view.horizontalHeader().setSortIndicator( -1, Qt.AscendingOrder) - self.table_view.setSortingEnabled(True) - self.table_view.horizontalHeader().setSortIndicatorShown(True) self.table_view.setAlternatingRowColors(True) self.table_view.verticalHeader().hide() @@ -195,32 +199,39 @@ class SyncRepresentationWidget(QtWidgets.QWidget): delegate = ImageDelegate(self) self.table_view.setItemDelegateForColumn(column, delegate) - for column_name, width in self.default_widths: - idx = model.get_header_index(column_name) - self.table_view.setColumnWidth(idx, width) - layout = QtWidgets.QVBoxLayout(self) layout.setContentsMargins(0, 0, 0, 0) layout.addLayout(top_bar_layout) layout.addWidget(self.table_view) self.table_view.doubleClicked.connect(self._double_clicked) - self.filter.textChanged.connect(lambda: model.set_filter( - self.filter.text())) + self.txt_filter.textChanged.connect(lambda: model.set_word_filter( + self.txt_filter.text())) self.table_view.customContextMenuRequested.connect( self._on_context_menu) model.refresh_started.connect(self._save_scrollbar) model.refresh_finished.connect(self._set_scrollbar) - self.table_view.model().modelReset.connect(self._set_selection) + model.modelReset.connect(self._set_selection) + + self.model = model self.selection_model = self.table_view.selectionModel() self.selection_model.selectionChanged.connect(self._selection_changed) + horizontal_header = HorizontalHeader(self) + + self.table_view.setHorizontalHeader(horizontal_header) + self.table_view.setSortingEnabled(True) + + for column_name, width in self.default_widths: + idx = model.get_header_index(column_name) + self.table_view.setColumnWidth(idx, width) + def _selection_changed(self, _new_selection): index = self.selection_model.currentIndex() self._selected_id = \ - self.table_view.model().data(index, Qt.UserRole) + self.model.data(index, Qt.UserRole) def _set_selection(self): """ @@ -229,7 +240,7 @@ class SyncRepresentationWidget(QtWidgets.QWidget): Keep selection during model refresh. """ if self._selected_id: - index = self.table_view.model().get_index(self._selected_id) + index = self.model.get_index(self._selected_id) if index and index.isValid(): mode = QtCore.QItemSelectionModel.Select | \ QtCore.QItemSelectionModel.Rows @@ -241,9 +252,9 @@ class SyncRepresentationWidget(QtWidgets.QWidget): """ Opens representation dialog with all files after doubleclick """ - _id = self.table_view.model().data(index, Qt.UserRole) + _id = self.model.data(index, Qt.UserRole) detail_window = SyncServerDetailWindow( - self.sync_server, _id, self.table_view.model().project) + self.sync_server, _id, self.model.project) detail_window.exec() def _on_context_menu(self, point): @@ -254,13 +265,12 @@ class SyncRepresentationWidget(QtWidgets.QWidget): if not point_index.isValid(): return - self.item = self.table_view.model()._data[point_index.row()] + self.item = self.model._data[point_index.row()] self.representation_id = self.item._id log.debug("menu representation _id:: {}". format(self.representation_id)) menu = QtWidgets.QMenu() - menu.setStyleSheet(style.load_stylesheet()) actions_mapping = {} actions_kwargs_mapping = {} @@ -271,7 +281,7 @@ class SyncRepresentationWidget(QtWidgets.QWidget): for site, progress in {local_site: local_progress, remote_site: remote_progress}.items(): - project = self.table_view.model().project + project = self.model.project provider = self.sync_server.get_provider_for_site(project, site) if provider == 'local_drive': @@ -291,17 +301,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) @@ -337,10 +347,10 @@ class SyncRepresentationWidget(QtWidgets.QWidget): if to_run: to_run(**to_run_kwargs) - self.table_view.model().refresh() + self.model.refresh() def _pause(self): - self.sync_server.pause_representation(self.table_view.model().project, + self.sync_server.pause_representation(self.model.project, self.representation_id, self.site_name) self.site_name = None @@ -348,7 +358,7 @@ class SyncRepresentationWidget(QtWidgets.QWidget): def _unpause(self): self.sync_server.unpause_representation( - self.table_view.model().project, + self.model.project, self.representation_id, self.site_name) self.site_name = None @@ -358,7 +368,7 @@ class SyncRepresentationWidget(QtWidgets.QWidget): # temporary here for testing, will be removed TODO def _add_site(self): log.info(self.representation_id) - project_name = self.table_view.model().project + project_name = self.model.project local_site_name = get_local_site_id() try: self.sync_server.add_site( @@ -386,15 +396,15 @@ class SyncRepresentationWidget(QtWidgets.QWidget): try: local_site = get_local_site_id() self.sync_server.remove_site( - self.table_view.model().project, + self.model.project, self.representation_id, local_site, True) self.message_generated.emit("Site {} removed".format(local_site)) except ValueError as exp: self.message_generated.emit("Error {}".format(str(exp))) - self.table_view.model().refresh( - load_records=self.table_view.model()._rec_loaded) + self.model.refresh( + load_records=self.model._rec_loaded) def _reset_local_site(self): """ @@ -402,11 +412,11 @@ class SyncRepresentationWidget(QtWidgets.QWidget): redo of upload/download """ self.sync_server.reset_provider_for_file( - self.table_view.model().project, + self.model.project, self.representation_id, 'local') - self.table_view.model().refresh( - load_records=self.table_view.model()._rec_loaded) + self.model.refresh( + load_records=self.model._rec_loaded) def _reset_remote_site(self): """ @@ -414,18 +424,18 @@ class SyncRepresentationWidget(QtWidgets.QWidget): redo of upload/download """ self.sync_server.reset_provider_for_file( - self.table_view.model().project, + self.model.project, self.representation_id, 'remote') - self.table_view.model().refresh( - load_records=self.table_view.model()._rec_loaded) + self.model.refresh( + load_records=self.model._rec_loaded) def _open_in_explorer(self, site): if not self.item: return fpath = self.item.path - project = self.table_view.model().project + project = self.model.project fpath = self.sync_server.get_local_file_path(project, site, fpath) @@ -466,8 +476,8 @@ class SyncRepresentationDetailWidget(QtWidgets.QWidget): ("local_site", 185), ("remote_site", 185), ("size", 60), - ("priority", 25), - ("state", 110) + ("priority", 60), + ("status", 110) ) def __init__(self, sync_server, _id=None, project=None, parent=None): @@ -482,64 +492,73 @@ class SyncRepresentationDetailWidget(QtWidgets.QWidget): self._selected_id = None - self.filter = QtWidgets.QLineEdit() - self.filter.setPlaceholderText("Filter representation..") + self.txt_filter = QtWidgets.QLineEdit() + self.txt_filter.setPlaceholderText("Quick filter representation..") + self.txt_filter.setClearButtonEnabled(True) + self.txt_filter.addAction(qtawesome.icon("fa.filter", color="gray"), + QtWidgets.QLineEdit.LeadingPosition) self._scrollbar_pos = None top_bar_layout = QtWidgets.QHBoxLayout() - top_bar_layout.addWidget(self.filter) + top_bar_layout.addWidget(self.txt_filter) - self.table_view = QtWidgets.QTableView() + table_view = QtWidgets.QTableView() headers = [item[0] for item in self.default_widths] model = SyncRepresentationDetailModel(sync_server, headers, _id, project) - self.table_view.setModel(model) - self.table_view.setContextMenuPolicy(QtCore.Qt.CustomContextMenu) - self.table_view.setSelectionMode( + table_view.setModel(model) + table_view.setContextMenuPolicy(QtCore.Qt.CustomContextMenu) + table_view.setSelectionMode( QtWidgets.QAbstractItemView.SingleSelection) - self.table_view.setSelectionBehavior( + table_view.setSelectionBehavior( QtWidgets.QTableView.SelectRows) - self.table_view.horizontalHeader().setSortIndicator(-1, - Qt.AscendingOrder) - self.table_view.setSortingEnabled(True) - self.table_view.horizontalHeader().setSortIndicatorShown(True) - self.table_view.setAlternatingRowColors(True) - self.table_view.verticalHeader().hide() + table_view.horizontalHeader().setSortIndicator(-1, Qt.AscendingOrder) + table_view.horizontalHeader().setSortIndicatorShown(True) + table_view.setAlternatingRowColors(True) + table_view.verticalHeader().hide() - column = self.table_view.model().get_header_index("local_site") + column = model.get_header_index("local_site") delegate = ImageDelegate(self) - self.table_view.setItemDelegateForColumn(column, delegate) + table_view.setItemDelegateForColumn(column, delegate) - column = self.table_view.model().get_header_index("remote_site") + column = model.get_header_index("remote_site") delegate = ImageDelegate(self) - self.table_view.setItemDelegateForColumn(column, delegate) - - for column_name, width in self.default_widths: - idx = model.get_header_index(column_name) - self.table_view.setColumnWidth(idx, width) + table_view.setItemDelegateForColumn(column, delegate) layout = QtWidgets.QVBoxLayout(self) layout.setContentsMargins(0, 0, 0, 0) layout.addLayout(top_bar_layout) - layout.addWidget(self.table_view) + layout.addWidget(table_view) - self.filter.textChanged.connect(lambda: model.set_filter( - self.filter.text())) - self.table_view.customContextMenuRequested.connect( - self._on_context_menu) + self.model = model + + self.selection_model = table_view.selectionModel() + self.selection_model.selectionChanged.connect(self._selection_changed) + + horizontal_header = HorizontalHeader(self) + + table_view.setHorizontalHeader(horizontal_header) + table_view.setSortingEnabled(True) + + for column_name, width in self.default_widths: + idx = model.get_header_index(column_name) + table_view.setColumnWidth(idx, width) + + self.table_view = table_view + + self.txt_filter.textChanged.connect(lambda: model.set_word_filter( + self.txt_filter.text())) + table_view.customContextMenuRequested.connect(self._on_context_menu) model.refresh_started.connect(self._save_scrollbar) model.refresh_finished.connect(self._set_scrollbar) - self.table_view.model().modelReset.connect(self._set_selection) - - self.selection_model = self.table_view.selectionModel() - self.selection_model.selectionChanged.connect(self._selection_changed) + model.modelReset.connect(self._set_selection) def _selection_changed(self): index = self.selection_model.currentIndex() - self._selected_id = self.table_view.model().data(index, Qt.UserRole) + self._selected_id = self.model.data(index, Qt.UserRole) def _set_selection(self): """ @@ -548,7 +567,7 @@ class SyncRepresentationDetailWidget(QtWidgets.QWidget): Keep selection during model refresh. """ if self._selected_id: - index = self.table_view.model().get_index(self._selected_id) + index = self.model.get_index(self._selected_id) if index and index.isValid(): mode = QtCore.QItemSelectionModel.Select | \ QtCore.QItemSelectionModel.Rows @@ -576,10 +595,9 @@ class SyncRepresentationDetailWidget(QtWidgets.QWidget): if not point_index.isValid(): return - self.item = self.table_view.model()._data[point_index.row()] + self.item = self.model._data[point_index.row()] menu = QtWidgets.QMenu() - menu.setStyleSheet(style.load_stylesheet()) actions_mapping = {} actions_kwargs_mapping = {} @@ -590,7 +608,7 @@ class SyncRepresentationDetailWidget(QtWidgets.QWidget): for site, progress in {local_site: local_progress, remote_site: remote_progress}.items(): - project = self.table_view.model().project + project = self.model.project provider = self.sync_server.get_provider_for_site(project, site) if provider == 'local_drive': @@ -604,7 +622,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) @@ -637,12 +655,12 @@ class SyncRepresentationDetailWidget(QtWidgets.QWidget): redo of upload/download """ self.sync_server.reset_provider_for_file( - self.table_view.model().project, + self.model.project, self.representation_id, 'local', self.item._id) - self.table_view.model().refresh( - load_records=self.table_view.model()._rec_loaded) + self.model.refresh( + load_records=self.model._rec_loaded) def _reset_remote_site(self): """ @@ -650,12 +668,12 @@ class SyncRepresentationDetailWidget(QtWidgets.QWidget): redo of upload/download """ self.sync_server.reset_provider_for_file( - self.table_view.model().project, + self.model.project, self.representation_id, 'remote', self.item._id) - self.table_view.model().refresh( - load_records=self.table_view.model()._rec_loaded) + self.model.refresh( + load_records=self.model._rec_loaded) def _open_in_explorer(self, site): if not self.item: @@ -818,3 +836,274 @@ class SyncRepresentationErrorWindow(QtWidgets.QDialog): self.setLayout(body_layout) self.setWindowTitle("Sync Representation Error Detail") + + +class TransparentWidget(QtWidgets.QWidget): + clicked = QtCore.Signal(str) + + def __init__(self, column_name, *args, **kwargs): + super(TransparentWidget, self).__init__(*args, **kwargs) + self.column_name = column_name + # self.setStyleSheet("background: red;") + + def mouseReleaseEvent(self, event): + if event.button() == QtCore.Qt.LeftButton: + self.clicked.emit(self.column_name) + + super(TransparentWidget, self).mouseReleaseEvent(event) + + +class HorizontalHeader(QtWidgets.QHeaderView): + + def __init__(self, parent=None): + super(HorizontalHeader, self).__init__(QtCore.Qt.Horizontal, parent) + self._parent = parent + self.checked_values = {} + + self.setModel(self._parent.model) + + self.setSectionsClickable(True) + + self.menu_items_dict = {} + self.menu = None + self.header_cells = [] + self.filter_buttons = {} + + self.filter_icon = qtawesome.icon("fa.filter", color="gray") + self.filter_set_icon = qtawesome.icon("fa.filter", color="white") + + self.init_layout() + + self._resetting = False + + @property + def model(self): + """Keep model synchronized with parent widget""" + return self._parent.model + + def init_layout(self): + for column_idx in range(self.model.columnCount()): + column_name, column_label = self.model.get_column(column_idx) + filter_rec = self.model.get_filters().get(column_name) + if not filter_rec: + continue + + icon = self.filter_icon + button = QtWidgets.QPushButton(icon, "", self) + + button.setFixedSize(24, 24) + button.setStyleSheet( + "QPushButton::menu-indicator{width:0px;}" + "QPushButton{border: none;background: transparent;}") + button.clicked.connect(partial(self._get_menu, + column_name, column_idx)) + button.setFlat(True) + self.filter_buttons[column_name] = button + + def showEvent(self, event): + super(HorizontalHeader, self).showEvent(event) + + for i in range(len(self.header_cells)): + cell_content = self.header_cells[i] + cell_content.setGeometry(self.sectionViewportPosition(i), 0, + self.sectionSize(i) - 1, self.height()) + + cell_content.show() + + def _set_filter_icon(self, column_name): + button = self.filter_buttons.get(column_name) + if button: + if self.checked_values.get(column_name): + button.setIcon(self.filter_set_icon) + else: + button.setIcon(self.filter_icon) + + def _reset_filter(self, column_name): + """ + Remove whole column from filter >> not in $match at all (faster) + """ + self._resetting = True # mark changes to consume them + if self.checked_values.get(column_name) is not None: + self.checked_values.pop(column_name) + self._set_filter_icon(column_name) + self._filter_and_refresh_model_and_menu(column_name, True, True) + self._resetting = False + + def _apply_filter(self, column_name, values, state): + """ + Sets 'values' to specific 'state' (checked/unchecked), + sends to model. + """ + if self._resetting: # event triggered by _resetting, skip it + return + + self._update_checked_values(column_name, values, state) + self._set_filter_icon(column_name) + self._filter_and_refresh_model_and_menu(column_name, True, False) + + def _apply_text_filter(self, column_name, items, line_edit): + """ + Resets all checkboxes, prefers inserted text. + """ + le_text = line_edit.text() + self._update_checked_values(column_name, items, 0) # reset other + if self.checked_values.get(column_name) is not None or \ + le_text == '': + self.checked_values.pop(column_name) # reset during typing + + if le_text: + self._update_checked_values(column_name, {le_text: le_text}, 2) + self._set_filter_icon(column_name) + self._filter_and_refresh_model_and_menu(column_name, True, True) + + def _filter_and_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.model.set_column_filtering(self.checked_values) + self.model.refresh() + if menu: + self._menu_refresh(column_name) + + def _get_menu(self, column_name, index): + """Prepares content of menu for 'column_name'""" + menu = QtWidgets.QMenu(self) + filter_rec = self.model.get_filters()[column_name] + self.menu_items_dict[column_name] = filter_rec.values() + + # text filtering only if labels same as values, not if codes are used + if 'text' in filter_rec.search_variants(): + line_edit = QtWidgets.QLineEdit(menu) + line_edit.setClearButtonEnabled(True) + line_edit.addAction(self.filter_icon, + QtWidgets.QLineEdit.LeadingPosition) + + line_edit.setFixedHeight(line_edit.height()) + txt = "" + if self.checked_values.get(column_name): + txt = list(self.checked_values.get(column_name).keys())[0] + line_edit.setText(txt) + + action_le = QtWidgets.QWidgetAction(menu) + action_le.setDefaultWidget(line_edit) + line_edit.textChanged.connect( + partial(self._apply_text_filter, column_name, + filter_rec.values(), line_edit)) + menu.addAction(action_le) + menu.addSeparator() + + if 'checkbox' in filter_rec.search_variants(): + action_all = QtWidgets.QAction("All", self) + 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, + filter_rec.values(), + 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 = self.menu_items_dict[column_name].keys() + else: + checked_keys = self.checked_values[column_name] + + for value, label in self.menu_items_dict[column_name].items(): + checkbox = QtWidgets.QCheckBox(str(label), menu) + + # temp + checkbox.setStyleSheet("QCheckBox{spacing: 5px;" + "padding:5px 5px 5px 5px;}") + 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._show_menu(index, menu) + + def _show_menu(self, index, menu): + """Shows 'menu' under header column of 'index'""" + global_pos_point = self.mapToGlobal( + QtCore.QPoint(self.sectionViewportPosition(index), 0)) + menu.setMinimumWidth(self.sectionSize(index)) + menu.setMinimumHeight(self.height()) + menu.exec_(QtCore.QPoint(global_pos_point.x(), + global_pos_point.y() + self.height())) + + 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' + """ + copy_menu_items = dict(self.menu_items_dict[column_name]) + checked = self.checked_values.get(column_name, copy_menu_items) + set_items = dict(values.items()) # prevent dict change during loop + for value, label in set_items.items(): + if state == 2 and label: # checked + checked[value] = label + elif state == 0 and checked.get(value): + checked.pop(value) + + self.checked_values[column_name] = checked + + def paintEvent(self, event): + self._fix_size() + super(HorizontalHeader, self).paintEvent(event) + + def _fix_size(self): + for column_idx in range(self.model.columnCount()): + vis_index = self.visualIndex(column_idx) + index = self.logicalIndex(vis_index) + section_width = self.sectionSize(index) + + column_name = self.model.headerData(column_idx, + QtCore.Qt.Horizontal, + lib.HeaderNameRole) + button = self.filter_buttons.get(column_name) + if not button: + continue + + pos_x = self.sectionViewportPosition( + index) + section_width - self.height() + + pos_y = 0 + if button.height() < self.height(): + pos_y = int((self.height() - button.height()) / 2) + button.setGeometry( + pos_x, + pos_y, + self.height(), + self.height())