Merge branch 'feature/SyncServer_gui_filtering' into feature/1336-allow-multiselection-in-sync-queue

This commit is contained in:
Petr Kalis 2021-04-22 10:52:26 +02:00
commit 502f30be12
3 changed files with 668 additions and 152 deletions

View file

@ -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:

View file

@ -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.

View file

@ -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())