diff --git a/api/project_folders/__init__.py b/api/project_folders/__init__.py new file mode 100644 index 000000000..b89ff5908 --- /dev/null +++ b/api/project_folders/__init__.py @@ -0,0 +1,7 @@ +__all__ = [ + "router", + "project_folders", +] + +from . import project_folders +from .router import router diff --git a/api/project_folders/project_folders.py b/api/project_folders/project_folders.py new file mode 100644 index 000000000..8c60b5a0a --- /dev/null +++ b/api/project_folders/project_folders.py @@ -0,0 +1,258 @@ +from typing import Annotated + +from ayon_server.api.dependencies import ( + CurrentUser, + FolderID, +) +from ayon_server.api.responses import EmptyResponse, EntityIdResponse +from ayon_server.exceptions import ( + ConflictException, + ForbiddenException, + NotFoundException, +) +from ayon_server.helpers.project_list import normalize_project_name +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.entity_id import EntityID +from ayon_server.utils.utils import dict_patch + +from .router import router + +# +# Project folder models +# + + +class ProjectFolderData(OPModel): + color: Annotated[str | None, Field(description="Hex color code")] = None + icon: Annotated[str | None, Field(description="Icon name")] = None + + +FFolderID = Field(title="Folder ID", **EntityID.META) +FFolderLabel = Field(title="Folder label", min_length=1, max_length=255) +FFolderParentID = Field(title="Parent folder ID", **EntityID.META) +FFolderData = Field( + title="Folder additional data", + default_factory=lambda: ProjectFolderData(), +) + +# +# Get / list item model +# + + +class ProjectFolderPostModel(OPModel): + id: Annotated[str | None, FFolderID] = None + label: Annotated[str, FFolderLabel] + parent_id: Annotated[str | None, FFolderParentID] = None + data: Annotated[ProjectFolderData, FFolderData] + + +class ProjectFolderPatchModel(OPModel): + label: Annotated[str | None, FFolderLabel] = None + parent_id: Annotated[str | None, FFolderParentID] = None + data: Annotated[ProjectFolderData | None, FFolderData] + + +class ProjectFolderModel(OPModel): + id: Annotated[str, FFolderID] + label: Annotated[str, FFolderLabel] + parent_id: Annotated[str | None, FFolderParentID] = None + position: Annotated[int, Field(title="Folder position", ge=0)] = 0 + data: Annotated[ProjectFolderData, FFolderData] + + +class ProjectFoldersResponseModel(OPModel): + folders: Annotated[list[ProjectFolderModel], Field(default_factory=list)] + + +# +# API endpoints +# + + +@router.get("/projectFolders") +async def get_project_folders(user: CurrentUser) -> ProjectFoldersResponseModel: + result = [] + async with Postgres.transaction(): + query = "SELECT * FROM project_folders ORDER BY parent_id, position, label" + stmt = await Postgres.prepare(query) + async for row in stmt.cursor(): + result.append(ProjectFolderModel(**row)) + + return ProjectFoldersResponseModel(folders=result) + + +@router.post("/projectFolders") +async def create_project_folder( + user: CurrentUser, + payload: ProjectFolderPostModel, +) -> EntityIdResponse: + if payload.id is None: + payload.id = EntityID.create() + + try: + await Postgres.execute( + """ + INSERT INTO project_folders + (id, label, parent_id, data) + VALUES ($1, $2, $3, $4) + """, + payload.id, + payload.label, + payload.parent_id, + payload.data.dict(exclude_unset=True), + ) + except Postgres.UniqueViolationError: + raise ConflictException("Folder with the given ID already exists") + return EntityIdResponse(id=payload.id) + + +@router.patch("/projectFolders/{folder_id}") +async def update_project_folder( + user: CurrentUser, + folder_id: FolderID, + payload: ProjectFolderPatchModel, +) -> EmptyResponse: + async with Postgres.transaction(): + res = await Postgres.fetchrow( + "SELECT * FROM project_folders WHERE id = $1", + folder_id, + ) + + if not res: + raise NotFoundException("Project folder not found") + + if not user.is_admin: + raise ForbiddenException("You don't have permission to update this folder") + + payload_dict = payload.dict(exclude_unset=True) + + new_payload = { + "label": payload_dict.get("label", res["label"]), + "parent_id": payload_dict.get("parent_id", res["parent_id"]), + "data": dict_patch(res["data"] or {}, payload_dict.pop("data", {}) or {}), + } + + await Postgres.execute( + """ + UPDATE project_folders + SET label = $2, + parent_id = $3, + data = $4 + WHERE id = $1 + """, + folder_id, + new_payload["label"], + new_payload["parent_id"], + new_payload["data"], + ) + + return EmptyResponse() + + +@router.delete("/projectFolders/{folder_id}") +async def delete_project_folder( + user: CurrentUser, + folder_id: FolderID, +) -> EmptyResponse: + if not user.is_admin: + raise ForbiddenException("You don't have permission to delete project folders") + await Postgres.execute( + "DELETE FROM project_folders WHERE id = $1", + folder_id, + ) + return EmptyResponse() + + +class ProjectFolderOrderModel(OPModel): + order: Annotated[ + list[str], + Field( + title="Ordered list of folder IDs", + min_items=1, + ), + ] + + +@router.post("/projectFolders/order") +async def set_project_folders_order( + user: CurrentUser, + payload: ProjectFolderOrderModel, +) -> EmptyResponse: + if not user.is_admin: + raise ForbiddenException("You don't have permission to reorder project folders") + + async with Postgres.transaction(): + for position, folder_id in enumerate(payload.order): + await Postgres.execute( + """ + UPDATE project_folders + SET position = $2 + WHERE id = $1 + """, + folder_id, + position, + ) + + return EmptyResponse() + + +class AssignProjectRequest(OPModel): + folder_id: Annotated[str | None, FFolderID] = None + project_names: Annotated[ + list[str], + Field( + title="List of project names to assign to the folder", + min_items=1, + ), + ] + + +@router.post("/projectFolders/assign") +async def assign_projects_to_folder( + user: CurrentUser, + payload: AssignProjectRequest, +) -> EmptyResponse: + """Assign one or more projects to a project folder. + + To remove projects from folders, set `folder_id` to `null`. + Only users with manager privileges can perform this action. + """ + if not user.is_manager: + raise ForbiddenException( + "You don't have permission to assign projects to folders" + ) + + for project_name_input in payload.project_names: + project_name = await normalize_project_name(project_name_input) + + folder_id = EntityID.parse(payload.folder_id, allow_nulls=True) + + if folder_id is None: + await Postgres.execute( + """ + UPDATE projects + SET data = data - 'projectFolder' + WHERE name = $1 + """, + project_name, + ) + else: + await Postgres.execute( + """ + UPDATE projects + SET data = jsonb_set( + COALESCE(data, '{}'::jsonb), + '{projectFolder}', + to_jsonb($2::text) + ) + WHERE name = $1 + """, + project_name, + folder_id, + ) + await Redis.delete("project-data", project_name) + + return EmptyResponse() diff --git a/api/project_folders/router.py b/api/project_folders/router.py new file mode 100644 index 000000000..3a943dd3d --- /dev/null +++ b/api/project_folders/router.py @@ -0,0 +1,3 @@ +from fastapi import APIRouter + +router = APIRouter(tags=["Project folders"]) diff --git a/api/projects/list_projects.py b/api/projects/list_projects.py index 0943f5f81..e91dad18c 100644 --- a/api/projects/list_projects.py +++ b/api/projects/list_projects.py @@ -1,7 +1,7 @@ """[GET] /projects (List projects)""" from datetime import datetime -from typing import Literal +from typing import Annotated, Literal from fastapi import Query @@ -14,66 +14,103 @@ class ListProjectsItemModel(OPModel): - name: str = Field(..., title="Project name") - code: str = Field(..., title="Project code") - active: bool = Field(..., title="Project is active") - createdAt: datetime = Field(..., title="Creation time") - updatedAt: datetime = Field(..., title="Last modified time") + name: Annotated[str, Field(title="Project name")] + code: Annotated[str, Field(title="Project code")] + active: Annotated[bool, Field(title="Project is active")] = True + library: Annotated[bool, Field(title="Project is a library project")] = False + pinned: Annotated[bool, Field(title="Project is pinned")] = False + project_folder: Annotated[str | None, Field(title="Project folder id")] = None + created_at: Annotated[datetime, Field(title="Creation time")] + updated_at: Annotated[datetime, Field(title="Last modified time")] class ListProjectsResponseModel(OPModel): - detail: str = Field("OK", example="Showing LENGTH of COUNT projects") - count: int = Field( - 0, - description="Total count of projects (regardless the pagination)", - example=1, - ) - projects: list[ListProjectsItemModel] = Field( - [], - description="List of projects", - example=[ - ListProjectsItemModel( - name="Example project", - code="ex", - createdAt=datetime.now(), - updatedAt=datetime.now(), - active=True, - ) - ], - ) + detail: Annotated[ + str, + Field( + example="Showing LENGTH of COUNT projects", + ), + ] = "OK" + count: Annotated[ + int, + Field( + description="Total count of projects (regardless the pagination)", + example=1, + ), + ] = 0 + projects: Annotated[ + list[ListProjectsItemModel], + Field( + description="List of projects", + default_factory=list, + example=[ + ListProjectsItemModel( + name="Example project", + code="ex", + created_at=datetime.now(), + updated_at=datetime.now(), + active=True, + ) + ], + ), + ] @router.get("/projects", dependencies=[AllowGuests]) async def list_projects( user: CurrentUser, - page: int = Query(1, title="Page", ge=1), - length: int | None = Query( - None, - title="Records per page", - description="If not provided, the result will not be limited", - ), - library: bool | None = Query( - None, - title="Show library projects", - description="If not provided, return projects regardless the flag", - ), - active: bool | None = Query( - None, - title="Show active projects", - description="If not provided, return projects regardless the flag", - ), - order: Literal["name", "createdAt", "updatedAt"] | None = Query( - None, title="Attribute to order the list by" - ), - desc: bool = Query(False, title="Sort in descending order"), - name: str | None = Query( - None, - title="Filter by name", - description="""Limit the result to project with the matching name, + page: Annotated[ + int, + Query( + title="Page", + description="Page number, starting from 1", + ge=1, + ), + ] = 1, + length: Annotated[ + int | None, + Query( + title="Records per page", + description="If not provided, the result will not be limited", + ), + ] = None, + library: Annotated[ + bool | None, + Query( + title="Show library projects", + description="If not provided, return projects regardless the flag", + ), + ] = None, + active: Annotated[ + bool | None, + Query( + title="Show active projects", + description="If not provided, return projects regardless the flag", + ), + ] = None, + order: Annotated[ + Literal["name", "createdAt", "updatedAt"] | None, + Query( + title="Order by", + description="Attribute to order the list by", + ), + ] = None, + desc: Annotated[ + bool, + Query( + title="Sort in descending order", + ), + ] = False, + name: Annotated[ + str | None, + Query( + title="Filter by name", + description="""Limit the result to project with the matching name, or its part. % character may be used as a wildcard""", - example="forest", - regex=NAME_REGEX, - ), + example="forest", + regex=NAME_REGEX, + ), + ] = None, ) -> ListProjectsResponseModel: """ Return a list of available projects. @@ -83,6 +120,8 @@ async def list_projects( projects = [] conditions = [] + pinned = user.data.get("frontendPreferences", {}).get("pinnedProjects", []) + if library is not None: conditions.append(f"library IS {'TRUE' if library else 'FALSE'}") if active is not None: @@ -113,6 +152,7 @@ async def list_projects( COUNT(name) OVER () AS count, name, code, + library, created_at, updated_at, active, @@ -147,16 +187,23 @@ async def list_projects( ListProjectsItemModel( name=row["name"], code=row["code"], - createdAt=row["created_at"], - updatedAt=row["updated_at"], + created_at=row["created_at"], + updated_at=row["updated_at"], active=row.get("active", True), + project_folder=row["data"].get("projectFolder"), + library=row.get("library", False), + pinned=row["name"] in pinned, ) ) if not projects: # No project is found (this includes the case # where the page is out of range) - return ListProjectsResponseModel(detail="No projects", count=count) + return ListProjectsResponseModel( + detail="No projects", + count=count, + projects=[], + ) return ListProjectsResponseModel( detail=f"Showing {len(projects)} of {count} projects", diff --git a/ayon_server/graphql/nodes/project.py b/ayon_server/graphql/nodes/project.py index 375ee1597..d2b5e5424 100644 --- a/ayon_server/graphql/nodes/project.py +++ b/ayon_server/graphql/nodes/project.py @@ -118,6 +118,7 @@ class ProjectNode: name: str = strawberry.field() project_name: str = strawberry.field() code: str = strawberry.field() + project_folder: str | None = strawberry.field() data: str | None config: str | None active: bool @@ -426,6 +427,7 @@ async def project_from_record( data=json_dumps(data) if data else None, config=json_dumps(config) if config else None, bundle=bundle, + project_folder=record.get("project_folder", None), created_at=record["created_at"], updated_at=record["updated_at"], _user=context["user"], diff --git a/ayon_server/graphql/resolvers/projects.py b/ayon_server/graphql/resolvers/projects.py index c0897f8e8..0d8ba5b2c 100644 --- a/ayon_server/graphql/resolvers/projects.py +++ b/ayon_server/graphql/resolvers/projects.py @@ -58,6 +58,7 @@ async def get_projects( "active", "created_at", "updated_at", + "data->'projectFolder' AS project_folder", ] if fields.has_any("config"): diff --git a/schemas/schema.public.sql b/schemas/schema.public.sql index 48c0dffeb..8a7c355de 100644 --- a/schemas/schema.public.sql +++ b/schemas/schema.public.sql @@ -35,6 +35,16 @@ CREATE TABLE IF NOT EXISTS public.projects( CREATE UNIQUE INDEX IF NOT EXISTS projectname_idx ON public.projects(LOWER(name)); CREATE UNIQUE INDEX IF NOT EXISTS projectcode_idx ON public.projects(LOWER(code)); +CREATE TABLE project_folders ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + label VARCHAR NOT NULL, + position INTEGER NOT NULL DEFAULT 0, + parent_id UUID REFERENCES project_folders(id) ON DELETE CASCADE, + data JSONB DEFAULT '{}'::JSONB +); + +CREATE UNIQUE INDEX uq_project_folder_parent_label ON project_folders(COALESCE(parent_id::varchar, ''), LOWER(label)); + -- Users CREATE TABLE IF NOT EXISTS public.users(