Skip to content
Open
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
100 changes: 99 additions & 1 deletion api/review/listing.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,13 @@
from typing import Any
from typing import Annotated, Any

from fastapi import Body, Query

from ayon_server.access.utils import ensure_entity_access
from ayon_server.api.dependencies import (
AllowGuests,
CurrentUser,
FolderID,
PathProjectLevelEntityType,
ProductID,
ProjectName,
TaskID,
Expand All @@ -16,6 +20,8 @@
UserEntity,
VersionEntity,
)
from ayon_server.exceptions import BadRequestException
from ayon_server.graphql.resolvers.common import argdesc
from ayon_server.helpers.ffprobe import availability_from_media_info
from ayon_server.lib.postgres import Postgres
from ayon_server.reviewables.models import (
Expand All @@ -28,6 +34,14 @@
from .router import router


class ReviewablesRequestModel(OPModel):
entity_ids: list[str] = Field(
...,
description="List of target Entity IDs (folders, products, versions, etc.)"
" to fetch reviewables for.",
)


class VersionReviewablesModel(OPModel):
id: str = Field(
..., title="Version ID", example="1a3b34ce-1b2c-4d5e-6f7a-8b9c0d1e2f3a"
Expand All @@ -54,12 +68,26 @@ async def get_reviewables(
project_name: str,
*,
version_id: str | None = None,
version_ids: Annotated[
list[str] | None, argdesc("List of parent version IDs to filter by")
] = None,
product_id: str | None = None,
product_ids: Annotated[
list[str] | None, argdesc("List of products IDs to filter by")
] = None,
task_id: str | None = None,
task_ids: Annotated[
list[str] | None, argdesc("List of tasks IDs to filter by")
] = None,
folder_id: str | None = None,
folder_ids: Annotated[
list[str] | None, argdesc("List of folder IDs to filter by")
] = None,
user: UserEntity | None = None,
latest_done: bool = False,
) -> list[VersionReviewablesModel]:
cond = ""
cval: str | list[str] | None = None
if version_id:
cond = "versions.id = $1"
cval = version_id
Expand All @@ -72,6 +100,18 @@ async def get_reviewables(
elif folder_id:
cond = "products.folder_id = $1"
cval = folder_id
elif version_ids:
cond = "versions.id = ANY($1::uuid[])"
cval = version_ids
elif product_ids:
cond = "versions.product_id = ANY($1::uuid[])"
cval = product_ids
elif task_ids:
cond = "versions.task_id = ANY($1::uuid[])"
cval = task_ids
elif folder_ids:
cond = "products.folder_id = ANY($1::uuid[])"
cval = folder_ids

if user and user.is_guest:
cond += f""" AND versions.id IN (
Expand All @@ -87,6 +127,18 @@ async def get_reviewables(
)
"""

if latest_done:
cond += f"""AND versions.id IN (
SELECT vv.id
FROM project_{project_name}.versions vv
JOIN project_{project_name}.statuses st
ON st.name = vv.status
WHERE vv.product_id = products.id
AND st.data->>'state' = 'done'
ORDER BY vv.version DESC
LIMIT 1
)"""

query = f"""
SELECT
files.id as file_id,
Expand Down Expand Up @@ -250,6 +302,9 @@ async def get_reviewables_for_product(
user: CurrentUser,
project_name: ProjectName,
product_id: ProductID,
latest_done: Annotated[
bool, Query(description="If True, returns only the latest approved versions")
] = False,
) -> list[VersionReviewablesModel]:
"""Returns a list of reviewables for a given product."""

Expand All @@ -262,6 +317,7 @@ async def get_reviewables_for_product(
project_name,
product_id=product_id,
user=user,
latest_done=latest_done,
)


Expand All @@ -270,6 +326,9 @@ async def get_reviewables_for_version(
user: CurrentUser,
project_name: ProjectName,
version_id: VersionID,
latest_done: Annotated[
bool, Query(description="If True, returns only the latest approved versions")
] = False,
) -> VersionReviewablesModel:
"""Returns a list of reviewables for a given version."""

Expand All @@ -283,6 +342,7 @@ async def get_reviewables_for_version(
project_name,
version_id=version_id,
user=user,
latest_done=latest_done,
)
)[0]

Expand All @@ -292,6 +352,9 @@ async def get_reviewables_for_task(
user: CurrentUser,
project_name: ProjectName,
task_id: TaskID,
latest_done: Annotated[
bool, Query(description="If True, returns only the latest approved versions")
] = False,
) -> list[VersionReviewablesModel]:
task = await TaskEntity.load(project_name, task_id)

Expand All @@ -302,6 +365,7 @@ async def get_reviewables_for_task(
project_name,
task_id=task_id,
user=user,
latest_done=latest_done,
)


Expand All @@ -310,6 +374,9 @@ async def get_reviewables_for_folder(
user: CurrentUser,
project_name: ProjectName,
folder_id: FolderID,
latest_done: Annotated[
bool, Query(description="If True, returns only the latest approved versions")
] = False,
) -> list[VersionReviewablesModel]:
folder = await FolderEntity.load(project_name, folder_id)

Expand All @@ -320,4 +387,35 @@ async def get_reviewables_for_folder(
project_name,
folder_id=folder_id,
user=user,
latest_done=latest_done,
)


@router.post("/{entity_type}/reviewables", dependencies=[AllowGuests])
async def get_reviewables_for_entities(
user: CurrentUser,
project_name: ProjectName,
entity_type: PathProjectLevelEntityType,
payload: Annotated[ReviewablesRequestModel, Body(...)],
latest_done: Annotated[
bool, Query(description="If True, returns only the latest approved versions")
] = False,
) -> list[VersionReviewablesModel]:
"""Fetches reviewables for a batch of entity IDs passed in the request."""

supported_types = {"version", "product", "task", "folder"}
if entity_type not in supported_types:
raise BadRequestException(
detail=f"Unsupported entity type for reviewables: {entity_type}"
)

if not user.is_guest:
await ensure_entity_access(user, project_name, entity_type, payload.entity_ids)

kwargs: dict[str, Any] = {
f"{entity_type}_ids": payload.entity_ids,
"user": user,
"latest_done": latest_done,
}

return await get_reviewables(project_name, **kwargs)
98 changes: 58 additions & 40 deletions ayon_server/access/utils.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
from typing import TYPE_CHECKING, Literal
from typing import TYPE_CHECKING, Literal, overload

from ayon_server.exceptions import ForbiddenException
from ayon_server.lib.postgres import Postgres
from ayon_server.utils import SQLTool

if TYPE_CHECKING:
from ayon_server.access.permissions import FolderAccessList
Expand Down Expand Up @@ -175,85 +174,104 @@ async def folder_access_list(
return path_list


@overload
async def ensure_entity_access(
user: "UserEntity",
project_name: str,
entity_type: "ProjectLevelEntityType",
entity_id: str | None,
access_type: "AccessType" = "read",
) -> Literal[True]: ...
Comment thread
github-code-quality[bot] marked this conversation as resolved.
Fixed


@overload
async def ensure_entity_access(
user: "UserEntity",
project_name: str,
entity_type: "ProjectLevelEntityType",
entity_id: list[str],
access_type: "AccessType" = "read",
) -> Literal[True]: ...
Comment thread
github-code-quality[bot] marked this conversation as resolved.
Fixed


async def ensure_entity_access(
user: "UserEntity",
project_name: str,
entity_type: "ProjectLevelEntityType",
entity_id: str | list[str] | None,
access_type: "AccessType" = "read",
) -> Literal[True]:
"""Check whether the user has access to a given entity.

Warning: THIS IS SLOW. DO NOT USE IN BATCHES!
Should handle both single and multi entity access.
"""

if entity_id is None:
raise ForbiddenException("Limited access to project")

if isinstance(entity_id, str):
ids_to_check = [entity_id]
else:
ids_to_check = entity_id

if not ids_to_check:
return True

access_list = await folder_access_list(
user,
project_name,
access_type=access_type,
)

if access_list is None:
return True

if entity_id is None:
raise ForbiddenException("Limited access to project")
access_list = [path.strip('"') for path in access_list]

conditions = [f"hierarchy.path like ANY ('{{{', '.join(access_list)}}}')"]
joins = []

if entity_type in ("product", "version", "representation"):
joins.append(
f"""
INNER JOIN project_{project_name}.products
ON products.folder_id = hierarchy.id
"""
f"INNER JOIN project_{project_name}.products "
f"ON products.folder_id = hierarchy.id"
)
if entity_type in ("version", "representation"):
joins.append(
f"""
INNER JOIN project_{project_name}.versions
ON versions.product_id = products.id
"""
f"INNER JOIN project_{project_name}.versions "
f"ON versions.product_id = products.id"
)
if entity_type == "representation":
joins.append(
f"""
INNER JOIN project_{project_name}.representations
ON representations.version_id = versions.id
"""
f"INNER JOIN project_{project_name}.representations "
f"ON representations.version_id = versions.id"
)

elif entity_type in ("task", "workfile"):
joins.append(
f"""
INNER JOIN project_{project_name}.tasks
ON tasks.folder_id = hierarchy.id
"""
f"INNER JOIN project_{project_name}.tasks ON tasks.folder_id = hierarchy.id"
)

if entity_type == "workfile":
joins.append(
f"""
INNER JOIN project_{project_name}.workfiles
ON workfiles.task_id = tasks.id
"""
f"INNER JOIN project_{project_name}.workfiles "
f"ON workfiles.task_id = tasks.id"
)

if entity_type == "folder":
conditions.append(f"hierarchy.id = '{entity_id}'")
else:
conditions.append(f"{entity_type}s.id = '{entity_id}'")
id_column = "hierarchy.id" if entity_type == "folder" else f"{entity_type}s.id"

query = f"""
SELECT hierarchy.id FROM project_{project_name}.hierarchy
{" ".join(joins)}
{SQLTool.conditions(conditions)}
"""
SELECT DISTINCT {id_column} AS permitted_id
FROM project_{project_name}.hierarchy
{" ".join(joins)}
WHERE {id_column} = ANY ($1::uuid[]) AND
hierarchy.path LIKE ANY ($2::text[])
"""

if await Postgres.fetchrow(query):
return True
rows = await Postgres.fetch(query, ids_to_check, access_list)

permitted_ids = {str(row["permitted_id"]) for row in rows}
for e_id in ids_to_check:
if e_id not in permitted_ids:
raise ForbiddenException(f"Access denied for {entity_type}: {e_id}")

raise ForbiddenException("Entity access denied")
return True


class TrieNode:
Expand Down