Skip to content

Commit

Permalink
Merge pull request #183 from ynput/182-web-actions
Browse files Browse the repository at this point in the history
WebActions backend
  • Loading branch information
martastain authored Jul 10, 2024
2 parents c2fb59f + 16f7d7d commit 9322113
Show file tree
Hide file tree
Showing 10 changed files with 833 additions and 1 deletion.
4 changes: 4 additions & 0 deletions api/actions/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
__all__ = ["actions", "router"]

from . import actions
from .router import router
241 changes: 241 additions & 0 deletions api/actions/actions.py
Original file line number Diff line number Diff line change
@@ -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
Loading

0 comments on commit 9322113

Please sign in to comment.