diff --git a/api/actions/__init__.py b/api/actions/__init__.py new file mode 100644 index 00000000..5eb99260 --- /dev/null +++ b/api/actions/__init__.py @@ -0,0 +1,4 @@ +__all__ = ["actions", "router"] + +from . import actions +from .router import router diff --git a/api/actions/actions.py b/api/actions/actions.py new file mode 100644 index 00000000..9627501a --- /dev/null +++ b/api/actions/actions.py @@ -0,0 +1,241 @@ +from typing import Literal +from urllib.parse import urlparse + +from fastapi import Path, Query, Request + +from ayon_server.actions.context import ActionContext +from ayon_server.actions.execute import ActionExecutor, ExecuteResponseModel +from ayon_server.actions.manifest import BaseActionManifest +from ayon_server.addons import AddonLibrary +from ayon_server.api.dependencies import CurrentUser +from ayon_server.exceptions import ForbiddenException, NotFoundException +from ayon_server.lib.postgres import Postgres +from ayon_server.types import Field, OPModel + +from .listing import AvailableActionsListModel, get_dynamic_actions, get_simple_actions +from .router import router + +ActionListMode = Literal["simple", "dynamic", "all"] + + +@router.post("/list", response_model_exclude_none=True) +async def list_available_actions_for_context( + context: ActionContext, + user: CurrentUser, + mode: ActionListMode = Query("simple", title="Action List Mode"), +) -> AvailableActionsListModel: + """Get available actions for a context. + + This endpoint is used to get a list of actions that can be performed + on a given context. The context is defined by the project name, entity type, + and entity ids. The resulting list is then displayed to the user, + who can choose to run one of the actions. + + Simple actions are actions that do not require any additional + computation, so the list may be returned relatively quickly. + + Dynamic actions are actions that require additional computation + to determine if they are available, so they cannot be listed as quickly as + simple actions. + + Simple actions may be pinned to the entity sidebar. + """ + + actions = [] + + if mode == "simple": + r = await get_simple_actions(user, context) + actions.extend(r.actions) + elif mode == "dynamic": + r = await get_dynamic_actions(user, context) + actions.extend(r.actions) + elif mode == "all": + r1 = await get_simple_actions(user, context) + actions.extend(r1.actions) + r2 = await get_dynamic_actions(user, context) + actions.extend(r2.actions) + + for action in actions: + if action.icon and action.icon.url: + action.icon.url = action.icon.url.format( + addon_url=f"/addons/{action.addon_name}/{action.addon_version}" + ) + + return AvailableActionsListModel(actions=actions) + + +@router.get("/manage") +async def list_all_actions(user: CurrentUser) -> list[BaseActionManifest]: + """Get a list of all available actions. + + This endpoint is used to get a list of all available actions, + regardless the context they are available in. + In order to get this list, addon has to implement "get_all_actions" method. + + This endpoint is used for managing actions (e.g. enable/disable/statistics...) + """ + + if not user.is_admin: + raise ForbiddenException("Only admins can manage actions") + + actions: list[BaseActionManifest] = [] + + # TODO: from which bundle to get the actions? + + return actions + + +@router.post("/execute") +async def execute_action( + request: Request, + user: CurrentUser, + context: ActionContext, + adddon_name: str = Query(..., title="Addon Name", alias="addonName"), + addon_version: str = Query(..., title="Addon Version", alias="addonVersion"), + variant: str = Query("production", title="Action Variant"), + identifier: str = Query(..., title="Action Identifier"), +) -> ExecuteResponseModel: + """Run an action. + + This endpoint is used to run an action on a context. + This is called from the frontend when the user selects an action to run. + """ + + # Get access token from the Authorization header + # to pass it to the action executor + # to allow launcher to call the server + + auth_header = request.headers.get("Authorization") + if not auth_header: + raise ForbiddenException("Authorization header is missing") + access_token = auth_header.split(" ")[1] + + # Attempt to get the referer header, which is used to determine + # the server URL to pass to the action executor + # This is also used for launcher actions + + referer = request.headers.get("referer") + if referer: + parsed_url = urlparse(referer) + server_url = f"{parsed_url.scheme}://{parsed_url.netloc}" + else: + server_url = "http://localhost:5000" + + # Get the addon + + addon = AddonLibrary.addon(adddon_name, addon_version) + if addon is None: + raise NotFoundException(f"Addon {adddon_name} {addon_version} not found") + + # Create an action executor and run the action + + executor = ActionExecutor() + executor.user = user + executor.access_token = access_token + executor.server_url = server_url + executor.addon_name = adddon_name + executor.addon_version = addon_version + executor.variant = variant + executor.identifier = identifier + executor.context = context + + return await addon.execute_action(executor) + + +class TakeResponseModel(OPModel): + event_id: str = Field( + ..., + title="Event ID", + example="aae4b3d4-7b7b-4b7b-8b7b-7b7b7b7b7b7b", + ) + action_identifier: str = Field( + ..., + title="Action Identifier", + example="launch-maya", + ) + args: list[str] = Field( + [], + title="Action Arguments", + example=["-file", "path/to/file.ma"], + ) + context: ActionContext = Field( + ..., + title="Action Context", + ) + addon_name: str = Field( + ..., + title="Addon Name", + example="maya", + ) + addon_version: str = Field( + ..., + title="Addon Version", + example="1.5.6", + ) + variant: str = Field( + ..., + title="Action Variant", + example="production", + ) + + +@router.get("/take/{token}") +async def take_action( + token: str = Path( + ..., + title="Action Token", + pattern=r"[a-f0-9]{64}", + ), +) -> TakeResponseModel: + """called by launcher + + This is called by the launcher when it is started via + `ayon-launcher://action?server_url=...&token=...` URI + + Launcher connects to the server using the server url and uses the + token to get the action event (token is the event.hash) + + The server then gets the event payload and updates the event status to in_progress + and returns the event payload to the launcher. + + Launcher is then responsible for executing the action based on the payload + and updating the event status to finished or failed + """ + + res = await Postgres.fetch( + """ + SELECT * FROM events + WHERE + hash = $1 + AND topic = 'action.launcher' + AND status = 'pending' + """, + token, + ) + + if not res: + raise NotFoundException("Invalid token") + + event = res[0] + + # update event and set status to in_progress + + result = TakeResponseModel( + event_id=event["id"], + args=event["payload"].get("args", []), + context=event["payload"].get("context", {}), + addon_name=event["summary"].get("addon_name", ""), + addon_version=event["summary"].get("addon_version", ""), + variant=event["summary"].get("variant", ""), + action_identifier=event["summary"].get("action_identifier", ""), + ) + + await Postgres.execute( + """ + UPDATE events SET status = 'in_progress' + WHERE id = $1 + """, + event["id"], + ) + + return result diff --git a/api/actions/listing.py b/api/actions/listing.py new file mode 100644 index 00000000..4e97fcf2 --- /dev/null +++ b/api/actions/listing.py @@ -0,0 +1,215 @@ +import aiocache + +from ayon_server.actions.context import ActionContext +from ayon_server.actions.manifest import BaseActionManifest, SimpleActionManifest +from ayon_server.addons import AddonLibrary, BaseServerAddon +from ayon_server.entities import UserEntity +from ayon_server.events import EventModel, EventStream +from ayon_server.lib.postgres import Postgres +from ayon_server.lib.redis import Redis +from ayon_server.types import Field, OPModel +from ayon_server.utils import json_dumps, json_loads + + +class AvailableActionsListModel(OPModel): + actions: list[BaseActionManifest] = Field( + default_factory=list, + description="The list of available actions", + ) + + +@aiocache.cached(ttl=60) +async def _load_relevant_addons( + user_name: str, + is_developer: bool, + user_last_modified: str, +) -> tuple[str, list[BaseServerAddon]]: + variant = None + query: tuple[str] | tuple[str, str] + _ = user_last_modified # this is used just to invalidate the cache + + if is_developer: + # get the list of addons from the development environment + query = ( + """ + SELECT name, data->'addons' as addons FROM bundles + WHERE is_dev AND active_user = $1""", + user_name, + ) + else: + # get the list of addons from the production bundle + query = ( + "SELECT name, data->'addons' as addons FROM bundles WHERE is_production", + ) + # we're in production mode + variant = "production" + + res = await Postgres.fetch(*query) + if not res: + return "production", [] + + result = [] + + # if the variant is not already "production", + # we use dev bundle name as the variant + if variant is None: + variant = res[0]["name"] + + for addon_name, addon_version in res[0]["addons"].items(): + if not addon_version: + continue + addon = AddonLibrary.addon(addon_name, addon_version) + if addon is None: + continue + result.append(addon) + return variant, result + + +async def get_relevant_addons(user: UserEntity) -> tuple[str, list[BaseServerAddon]]: + """Get the list of addons that are relevant for the user. + + Normally it means addons in the production bundle, + but if the user has developerMode enabled, it will return addons + set up in their development environment. + + returns a tuple of variant and list of addons + """ + is_developer = user.is_developer and user.attrib.developerMode + user_last_modified = str(user.updated_at) + + return await _load_relevant_addons( + user.name, + is_developer, + user_last_modified, + ) + + +async def evaluate_simple_action( + action: SimpleActionManifest, + context: ActionContext, +) -> bool: + """Evaluate if a simple action is available for a given context. + + This compares action entity_type, entity_subtypes and allow_muliselection + attributes with the context and returns True if the action is available. + """ + + if action.entity_type != context.entity_type: + return False + + if context.entity_type: + if not context.entity_ids: + return False + + if action.allow_multiselection and len(context.entity_ids) != 1: + return False + + if action.entity_subtypes: + if not context.entity_subtypes: + return False + + if not set(action.entity_subtypes) & set(context.entity_subtypes): + return False + + return True + + +class SimpleActionCache: + hooks_installed: bool = False + ns: str = "addon_simple_actions" + + @classmethod + async def handle_project_changed(cls, event: EventModel): + keys = await Redis.keys(cls.ns) + for key in keys: + _, _, project_name, _ = key.split("|") + if project_name == event.project: + await Redis.delete(cls.ns, key) + + @classmethod + async def handle_settings_changed(cls, event: EventModel): + addon_name = event.summary["addon_name"] + addon_version = event.summary["addon_version"] + variant = event.summary["variant"] + + keys = await Redis.keys(cls.ns) + for key in keys: + addon, version, _, v = key.split("|") + if addon == addon_name and version == addon_version and v == variant: + await Redis.delete(cls.ns, key) + + @classmethod + async def get( + cls, + addon: BaseServerAddon, + project_name: str, + variant: str, + ) -> list[SimpleActionManifest]: + """Get a list of simple actions for a given context. + + This method is called for each addon to get a list of simple actions + that can be performed on a given context. The context is defined by the + project name and variant. + + The resulting list is then displayed to the user, who can choose to run + one of the actions. + """ + + if not cls.hooks_installed: + EventStream.subscribe("entity.project.changed", cls.handle_project_changed) + EventStream.subscribe("settings.changed", cls.handle_settings_changed) + cls.hooks_installed = True + + # The cache key + cache_key = f"{addon.name}|{addon.version}|{project_name}|{variant}" + + cached_data = await Redis.get(cls.ns, cache_key) + if cached_data is None: + r = await addon.get_simple_actions(project_name, variant) + # Cache the data + cached_data = [x.dict() for x in r] + await Redis.set(cls.ns, cache_key, json_dumps(cached_data)) + # return the model + return r + + return [SimpleActionManifest(**x) for x in json_loads(cached_data)] + + +async def get_simple_actions( + user: UserEntity, + context: ActionContext, +) -> AvailableActionsListModel: + actions = [] + variant, addons = await get_relevant_addons(user) + project_name = context.project_name + for addon in addons: + simple_actions = await SimpleActionCache.get(addon, project_name, variant) + for action in simple_actions: + if await evaluate_simple_action(action, context): + action.addon_name = addon.name + action.addon_version = addon.version + action.variant = variant + actions.append(action) + # TODO: use caching for the entire list as well + return AvailableActionsListModel(actions=actions) + + +async def get_dynamic_actions( + user: UserEntity, + context: ActionContext, +) -> AvailableActionsListModel: + """Get a list of dynamic actions for a given context. + + This method is called for each addon to get a list of dynamic actions + that can be performed on a given context. The context is defined by the + project name, entity type, and entity ids. + + The resulting list is then displayed to the user, who can choose to run + one of the actions. + """ + + actions = [] + variant, addons = await get_relevant_addons(user) + for addon in addons: + actions.extend(await addon.get_dynamic_actions(context, variant)) + return AvailableActionsListModel(actions=actions) diff --git a/api/actions/router.py b/api/actions/router.py new file mode 100644 index 00000000..2d0354ab --- /dev/null +++ b/api/actions/router.py @@ -0,0 +1,6 @@ +from fastapi import APIRouter + +router = APIRouter( + prefix="/actions", + tags=["Actions"], +) diff --git a/ayon_server/actions/__init__.py b/ayon_server/actions/__init__.py new file mode 100644 index 00000000..d940e4ad --- /dev/null +++ b/ayon_server/actions/__init__.py @@ -0,0 +1,16 @@ +__all__ = [ + "ActionContext", + "ActionExecutor", + "BaseActionManifest", + "ExecuteResponseModel", + "SimpleActionManifest", + "DynamicActionManifest", +] + +from .context import ActionContext +from .execute import ActionExecutor, ExecuteResponseModel +from .manifest import ( + BaseActionManifest, + DynamicActionManifest, + SimpleActionManifest, +) diff --git a/ayon_server/actions/context.py b/ayon_server/actions/context.py new file mode 100644 index 00000000..f99b0784 --- /dev/null +++ b/ayon_server/actions/context.py @@ -0,0 +1,84 @@ +from typing import Any + +from ayon_server.entities import ProjectEntity +from ayon_server.entities.core import ProjectLevelEntity +from ayon_server.exceptions import NotFoundException +from ayon_server.helpers.get_entity_class import get_entity_class +from ayon_server.types import Field, OPModel, ProjectLevelEntityType + + +class ActionContext(OPModel): + """ + frontend sends this to backend. + backend asks addons for actions based on this model. + """ + + _project_entity: ProjectEntity | None = None + _entities: list[ProjectLevelEntity] | None = None + + project_name: str = Field( + ..., + description="The name of the project", + example="my_project", + ) + entity_type: ProjectLevelEntityType | None = Field( + ..., + description="The type of the entity", + example="folder", + ) + + # frontend already knows this, so it can speed up + # the action resolving process when it sends this. + entity_subtypes: list[str] | None = Field( + None, + description="List of subtypes present in the entity list", + example=["asset"], + ) + + entity_ids: list[str] | None = Field( + ..., + title="Entity IDs", + description="The IDs of the entities", + example=["1a3bfe33-1b1b-4b1b-8b1b-1b1b1b1b1b1b"], + ) + + async def get_entities(self) -> list[ProjectLevelEntity]: + """Cached entities DURING THE REQUEST""" + + if self.entity_type is None or self.entity_ids is None: + return [] + + if self._entities is None: + result = [] + entity_class = get_entity_class(self.entity_type) + for entity_id in self.entity_ids: + try: + entity = await entity_class.load(self.project_name, entity_id) + except NotFoundException: + continue + result.append(entity) + + self._entities = result + return self._entities + + async def get_project_entity(self) -> ProjectEntity: + """Cached project entity DURING THE REQUEST""" + + if self._project_entity is None: + self._project_entity = await ProjectEntity.load(self.project_name) + return self._project_entity + + def __hash__(self): + elength = len(self.entity_ids) > 1 if self.entity_ids else 0 + hash_base = ( + self.project_name, + self.entity_type, + self.entity_subtypes, + elength, + ) + return hash(hash_base) + + def __eq__(self, other: Any): + if isinstance(other, ActionContext): + return self.__hash__() == other.__hash__() + return False diff --git a/ayon_server/actions/execute.py b/ayon_server/actions/execute.py new file mode 100644 index 00000000..93cad002 --- /dev/null +++ b/ayon_server/actions/execute.py @@ -0,0 +1,118 @@ +import urllib.parse +from typing import Literal + +from ayon_server.actions.context import ActionContext +from ayon_server.entities import UserEntity +from ayon_server.events import EventStream +from ayon_server.types import Field, OPModel +from ayon_server.utils import create_hash + + +class ExecuteResponseModel(OPModel): + type: Literal["launcher", "server"] = Field( + ..., + description="The type of response", + example="launcher", + ) + success: bool = Field( + True, + description="Whether the action was successful", + ) + message: str | None = Field( + None, + description="The message to display", + example="Action executed successfully", + ) + uri: str | None = Field( + None, + description="The uri to call from the browser", + example="ayon-launcher://action?server_url=http%3A%2F%2Flocalhost%3A8000%2F&token=eyJaaaa", + ) + + # TODO: for http/browser actions + # payload: dict | None = Field(None, description="The payload of the request") + + +class ActionExecutor: + user: UserEntity + server_url: str + access_token: str | None + addon_name: str + addon_version: str + variant: str + identifier: str + context: ActionContext + + async def get_launcher_action_response( + self, + args: list[str], + message: str | None = None, + ) -> ExecuteResponseModel: + """Return a response for a launcher action + + Launcher actions are actions that open the Ayon Launcher + with the given arguments. + + An event is dispatched to the EventStream to track the progress of the action. + The hash of the event is returned as a part of the URI. + + Uri is then used by the frontend to open the launcher. + + Launcher then uses the event hash to get the event details + and update the event status. + """ + payload = { + "args": args, + "context": self.context.dict(), + } + + summary = { + "addon_name": self.addon_name, + "addon_version": self.addon_version, + "variant": self.variant, + "action_identifier": self.identifier, + } + + hash = create_hash() + + await EventStream.dispatch( + "action.launcher", + hash=hash, + description=message or "Running action", + summary=summary, + payload=payload, + user=self.user.name, + project=self.context.project_name, + finished=False, + ) + + encoded_url = urllib.parse.quote_plus(self.server_url) + + return ExecuteResponseModel( + success=True, + type="launcher", + uri=f"ayon-launcher://action?server_url={encoded_url}&token={hash}", + message=message, + ) + + async def get_server_action_response( + self, + success: bool = True, + message: str | None = None, + ) -> ExecuteResponseModel: + """Return a response for a server actions + + Server actions are actions that are only executed on the server. + They only return a message to display in the frontend + after the action is executed. + """ + + if message is None: + message = f"Action {self.identifier} executed successfully" + + return ExecuteResponseModel( + success=success, + type="server", + message=message, + uri=None, + ) diff --git a/ayon_server/actions/manifest.py b/ayon_server/actions/manifest.py new file mode 100644 index 00000000..5c72360d --- /dev/null +++ b/ayon_server/actions/manifest.py @@ -0,0 +1,103 @@ +"""Action manifest contains the metadata of an action. + +The metadata includes the label, position, order, icon, addon name, and addon version. +This is all the information needed to display the action in the frontend. +""" + +from typing import Literal + +from ayon_server.types import Field, OPModel + + +class IconModel(OPModel): + type: Literal["material-symbols", "url"] = Field("url") + name: str | None = Field( + None, description="The name of the icon (for material-symbols)" + ) + color: str | None = Field( + None, description="The color of the icon (for material-symbols)" + ) + url: str | None = Field(None, description="The URL of the icon (for url)") + + +class BaseActionManifest(OPModel): + identifier: str = Field( + ..., + description="The identifier of the action", + example="maya.launch", + ) + + label: str = Field( + ..., + title="Label", + description="Human-friendly name of the action", + example="Launch Maya", + ) + category: str = Field( + "General", + title="Category", + description="Action category", + example="Launch", + ) + order: int = Field( + 100, + title="Order", + description="The order of the action", + example=100, + ) + icon: IconModel | None = Field( + None, + description="Path to the action icon", + example={"type": "material-symbols", "name": "launch"}, + ) + + # auto-populated by endpoints based on user preferences + + featured: bool = Field(False) + + # Addon name and addon version are auto-populated by the server + + addon_name: str | None = Field( + None, + title="Addon Name", + description="The name of the addon providing the action", + example="maya", + ) + addon_version: str | None = Field( + None, + title="Addon Version", + description="The version of the addon providing the action", + example="1.5.6", + ) + + variant: str | None = Field( + None, + description="The settings variant of the addon", + example="production", + ) + + +class SimpleActionManifest(BaseActionManifest): + _action_type = "simple" + + entity_type: str | None = Field( + None, + title="Entity Type", + description="The type of the entity", + example="folder", + ) + entity_subtypes: list[str] | None = Field( + default_factory=list, + title="Entity Subtypes", + description="The subtype of the entity (folder type, task type)", + example=["asset"], + ) + allow_multiselection: bool = Field( + False, + title="Allow Multiselection", + description="Allow multiple entities to be selected", + ) + + +class DynamicActionManifest(BaseActionManifest): + _action_type = "dynamic" diff --git a/ayon_server/addons/addon.py b/ayon_server/addons/addon.py index 481416cf..9d9d4679 100644 --- a/ayon_server/addons/addon.py +++ b/ayon_server/addons/addon.py @@ -9,6 +9,12 @@ from nxtools import log_traceback, logging +from ayon_server.actions.context import ActionContext +from ayon_server.actions.execute import ActionExecutor, ExecuteResponseModel +from ayon_server.actions.manifest import ( + DynamicActionManifest, + SimpleActionManifest, +) from ayon_server.addons.models import ServerSourceInfo, SourceInfo, SSOOption from ayon_server.exceptions import AyonException, BadRequestException, NotFoundException from ayon_server.lib.postgres import Postgres @@ -598,3 +604,38 @@ async def get_app_host_names(self) -> list[str]: if self.app_host_name is None: return [] return [self.app_host_name] + + # + # Actions + # + + async def get_simple_actions( + self, + project_name: str | None = None, + variant: str = "production", + ) -> list[SimpleActionManifest]: + """Return a list of simple actions provided by the addon""" + return [] + + async def get_dynamic_actions( + self, + context: ActionContext, + variant: str = "production", + ) -> list[DynamicActionManifest]: + """Return a list of dynamic actions provided by the addon""" + return [] + + # TODO: do we need this? + # async def get_all_actions( + # self, + # context: ActionContext, + # ) -> list[BaseActionManifest]: + # """Return a list of all actions provided by the addon""" + # return await self.get_simple_actions() + + async def execute_action( + self, + executor: ActionExecutor, + ) -> ExecuteResponseModel: + """Execute an action provided by the addon""" + raise ValueError(f"Unknown action: {executor.identifier}") diff --git a/ayon_server/lib/redis.py b/ayon_server/lib/redis.py index 02242766..490e8e48 100644 --- a/ayon_server/lib/redis.py +++ b/ayon_server/lib/redis.py @@ -104,7 +104,11 @@ async def publish(cls, message: str, channel: str | None = None) -> None: async def keys(cls, namespace: str) -> list[str]: if not cls.connected: await cls.connect() - return await cls.redis_pool.keys(f"{cls.prefix}{namespace}-*") + keys = await cls.redis_pool.keys(f"{cls.prefix}{namespace}-*") + return [ + key.decode("ascii").removeprefix(f"{cls.prefix}{namespace}-") + for key in keys + ] @classmethod async def iterate(cls, namespace: str):