From 979a82031395320ae050ae1698afeb8693511a27 Mon Sep 17 00:00:00 2001 From: Martin Wacker Date: Wed, 15 May 2024 13:11:51 +0200 Subject: [PATCH 01/16] chore: webactions initial commit --- ayon_server/actions/__init__.py | 0 ayon_server/actions/models.py | 29 +++++++++++++++++++++++++++++ 2 files changed, 29 insertions(+) create mode 100644 ayon_server/actions/__init__.py create mode 100644 ayon_server/actions/models.py diff --git a/ayon_server/actions/__init__.py b/ayon_server/actions/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/ayon_server/actions/models.py b/ayon_server/actions/models.py new file mode 100644 index 00000000..0eb3fba3 --- /dev/null +++ b/ayon_server/actions/models.py @@ -0,0 +1,29 @@ +from ayon_server.types import Field, OPModel, ProjectLevelEntityType + + +class ActionContextModel(OPModel): + """ + frontend sends this to backend. + backend asks addons for actions based on this model. + """ + + project_name: str = Field( + ..., + description="The name of the project", + ) + entity_type: ProjectLevelEntityType | None = Field( + ..., + description="The type of the entity", + ) + entity_ids: list[str] | None = Field( + ..., + title="Entity IDs", + ) + + def get_entities(self): + """Cached entities DURING THE REQUEST""" + ... + + def get_project_entity(self): + """Cached project entity DURING THE REQUEST""" + ... From eb43b2c4c56335b5dcc35c8ec7b19efd7b9083ac Mon Sep 17 00:00:00 2001 From: Martin Wacker Date: Mon, 20 May 2024 17:47:02 +0200 Subject: [PATCH 02/16] feat: cached context --- ayon_server/actions/context.py | 54 ++++++++++++++++++++++++++++++++++ ayon_server/actions/models.py | 29 ------------------ 2 files changed, 54 insertions(+), 29 deletions(-) create mode 100644 ayon_server/actions/context.py delete mode 100644 ayon_server/actions/models.py diff --git a/ayon_server/actions/context.py b/ayon_server/actions/context.py new file mode 100644 index 00000000..f30b2bdf --- /dev/null +++ b/ayon_server/actions/context.py @@ -0,0 +1,54 @@ +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 ActionContextModel(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", + ) + entity_type: ProjectLevelEntityType | None = Field( + ..., + description="The type of the entity", + ) + entity_ids: list[str] | None = Field( + ..., + title="Entity IDs", + ) + + 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 diff --git a/ayon_server/actions/models.py b/ayon_server/actions/models.py deleted file mode 100644 index 0eb3fba3..00000000 --- a/ayon_server/actions/models.py +++ /dev/null @@ -1,29 +0,0 @@ -from ayon_server.types import Field, OPModel, ProjectLevelEntityType - - -class ActionContextModel(OPModel): - """ - frontend sends this to backend. - backend asks addons for actions based on this model. - """ - - project_name: str = Field( - ..., - description="The name of the project", - ) - entity_type: ProjectLevelEntityType | None = Field( - ..., - description="The type of the entity", - ) - entity_ids: list[str] | None = Field( - ..., - title="Entity IDs", - ) - - def get_entities(self): - """Cached entities DURING THE REQUEST""" - ... - - def get_project_entity(self): - """Cached project entity DURING THE REQUEST""" - ... From 56363cf73aad228a929c2057cc42625fc81fe925 Mon Sep 17 00:00:00 2001 From: Martin Wacker Date: Thu, 23 May 2024 17:28:29 +0200 Subject: [PATCH 03/16] feat: actions api skeleton (wip) --- ayon_server/actions/context.py | 2 +- ayon_server/addons/addon.py | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/ayon_server/actions/context.py b/ayon_server/actions/context.py index f30b2bdf..d4043e3c 100644 --- a/ayon_server/actions/context.py +++ b/ayon_server/actions/context.py @@ -5,7 +5,7 @@ from ayon_server.types import Field, OPModel, ProjectLevelEntityType -class ActionContextModel(OPModel): +class ActionContext(OPModel): """ frontend sends this to backend. backend asks addons for actions based on this model. diff --git a/ayon_server/addons/addon.py b/ayon_server/addons/addon.py index 094e8a58..635292d3 100644 --- a/ayon_server/addons/addon.py +++ b/ayon_server/addons/addon.py @@ -9,6 +9,9 @@ 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 @@ -579,3 +582,32 @@ async def convert_str_to_list_str(value: str | list[str]) -> list[str]: new_model_class=model_class, defaults=defaults.dict(), ) + + # + # Actions + # + + async def get_simple_actions(self) -> list[SimpleActionManifest]: + """Return a list of simple actions provided by the addon""" + return [] + + async def get_dynamic_actions( + self, context: ActionContext + ) -> list[DynamicActionManifest]: + """Return a list of dynamic actions provided by the addon""" + return [] + + async def get_all_actions( + self, context: ActionContext + ) -> list[SimpleActionManifest | DynamicActionManifest]: + """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""" + + if executor.identifier == "moje-launcher-akce": + return await executor.create_launcher_action(args=["blabla"]) From f660d5ba17deac7b97f1bbc6c98f13029f54500f Mon Sep 17 00:00:00 2001 From: Martin Wacker Date: Thu, 23 May 2024 17:33:35 +0200 Subject: [PATCH 04/16] feat: actions api skeleton (wip) --- api/actions/__init__.py | 4 + api/actions/actions.py | 168 ++++++++++++++++++++++++++++++++ api/actions/listing.py | 114 ++++++++++++++++++++++ api/actions/router.py | 6 ++ ayon_server/actions/execute.py | 76 +++++++++++++++ ayon_server/actions/manifest.py | 109 +++++++++++++++++++++ 6 files changed, 477 insertions(+) create mode 100644 api/actions/__init__.py create mode 100644 api/actions/actions.py create mode 100644 api/actions/listing.py create mode 100644 api/actions/router.py create mode 100644 ayon_server/actions/execute.py create mode 100644 ayon_server/actions/manifest.py 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..c76cbe58 --- /dev/null +++ b/api/actions/actions.py @@ -0,0 +1,168 @@ +from typing import Literal +from urllib.parse import urlparse + +from fastapi import 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.types import Field, OPModel + +from .listing import get_dynamic_actions, get_simple_actions +from .router import router + +ActionListMode = Literal["simple", "dynamic", "all"] + + +class AvailableActionsListModel(OPModel): + variant: str | None = Field( + None, + description="The variant of the bundle", + ) + actions: list[BaseActionManifest] = Field( + default_factory=list, + description="The list of available actions", + ) + + +@router.post("list") +async def list_available_actions_for_context( + context: ActionContext, + user: CurrentUser, + mode: ActionListMode = "simple", +) -> 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 in ("simple", "all"): + r = await get_simple_actions(user, context) + actions.extend(r.actions) + variant = r.variant + + if mode in ("dynamic", "all"): + r = await get_dynamic_actions(user, context) + actions.extend(r.actions) + variant = r.variant + + return AvailableActionsListModel(variant=variant, 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 = [] + + # 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"), + addon_version: str = Query(..., title="Addon Version"), + 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) + + +@router.get("take/{event_id}") +async def take_action(event_id): + """called by launcher + + This is called by the launcher when it is started via ayon-launcher:// uri + + Launcher connects to the server using the server url and access token + provided in the JWT token and calls this endpoint with the event id + + 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 + """ + + # query events by id + # ensure it is an "launcher.action" + + # update event and set status to in_progress + + # return event.payload diff --git a/api/actions/listing.py b/api/actions/listing.py new file mode 100644 index 00000000..416d3e75 --- /dev/null +++ b/api/actions/listing.py @@ -0,0 +1,114 @@ +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.lib.postgres import Postgres + +# TODO: This HAS TO BE cached somehow +# because it is executed every time the user changes the selection + + +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 + variant = None + + if is_developer: + # get the list of addons from the development environment + query = ( + """ + SELECT name, data->'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' 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"]: + addon = AddonLibrary.addon(addon_name, addon_version) + if addon is None: + continue + result.append(addon) + return variant, result + + +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: + pass # TODO: implement this + + return True + + +async def get_simple_actions( + user: UserEntity, context: ActionContext +) -> list[SimpleActionManifest]: + actions = [] + variant, addons = await get_relevant_addons(user) + for addon in addons: + simple_actions = await addon.get_simple_actions() + for action in simple_actions: + if evaluate_simple_action(action, context): + actions.append(action) + return actions + + +async def get_dynamic_actions( + user: UserEntity, + context: ActionContext, +) -> tuple[str, list[BaseActionManifest]]: + """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)) + return variant, 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/execute.py b/ayon_server/actions/execute.py new file mode 100644 index 00000000..69011dd5 --- /dev/null +++ b/ayon_server/actions/execute.py @@ -0,0 +1,76 @@ +import time +from typing import Literal + +import jwt + +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 + + +class ExecuteResponseModel(OPModel): + type: Literal["launcher", "void"] = Field(...) + message: str | None = Field(None, description="The message to display") + uri: str | None = Field(None, description="The url to open in the browser") + + # 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 + user: UserEntity + identifier: str + context: ActionContext + + async def get_launcher_action( + self, args: list[str], message: str | None = None + ) -> ExecuteResponseModel: + payload = { + "args": args, + "variant": self.variant, + } + + summary = { + "addon_name": self.addon_name, + "addon_version": self.addon_version, + "variant": self.variant, + "action_identifier": self.identifier, + } + + event_id = await EventStream.dispatch( + "action.launcher", + description=message or "Running action", + summary=summary, + payload=payload, + user=self.user.name, + project=self.context.project_name, + finished=False, + ) + + token = jwt.encode( + { + "jti": event_id, + "aud": self.server_url, + "iat": time.time(), + "exp": time.time() + 60, + "sub": self.access_token, + }, + "secret", + algorithm="HS256", + ) + + return ExecuteResponseModel( + type="launcher", + uri=f"ayon-launcher://action?token={token}", + message=message, + ) + + def get_void_action(self, message: str | None = None) -> ExecuteResponseModel: + return ExecuteResponseModel(type="void", message=message) diff --git a/ayon_server/actions/manifest.py b/ayon_server/actions/manifest.py new file mode 100644 index 00000000..2aba9419 --- /dev/null +++ b/ayon_server/actions/manifest.py @@ -0,0 +1,109 @@ +"""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 ActionTemplate(OPModel): + """ + + launcher: + action returns ayon-uri which the frontend uses to open the launcher. + + """ + + type: Literal["launcher", "http"] = Field( + ..., + title="Template type", + description="The type of the template", + example="http", + ) + url: str | None = Field( + None, + description="The url to open in the browser", + example="https:", + ) + method: Literal["GET", "POST"] = Field( + "GET", + description="The method of the request", + ) + payload: dict | None = Field( + None, + description="The payload of the request", + ) + + +class BaseActionManifest(OPModel): + identifier: str = Field( + ..., + description="The identifier of the action", + ) + + label: str = Field( + ..., + title="Label", + description="Human-friendly name of the action", + ) + position: list[str] | None = Field( + None, + title="Position", + description="path to the action within tree/context menu", + ) + order: int = Field( + 100, + title="Order", + description="The order of the action", + ) + icon: str | None = Field( + None, + description="The icon of the action. TBD", + ) + + # 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", + ) + addon_version: str | None = Field( + None, + title="Addon Version", + description="The version of the addon providing the action", + ) + + variant: str | None = Field(None, description="The variant of the addon") + + +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=["shot"], + ) + allow_multiselection: bool = Field( + False, + title="Allow Multiselection", + description="Allow multiple entities to be selected", + ) + + +class DynamicActionManifest(BaseActionManifest): + _action_type = "dynamic" From 1aede09f71741c859f80f34ac894da72171ba7b2 Mon Sep 17 00:00:00 2001 From: Martin Wacker Date: Mon, 24 Jun 2024 14:15:33 +0200 Subject: [PATCH 05/16] feat: action listing POC --- api/actions/actions.py | 35 +++++++++------------- api/actions/listing.py | 35 +++++++++++++++------- ayon_server/actions/__init__.py | 16 ++++++++++ ayon_server/actions/execute.py | 5 ++-- ayon_server/actions/manifest.py | 4 +-- ayon_server/addons/addon.py | 53 +++++++++++++++++---------------- 6 files changed, 88 insertions(+), 60 deletions(-) diff --git a/api/actions/actions.py b/api/actions/actions.py index c76cbe58..7fd95d1a 100644 --- a/api/actions/actions.py +++ b/api/actions/actions.py @@ -9,30 +9,18 @@ from ayon_server.addons import AddonLibrary from ayon_server.api.dependencies import CurrentUser from ayon_server.exceptions import ForbiddenException, NotFoundException -from ayon_server.types import Field, OPModel -from .listing import get_dynamic_actions, get_simple_actions +from .listing import AvailableActionsListModel, get_dynamic_actions, get_simple_actions from .router import router ActionListMode = Literal["simple", "dynamic", "all"] -class AvailableActionsListModel(OPModel): - variant: str | None = Field( - None, - description="The variant of the bundle", - ) - actions: list[BaseActionManifest] = Field( - default_factory=list, - description="The list of available actions", - ) - - -@router.post("list") +@router.post("/list") async def list_available_actions_for_context( context: ActionContext, user: CurrentUser, - mode: ActionListMode = "simple", + mode: ActionListMode = Query("simple", title="Action List Mode"), ) -> AvailableActionsListModel: """Get available actions for a context. @@ -53,20 +41,25 @@ async def list_available_actions_for_context( actions = [] - if mode in ("simple", "all"): + if mode == "simple": r = await get_simple_actions(user, context) actions.extend(r.actions) variant = r.variant - - if mode in ("dynamic", "all"): + elif mode == "dynamic": r = await get_dynamic_actions(user, context) actions.extend(r.actions) variant = r.variant + 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) + variant = r2.variant return AvailableActionsListModel(variant=variant, actions=actions) -@router.get("manage") +@router.get("/manage") async def list_all_actions(user: CurrentUser) -> list[BaseActionManifest]: """Get a list of all available actions. @@ -87,7 +80,7 @@ async def list_all_actions(user: CurrentUser) -> list[BaseActionManifest]: return actions -@router.post("execute") +@router.post("/execute") async def execute_action( request: Request, user: CurrentUser, @@ -144,7 +137,7 @@ async def execute_action( return await addon.execute_action(executor) -@router.get("take/{event_id}") +@router.get("/take/{event_id}") async def take_action(event_id): """called by launcher diff --git a/api/actions/listing.py b/api/actions/listing.py index 416d3e75..6b948e29 100644 --- a/api/actions/listing.py +++ b/api/actions/listing.py @@ -3,9 +3,18 @@ from ayon_server.addons import AddonLibrary, BaseServerAddon from ayon_server.entities import UserEntity from ayon_server.lib.postgres import Postgres +from ayon_server.types import Field, OPModel -# TODO: This HAS TO BE cached somehow -# because it is executed every time the user changes the selection + +class AvailableActionsListModel(OPModel): + variant: str | None = Field( + None, + description="The variant of the bundle", + ) + actions: list[BaseActionManifest] = Field( + default_factory=list, + description="The list of available actions", + ) async def get_relevant_addons(user: UserEntity) -> tuple[str, list[BaseServerAddon]]: @@ -17,6 +26,8 @@ async def get_relevant_addons(user: UserEntity) -> tuple[str, list[BaseServerAdd returns a tuple of variant and list of addons """ + # TODO: This HAS TO BE cached somehow + # because it is executed every time the user changes the selection is_developer = user.is_developer and user.attrib.developerMode variant = None @@ -25,13 +36,15 @@ async def get_relevant_addons(user: UserEntity) -> tuple[str, list[BaseServerAdd # get the list of addons from the development environment query = ( """ - SELECT name, data->'addons' FROM bundles + 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' FROM bundles WHERE is_production" + query = ( + "SELECT name, data->'addons' as addons FROM bundles WHERE is_production", + ) # we're in production mode variant = "production" @@ -46,7 +59,9 @@ async def get_relevant_addons(user: UserEntity) -> tuple[str, list[BaseServerAdd if variant is None: variant = res[0]["name"] - for addon_name, addon_version in res[0]["addons"]: + 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 @@ -54,7 +69,7 @@ async def get_relevant_addons(user: UserEntity) -> tuple[str, list[BaseServerAdd return variant, result -async def evaluate_simple_action( +def evaluate_simple_action( action: SimpleActionManifest, context: ActionContext, ) -> bool: @@ -82,7 +97,7 @@ async def evaluate_simple_action( async def get_simple_actions( user: UserEntity, context: ActionContext -) -> list[SimpleActionManifest]: +) -> AvailableActionsListModel: actions = [] variant, addons = await get_relevant_addons(user) for addon in addons: @@ -90,13 +105,13 @@ async def get_simple_actions( for action in simple_actions: if evaluate_simple_action(action, context): actions.append(action) - return actions + return AvailableActionsListModel(variant=variant, actions=actions) async def get_dynamic_actions( user: UserEntity, context: ActionContext, -) -> tuple[str, list[BaseActionManifest]]: +) -> 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 @@ -111,4 +126,4 @@ async def get_dynamic_actions( variant, addons = await get_relevant_addons(user) for addon in addons: actions.extend(await addon.get_dynamic_actions(context)) - return variant, actions + return AvailableActionsListModel(variant=variant, actions=actions) diff --git a/ayon_server/actions/__init__.py b/ayon_server/actions/__init__.py index e69de29b..d940e4ad 100644 --- a/ayon_server/actions/__init__.py +++ 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/execute.py b/ayon_server/actions/execute.py index 69011dd5..e103647d 100644 --- a/ayon_server/actions/execute.py +++ b/ayon_server/actions/execute.py @@ -25,12 +25,13 @@ class ActionExecutor: addon_name: str addon_version: str variant: str - user: UserEntity identifier: str context: ActionContext async def get_launcher_action( - self, args: list[str], message: str | None = None + self, + args: list[str], + message: str | None = None, ) -> ExecuteResponseModel: payload = { "args": args, diff --git a/ayon_server/actions/manifest.py b/ayon_server/actions/manifest.py index 2aba9419..da122ca6 100644 --- a/ayon_server/actions/manifest.py +++ b/ayon_server/actions/manifest.py @@ -7,7 +7,7 @@ """ -from typing import Literal +from typing import Any, Literal from ayon_server.types import Field, OPModel @@ -35,7 +35,7 @@ class ActionTemplate(OPModel): "GET", description="The method of the request", ) - payload: dict | None = Field( + payload: dict[str, Any] | None = Field( None, description="The payload of the request", ) diff --git a/ayon_server/addons/addon.py b/ayon_server/addons/addon.py index 74ef2379..a8957d29 100644 --- a/ayon_server/addons/addon.py +++ b/ayon_server/addons/addon.py @@ -11,7 +11,10 @@ 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.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 @@ -587,6 +590,20 @@ async def convert_str_to_list_str(value: str | list[str]) -> list[str]: defaults=defaults.dict(), ) + async def get_app_host_names(self) -> list[str]: + """Return a list of application host names that the addon uses. + + Addon may reimplment this method to return a list of host names that + the addon uses. + + By default, it returns a single host name from the + addon's attributes. If the addon uses multiple host names, you should + override this method. + """ + + if self.app_host_name is None: + return [] + return [self.app_host_name] # # Actions @@ -597,37 +614,23 @@ async def get_simple_actions(self) -> list[SimpleActionManifest]: return [] async def get_dynamic_actions( - self, context: ActionContext + self, + context: ActionContext, ) -> list[DynamicActionManifest]: """Return a list of dynamic actions provided by the addon""" return [] - async def get_all_actions( - self, context: ActionContext - ) -> list[SimpleActionManifest | DynamicActionManifest]: - """Return a list of all actions provided by the addon""" - return await self.get_simple_actions() + # 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""" - - if executor.identifier == "moje-launcher-akce": - return await executor.create_launcher_action(args=["blabla"]) - - async def get_app_host_names(self) -> list[str]: - """Return a list of application host names that the addon uses. - - Addon may reimplment this method to return a list of host names that - the addon uses. - - By default, it returns a single host name from the - addon's attributes. If the addon uses multiple host names, you should - override this method. - """ - - if self.app_host_name is None: - return [] - return [self.app_host_name] + raise ValueError(f"Unknown action: {executor.identifier}") From cc7ac5037fbeb11526fb2a12fbf220febdc1a4e2 Mon Sep 17 00:00:00 2001 From: Martin Wacker Date: Mon, 24 Jun 2024 18:09:25 +0200 Subject: [PATCH 06/16] feat: void actions, changed uri format --- api/actions/actions.py | 4 +-- api/actions/listing.py | 13 +++++-- ayon_server/actions/context.py | 7 ++++ ayon_server/actions/execute.py | 66 +++++++++++++++++++++++----------- 4 files changed, 65 insertions(+), 25 deletions(-) diff --git a/api/actions/actions.py b/api/actions/actions.py index 7fd95d1a..a1439c92 100644 --- a/api/actions/actions.py +++ b/api/actions/actions.py @@ -85,8 +85,8 @@ async def execute_action( request: Request, user: CurrentUser, context: ActionContext, - adddon_name: str = Query(..., title="Addon Name"), - addon_version: str = Query(..., title="Addon Version"), + 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: diff --git a/api/actions/listing.py b/api/actions/listing.py index 6b948e29..2015d5f9 100644 --- a/api/actions/listing.py +++ b/api/actions/listing.py @@ -69,7 +69,7 @@ async def get_relevant_addons(user: UserEntity) -> tuple[str, list[BaseServerAdd return variant, result -def evaluate_simple_action( +async def evaluate_simple_action( action: SimpleActionManifest, context: ActionContext, ) -> bool: @@ -90,7 +90,11 @@ def evaluate_simple_action( return False if action.entity_subtypes: - pass # TODO: implement this + if not context.entity_subtypes: + return False + + if not set(action.entity_subtypes) & set(context.entity_subtypes): + return False return True @@ -103,7 +107,10 @@ async def get_simple_actions( for addon in addons: simple_actions = await addon.get_simple_actions() for action in simple_actions: - if evaluate_simple_action(action, context): + if await evaluate_simple_action(action, context): + action.addon_name = addon.name + action.addon_version = addon.version + action.variant = variant actions.append(action) return AvailableActionsListModel(variant=variant, actions=actions) diff --git a/ayon_server/actions/context.py b/ayon_server/actions/context.py index d4043e3c..1557c645 100644 --- a/ayon_server/actions/context.py +++ b/ayon_server/actions/context.py @@ -22,6 +22,13 @@ class ActionContext(OPModel): ..., description="The type of the entity", ) + + # 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" + ) + entity_ids: list[str] | None = Field( ..., title="Entity IDs", diff --git a/ayon_server/actions/execute.py b/ayon_server/actions/execute.py index e103647d..0cb13995 100644 --- a/ayon_server/actions/execute.py +++ b/ayon_server/actions/execute.py @@ -1,18 +1,18 @@ -import time +import urllib.parse from typing import Literal -import jwt - 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", "void"] = Field(...) + success: bool = Field(True) message: str | None = Field(None, description="The message to display") - uri: str | None = Field(None, description="The url to open in the browser") + uri: str | None = Field(None, description="The uri to open in the browser") # TODO: for http/browser actions # payload: dict | None = Field(None, description="The payload of the request") @@ -28,11 +28,24 @@ class ActionExecutor: identifier: str context: ActionContext - async def get_launcher_action( + 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, "variant": self.variant, @@ -45,8 +58,11 @@ async def get_launcher_action( "action_identifier": self.identifier, } - event_id = await EventStream.dispatch( + hash = create_hash() + + await EventStream.dispatch( "action.launcher", + hash=hash, description=message or "Running action", summary=summary, payload=payload, @@ -55,23 +71,33 @@ async def get_launcher_action( finished=False, ) - token = jwt.encode( - { - "jti": event_id, - "aud": self.server_url, - "iat": time.time(), - "exp": time.time() + 60, - "sub": self.access_token, - }, - "secret", - algorithm="HS256", - ) + encoded_url = urllib.parse.quote_plus(self.server_url) return ExecuteResponseModel( + success=True, type="launcher", - uri=f"ayon-launcher://action?token={token}", + uri=f"ayon-launcher://action?server_url={encoded_url}&token={hash}", message=message, ) - def get_void_action(self, message: str | None = None) -> ExecuteResponseModel: - return ExecuteResponseModel(type="void", message=message) + async def get_void_action_response( + self, + success: bool = True, + message: str | None = None, + ) -> ExecuteResponseModel: + """Return a response for a void actions + + Void 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="void", + message=message, + uri=None, + ) From 3a332926b711824a9ef8a4bc418f4b0769d50749 Mon Sep 17 00:00:00 2001 From: Martin Wacker Date: Tue, 25 Jun 2024 12:51:45 +0200 Subject: [PATCH 07/16] feat: implement /api/actions/take, added model examples and docs --- api/actions/actions.py | 102 +++++++++++++++++++++++++++----- api/actions/listing.py | 8 +-- ayon_server/actions/context.py | 8 ++- ayon_server/actions/execute.py | 25 ++++++-- ayon_server/actions/manifest.py | 53 ++++++----------- 5 files changed, 135 insertions(+), 61 deletions(-) diff --git a/api/actions/actions.py b/api/actions/actions.py index a1439c92..bc5229ea 100644 --- a/api/actions/actions.py +++ b/api/actions/actions.py @@ -1,7 +1,7 @@ from typing import Literal from urllib.parse import urlparse -from fastapi import Query, Request +from fastapi import Path, Query, Request from ayon_server.actions.context import ActionContext from ayon_server.actions.execute import ActionExecutor, ExecuteResponseModel @@ -9,6 +9,8 @@ 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 @@ -44,19 +46,16 @@ async def list_available_actions_for_context( if mode == "simple": r = await get_simple_actions(user, context) actions.extend(r.actions) - variant = r.variant elif mode == "dynamic": r = await get_dynamic_actions(user, context) actions.extend(r.actions) - variant = r.variant 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) - variant = r2.variant - return AvailableActionsListModel(variant=variant, actions=actions) + return AvailableActionsListModel(actions=actions) @router.get("/manage") @@ -73,7 +72,7 @@ async def list_all_actions(user: CurrentUser) -> list[BaseActionManifest]: if not user.is_admin: raise ForbiddenException("Only admins can manage actions") - actions = [] + actions: list[BaseActionManifest] = [] # TODO: from which bundle to get the actions? @@ -137,14 +136,58 @@ async def execute_action( return await addon.execute_action(executor) -@router.get("/take/{event_id}") -async def take_action(event_id): +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}", + ), +): """called by launcher - This is called by the launcher when it is started via ayon-launcher:// uri + 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 access token - provided in the JWT token and calls this endpoint with the event id + 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. @@ -153,9 +196,40 @@ async def take_action(event_id): and updating the event status to finished or failed """ - # query events by id - # ensure it is an "launcher.action" + res = await Postgres.fetch( + """ + SELECT * FROM events + WHERE + hash = $1 + AND topic = 'launcher.action' + AND status = 'pending' + """, + token, + ) + + if not res: + raise NotFoundException("Invalid token") + + event = res[0] # update event and set status to in_progress - # return event.payload + 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 index 2015d5f9..43d59cd5 100644 --- a/api/actions/listing.py +++ b/api/actions/listing.py @@ -7,10 +7,6 @@ class AvailableActionsListModel(OPModel): - variant: str | None = Field( - None, - description="The variant of the bundle", - ) actions: list[BaseActionManifest] = Field( default_factory=list, description="The list of available actions", @@ -32,6 +28,8 @@ async def get_relevant_addons(user: UserEntity) -> tuple[str, list[BaseServerAdd is_developer = user.is_developer and user.attrib.developerMode variant = None + query: tuple[str] | tuple[str, str] + if is_developer: # get the list of addons from the development environment query = ( @@ -112,7 +110,7 @@ async def get_simple_actions( action.addon_version = addon.version action.variant = variant actions.append(action) - return AvailableActionsListModel(variant=variant, actions=actions) + return AvailableActionsListModel(actions=actions) async def get_dynamic_actions( diff --git a/ayon_server/actions/context.py b/ayon_server/actions/context.py index 1557c645..d4ff22ff 100644 --- a/ayon_server/actions/context.py +++ b/ayon_server/actions/context.py @@ -17,21 +17,27 @@ class ActionContext(OPModel): 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" + 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]: diff --git a/ayon_server/actions/execute.py b/ayon_server/actions/execute.py index 0cb13995..19b04505 100644 --- a/ayon_server/actions/execute.py +++ b/ayon_server/actions/execute.py @@ -9,10 +9,25 @@ class ExecuteResponseModel(OPModel): - type: Literal["launcher", "void"] = Field(...) - success: bool = Field(True) - message: str | None = Field(None, description="The message to display") - uri: str | None = Field(None, description="The uri to open in the browser") + type: Literal["launcher", "void"] = 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") @@ -48,7 +63,7 @@ async def get_launcher_action_response( """ payload = { "args": args, - "variant": self.variant, + "context": self.context.dict(), } summary = { diff --git a/ayon_server/actions/manifest.py b/ayon_server/actions/manifest.py index da122ca6..f33c5d75 100644 --- a/ayon_server/actions/manifest.py +++ b/ayon_server/actions/manifest.py @@ -2,85 +2,66 @@ 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 Any, Literal - from ayon_server.types import Field, OPModel -class ActionTemplate(OPModel): - """ - - launcher: - action returns ayon-uri which the frontend uses to open the launcher. - - """ - - type: Literal["launcher", "http"] = Field( - ..., - title="Template type", - description="The type of the template", - example="http", - ) - url: str | None = Field( - None, - description="The url to open in the browser", - example="https:", - ) - method: Literal["GET", "POST"] = Field( - "GET", - description="The method of the request", - ) - payload: dict[str, Any] | None = Field( - None, - description="The payload of the request", - ) - - 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", ) position: list[str] | None = Field( None, title="Position", description="path to the action within tree/context menu", + example=["DCC", "Launch"], ) order: int = Field( 100, title="Order", description="The order of the action", + example=100, ) icon: str | None = Field( None, description="The icon of the action. TBD", + icon="maya", ) + # auto-populated by endpoints based on user preferences + + pinned: bool = Field(False, description="Whether the action is pinned") + # 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 variant of the addon") + variant: str | None = Field( + None, + description="The settings variant of the addon", + example="production", + ) class SimpleActionManifest(BaseActionManifest): @@ -96,7 +77,7 @@ class SimpleActionManifest(BaseActionManifest): default_factory=list, title="Entity Subtypes", description="The subtype of the entity (folder type, task type)", - example=["shot"], + example=["asset"], ) allow_multiselection: bool = Field( False, From 4078ec24a2c6798d71445d5bb9906240031832e3 Mon Sep 17 00:00:00 2001 From: Martin Wacker Date: Tue, 25 Jun 2024 15:59:27 +0200 Subject: [PATCH 08/16] refactor: updated action manifest schema, icons resolving --- api/actions/actions.py | 8 +++++++- api/actions/listing.py | 3 ++- ayon_server/actions/execute.py | 10 +++++----- ayon_server/actions/manifest.py | 12 ++++++------ 4 files changed, 20 insertions(+), 13 deletions(-) diff --git a/api/actions/actions.py b/api/actions/actions.py index bc5229ea..af82c036 100644 --- a/api/actions/actions.py +++ b/api/actions/actions.py @@ -55,6 +55,12 @@ async def list_available_actions_for_context( r2 = await get_dynamic_actions(user, context) actions.extend(r2.actions) + for action in actions: + if action.icon: + action.icon = action.icon.format( + addon_url=f"/addons/{action.addon_name}/{action.addon_version}" + ) + return AvailableActionsListModel(actions=actions) @@ -180,7 +186,7 @@ async def take_action( title="Action Token", pattern=r"[a-f0-9]{64}", ), -): +) -> TakeResponseModel: """called by launcher This is called by the launcher when it is started via diff --git a/api/actions/listing.py b/api/actions/listing.py index 43d59cd5..8586977f 100644 --- a/api/actions/listing.py +++ b/api/actions/listing.py @@ -98,7 +98,8 @@ async def evaluate_simple_action( async def get_simple_actions( - user: UserEntity, context: ActionContext + user: UserEntity, + context: ActionContext, ) -> AvailableActionsListModel: actions = [] variant, addons = await get_relevant_addons(user) diff --git a/ayon_server/actions/execute.py b/ayon_server/actions/execute.py index 19b04505..93cad002 100644 --- a/ayon_server/actions/execute.py +++ b/ayon_server/actions/execute.py @@ -9,7 +9,7 @@ class ExecuteResponseModel(OPModel): - type: Literal["launcher", "void"] = Field( + type: Literal["launcher", "server"] = Field( ..., description="The type of response", example="launcher", @@ -95,14 +95,14 @@ async def get_launcher_action_response( message=message, ) - async def get_void_action_response( + async def get_server_action_response( self, success: bool = True, message: str | None = None, ) -> ExecuteResponseModel: - """Return a response for a void actions + """Return a response for a server actions - Void actions are actions that are only executed on the server. + 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. """ @@ -112,7 +112,7 @@ async def get_void_action_response( return ExecuteResponseModel( success=success, - type="void", + type="server", message=message, uri=None, ) diff --git a/ayon_server/actions/manifest.py b/ayon_server/actions/manifest.py index f33c5d75..00fd118b 100644 --- a/ayon_server/actions/manifest.py +++ b/ayon_server/actions/manifest.py @@ -20,11 +20,11 @@ class BaseActionManifest(OPModel): description="Human-friendly name of the action", example="Launch Maya", ) - position: list[str] | None = Field( - None, - title="Position", - description="path to the action within tree/context menu", - example=["DCC", "Launch"], + category: str = Field( + "General", + title="Category", + description="Action category", + example="Launch", ) order: int = Field( 100, @@ -34,7 +34,7 @@ class BaseActionManifest(OPModel): ) icon: str | None = Field( None, - description="The icon of the action. TBD", + description="Path to the action icon", icon="maya", ) From b9baecbcac56257449c94082fa4498a37ab77042 Mon Sep 17 00:00:00 2001 From: Martin Wacker Date: Tue, 25 Jun 2024 16:32:45 +0200 Subject: [PATCH 09/16] refactor: renamed action.pinned to action.featured --- ayon_server/actions/manifest.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ayon_server/actions/manifest.py b/ayon_server/actions/manifest.py index 00fd118b..f0867dbb 100644 --- a/ayon_server/actions/manifest.py +++ b/ayon_server/actions/manifest.py @@ -40,7 +40,7 @@ class BaseActionManifest(OPModel): # auto-populated by endpoints based on user preferences - pinned: bool = Field(False, description="Whether the action is pinned") + featured: bool = Field(False) # Addon name and addon version are auto-populated by the server From 256598cdf2a707e3bba0cada54a6f740c33942fb Mon Sep 17 00:00:00 2001 From: Martin Wacker Date: Thu, 27 Jun 2024 15:52:46 +0200 Subject: [PATCH 10/16] feat: enhance action icon model --- api/actions/actions.py | 6 +++--- ayon_server/actions/manifest.py | 17 +++++++++++++++-- 2 files changed, 18 insertions(+), 5 deletions(-) diff --git a/api/actions/actions.py b/api/actions/actions.py index af82c036..a7d4c47a 100644 --- a/api/actions/actions.py +++ b/api/actions/actions.py @@ -18,7 +18,7 @@ ActionListMode = Literal["simple", "dynamic", "all"] -@router.post("/list") +@router.post("/list", response_model_exclude_none=True) async def list_available_actions_for_context( context: ActionContext, user: CurrentUser, @@ -56,8 +56,8 @@ async def list_available_actions_for_context( actions.extend(r2.actions) for action in actions: - if action.icon: - action.icon = action.icon.format( + if action.icon and action.icon.type == "url": + action.icon.url = action.icon.url.format( addon_url=f"/addons/{action.addon_name}/{action.addon_version}" ) diff --git a/ayon_server/actions/manifest.py b/ayon_server/actions/manifest.py index f0867dbb..5c72360d 100644 --- a/ayon_server/actions/manifest.py +++ b/ayon_server/actions/manifest.py @@ -4,9 +4,22 @@ 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( ..., @@ -32,10 +45,10 @@ class BaseActionManifest(OPModel): description="The order of the action", example=100, ) - icon: str | None = Field( + icon: IconModel | None = Field( None, description="Path to the action icon", - icon="maya", + example={"type": "material-symbols", "name": "launch"}, ) # auto-populated by endpoints based on user preferences From c65a48b5335b338ff0a8dd49e214891832fe3d75 Mon Sep 17 00:00:00 2001 From: Martin Wacker Date: Thu, 27 Jun 2024 16:04:15 +0200 Subject: [PATCH 11/16] fix: type fix in icon model --- api/actions/actions.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/actions/actions.py b/api/actions/actions.py index a7d4c47a..306c1821 100644 --- a/api/actions/actions.py +++ b/api/actions/actions.py @@ -56,7 +56,7 @@ async def list_available_actions_for_context( actions.extend(r2.actions) for action in actions: - if action.icon and action.icon.type == "url": + if action.icon and action.icon.url: action.icon.url = action.icon.url.format( addon_url=f"/addons/{action.addon_name}/{action.addon_version}" ) From 044b74403ea883524f3ff5c5985cb1d501278480 Mon Sep 17 00:00:00 2001 From: Martin Wacker Date: Fri, 28 Jun 2024 17:48:22 +0200 Subject: [PATCH 12/16] feat: resolve simple actions by project name and variant --- api/actions/listing.py | 118 ++++++++++++++++++++++++++++++------ ayon_server/addons/addon.py | 7 ++- 2 files changed, 106 insertions(+), 19 deletions(-) diff --git a/api/actions/listing.py b/api/actions/listing.py index 8586977f..6ce6145d 100644 --- a/api/actions/listing.py +++ b/api/actions/listing.py @@ -1,9 +1,14 @@ +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): @@ -13,22 +18,15 @@ class AvailableActionsListModel(OPModel): ) -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 - """ - # TODO: This HAS TO BE cached somehow - # because it is executed every time the user changes the selection - - is_developer = user.is_developer and user.attrib.developerMode +@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 @@ -36,7 +34,7 @@ async def get_relevant_addons(user: UserEntity) -> tuple[str, list[BaseServerAdd """ SELECT name, data->'addons' as addons FROM bundles WHERE is_dev AND active_user = $1""", - user.name, + user_name, ) else: # get the list of addons from the production bundle @@ -67,6 +65,25 @@ async def get_relevant_addons(user: UserEntity) -> tuple[str, list[BaseServerAdd 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, @@ -97,20 +114,85 @@ async def evaluate_simple_action( 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: + print("Invalidating simple actions cache for", project_name) + 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: + print("Invalidating simple actions cache for", addon_name) + 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 addon.get_simple_actions() + 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) @@ -131,5 +213,5 @@ async def get_dynamic_actions( actions = [] variant, addons = await get_relevant_addons(user) for addon in addons: - actions.extend(await addon.get_dynamic_actions(context)) - return AvailableActionsListModel(variant=variant, actions=actions) + actions.extend(await addon.get_dynamic_actions(context, variant)) + return AvailableActionsListModel(actions=actions) diff --git a/ayon_server/addons/addon.py b/ayon_server/addons/addon.py index a8957d29..9d9d4679 100644 --- a/ayon_server/addons/addon.py +++ b/ayon_server/addons/addon.py @@ -609,13 +609,18 @@ async def get_app_host_names(self) -> list[str]: # Actions # - async def get_simple_actions(self) -> list[SimpleActionManifest]: + 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 [] From 5db7a0d5b718466185aaaecc9169a506d13d1734 Mon Sep 17 00:00:00 2001 From: Martin Wacker Date: Fri, 28 Jun 2024 18:13:11 +0200 Subject: [PATCH 13/16] fix: typo in simple_actions invalidation --- api/actions/listing.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/api/actions/listing.py b/api/actions/listing.py index 6ce6145d..4e97fcf2 100644 --- a/api/actions/listing.py +++ b/api/actions/listing.py @@ -124,8 +124,7 @@ async def handle_project_changed(cls, event: EventModel): for key in keys: _, _, project_name, _ = key.split("|") if project_name == event.project: - print("Invalidating simple actions cache for", project_name) - await Redis.delete(cls.ns, key) + await Redis.delete(cls.ns, key) @classmethod async def handle_settings_changed(cls, event: EventModel): @@ -137,7 +136,6 @@ async def handle_settings_changed(cls, event: EventModel): for key in keys: addon, version, _, v = key.split("|") if addon == addon_name and version == addon_version and v == variant: - print("Invalidating simple actions cache for", addon_name) await Redis.delete(cls.ns, key) @classmethod From 9876aef767bb3ebc7460b09ccfc46ca5f107069e Mon Sep 17 00:00:00 2001 From: Martin Wacker Date: Fri, 28 Jun 2024 18:17:49 +0200 Subject: [PATCH 14/16] feat: action context hashing --- ayon_server/actions/context.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/ayon_server/actions/context.py b/ayon_server/actions/context.py index d4ff22ff..f99b0784 100644 --- a/ayon_server/actions/context.py +++ b/ayon_server/actions/context.py @@ -1,3 +1,5 @@ +from typing import Any + from ayon_server.entities import ProjectEntity from ayon_server.entities.core import ProjectLevelEntity from ayon_server.exceptions import NotFoundException @@ -65,3 +67,18 @@ async def get_project_entity(self) -> ProjectEntity: 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 From 6c249e3355186e524a79e684c86b437c8a1c5051 Mon Sep 17 00:00:00 2001 From: Martin Wacker Date: Mon, 1 Jul 2024 14:08:13 +0200 Subject: [PATCH 15/16] fix: wrong event topic in /actions/take --- api/actions/actions.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/actions/actions.py b/api/actions/actions.py index 306c1821..9627501a 100644 --- a/api/actions/actions.py +++ b/api/actions/actions.py @@ -207,7 +207,7 @@ async def take_action( SELECT * FROM events WHERE hash = $1 - AND topic = 'launcher.action' + AND topic = 'action.launcher' AND status = 'pending' """, token, From 16f7d7d9bcb279d2f17911f66b500f4dae6a7547 Mon Sep 17 00:00:00 2001 From: Martin Wacker Date: Mon, 8 Jul 2024 15:23:50 +0200 Subject: [PATCH 16/16] fix: redis.keys() now returns list[str] --- ayon_server/lib/redis.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/ayon_server/lib/redis.py b/ayon_server/lib/redis.py index 91685ab8..e3797eee 100644 --- a/ayon_server/lib/redis.py +++ b/ayon_server/lib/redis.py @@ -86,7 +86,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):