From 523f0230334a8f17e592bc56188e3a5b00d2c7b3 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Mon, 4 Dec 2023 11:27:43 +0100 Subject: [PATCH] SiteSync: implemented in Ayon Loader (#5962) * Added new SiteSync model Used to get information from SiteSync module to enhance Loader UI. * Added new SiteSync method to controller Other models will be using these to get information pertaining SiteSync * Added missed commit * Implemented collection of SiteSync info * Added AvailabilityDelegate Shows how many representations are present locally and remotely in Loader summary page. * Added fields to store progress info * Fix HiddenAttr to carry value * Refactored to internal variable Changes made after discussion * Implemented ActionItems for upload/download/remove Replaced old Launcher approach, now it is not necessary after refactor of Ayon launcher. * Update openpype/tools/ayon_loader/abstract.py Co-authored-by: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> * Hound * Refactor better retrieval of icon * Refactor better readability * Refactor renamed delegate * Refactor better retrieval of icons * Refactor better readability * Refactor removed unneeded explicit refresh * Hound * Hound * Hound * Fix used wrong type * Update openpype/tools/ayon_loader/ui/products_delegates.py Co-authored-by: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> * Refactor renamed variable name * Refactor formatting * Added progress for representations * cache version availability * cache representations sync status * changed representations count logic and moved it to products model * site sync enabled is cached * active and remote site names are cached * small tweaks in site sync model * change methods called by controller * hide site sync columns if site sync not enabled * use string conversion before iteration * smal formatting changes * updated abstract class with abstract methods * renamed site sync model variable * fixed method name * fix used method name * rename '_sitesync_addon' to '_site_sync_addon' * fix remote site name cache * small formatting changes in delegate * modify site sync delegate to be more dynamic * fix delegate painting * do not handle repre progress in products model * Add comma back * simplify delegate code --------- Co-authored-by: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Co-authored-by: Jakub Trllo --- openpype/tools/attribute_defs/widgets.py | 2 +- openpype/tools/ayon_loader/abstract.py | 97 +++- openpype/tools/ayon_loader/control.py | 61 ++- openpype/tools/ayon_loader/models/__init__.py | 2 + openpype/tools/ayon_loader/models/products.py | 36 ++ .../tools/ayon_loader/models/site_sync.py | 509 ++++++++++++++++++ .../ayon_loader/ui/products_delegates.py | 80 +++ .../tools/ayon_loader/ui/products_model.py | 51 +- .../tools/ayon_loader/ui/products_widget.py | 30 +- .../tools/ayon_loader/ui/repres_widget.py | 83 ++- 10 files changed, 931 insertions(+), 20 deletions(-) create mode 100644 openpype/tools/ayon_loader/models/site_sync.py diff --git a/openpype/tools/attribute_defs/widgets.py b/openpype/tools/attribute_defs/widgets.py index 8957f2b19d..7dea01e0a8 100644 --- a/openpype/tools/attribute_defs/widgets.py +++ b/openpype/tools/attribute_defs/widgets.py @@ -608,7 +608,7 @@ class UnknownAttrWidget(_BaseAttrDefWidget): class HiddenAttrWidget(_BaseAttrDefWidget): def _ui_init(self): self.setVisible(False) - self._value = None + self._value = self.attr_def.default self._multivalue = False def setVisible(self, visible): diff --git a/openpype/tools/ayon_loader/abstract.py b/openpype/tools/ayon_loader/abstract.py index 45042395d9..bf3e81d485 100644 --- a/openpype/tools/ayon_loader/abstract.py +++ b/openpype/tools/ayon_loader/abstract.py @@ -137,7 +137,7 @@ class VersionItem: handles, step, comment, - source + source, ): self.version_id = version_id self.product_id = product_id @@ -215,7 +215,7 @@ class RepreItem: representation_name, representation_icon, product_name, - folder_label, + folder_label ): self.representation_id = representation_id self.representation_name = representation_name @@ -590,6 +590,22 @@ class FrontendLoaderController(_BaseLoaderController): pass + @abstractmethod + def get_versions_representation_count( + self, project_name, version_ids, sender=None + ): + """ + Args: + project_name (str): Project name. + version_ids (Iterable[str]): Version ids. + sender (Optional[str]): Sender who requested the items. + + Returns: + dict[str, int]: Representation count by version id. + """ + + pass + @abstractmethod def get_thumbnail_path(self, project_name, thumbnail_id): """Get thumbnail path for thumbnail id. @@ -849,3 +865,80 @@ class FrontendLoaderController(_BaseLoaderController): """ pass + + # Site sync functions + @abstractmethod + def is_site_sync_enabled(self, project_name=None): + """Is site sync enabled. + + Site sync addon can be enabled but can be disabled per project. + + When asked for enabled state without project name, it should return + True if site sync addon is available and enabled. + + Args: + project_name (Optional[str]): Project name. + + Returns: + bool: True if site sync is enabled. + """ + + pass + + @abstractmethod + def get_active_site_icon_def(self, project_name): + """Active site icon definition. + + Args: + project_name (Union[str, None]): Project name. + + Returns: + Union[dict[str, Any], None]: Icon definition or None if site sync + is not enabled for the project. + """ + + pass + + @abstractmethod + def get_remote_site_icon_def(self, project_name): + """Remote site icon definition. + + Args: + project_name (Union[str, None]): Project name. + + Returns: + Union[dict[str, Any], None]: Icon definition or None if site sync + is not enabled for the project. + """ + + pass + + @abstractmethod + def get_version_sync_availability(self, project_name, version_ids): + """Version sync availability. + + Args: + project_name (str): Project name. + version_ids (Iterable[str]): Version ids. + + Returns: + dict[str, tuple[int, int]]: Sync availability by version id. + """ + + pass + + @abstractmethod + def get_representations_sync_status( + self, project_name, representation_ids + ): + """Representations sync status. + + Args: + project_name (str): Project name. + representation_ids (Iterable[str]): Representation ids. + + Returns: + dict[str, tuple[int, int]]: Sync status by representation id. + """ + + pass diff --git a/openpype/tools/ayon_loader/control.py b/openpype/tools/ayon_loader/control.py index 8ec0d96e2e..060cef6661 100644 --- a/openpype/tools/ayon_loader/control.py +++ b/openpype/tools/ayon_loader/control.py @@ -15,7 +15,12 @@ from openpype.tools.ayon_utils.models import ( ) from .abstract import BackendLoaderController, FrontendLoaderController -from .models import SelectionModel, ProductsModel, LoaderActionsModel +from .models import ( + SelectionModel, + ProductsModel, + LoaderActionsModel, + SiteSyncModel +) class ExpectedSelection: @@ -108,6 +113,7 @@ class LoaderController(BackendLoaderController, FrontendLoaderController): self._products_model = ProductsModel(self) self._loader_actions_model = LoaderActionsModel(self) self._thumbnails_model = ThumbnailsModel() + self._site_sync_model = SiteSyncModel(self) @property def log(self): @@ -143,6 +149,7 @@ class LoaderController(BackendLoaderController, FrontendLoaderController): self._loader_actions_model.reset() self._projects_model.reset() self._thumbnails_model.reset() + self._site_sync_model.reset() self._projects_model.refresh() @@ -195,13 +202,22 @@ class LoaderController(BackendLoaderController, FrontendLoaderController): project_name, version_ids, sender ) + def get_versions_representation_count( + self, project_name, version_ids, sender=None + ): + return self._products_model.get_versions_repre_count( + project_name, version_ids, sender + ) + def get_folder_thumbnail_ids(self, project_name, folder_ids): return self._thumbnails_model.get_folder_thumbnail_ids( - project_name, folder_ids) + project_name, folder_ids + ) def get_version_thumbnail_ids(self, project_name, version_ids): return self._thumbnails_model.get_version_thumbnail_ids( - project_name, version_ids) + project_name, version_ids + ) def get_thumbnail_path(self, project_name, thumbnail_id): return self._thumbnails_model.get_thumbnail_path( @@ -219,8 +235,16 @@ class LoaderController(BackendLoaderController, FrontendLoaderController): def get_representations_action_items( self, project_name, representation_ids): - return self._loader_actions_model.get_representations_action_items( + action_items = ( + self._loader_actions_model.get_representations_action_items( + project_name, representation_ids) + ) + + action_items.extend(self._site_sync_model.get_site_sync_action_items( project_name, representation_ids) + ) + + return action_items def trigger_action_item( self, @@ -230,6 +254,14 @@ class LoaderController(BackendLoaderController, FrontendLoaderController): version_ids, representation_ids ): + if self._site_sync_model.is_site_sync_action(identifier): + self._site_sync_model.trigger_action_item( + identifier, + project_name, + representation_ids + ) + return + self._loader_actions_model.trigger_action_item( identifier, options, @@ -336,6 +368,27 @@ class LoaderController(BackendLoaderController, FrontendLoaderController): self._loaded_products_cache.update_data(product_ids) return self._loaded_products_cache.get_data() + def is_site_sync_enabled(self, project_name=None): + return self._site_sync_model.is_site_sync_enabled(project_name) + + def get_active_site_icon_def(self, project_name): + return self._site_sync_model.get_active_site_icon_def(project_name) + + def get_remote_site_icon_def(self, project_name): + return self._site_sync_model.get_remote_site_icon_def(project_name) + + def get_version_sync_availability(self, project_name, version_ids): + return self._site_sync_model.get_version_sync_availability( + project_name, version_ids + ) + + def get_representations_sync_status( + self, project_name, representation_ids + ): + return self._site_sync_model.get_representations_sync_status( + project_name, representation_ids + ) + def is_loaded_products_supported(self): return self._host is not None diff --git a/openpype/tools/ayon_loader/models/__init__.py b/openpype/tools/ayon_loader/models/__init__.py index 6adfe71d86..8e640659a0 100644 --- a/openpype/tools/ayon_loader/models/__init__.py +++ b/openpype/tools/ayon_loader/models/__init__.py @@ -1,10 +1,12 @@ from .selection import SelectionModel from .products import ProductsModel from .actions import LoaderActionsModel +from .site_sync import SiteSyncModel __all__ = ( "SelectionModel", "ProductsModel", "LoaderActionsModel", + "SiteSyncModel", ) diff --git a/openpype/tools/ayon_loader/models/products.py b/openpype/tools/ayon_loader/models/products.py index 816dabaf90..daa36aefdc 100644 --- a/openpype/tools/ayon_loader/models/products.py +++ b/openpype/tools/ayon_loader/models/products.py @@ -317,6 +317,42 @@ class ProductsModel: return output + def get_versions_repre_count(self, project_name, version_ids, sender): + """Get representation count for passed version ids. + + Args: + project_name (str): Project name. + version_ids (Iterable[str]): Version ids. + sender (Union[str, None]): Who triggered the method. + + Returns: + dict[str, int]: Number of representations by version id. + """ + + output = {} + if not any((project_name, version_ids)): + return output + + invalid_version_ids = set() + project_cache = self._repre_items_cache[project_name] + for version_id in version_ids: + version_cache = project_cache[version_id] + if version_cache.is_valid: + output[version_id] = len(version_cache.get_data()) + else: + invalid_version_ids.add(version_id) + + if invalid_version_ids: + self.refresh_representation_items( + project_name, invalid_version_ids, sender + ) + + for version_id in invalid_version_ids: + version_cache = project_cache[version_id] + output[version_id] = len(version_cache.get_data()) + + return output + def change_products_group(self, project_name, product_ids, group_name): """Change group name for passed product ids. diff --git a/openpype/tools/ayon_loader/models/site_sync.py b/openpype/tools/ayon_loader/models/site_sync.py new file mode 100644 index 0000000000..90852b6954 --- /dev/null +++ b/openpype/tools/ayon_loader/models/site_sync.py @@ -0,0 +1,509 @@ +import collections + +from openpype.lib import Logger +from openpype.client.entities import get_representations +from openpype.client import get_linked_representation_id +from openpype.modules import ModulesManager +from openpype.tools.ayon_utils.models import NestedCacheItem +from openpype.tools.ayon_loader.abstract import ActionItem + +DOWNLOAD_IDENTIFIER = "sitesync.download" +UPLOAD_IDENTIFIER = "sitesync.upload" +REMOVE_IDENTIFIER = "sitesync.remove" + +log = Logger.get_logger(__name__) + + +def _default_version_availability(): + return 0, 0 + + +def _default_repre_status(): + return 0.0, 0.0 + + +class SiteSyncModel: + """Model handling site sync logic. + + Model cares about handling of site sync functionality. All public + functions should be possible to call even if site sync is not available. + """ + + lifetime = 60 # In seconds (minute by default) + status_lifetime = 20 + + def __init__(self, controller): + self._controller = controller + + self._site_icons = None + self._site_sync_enabled_cache = NestedCacheItem( + levels=1, lifetime=self.lifetime + ) + self._active_site_cache = NestedCacheItem( + levels=1, lifetime=self.lifetime + ) + self._remote_site_cache = NestedCacheItem( + levels=1, lifetime=self.lifetime + ) + self._version_availability_cache = NestedCacheItem( + levels=2, + default_factory=_default_version_availability, + lifetime=self.status_lifetime + ) + self._repre_status_cache = NestedCacheItem( + levels=2, + default_factory=_default_repre_status, + lifetime=self.status_lifetime + ) + + manager = ModulesManager() + self._site_sync_addon = manager.get("sync_server") + + def reset(self): + self._site_icons = None + self._site_sync_enabled_cache.reset() + self._active_site_cache.reset() + self._remote_site_cache.reset() + self._version_availability_cache.reset() + self._repre_status_cache.reset() + + def is_site_sync_enabled(self, project_name=None): + """Site sync is enabled for a project. + + Returns false if site sync addon is not available or enabled + or project has disabled it. + + Args: + project_name (Union[str, None]): Project name. If project name + is 'None', True is returned if site sync addon + is available and enabled. + + Returns: + bool: Site sync is enabled. + """ + + if not self._is_site_sync_addon_enabled(): + return False + cache = self._site_sync_enabled_cache[project_name] + if not cache.is_valid: + enabled = True + if project_name: + enabled = self._site_sync_addon.is_project_enabled( + project_name, single=True + ) + cache.update_data(enabled) + return cache.get_data() + + def get_active_site(self, project_name): + """Active site name for a project. + + Args: + project_name (str): Project name. + + Returns: + Union[str, None]: Remote site name. + """ + + cache = self._active_site_cache[project_name] + if not cache.is_valid: + site_name = None + if project_name and self._is_site_sync_addon_enabled(): + site_name = self._site_sync_addon.get_active_site(project_name) + cache.update_data(site_name) + return cache.get_data() + + def get_remote_site(self, project_name): + """Remote site name for a project. + + Args: + project_name (str): Project name. + + Returns: + Union[str, None]: Remote site name. + """ + + cache = self._remote_site_cache[project_name] + if not cache.is_valid: + site_name = None + if project_name and self._is_site_sync_addon_enabled(): + site_name = self._site_sync_addon.get_remote_site(project_name) + cache.update_data(site_name) + return cache.get_data() + + def get_active_site_icon_def(self, project_name): + """Active site icon definition. + + Args: + project_name (Union[str, None]): Name of project. + + Returns: + Union[dict[str, Any], None]: Site icon definition. + """ + + if not project_name: + return None + + active_site = self.get_active_site(project_name) + provider = self._get_provider_for_site(project_name, active_site) + return self._get_provider_icon(provider) + + def get_remote_site_icon_def(self, project_name): + """Remote site icon definition. + + Args: + project_name (Union[str, None]): Name of project. + + Returns: + Union[dict[str, Any], None]: Site icon definition. + """ + + if not project_name or not self.is_site_sync_enabled(project_name): + return None + remote_site = self.get_remote_site(project_name) + provider = self._get_provider_for_site(project_name, remote_site) + return self._get_provider_icon(provider) + + def get_version_sync_availability(self, project_name, version_ids): + """Returns how many representations are available on sites. + + Returned value `{version_id: (4, 6)}` denotes that locally are + available 4 and remotely 6 representation. + NOTE: Available means they were synced to site. + + Returns: + dict[str, tuple[int, int]] + """ + + if not self.is_site_sync_enabled(project_name): + return { + version_id: _default_version_availability() + for version_id in version_ids + } + + output = {} + project_cache = self._version_availability_cache[project_name] + invalid_ids = set() + for version_id in version_ids: + repre_cache = project_cache[version_id] + if repre_cache.is_valid: + output[version_id] = repre_cache.get_data() + else: + invalid_ids.add(version_id) + + if invalid_ids: + self._refresh_version_availability( + project_name, invalid_ids + ) + for version_id in invalid_ids: + version_cache = project_cache[version_id] + output[version_id] = version_cache.get_data() + return output + + def get_representations_sync_status( + self, project_name, representation_ids + ): + """ + + Args: + project_name (str): Project name. + representation_ids (Iterable[str]): Representation ids. + + Returns: + dict[str, tuple[float, float]] + """ + + if not self.is_site_sync_enabled(project_name): + return { + repre_id: _default_repre_status() + for repre_id in representation_ids + } + + output = {} + project_cache = self._repre_status_cache[project_name] + invalid_ids = set() + for repre_id in representation_ids: + repre_cache = project_cache[repre_id] + if repre_cache.is_valid: + output[repre_id] = repre_cache.get_data() + else: + invalid_ids.add(repre_id) + + if invalid_ids: + self._refresh_representations_sync_status( + project_name, invalid_ids + ) + for repre_id in invalid_ids: + repre_cache = project_cache[repre_id] + output[repre_id] = repre_cache.get_data() + return output + + def get_site_sync_action_items(self, project_name, representation_ids): + """ + + Args: + project_name (str): Project name. + representation_ids (Iterable[str]): Representation ids. + + Returns: + list[ActionItem]: Actions that can be shown in loader. + """ + + if not self.is_site_sync_enabled(project_name): + return [] + + repres_status = self.get_representations_sync_status( + project_name, representation_ids + ) + + repre_ids_per_identifier = collections.defaultdict(set) + for repre_id in representation_ids: + repre_status = repres_status[repre_id] + local_status, remote_status = repre_status + + if local_status: + repre_ids_per_identifier[UPLOAD_IDENTIFIER].add(repre_id) + repre_ids_per_identifier[REMOVE_IDENTIFIER].add(repre_id) + + if remote_status: + repre_ids_per_identifier[DOWNLOAD_IDENTIFIER].add(repre_id) + + action_items = [] + for identifier, repre_ids in repre_ids_per_identifier.items(): + if identifier == DOWNLOAD_IDENTIFIER: + action_items.append(self._create_download_action_item( + project_name, repre_ids + )) + elif identifier == UPLOAD_IDENTIFIER: + action_items.append(self._create_upload_action_item( + project_name, repre_ids + )) + elif identifier == REMOVE_IDENTIFIER: + action_items.append(self._create_delete_action_item( + project_name, repre_ids + )) + + return action_items + + def is_site_sync_action(self, identifier): + """Should be `identifier` handled by SiteSync. + + Args: + identifier (str): Action identifier. + + Returns: + bool: Should action be handled by SiteSync. + """ + + return identifier in { + UPLOAD_IDENTIFIER, + DOWNLOAD_IDENTIFIER, + REMOVE_IDENTIFIER, + } + + def trigger_action_item( + self, + identifier, + project_name, + representation_ids + ): + """Resets status for site_name or remove local files. + + Args: + identifier (str): Action identifier. + project_name (str): Project name. + representation_ids (Iterable[str]): Representation ids. + """ + + active_site = self.get_active_site(project_name) + remote_site = self.get_remote_site(project_name) + + repre_docs = list(get_representations( + project_name, representation_ids=representation_ids + )) + families_per_repre_id = { + item["_id"]: item["context"]["family"] + for item in repre_docs + } + + for repre_id in representation_ids: + family = families_per_repre_id[repre_id] + if identifier == DOWNLOAD_IDENTIFIER: + self._add_site( + project_name, repre_id, active_site, family + ) + + elif identifier == UPLOAD_IDENTIFIER: + self._add_site( + project_name, repre_id, remote_site, family + ) + + elif identifier == REMOVE_IDENTIFIER: + self._site_sync_addon.remove_site( + project_name, + repre_id, + active_site, + remove_local_files=True + ) + + def _is_site_sync_addon_enabled(self): + """ + Returns: + bool: Site sync addon is enabled. + """ + + if self._site_sync_addon is None: + return False + return self._site_sync_addon.enabled + + def _get_provider_for_site(self, project_name, site_name): + """Provider for a site. + + Args: + project_name (str): Project name. + site_name (str): Site name. + + Returns: + Union[str, None]: Provider name. + """ + + if not self._is_site_sync_addon_enabled(): + return None + return self._site_sync_addon.get_provider_for_site( + project_name, site_name + ) + + def _get_provider_icon(self, provider): + """site provider icons. + + Returns: + Union[dict[str, Any], None]: Icon of site provider. + """ + + if not provider: + return None + + if self._site_icons is None: + self._site_icons = self._site_sync_addon.get_site_icons() + return self._site_icons.get(provider) + + def _refresh_version_availability(self, project_name, version_ids): + if not project_name or not version_ids: + return + project_cache = self._version_availability_cache[project_name] + + avail_by_id = self._site_sync_addon.get_version_availability( + project_name, + version_ids, + self.get_active_site(project_name), + self.get_remote_site(project_name), + ) + for version_id in version_ids: + status = avail_by_id.get(version_id) + if status is None: + status = _default_version_availability() + project_cache[version_id].update_data(status) + + def _refresh_representations_sync_status( + self, project_name, representation_ids + ): + if not project_name or not representation_ids: + return + project_cache = self._repre_status_cache[project_name] + status_by_repre_id = ( + self._site_sync_addon.get_representations_sync_state( + project_name, + representation_ids, + self.get_active_site(project_name), + self.get_remote_site(project_name), + ) + ) + for repre_id in representation_ids: + status = status_by_repre_id.get(repre_id) + if status is None: + status = _default_repre_status() + project_cache[repre_id].update_data(status) + + def _create_download_action_item(self, project_name, representation_ids): + return self._create_action_item( + project_name, + representation_ids, + DOWNLOAD_IDENTIFIER, + "Download", + "Mark representation for download locally", + "fa.download" + ) + + def _create_upload_action_item(self, project_name, representation_ids): + return self._create_action_item( + project_name, + representation_ids, + UPLOAD_IDENTIFIER, + "Upload", + "Mark representation for upload remotely", + "fa.upload" + ) + + def _create_delete_action_item(self, project_name, representation_ids): + return self._create_action_item( + project_name, + representation_ids, + REMOVE_IDENTIFIER, + "Remove from local", + "Remove local synchronization", + "fa.trash" + ) + + def _create_action_item( + self, + project_name, + representation_ids, + identifier, + label, + tooltip, + icon_name + ): + return ActionItem( + identifier, + label, + icon={ + "type": "awesome-font", + "name": icon_name, + "color": "#999999" + }, + tooltip=tooltip, + options={}, + order=1, + project_name=project_name, + folder_ids=[], + product_ids=[], + version_ids=[], + representation_ids=representation_ids, + ) + + def _add_site(self, project_name, repre_id, site_name, family): + self._site_sync_addon.add_site( + project_name, repre_id, site_name, force=True + ) + + # TODO this should happen in site sync addon + if family != "workfile": + return + + links = get_linked_representation_id( + project_name, + repre_id=repre_id, + link_type="reference" + ) + for link_repre_id in links: + try: + print("Adding {} to linked representation: {}".format( + site_name, link_repre_id)) + self._site_sync_addon.add_site( + project_name, + link_repre_id, + site_name, + force=False + ) + except Exception: + # do not add/reset working site for references + log.debug("Site present", exc_info=True) diff --git a/openpype/tools/ayon_loader/ui/products_delegates.py b/openpype/tools/ayon_loader/ui/products_delegates.py index 6729468bfa..979fa57fd2 100644 --- a/openpype/tools/ayon_loader/ui/products_delegates.py +++ b/openpype/tools/ayon_loader/ui/products_delegates.py @@ -8,6 +8,11 @@ from .products_model import ( VERSION_NAME_EDIT_ROLE, VERSION_ID_ROLE, PRODUCT_IN_SCENE_ROLE, + ACTIVE_SITE_ICON_ROLE, + REMOTE_SITE_ICON_ROLE, + REPRESENTATIONS_COUNT_ROLE, + SYNC_ACTIVE_SITE_AVAILABILITY, + SYNC_REMOTE_SITE_AVAILABILITY, ) @@ -189,3 +194,78 @@ class LoadedInSceneDelegate(QtWidgets.QStyledItemDelegate): value = index.data(PRODUCT_IN_SCENE_ROLE) color = self._colors.get(value, self._default_color) option.palette.setBrush(QtGui.QPalette.Text, color) + + +class SiteSyncDelegate(QtWidgets.QStyledItemDelegate): + """Paints icons and downloaded representation ration for both sites.""" + + def paint(self, painter, option, index): + super(SiteSyncDelegate, self).paint(painter, option, index) + option = QtWidgets.QStyleOptionViewItem(option) + option.showDecorationSelected = True + + active_icon = index.data(ACTIVE_SITE_ICON_ROLE) + remote_icon = index.data(REMOTE_SITE_ICON_ROLE) + + availability_active = "{}/{}".format( + index.data(SYNC_ACTIVE_SITE_AVAILABILITY), + index.data(REPRESENTATIONS_COUNT_ROLE) + ) + availability_remote = "{}/{}".format( + index.data(SYNC_REMOTE_SITE_AVAILABILITY), + index.data(REPRESENTATIONS_COUNT_ROLE) + ) + + if availability_active is None or availability_remote is None: + return + + items_to_draw = [ + (value, icon) + for value, icon in ( + (availability_active, active_icon), + (availability_remote, remote_icon), + ) + if icon + ] + if not items_to_draw: + return + + icon_size = QtCore.QSize(24, 24) + padding = 10 + pos_x = option.rect.x() + + item_width = int(option.rect.width() / len(items_to_draw)) + if item_width < 1: + item_width = 0 + + for value, icon in items_to_draw: + item_rect = QtCore.QRect( + pos_x, + option.rect.y(), + item_width, + option.rect.height() + ) + # Prepare pos_x for next item + pos_x = item_rect.x() + item_rect.width() + + pixmap = icon.pixmap(icon.actualSize(icon_size)) + point = QtCore.QPoint( + item_rect.x() + padding, + item_rect.y() + ((item_rect.height() - pixmap.height()) * 0.5) + ) + painter.drawPixmap(point, pixmap) + + icon_offset = icon_size.width() + (padding * 2) + text_rect = QtCore.QRect(item_rect) + text_rect.setLeft(text_rect.left() + icon_offset) + if text_rect.width() < 1: + continue + + painter.drawText( + text_rect, + option.displayAlignment, + value + ) + + def displayText(self, value, locale): + pass diff --git a/openpype/tools/ayon_loader/ui/products_model.py b/openpype/tools/ayon_loader/ui/products_model.py index 741f15766b..84f5bc9a5f 100644 --- a/openpype/tools/ayon_loader/ui/products_model.py +++ b/openpype/tools/ayon_loader/ui/products_model.py @@ -29,6 +29,11 @@ VERSION_HANDLES_ROLE = QtCore.Qt.UserRole + 18 VERSION_STEP_ROLE = QtCore.Qt.UserRole + 19 VERSION_AVAILABLE_ROLE = QtCore.Qt.UserRole + 20 VERSION_THUMBNAIL_ID_ROLE = QtCore.Qt.UserRole + 21 +ACTIVE_SITE_ICON_ROLE = QtCore.Qt.UserRole + 22 +REMOTE_SITE_ICON_ROLE = QtCore.Qt.UserRole + 23 +REPRESENTATIONS_COUNT_ROLE = QtCore.Qt.UserRole + 24 +SYNC_ACTIVE_SITE_AVAILABILITY = QtCore.Qt.UserRole + 25 +SYNC_REMOTE_SITE_AVAILABILITY = QtCore.Qt.UserRole + 26 class ProductsModel(QtGui.QStandardItemModel): @@ -68,6 +73,7 @@ class ProductsModel(QtGui.QStandardItemModel): published_time_col = column_labels.index("Time") folders_label_col = column_labels.index("Folder") in_scene_col = column_labels.index("In scene") + site_sync_avail_col = column_labels.index("Availability") def __init__(self, controller): super(ProductsModel, self).__init__() @@ -303,7 +309,26 @@ class ProductsModel(QtGui.QStandardItemModel): model_item.setData( version_item.thumbnail_id, VERSION_THUMBNAIL_ID_ROLE) - def _get_product_model_item(self, product_item): + # TODO call site sync methods for all versions at once + project_name = self._last_project_name + version_id = version_item.version_id + repre_count = self._controller.get_versions_representation_count( + project_name, [version_id] + )[version_id] + active, remote = self._controller.get_version_sync_availability( + project_name, [version_id] + )[version_id] + + model_item.setData(repre_count, REPRESENTATIONS_COUNT_ROLE) + model_item.setData(active, SYNC_ACTIVE_SITE_AVAILABILITY) + model_item.setData(remote, SYNC_REMOTE_SITE_AVAILABILITY) + + def _get_product_model_item( + self, + product_item, + active_site_icon, + remote_site_icon + ): model_item = self._items_by_id.get(product_item.product_id) versions = list(product_item.version_items.values()) versions.sort() @@ -329,6 +354,9 @@ class ProductsModel(QtGui.QStandardItemModel): in_scene = 1 if product_item.product_in_scene else 0 model_item.setData(in_scene, PRODUCT_IN_SCENE_ROLE) + model_item.setData(active_site_icon, ACTIVE_SITE_ICON_ROLE) + model_item.setData(remote_site_icon, REMOTE_SITE_ICON_ROLE) + self._set_version_data_to_product_item(model_item, last_version) return model_item @@ -341,6 +369,15 @@ class ProductsModel(QtGui.QStandardItemModel): self._last_project_name = project_name self._last_folder_ids = folder_ids + active_site_icon_def = self._controller.get_active_site_icon_def( + project_name + ) + remote_site_icon_def = self._controller.get_remote_site_icon_def( + project_name + ) + active_site_icon = get_qt_icon(active_site_icon_def) + remote_site_icon = get_qt_icon(remote_site_icon_def) + product_items = self._controller.get_product_items( project_name, folder_ids, @@ -402,7 +439,11 @@ class ProductsModel(QtGui.QStandardItemModel): new_root_items.append(parent_item) for product_item in top_items: - item = self._get_product_model_item(product_item) + item = self._get_product_model_item( + product_item, + active_site_icon, + remote_site_icon, + ) new_items.append(item) for path_info in merged_product_items.values(): @@ -418,7 +459,11 @@ class ProductsModel(QtGui.QStandardItemModel): merged_product_types = set() new_merged_items = [] for product_item in product_items: - item = self._get_product_model_item(product_item) + item = self._get_product_model_item( + product_item, + active_site_icon, + remote_site_icon, + ) new_merged_items.append(item) merged_product_types.add(product_item.product_type) diff --git a/openpype/tools/ayon_loader/ui/products_widget.py b/openpype/tools/ayon_loader/ui/products_widget.py index 2d4959dc19..99faefe693 100644 --- a/openpype/tools/ayon_loader/ui/products_widget.py +++ b/openpype/tools/ayon_loader/ui/products_widget.py @@ -19,7 +19,11 @@ from .products_model import ( VERSION_ID_ROLE, VERSION_THUMBNAIL_ID_ROLE, ) -from .products_delegates import VersionDelegate, LoadedInSceneDelegate +from .products_delegates import ( + VersionDelegate, + LoadedInSceneDelegate, + SiteSyncDelegate +) from .actions_utils import show_actions_menu @@ -92,7 +96,7 @@ class ProductsWidget(QtWidgets.QWidget): 55, # Handles 10, # Step 25, # Loaded in scene - 65, # Site info (maybe?) + 65, # Site sync info ) def __init__(self, controller, parent): @@ -135,6 +139,10 @@ class ProductsWidget(QtWidgets.QWidget): products_view.setItemDelegateForColumn( products_model.in_scene_col, in_scene_delegate) + site_sync_delegate = SiteSyncDelegate() + products_view.setItemDelegateForColumn( + products_model.site_sync_avail_col, site_sync_delegate) + main_layout = QtWidgets.QHBoxLayout(self) main_layout.setContentsMargins(0, 0, 0, 0) main_layout.addWidget(products_view, 1) @@ -167,6 +175,8 @@ class ProductsWidget(QtWidgets.QWidget): self._version_delegate = version_delegate self._time_delegate = time_delegate + self._in_scene_delegate = in_scene_delegate + self._site_sync_delegate = site_sync_delegate self._selected_project_name = None self._selected_folder_ids = set() @@ -182,6 +192,9 @@ class ProductsWidget(QtWidgets.QWidget): products_model.in_scene_col, not controller.is_loaded_products_supported() ) + self._set_site_sync_visibility( + self._controller.is_site_sync_enabled() + ) def set_name_filter(self, name): """Set filter of product name. @@ -216,6 +229,12 @@ class ProductsWidget(QtWidgets.QWidget): def refresh(self): self._refresh_model() + def _set_site_sync_visibility(self, site_sync_enabled): + self._products_view.setColumnHidden( + self._products_model.site_sync_avail_col, + not site_sync_enabled + ) + def _fill_version_editor(self): model = self._products_proxy_model index_queue = collections.deque() @@ -375,7 +394,12 @@ class ProductsWidget(QtWidgets.QWidget): self._on_selection_change() def _on_folders_selection_change(self, event): - self._selected_project_name = event["project_name"] + project_name = event["project_name"] + site_sync_enabled = self._controller.is_site_sync_enabled( + project_name + ) + self._set_site_sync_visibility(site_sync_enabled) + self._selected_project_name = project_name self._selected_folder_ids = event["folder_ids"] self._refresh_model() self._update_folders_label_visible() diff --git a/openpype/tools/ayon_loader/ui/repres_widget.py b/openpype/tools/ayon_loader/ui/repres_widget.py index 7de582e629..efc1bb89a4 100644 --- a/openpype/tools/ayon_loader/ui/repres_widget.py +++ b/openpype/tools/ayon_loader/ui/repres_widget.py @@ -14,6 +14,10 @@ REPRESENTATION_ID_ROLE = QtCore.Qt.UserRole + 2 PRODUCT_NAME_ROLE = QtCore.Qt.UserRole + 3 FOLDER_LABEL_ROLE = QtCore.Qt.UserRole + 4 GROUP_TYPE_ROLE = QtCore.Qt.UserRole + 5 +ACTIVE_SITE_ICON_ROLE = QtCore.Qt.UserRole + 6 +REMOTE_SITE_ICON_ROLE = QtCore.Qt.UserRole + 7 +SYNC_ACTIVE_SITE_PROGRESS = QtCore.Qt.UserRole + 8 +SYNC_REMOTE_SITE_PROGRESS = QtCore.Qt.UserRole + 9 class RepresentationsModel(QtGui.QStandardItemModel): @@ -22,12 +26,14 @@ class RepresentationsModel(QtGui.QStandardItemModel): ("Name", 120), ("Product name", 125), ("Folder", 125), - # ("Active site", 85), - # ("Remote site", 85) + ("Active site", 85), + ("Remote site", 85) ] column_labels = [label for label, _ in colums_info] column_widths = [width for _, width in colums_info] folder_column = column_labels.index("Product name") + active_site_column = column_labels.index("Active site") + remote_site_column = column_labels.index("Remote site") def __init__(self, controller): super(RepresentationsModel, self).__init__() @@ -59,7 +65,7 @@ class RepresentationsModel(QtGui.QStandardItemModel): repre_items = self._controller.get_representation_items( self._selected_project_name, self._selected_version_ids ) - self._fill_items(repre_items) + self._fill_items(repre_items, self._selected_project_name) self.refreshed.emit() def data(self, index, role=None): @@ -69,13 +75,23 @@ class RepresentationsModel(QtGui.QStandardItemModel): col = index.column() if col != 0: if role == QtCore.Qt.DecorationRole: - return None + if col == 3: + role = ACTIVE_SITE_ICON_ROLE + elif col == 4: + role = REMOTE_SITE_ICON_ROLE + else: + return None if role == QtCore.Qt.DisplayRole: if col == 1: role = PRODUCT_NAME_ROLE elif col == 2: role = FOLDER_LABEL_ROLE + elif col == 3: + role = SYNC_ACTIVE_SITE_PROGRESS + elif col == 4: + role = SYNC_REMOTE_SITE_PROGRESS + index = self.index(index.row(), 0, index.parent()) return super(RepresentationsModel, self).data(index, role) @@ -89,7 +105,13 @@ class RepresentationsModel(QtGui.QStandardItemModel): root_item = self.invisibleRootItem() root_item.removeRows(0, root_item.rowCount()) - def _get_repre_item(self, repre_item): + def _get_repre_item( + self, + repre_item, + active_site_icon, + remote_site_icon, + repres_sync_status + ): repre_id = repre_item.representation_id repre_name = repre_item.representation_name repre_icon = repre_item.representation_icon @@ -102,6 +124,12 @@ class RepresentationsModel(QtGui.QStandardItemModel): item.setColumnCount(self.columnCount()) item.setEditable(False) + sync_status = repres_sync_status[repre_id] + active_progress, remote_progress = sync_status + + active_site_progress = "{}%".format(int(active_progress * 100)) + remote_site_progress = "{}%".format(int(remote_progress * 100)) + icon = get_qt_icon(repre_icon) item.setData(repre_name, QtCore.Qt.DisplayRole) item.setData(icon, QtCore.Qt.DecorationRole) @@ -109,6 +137,10 @@ class RepresentationsModel(QtGui.QStandardItemModel): item.setData(repre_id, REPRESENTATION_ID_ROLE) item.setData(repre_item.product_name, PRODUCT_NAME_ROLE) item.setData(repre_item.folder_label, FOLDER_LABEL_ROLE) + item.setData(active_site_icon, ACTIVE_SITE_ICON_ROLE) + item.setData(remote_site_icon, REMOTE_SITE_ICON_ROLE) + item.setData(active_site_progress, SYNC_ACTIVE_SITE_PROGRESS) + item.setData(remote_site_progress, SYNC_REMOTE_SITE_PROGRESS) return is_new_item, item def _get_group_icon(self): @@ -134,14 +166,29 @@ class RepresentationsModel(QtGui.QStandardItemModel): self._groups_items_by_name[repre_name] = item return True, item - def _fill_items(self, repre_items): + def _fill_items(self, repre_items, project_name): + active_site_icon_def = self._controller.get_active_site_icon_def( + project_name + ) + remote_site_icon_def = self._controller.get_remote_site_icon_def( + project_name + ) + active_site_icon = get_qt_icon(active_site_icon_def) + remote_site_icon = get_qt_icon(remote_site_icon_def) + items_to_remove = set(self._items_by_id.keys()) repre_items_by_name = collections.defaultdict(list) + repre_ids = set() for repre_item in repre_items: + repre_ids.add(repre_item.representation_id) items_to_remove.discard(repre_item.representation_id) repre_name = repre_item.representation_name repre_items_by_name[repre_name].append(repre_item) + repres_sync_status = self._controller.get_representations_sync_status( + project_name, repre_ids + ) + root_item = self.invisibleRootItem() for repre_id in items_to_remove: item = self._items_by_id.pop(repre_id) @@ -164,7 +211,12 @@ class RepresentationsModel(QtGui.QStandardItemModel): new_group_items = [] for repre_item in repre_name_items: - is_new_item, item = self._get_repre_item(repre_item) + is_new_item, item = self._get_repre_item( + repre_item, + active_site_icon, + remote_site_icon, + repres_sync_status + ) item_parent = item.parent() if item_parent is None: item_parent = root_item @@ -255,6 +307,9 @@ class RepresentationsWidget(QtWidgets.QWidget): self._repre_model = repre_model self._repre_proxy_model = repre_proxy_model + self._set_site_sync_visibility( + self._controller.is_site_sync_enabled() + ) self._set_multiple_folders_selected(False) def refresh(self): @@ -265,6 +320,20 @@ class RepresentationsWidget(QtWidgets.QWidget): def _on_project_change(self, event): self._selected_project_name = event["project_name"] + site_sync_enabled = self._controller.is_site_sync_enabled( + self._selected_project_name + ) + self._set_site_sync_visibility(site_sync_enabled) + + def _set_site_sync_visibility(self, site_sync_enabled): + self._repre_view.setColumnHidden( + self._repre_model.active_site_column, + not site_sync_enabled + ) + self._repre_view.setColumnHidden( + self._repre_model.remote_site_column, + not site_sync_enabled + ) def _set_multiple_folders_selected(self, selected_multiple_folders): if selected_multiple_folders == self._selected_multiple_folders: