import os import subprocess import sys from functools import partial from Qt import QtWidgets, QtCore, QtGui from Qt.QtCore import Qt from openpype.tools.settings import ( ProjectListWidget, style ) 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, SyncRepresentationDetailModel ) from openpype.modules.sync_server.tray import lib from openpype.modules.sync_server.tray import delegates log = PypeLogger().get_logger("SyncServer") class SyncProjectListWidget(ProjectListWidget): """ Lists all projects that are synchronized to choose from """ def __init__(self, sync_server, parent): super(SyncProjectListWidget, self).__init__(parent) self.sync_server = sync_server self.project_list.setContextMenuPolicy(QtCore.Qt.CustomContextMenu) self.project_list.customContextMenuRequested.connect( self._on_context_menu) self.project_name = None self.local_site = None self.icons = {} self.layout().setContentsMargins(0, 0, 0, 0) def validate_context_change(self): return True def refresh(self): model = self.project_list.model() model.clear() project_name = None for project_name in self.sync_server.sync_project_settings.\ keys(): if self.sync_server.is_paused() or \ self.sync_server.is_project_paused(project_name): icon = self._get_icon("paused") else: icon = self._get_icon("synced") model.appendRow(QtGui.QStandardItem(icon, project_name)) if len(self.sync_server.sync_project_settings.keys()) == 0: model.appendRow(QtGui.QStandardItem(lib.DUMMY_PROJECT)) self.current_project = self.project_list.currentIndex().data( QtCore.Qt.DisplayRole ) if not self.current_project: self.current_project = self.project_list.model().item(0). \ data(QtCore.Qt.DisplayRole) if project_name: self.local_site = self.sync_server.get_active_site(project_name) def _get_icon(self, status): if not self.icons.get(status): resource_path = os.path.dirname(__file__) resource_path = os.path.join(resource_path, "..", "resources") pix_url = "{}/{}.png".format(resource_path, status) icon = QtGui.QIcon(pix_url) self.icons[status] = icon else: icon = self.icons[status] return icon def _on_context_menu(self, point): point_index = self.project_list.indexAt(point) if not point_index.isValid(): return self.project_name = point_index.data(QtCore.Qt.DisplayRole) menu = QtWidgets.QMenu(self) actions_mapping = {} can_edit = self.model.can_edit if can_edit: if self.sync_server.is_project_paused(self.project_name): action = QtWidgets.QAction("Unpause") actions_mapping[action] = self._unpause else: action = QtWidgets.QAction("Pause") actions_mapping[action] = self._pause menu.addAction(action) if self.local_site == get_local_site_id(): action = QtWidgets.QAction("Clear local project") actions_mapping[action] = self._clear_project menu.addAction(action) result = menu.exec_(QtGui.QCursor.pos()) if result: to_run = actions_mapping[result] if to_run: to_run() def _pause(self): if self.project_name: self.sync_server.pause_project(self.project_name) self.project_name = None self.refresh() def _unpause(self): if self.project_name: self.sync_server.unpause_project(self.project_name) self.project_name = None self.refresh() def _clear_project(self): if self.project_name: self.sync_server.clear_project(self.project_name, self.local_site) self.project_name = None self.refresh() class _SyncRepresentationWidget(QtWidgets.QWidget): """ Summary dialog with list of representations that matches current settings 'local_site' and 'remote_site'. """ active_changed = QtCore.Signal() # active index changed message_generated = QtCore.Signal(str) def _selection_changed(self, _new_selected, _all_selected): idxs = self.selection_model.selectedRows() self._selected_ids = set() for index in idxs: self._selected_ids.add(self.model.data(index, Qt.UserRole)) def _set_selection(self): """ Sets selection to 'self._selected_id' if exists. Keep selection during model refresh. """ existing_ids = set() for selected_id in self._selected_ids: index = self.model.get_index(selected_id) if index and index.isValid(): mode = QtCore.QItemSelectionModel.Select | \ QtCore.QItemSelectionModel.Rows self.selection_model.select(index, mode) existing_ids.add(selected_id) self._selected_ids = existing_ids def _double_clicked(self, index): """ Opens representation dialog with all files after doubleclick """ # priority editing if self.model.can_edit: column_name = self.model.get_column(index.column()) if column_name[0] in self.model.EDITABLE_COLUMNS: self.model.is_editing = True self.table_view.openPersistentEditor(index) return _id = self.model.data(index, Qt.UserRole) detail_window = SyncServerDetailWindow( self.sync_server, _id, self.model.project, parent=self) detail_window.exec() def _on_context_menu(self, point): """ Shows menu with loader actions on Right-click. Supports multiple selects - adds all available actions, each action handles if it appropriate for item itself, if not it skips. """ is_multi = len(self._selected_ids) > 1 point_index = self.table_view.indexAt(point) if not point_index.isValid() and not is_multi: return if is_multi: index = self.model.get_index(list(self._selected_ids)[0]) item = self.model.data(index, lib.FullItemRole) else: item = self.model.data(point_index, lib.FullItemRole) can_edit = self.model.can_edit action_kwarg_map, actions_mapping, menu = self._prepare_menu(item, is_multi, can_edit) result = menu.exec_(QtGui.QCursor.pos()) if result: to_run = actions_mapping[result] to_run_kwargs = action_kwarg_map.get(result, {}) if to_run: to_run(**to_run_kwargs) self.model.refresh() def _prepare_menu(self, item, is_multi, can_edit): menu = QtWidgets.QMenu(self) actions_mapping = {} action_kwarg_map = {} active_site = self.model.active_site remote_site = self.model.remote_site local_progress = item.local_progress remote_progress = item.remote_progress project = self.model.project for site, progress in {active_site: local_progress, remote_site: remote_progress}.items(): provider = self.sync_server.get_provider_for_site(site=site) if provider == 'local_drive': if 'studio' in site: txt = " studio version" else: txt = " local version" action = QtWidgets.QAction("Open in explorer" + txt) if progress == 1.0 or is_multi: actions_mapping[action] = self._open_in_explorer action_kwarg_map[action] = \ self._get_action_kwargs(site) menu.addAction(action) if can_edit and (remote_progress == 1.0 or is_multi): action = QtWidgets.QAction("Re-sync Active site") action_kwarg_map[action] = self._get_action_kwargs(active_site) actions_mapping[action] = self._reset_site menu.addAction(action) if can_edit and (local_progress == 1.0 or is_multi): action = QtWidgets.QAction("Re-sync Remote site") action_kwarg_map[action] = self._get_action_kwargs(remote_site) actions_mapping[action] = self._reset_site menu.addAction(action) if can_edit and active_site == get_local_site_id(): action = QtWidgets.QAction("Completely remove from local") action_kwarg_map[action] = self._get_action_kwargs(active_site) actions_mapping[action] = self._remove_site menu.addAction(action) if can_edit: action = QtWidgets.QAction("Change priority") action_kwarg_map[action] = self._get_action_kwargs(active_site) actions_mapping[action] = self._change_priority menu.addAction(action) # # temp for testing only !!! # action = QtWidgets.QAction("Download") # action_kwarg_map[action] = self._get_action_kwargs(active_site) # actions_mapping[action] = self._add_site # menu.addAction(action) if not actions_mapping: action = QtWidgets.QAction("< No action >") actions_mapping[action] = None menu.addAction(action) return action_kwarg_map, actions_mapping, menu def _pause(self, selected_ids=None): log.debug("Pause {}".format(selected_ids)) for representation_id in selected_ids: item = lib.get_item_by_id(self.model, representation_id) if item.status not in [lib.STATUS[0], lib.STATUS[1]]: continue for site_name in [self.model.active_site, self.model.remote_site]: check_progress = self._get_progress(item, site_name) if check_progress < 1: self.sync_server.pause_representation(self.model.project, representation_id, site_name) self.message_generated.emit("Paused {}".format(representation_id)) def _unpause(self, selected_ids=None): log.debug("UnPause {}".format(selected_ids)) for representation_id in selected_ids: item = lib.get_item_by_id(self.model, representation_id) if item.status not in lib.STATUS[3]: continue for site_name in [self.model.active_site, self.model.remote_site]: check_progress = self._get_progress(item, site_name) if check_progress < 1: self.sync_server.unpause_representation( self.model.project, representation_id, site_name) self.message_generated.emit("Unpause {}".format(representation_id)) # temporary here for testing, will be removed TODO def _add_site(self, selected_ids=None, site_name=None): log.debug("Add site {}:{}".format(selected_ids, site_name)) for representation_id in selected_ids: item = lib.get_item_by_id(self.model, representation_id) if item.local_site == site_name or item.remote_site == site_name: # site already exists skip continue try: self.sync_server.add_site( self.model.project, representation_id, site_name) self.message_generated.emit( "Site {} added for {}".format(site_name, representation_id)) except ValueError as exp: self.message_generated.emit("Error {}".format(str(exp))) self.sync_server.reset_timer() def _remove_site(self, selected_ids=None, site_name=None): """ Removes site record AND files. This is ONLY for representations stored on local site, which cannot be same as SyncServer.DEFAULT_SITE. This could only happen when artist work on local machine, not connected to studio mounted drives. """ log.debug("Remove site {}:{}".format(selected_ids, site_name)) for representation_id in selected_ids: log.info("Removing {}".format(representation_id)) try: self.sync_server.remove_site( self.model.project, representation_id, site_name, True) self.message_generated.emit( "Site {} removed".format(site_name)) except ValueError as exp: self.message_generated.emit("Error {}".format(str(exp))) self.model.refresh( load_records=self.model._rec_loaded) self.sync_server.reset_timer() def _reset_site(self, selected_ids=None, site_name=None): """ Removes errors or success metadata for particular file >> forces redo of upload/download """ log.debug("Reset site {}:{}".format(selected_ids, site_name)) for representation_id in selected_ids: item = lib.get_item_by_id(self.model, representation_id) check_progress = self._get_progress(item, site_name, True) # do not reset if opposite side is not fully there if check_progress != 1: log.debug("Not fully available {} on other side, skipping". format(check_progress)) continue self.sync_server.reset_provider_for_file( self.model.project, representation_id, site_name=site_name, force=True) self.model.refresh( load_records=self.model._rec_loaded) self.sync_server.reset_timer() def _open_in_explorer(self, selected_ids=None, site_name=None): log.debug("Open in Explorer {}:{}".format(selected_ids, site_name)) for selected_id in selected_ids: item = lib.get_item_by_id(self.model, selected_id) if not item: return fpath = item.path project = self.model.project fpath = self.sync_server.get_local_file_path(project, site_name, fpath) fpath = os.path.normpath(os.path.dirname(fpath)) if os.path.isdir(fpath): if 'win' in sys.platform: # windows subprocess.Popen('explorer "%s"' % fpath) elif sys.platform == 'darwin': # macOS subprocess.Popen(['open', fpath]) else: # linux try: subprocess.Popen(['xdg-open', fpath]) except OSError: raise OSError('unsupported xdg-open call??') def _change_priority(self, **kwargs): """Open editor to change priority on first selected row""" if self._selected_ids: # get_index returns dummy index with column equals to 0 index = self.model.get_index(list(self._selected_ids)[0]) column_no = self.model.get_header_index("priority") # real column real_index = self.model.index(index.row(), column_no) self.model.is_editing = True self.table_view.openPersistentEditor(real_index) def _get_progress(self, item, site_name, opposite=False): """Returns progress value according to site (side)""" progress = {'local': item.local_progress, 'remote': item.remote_progress} side = 'remote' if site_name == self.model.active_site: side = 'local' if opposite: side = 'remote' if side == 'local' else 'local' return progress[side] def _get_action_kwargs(self, site_name): """Default format of kwargs for action""" return {"selected_ids": self._selected_ids, "site_name": site_name} def _save_scrollbar(self): self._scrollbar_pos = self.table_view.verticalScrollBar().value() def _set_scrollbar(self): if self._scrollbar_pos: self.table_view.verticalScrollBar().setValue(self._scrollbar_pos) class SyncRepresentationSummaryWidget(_SyncRepresentationWidget): default_widths = ( ("asset", 190), ("subset", 170), ("version", 60), ("representation", 145), ("local_site", 160), ("remote_site", 160), ("files_count", 50), ("files_size", 60), ("priority", 70), ("status", 110) ) def __init__(self, sync_server, project=None, parent=None): super(SyncRepresentationSummaryWidget, self).__init__(parent) self.sync_server = sync_server self._selected_ids = set() # keep last selected _id txt_filter = QtWidgets.QLineEdit() txt_filter.setPlaceholderText("Quick filter representations..") txt_filter.setClearButtonEnabled(True) txt_filter.addAction( qtawesome.icon("fa.filter", color="gray"), QtWidgets.QLineEdit.LeadingPosition) self.txt_filter = txt_filter self._scrollbar_pos = None top_bar_layout = QtWidgets.QHBoxLayout() top_bar_layout.addWidget(self.txt_filter) table_view = QtWidgets.QTableView() headers = [item[0] for item in self.default_widths] model = SyncRepresentationSummaryModel(sync_server, headers, project, parent=self) table_view.setModel(model) table_view.setContextMenuPolicy(QtCore.Qt.CustomContextMenu) table_view.setSelectionMode( QtWidgets.QAbstractItemView.ExtendedSelection) table_view.setSelectionBehavior( QtWidgets.QAbstractItemView.SelectRows) table_view.horizontalHeader().setSortIndicator( -1, Qt.AscendingOrder) table_view.setAlternatingRowColors(True) table_view.verticalHeader().hide() table_view.viewport().setAttribute(QtCore.Qt.WA_Hover, True) column = table_view.model().get_header_index("local_site") delegate = delegates.ImageDelegate(self) table_view.setItemDelegateForColumn(column, delegate) column = table_view.model().get_header_index("remote_site") delegate = delegates.ImageDelegate(self) table_view.setItemDelegateForColumn(column, delegate) column = table_view.model().get_header_index("priority") priority_delegate = delegates.PriorityDelegate(self) table_view.setItemDelegateForColumn(column, priority_delegate) layout = QtWidgets.QVBoxLayout(self) layout.setContentsMargins(0, 0, 0, 0) layout.addLayout(top_bar_layout) layout.addWidget(table_view) self.table_view = table_view self.model = model 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) table_view.doubleClicked.connect(self._double_clicked) 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) model.modelReset.connect(self._set_selection) self.selection_model = self.table_view.selectionModel() self.selection_model.selectionChanged.connect(self._selection_changed) def _prepare_menu(self, item, is_multi, can_edit): action_kwarg_map, actions_mapping, menu = \ super()._prepare_menu(item, is_multi, can_edit) if can_edit and ( item.status in [lib.STATUS[0], lib.STATUS[1]] or is_multi): action = QtWidgets.QAction("Pause in queue") actions_mapping[action] = self._pause # pause handles which site_name it will pause itself action_kwarg_map[action] = {"selected_ids": self._selected_ids} menu.addAction(action) if can_edit and (item.status == lib.STATUS[3] or is_multi): action = QtWidgets.QAction("Unpause in queue") actions_mapping[action] = self._unpause action_kwarg_map[action] = {"selected_ids": self._selected_ids} menu.addAction(action) return action_kwarg_map, actions_mapping, menu class SyncServerDetailWindow(QtWidgets.QDialog): """Wrapper window for SyncRepresentationDetailWidget Creates standalone window with list of files for selected repre_id. """ def __init__(self, sync_server, _id, project, parent=None): log.debug( "!!! SyncServerDetailWindow _id:: {}".format(_id)) super(SyncServerDetailWindow, self).__init__(parent) self.setWindowFlags(QtCore.Qt.Window) self.setFocusPolicy(QtCore.Qt.StrongFocus) self.setStyleSheet(style.load_stylesheet()) self.setWindowIcon(QtGui.QIcon(style.app_icon_path())) self.resize(1000, 400) body = QtWidgets.QWidget() footer = QtWidgets.QWidget() footer.setFixedHeight(20) container = SyncRepresentationDetailWidget(sync_server, _id, project, parent=self) body_layout = QtWidgets.QHBoxLayout(body) body_layout.addWidget(container) body_layout.setContentsMargins(0, 0, 0, 0) self.message = QtWidgets.QLabel() self.message.hide() footer_layout = QtWidgets.QVBoxLayout(footer) footer_layout.addWidget(self.message) footer_layout.setContentsMargins(0, 0, 0, 0) layout = QtWidgets.QVBoxLayout(self) layout.addWidget(body) layout.addWidget(footer) self.setWindowTitle("Sync Representation Detail") class SyncRepresentationDetailWidget(_SyncRepresentationWidget): """ Widget to display list of synchronizable files for single repre. Args: _id (str): representation _id project (str): name of project with repre parent (QDialog): SyncServerDetailWindow """ active_changed = QtCore.Signal() # active index changed default_widths = ( ("file", 290), ("local_site", 185), ("remote_site", 185), ("size", 60), ("priority", 60), ("status", 110) ) def __init__(self, sync_server, _id=None, project=None, parent=None): super(SyncRepresentationDetailWidget, self).__init__(parent) log.debug("Representation_id:{}".format(_id)) self.project = project self.sync_server = sync_server self.representation_id = _id self._selected_ids = set() 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.txt_filter) table_view = QtWidgets.QTableView() headers = [item[0] for item in self.default_widths] model = SyncRepresentationDetailModel(sync_server, headers, _id, project) model.is_running = True table_view.setModel(model) table_view.setContextMenuPolicy(QtCore.Qt.CustomContextMenu) table_view.setSelectionMode( QtWidgets.QAbstractItemView.ExtendedSelection) table_view.setSelectionBehavior( QtWidgets.QTableView.SelectRows) table_view.horizontalHeader().setSortIndicator(-1, Qt.AscendingOrder) table_view.horizontalHeader().setSortIndicatorShown(True) table_view.setAlternatingRowColors(True) table_view.verticalHeader().hide() column = model.get_header_index("local_site") delegate = delegates.ImageDelegate(self) table_view.setItemDelegateForColumn(column, delegate) column = model.get_header_index("remote_site") delegate = delegates.ImageDelegate(self) table_view.setItemDelegateForColumn(column, delegate) if model.can_edit: column = table_view.model().get_header_index("priority") priority_delegate = delegates.PriorityDelegate(self) table_view.setItemDelegateForColumn(column, priority_delegate) layout = QtWidgets.QVBoxLayout(self) layout.setContentsMargins(0, 0, 0, 0) layout.addLayout(top_bar_layout) layout.addWidget(table_view) 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.doubleClicked.connect(self._double_clicked) table_view.customContextMenuRequested.connect(self._on_context_menu) model.refresh_started.connect(self._save_scrollbar) model.refresh_finished.connect(self._set_scrollbar) model.modelReset.connect(self._set_selection) def _double_clicked(self, index): """ Opens representation dialog with all files after doubleclick """ # priority editing if self.model.can_edit: column_name = self.model.get_column(index.column()) if column_name[0] in self.model.EDITABLE_COLUMNS: self.model.is_editing = True self.table_view.openPersistentEditor(index) return def _show_detail(self, selected_ids=None): """ Shows windows with error message for failed sync of a file. """ detail_window = SyncRepresentationErrorWindow(self.model, selected_ids) detail_window.exec() def _prepare_menu(self, item, is_multi, can_edit): """Adds view (and model) dependent actions to default ones""" action_kwarg_map, actions_mapping, menu = \ super()._prepare_menu(item, is_multi, can_edit) if item.status == lib.STATUS[2] or is_multi: action = QtWidgets.QAction("Open error detail") actions_mapping[action] = self._show_detail action_kwarg_map[action] = {"selected_ids": self._selected_ids} menu.addAction(action) return action_kwarg_map, actions_mapping, menu def _reset_site(self, selected_ids=None, site_name=None): """ Removes errors or success metadata for particular file >> forces redo of upload/download """ for file_id in selected_ids: item = lib.get_item_by_id(self.model, file_id) check_progress = self._get_progress(item, site_name, True) # do not reset if opposite side is not fully there if check_progress != 1: log.debug("Not fully available {} on other side, skipping". format(check_progress)) continue self.sync_server.reset_provider_for_file( self.model.project, self.representation_id, site_name=site_name, file_id=file_id, force=True) self.model.refresh( load_records=self.model._rec_loaded) class SyncRepresentationErrorWindow(QtWidgets.QDialog): """Wrapper window to show errors during sync on file(s)""" def __init__(self, model, selected_ids, parent=None): super(SyncRepresentationErrorWindow, self).__init__(parent) self.setWindowFlags(QtCore.Qt.Window) self.setFocusPolicy(QtCore.Qt.StrongFocus) self.setStyleSheet(style.load_stylesheet()) self.setWindowIcon(QtGui.QIcon(style.app_icon_path())) self.resize(900, 150) body = QtWidgets.QWidget() container = SyncRepresentationErrorWidget(model, selected_ids, parent=self) body_layout = QtWidgets.QHBoxLayout(body) body_layout.addWidget(container) body_layout.setContentsMargins(0, 0, 0, 0) message = QtWidgets.QLabel() message.hide() layout = QtWidgets.QVBoxLayout(self) layout.addWidget(body) self.setLayout(body_layout) self.setWindowTitle("Sync Representation Error Detail") class SyncRepresentationErrorWidget(QtWidgets.QWidget): """ Dialog to show when sync error happened, prints formatted error message """ def __init__(self, model, selected_ids, parent=None): super(SyncRepresentationErrorWidget, self).__init__(parent) layout = QtWidgets.QVBoxLayout(self) no_errors = True for file_id in selected_ids: item = lib.get_item_by_id(model, file_id) if not item.created_dt or not item.sync_dt or not item.error: continue no_errors = False dt = max(item.created_dt, item.sync_dt) txts = [] txts.append("{}: {}
".format("Last update date", pretty_timestamp(dt))) txts.append("{}: {}
".format("Retries", str(item.tries))) txts.append("{}: {}
".format("Error message", item.error)) text_area = QtWidgets.QTextEdit("\n\n".join(txts)) text_area.setReadOnly(True) layout.addWidget(text_area) if no_errors: text_area = QtWidgets.QTextEdit() text_area.setText("

No errors located

") text_area.setReadOnly(True) layout.addWidget(text_area) class HorizontalHeader(QtWidgets.QHeaderView): """Reiplemented QHeaderView to contain clickable changeable button""" 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): """Initial preparation of header's content""" 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): """Paint header""" 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): """Set different states of button depending on its engagement""" 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())