ayon-core/openpype/tools/launcher/models.py
2023-06-06 18:51:44 +02:00

906 lines
29 KiB
Python

import re
import uuid
import copy
import logging
import collections
import time
import appdirs
from qtpy import QtCore, QtGui
import qtawesome
from openpype.client import (
get_projects,
get_project,
get_assets,
)
from openpype.lib import JSONSettingRegistry
from openpype.lib.applications import (
CUSTOM_LAUNCH_APP_GROUPS,
ApplicationManager
)
from openpype.settings import get_project_settings
from openpype.pipeline import discover_launcher_actions
from openpype.tools.utils.lib import (
DynamicQThread,
get_project_icon,
)
from openpype.tools.utils.assets_widget import (
AssetModel,
ASSET_NAME_ROLE
)
from openpype.tools.utils.tasks_widget import (
TasksModel,
TasksProxyModel,
TASK_TYPE_ROLE,
TASK_ASSIGNEE_ROLE
)
from . import lib
from .constants import (
ACTION_ROLE,
GROUP_ROLE,
VARIANT_GROUP_ROLE,
ACTION_ID_ROLE,
FORCE_NOT_OPEN_WORKFILE_ROLE
)
from .actions import ApplicationAction
log = logging.getLogger(__name__)
# Must be different than roles in default asset model
ASSET_TASK_TYPES_ROLE = QtCore.Qt.UserRole + 10
ASSET_ASSIGNEE_ROLE = QtCore.Qt.UserRole + 11
class ActionModel(QtGui.QStandardItemModel):
def __init__(self, dbcon, parent=None):
super(ActionModel, self).__init__(parent=parent)
self.dbcon = dbcon
self.application_manager = ApplicationManager()
self.default_icon = qtawesome.icon("fa.cube", color="white")
# Cache of available actions
self._registered_actions = list()
self.items_by_id = {}
path = appdirs.user_data_dir("openpype", "pypeclub")
self.launcher_registry = JSONSettingRegistry("launcher", path)
try:
_ = self.launcher_registry.get_item("force_not_open_workfile")
except ValueError:
self.launcher_registry.set_item("force_not_open_workfile", [])
def discover(self):
"""Set up Actions cache. Run this for each new project."""
# Discover all registered actions
actions = discover_launcher_actions()
# Get available project actions and the application actions
app_actions = self.get_application_actions()
actions.extend(app_actions)
self._registered_actions = actions
self.filter_actions()
def get_application_actions(self):
actions = []
if not self.dbcon.current_project():
return actions
project_name = self.dbcon.active_project()
project_doc = get_project(project_name, fields=["config.apps"])
if not project_doc:
return actions
project_settings = get_project_settings(project_name)
only_available = project_settings["applications"]["only_available"]
self.application_manager.refresh()
for app_def in project_doc["config"]["apps"]:
app_name = app_def["name"]
app = self.application_manager.applications.get(app_name)
if not app or not app.enabled:
continue
if app.group.name in CUSTOM_LAUNCH_APP_GROUPS:
continue
if only_available and not app.find_executable():
continue
# Get from app definition, if not there from app in project
action = type(
"app_{}".format(app_name),
(ApplicationAction,),
{
"application": app,
"name": app.name,
"label": app.group.label,
"label_variant": app.label,
"group": None,
"icon": app.icon,
"color": getattr(app, "color", None),
"order": getattr(app, "order", None) or 0,
"data": {}
}
)
actions.append(action)
return actions
def get_icon(self, action, skip_default=False):
icon = lib.get_action_icon(action)
if not icon and not skip_default:
return self.default_icon
return icon
def filter_actions(self):
self.items_by_id.clear()
# Validate actions based on compatibility
self.clear()
actions = self.filter_compatible_actions(self._registered_actions)
single_actions = []
varianted_actions = collections.defaultdict(list)
grouped_actions = collections.defaultdict(list)
for action in actions:
# Groups
group_name = getattr(action, "group", None)
# Label variants
label = getattr(action, "label", None)
label_variant = getattr(action, "label_variant", None)
if label_variant and not label:
print((
"Invalid action \"{}\" has set `label_variant` to \"{}\""
", but doesn't have set `label` attribute"
).format(action.name, label_variant))
action.label_variant = None
label_variant = None
if group_name:
grouped_actions[group_name].append(action)
elif label_variant:
varianted_actions[label].append(action)
else:
single_actions.append(action)
items_by_order = collections.defaultdict(list)
for label, actions in tuple(varianted_actions.items()):
if len(actions) == 1:
varianted_actions.pop(label)
single_actions.append(actions[0])
continue
icon = None
order = None
for action in actions:
if icon is None:
_icon = lib.get_action_icon(action)
if _icon:
icon = _icon
if order is None or action.order < order:
order = action.order
if icon is None:
icon = self.default_icon
item = QtGui.QStandardItem(icon, label)
item.setData(label, QtCore.Qt.ToolTipRole)
item.setData(actions, ACTION_ROLE)
item.setData(True, VARIANT_GROUP_ROLE)
items_by_order[order].append(item)
for action in single_actions:
icon = self.get_icon(action)
label = lib.get_action_label(action)
item = QtGui.QStandardItem(icon, label)
item.setData(label, QtCore.Qt.ToolTipRole)
item.setData(action, ACTION_ROLE)
items_by_order[action.order].append(item)
for group_name, actions in grouped_actions.items():
icon = None
order = None
for action in actions:
if order is None or action.order < order:
order = action.order
if icon is None:
_icon = lib.get_action_icon(action)
if _icon:
icon = _icon
if icon is None:
icon = self.default_icon
item = QtGui.QStandardItem(icon, group_name)
item.setData(actions, ACTION_ROLE)
item.setData(True, GROUP_ROLE)
items_by_order[order].append(item)
self.beginResetModel()
stored = self.launcher_registry.get_item("force_not_open_workfile")
items = []
for order in sorted(items_by_order.keys()):
for item in items_by_order[order]:
item_id = str(uuid.uuid4())
item.setData(item_id, ACTION_ID_ROLE)
if self.is_force_not_open_workfile(item,
stored):
self.change_action_item(item, True)
self.items_by_id[item_id] = item
items.append(item)
self.invisibleRootItem().appendRows(items)
self.endResetModel()
def filter_compatible_actions(self, actions):
"""Collect all actions which are compatible with the environment
Each compatible action will be translated to a dictionary to ensure
the action can be visualized in the launcher.
Args:
actions (list): list of classes
Returns:
list: collection of dictionaries sorted on order int he
"""
compatible = []
_session = copy.deepcopy(self.dbcon.Session)
session = {
key: value
for key, value in _session.items()
if value
}
for action in actions:
if action().is_compatible(session):
compatible.append(action)
# Sort by order and name
return sorted(
compatible,
key=lambda action: (action.order, lib.get_action_label(action))
)
def update_force_not_open_workfile_settings(self, is_checked, action_id):
"""Store/remove config for forcing to skip opening last workfile.
Args:
is_checked (bool): True to add, False to remove
action_id (str)
"""
action_item = self.items_by_id.get(action_id)
if not action_item:
return
actions = action_item.data(ACTION_ROLE)
if not isinstance(actions, list):
actions = [actions]
action_actions_data = [
self._prepare_compare_data(action)
for action in actions
]
stored = self.launcher_registry.get_item("force_not_open_workfile")
for actual_data in action_actions_data:
if is_checked:
stored.append(actual_data)
else:
final_values = []
for config in stored:
if config != actual_data:
final_values.append(config)
stored = final_values
self.launcher_registry.set_item("force_not_open_workfile", stored)
self.launcher_registry._get_item.cache_clear()
self.change_action_item(action_item, is_checked)
def change_action_item(self, item, checked):
"""Modifies tooltip and sets if opening of last workfile forbidden"""
tooltip = item.data(QtCore.Qt.ToolTipRole)
if checked:
tooltip += " (Not opening last workfile)"
item.setData(tooltip, QtCore.Qt.ToolTipRole)
item.setData(checked, FORCE_NOT_OPEN_WORKFILE_ROLE)
def is_application_action(self, action):
"""Checks if item is of a ApplicationAction type
Args:
action (action)
"""
if isinstance(action, list) and action:
action = action[0]
return ApplicationAction in action.__bases__
def is_force_not_open_workfile(self, item, stored):
"""Checks if application for task is marked to not open workfile
There might be specific tasks where is unwanted to open workfile right
always (broken file, low performance). This allows artist to mark to
skip opening for combination (project, asset, task_name, app)
Args:
item (QStandardItem)
stored (list) of dict
"""
actions = item.data(ACTION_ROLE)
if not isinstance(actions, list):
actions = [actions]
if not self.is_application_action(actions[0]):
return False
action_actions_data = [
self._prepare_compare_data(action)
for action in actions
]
for config in stored:
if config in action_actions_data:
return True
return False
def _prepare_compare_data(self, action):
compare_data = {}
if action and action.label:
compare_data = {
"app_label": action.label.lower(),
"project_name": self.dbcon.Session["AVALON_PROJECT"],
"asset": self.dbcon.Session["AVALON_ASSET"],
"task_name": self.dbcon.Session["AVALON_TASK"]
}
return compare_data
class LauncherModel(QtCore.QObject):
# Refresh interval of projects
refresh_interval = 10000
# Signals
# Current project has changed
project_changed = QtCore.Signal(str)
# Filters has changed (any)
filters_changed = QtCore.Signal()
# Projects were refreshed
projects_refreshed = QtCore.Signal()
# Signals ONLY for assets model!
# - other objects should listen to asset model signals
# Asset refresh started
assets_refresh_started = QtCore.Signal()
# Assets refresh finished
assets_refreshed = QtCore.Signal()
# Refresh timer timeout
# - give ability to tell parent window that this timer still runs
timer_timeout = QtCore.Signal()
# Duplication from AssetsModel with "data.tasks"
_asset_projection = {
"name": 1,
"parent": 1,
"data.visualParent": 1,
"data.label": 1,
"data.icon": 1,
"data.color": 1,
"data.tasks": 1
}
def __init__(self, dbcon):
super(LauncherModel, self).__init__()
# Refresh timer
# - should affect only projects
refresh_timer = QtCore.QTimer()
refresh_timer.setInterval(self.refresh_interval)
refresh_timer.timeout.connect(self._on_timeout)
self._refresh_timer = refresh_timer
# Launcher is active
self._active = False
# Global data
self._dbcon = dbcon
# Available project names
self._project_names = set()
self._project_docs_by_name = {}
# Context data
self._asset_docs = []
self._asset_docs_by_id = {}
self._asset_filter_data_by_id = {}
self._assignees = set()
self._task_types = set()
# Filters
self._asset_name_filter = ""
self._assignee_filters = set()
self._task_type_filters = set()
# Last project for which were assets queried
self._last_project_name = None
# Asset refresh thread is running
self._refreshing_assets = False
# Asset refresh thread
self._asset_refresh_thread = None
def _on_timeout(self):
"""Refresh timer timeout."""
if self._active:
self.timer_timeout.emit()
self.refresh_projects()
def set_active(self, active):
"""Window change active state."""
self._active = active
def start_refresh_timer(self, trigger=False):
"""Start refresh timer."""
self._refresh_timer.start()
if trigger:
self._on_timeout()
def stop_refresh_timer(self):
"""Stop refresh timer."""
self._refresh_timer.stop()
@property
def project_name(self):
"""Current project name."""
return self._dbcon.current_project()
@property
def refreshing_assets(self):
"""Refreshing thread is running."""
return self._refreshing_assets
@property
def asset_docs(self):
"""Access to asset docs."""
return self._asset_docs
@property
def project_names(self):
"""Available project names."""
return self._project_names
def get_project_doc(self, project_name):
return self._project_docs_by_name.get(project_name)
@property
def asset_filter_data_by_id(self):
"""Prepared filter data by asset id."""
return self._asset_filter_data_by_id
@property
def assignees(self):
"""All assignees for all assets in current project."""
return self._assignees
@property
def task_types(self):
"""All task types for all assets in current project.
TODO: This could be maybe taken from project document where are all
task types...
"""
return self._task_types
@property
def task_type_filters(self):
"""Currently set task type filters."""
return self._task_type_filters
@property
def assignee_filters(self):
"""Currently set assignee filters."""
return self._assignee_filters
@property
def asset_name_filter(self):
"""Asset name filter (can be used as regex filter)."""
return self._asset_name_filter
def get_asset_doc(self, asset_id):
"""Get single asset document by id."""
return self._asset_docs_by_id.get(asset_id)
def set_project_name(self, project_name):
"""Change project name and refresh asset documents."""
if project_name == self.project_name:
return
self._dbcon.Session["AVALON_PROJECT"] = project_name
self.project_changed.emit(project_name)
self.refresh_assets(force=True)
def refresh(self):
"""Trigger refresh of whole model."""
self.refresh_projects()
self.refresh_assets(force=False)
def refresh_projects(self):
"""Refresh projects."""
current_project = self.project_name
project_names = set()
project_docs_by_name = {}
for project_doc in get_projects():
project_name = project_doc["name"]
project_names.add(project_name)
project_docs_by_name[project_name] = project_doc
self._project_docs_by_name = project_docs_by_name
self._project_names = project_names
self.projects_refreshed.emit()
if (
current_project is not None
and current_project not in project_names
):
self.set_project_name(None)
def _set_asset_docs(self, asset_docs=None):
"""Set asset documents and all related data.
Method extract and prepare data needed for assets and tasks widget and
prepare filtering data.
"""
if asset_docs is None:
asset_docs = []
all_task_types = set()
all_assignees = set()
asset_docs_by_id = {}
asset_filter_data_by_id = {}
for asset_doc in asset_docs:
task_types = set()
assignees = set()
asset_id = asset_doc["_id"]
asset_docs_by_id[asset_id] = asset_doc
asset_tasks = asset_doc.get("data", {}).get("tasks")
asset_filter_data_by_id[asset_id] = {
"assignees": assignees,
"task_types": task_types
}
if not asset_tasks:
continue
for task_data in asset_tasks.values():
task_assignees = set()
_task_assignees = task_data.get("assignees")
if _task_assignees:
for assignee in _task_assignees:
task_assignees.add(assignee["username"])
task_type = task_data.get("type")
if task_assignees:
assignees |= set(task_assignees)
if task_type:
task_types.add(task_type)
all_task_types |= task_types
all_assignees |= assignees
self._asset_docs_by_id = asset_docs_by_id
self._asset_docs = asset_docs
self._asset_filter_data_by_id = asset_filter_data_by_id
self._assignees = all_assignees
self._task_types = all_task_types
self.assets_refreshed.emit()
def set_task_type_filter(self, task_types):
"""Change task type filter.
Args:
task_types (set): Set of task types that should be visible.
Pass empty set to turn filter off.
"""
self._task_type_filters = task_types
self.filters_changed.emit()
def set_assignee_filter(self, assignees):
"""Change assignees filter.
Args:
assignees (set): Set of assignees that should be visible.
Pass empty set to turn filter off.
"""
self._assignee_filters = assignees
self.filters_changed.emit()
def set_asset_name_filter(self, text_filter):
"""Change asset name filter.
Args:
text_filter (str): Asset name filter. Pass empty string to
turn filter off.
"""
self._asset_name_filter = text_filter
self.filters_changed.emit()
def refresh_assets(self, force=True):
"""Refresh assets."""
self.assets_refresh_started.emit()
if self.project_name is None:
self._set_asset_docs()
return
if (
not force
and self._last_project_name == self.project_name
):
return
self._stop_fetch_thread()
self._refreshing_assets = True
self._last_project_name = self.project_name
self._asset_refresh_thread = DynamicQThread(self._refresh_assets)
self._asset_refresh_thread.start()
def _stop_fetch_thread(self):
self._refreshing_assets = False
if self._asset_refresh_thread is not None:
while self._asset_refresh_thread.isRunning():
# TODO this is blocking UI should be done in a different way
time.sleep(0.01)
self._asset_refresh_thread = None
def _refresh_assets(self):
asset_docs = list(get_assets(
self._last_project_name, fields=self._asset_projection.keys()
))
if not self._refreshing_assets:
return
self._refreshing_assets = False
self._set_asset_docs(asset_docs)
class LauncherTasksProxyModel(TasksProxyModel):
"""Tasks proxy model with more filtering.
TODO:
This can be (with few modifications) used in default tasks widget too.
"""
def __init__(self, launcher_model, *args, **kwargs):
self._launcher_model = launcher_model
super(LauncherTasksProxyModel, self).__init__(*args, **kwargs)
launcher_model.filters_changed.connect(self._on_filter_change)
self._task_types_filter = set()
self._assignee_filter = set()
def _on_filter_change(self):
self._task_types_filter = self._launcher_model.task_type_filters
self._assignee_filter = self._launcher_model.assignee_filters
self.invalidateFilter()
def filterAcceptsRow(self, row, parent):
if not self._task_types_filter and not self._assignee_filter:
return True
model = self.sourceModel()
source_index = model.index(row, self.filterKeyColumn(), parent)
if not source_index.isValid():
return False
# Check current index itself
if self._task_types_filter:
task_type = model.data(source_index, TASK_TYPE_ROLE)
if task_type not in self._task_types_filter:
return False
if self._assignee_filter:
assignee = model.data(source_index, TASK_ASSIGNEE_ROLE)
if not self._assignee_filter.intersection(assignee):
return False
return True
class LauncherTaskModel(TasksModel):
def __init__(self, launcher_model, *args, **kwargs):
self._launcher_model = launcher_model
super(LauncherTaskModel, self).__init__(*args, **kwargs)
def _refresh_project_doc(self):
self._project_doc = self._launcher_model.get_project_doc(
self._launcher_model.project_name
)
def set_asset_id(self, asset_id):
asset_doc = None
if self._context_is_valid():
asset_doc = self._launcher_model.get_asset_doc(asset_id)
self._set_asset(asset_doc)
class AssetRecursiveSortFilterModel(QtCore.QSortFilterProxyModel):
def __init__(self, launcher_model, *args, **kwargs):
self._launcher_model = launcher_model
super(AssetRecursiveSortFilterModel, self).__init__(*args, **kwargs)
launcher_model.filters_changed.connect(self._on_filter_change)
self._name_filter = ""
self._task_types_filter = set()
self._assignee_filter = set()
def _on_filter_change(self):
self._name_filter = self._launcher_model.asset_name_filter
self._task_types_filter = self._launcher_model.task_type_filters
self._assignee_filter = self._launcher_model.assignee_filters
self.invalidateFilter()
def filterAcceptsRow(self, row, parent):
if (
not self._name_filter
and not self._task_types_filter
and not self._assignee_filter
):
return True
model = self.sourceModel()
source_index = model.index(row, self.filterKeyColumn(), parent)
if not source_index.isValid():
return False
# Check current index itself
valid = True
if self._name_filter:
name = model.data(source_index, ASSET_NAME_ROLE)
if (
name is None
or not re.search(self._name_filter, name, re.IGNORECASE)
):
valid = False
if valid and self._task_types_filter:
task_types = model.data(source_index, ASSET_TASK_TYPES_ROLE)
if not self._task_types_filter.intersection(task_types):
valid = False
if valid and self._assignee_filter:
assignee = model.data(source_index, ASSET_ASSIGNEE_ROLE)
if not self._assignee_filter.intersection(assignee):
valid = False
if valid:
return True
# Check children
rows = model.rowCount(source_index)
for child_row in range(rows):
if self.filterAcceptsRow(child_row, source_index):
return True
return False
class LauncherAssetsModel(AssetModel):
def __init__(self, launcher_model, dbcon, parent=None):
self._launcher_model = launcher_model
# Make sure that variable is available (even if is in AssetModel)
self._last_project_name = None
super(LauncherAssetsModel, self).__init__(dbcon, parent)
launcher_model.project_changed.connect(self._on_project_change)
launcher_model.assets_refresh_started.connect(
self._on_launcher_refresh_start
)
launcher_model.assets_refreshed.connect(self._on_launcher_refresh)
def _on_launcher_refresh_start(self):
self._refreshing = True
project_name = self._launcher_model.project_name
if self._last_project_name != project_name:
self._clear_items()
self._last_project_name = project_name
def _on_launcher_refresh(self):
self._fill_assets(self._launcher_model.asset_docs)
self._refreshing = False
self.refreshed.emit(bool(self._items_by_asset_id))
def _fill_assets(self, *args, **kwargs):
super(LauncherAssetsModel, self)._fill_assets(*args, **kwargs)
asset_filter_data_by_id = self._launcher_model.asset_filter_data_by_id
for asset_id, item in self._items_by_asset_id.items():
filter_data = asset_filter_data_by_id.get(asset_id)
assignees = filter_data["assignees"]
task_types = filter_data["task_types"]
item.setData(assignees, ASSET_ASSIGNEE_ROLE)
item.setData(task_types, ASSET_TASK_TYPES_ROLE)
def _on_project_change(self):
self._clear_items()
def refresh(self, *args, **kwargs):
raise ValueError("This is a bug!")
def stop_refresh(self, *args, **kwargs):
raise ValueError("This is a bug!")
class ProjectModel(QtGui.QStandardItemModel):
"""List of projects"""
def __init__(self, launcher_model, parent=None):
super(ProjectModel, self).__init__(parent=parent)
self._launcher_model = launcher_model
self._project_names = set()
launcher_model.projects_refreshed.connect(self._on_refresh)
def _on_refresh(self):
project_names = set(self._launcher_model.project_names)
origin_project_names = set(self._project_names)
self._project_names = project_names
project_names_to_remove = origin_project_names - project_names
if project_names_to_remove:
row_counts = {}
continuous = None
for row in range(self.rowCount()):
index = self.index(row, 0)
index_name = index.data(QtCore.Qt.DisplayRole)
if index_name in project_names_to_remove:
if continuous is None:
continuous = row
row_counts[continuous] = 0
row_counts[continuous] += 1
else:
continuous = None
for row in reversed(sorted(row_counts.keys())):
count = row_counts[row]
self.removeRows(row, count)
continuous = None
row_counts = {}
for idx, project_name in enumerate(sorted(project_names)):
if project_name in origin_project_names:
continuous = None
continue
if continuous is None:
continuous = idx
row_counts[continuous] = []
row_counts[continuous].append(project_name)
for row in reversed(sorted(row_counts.keys())):
items = []
for project_name in row_counts[row]:
project_doc = self._launcher_model.get_project_doc(
project_name
)
icon = get_project_icon(project_doc)
item = QtGui.QStandardItem(icon, project_name)
items.append(item)
self.invisibleRootItem().insertRows(row, items)