From 299b5f71c0afc789ee4f7ac210efde339647a56d Mon Sep 17 00:00:00 2001 From: Martastain Date: Tue, 16 Dec 2025 10:06:29 +0100 Subject: [PATCH 01/12] chore: add project_folders table --- schemas/schema.public.sql | 10 ++++++++++ 1 file changed, 10 insertions(+) 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( From 8af9d35603775c6e7ae77055cc4b1e27ec2da7da Mon Sep 17 00:00:00 2001 From: Martastain Date: Tue, 16 Dec 2025 10:06:47 +0100 Subject: [PATCH 02/12] feat: add projectFolders endpoints --- api/projects/__init__.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/api/projects/__init__.py b/api/projects/__init__.py index d16ab47bc..8368c3ba6 100644 --- a/api/projects/__init__.py +++ b/api/projects/__init__.py @@ -6,6 +6,7 @@ "guests", "list_projects", "product_types", + "project_folders", "projects", "roots", "tags", @@ -19,6 +20,7 @@ guests, list_projects, product_types, + project_folders, projects, roots, tags, From a60c271a55286ea5cc509c66a81b034ec71e703d Mon Sep 17 00:00:00 2001 From: Martastain Date: Tue, 16 Dec 2025 10:17:52 +0100 Subject: [PATCH 03/12] feat: project folders endpoints and graphql support --- api/projects/list_projects.py | 2 + api/projects/project_folders.py | 207 ++++++++++++++++++++++ ayon_server/graphql/nodes/project.py | 2 + ayon_server/graphql/resolvers/projects.py | 1 + 4 files changed, 212 insertions(+) create mode 100644 api/projects/project_folders.py diff --git a/api/projects/list_projects.py b/api/projects/list_projects.py index 0943f5f81..4ff83ebab 100644 --- a/api/projects/list_projects.py +++ b/api/projects/list_projects.py @@ -17,6 +17,7 @@ class ListProjectsItemModel(OPModel): name: str = Field(..., title="Project name") code: str = Field(..., title="Project code") active: bool = Field(..., title="Project is active") + project_folder: str | None = Field(None, title="Project folder id") createdAt: datetime = Field(..., title="Creation time") updatedAt: datetime = Field(..., title="Last modified time") @@ -150,6 +151,7 @@ async def list_projects( createdAt=row["created_at"], updatedAt=row["updated_at"], active=row.get("active", True), + project_folder=row["data"].get("projectFolder"), ) ) diff --git a/api/projects/project_folders.py b/api/projects/project_folders.py new file mode 100644 index 000000000..b4d6f36f3 --- /dev/null +++ b/api/projects/project_folders.py @@ -0,0 +1,207 @@ +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.lib.postgres import Postgres +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_entity_list_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: + async with Postgres.transaction(): + res = await Postgres.fetchrow( + "SELECT owner 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 delete this folder") + + await Postgres.execute( + "DELETE FROM project_folders WHERE id = $1", + folder_id, + ) + + return EmptyResponse() + + +class EntityListFolderOrderModel(OPModel): + order: Annotated[ + list[str], + Field( + title="Ordered list of folder IDs", + min_items=1, + ), + ] + + +@router.post("/projectFolders/order") +async def set_entity_list_folders_order( + user: CurrentUser, + payload: EntityListFolderOrderModel, +) -> 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 entity_list_folders + SET position = $2 + WHERE id = $1 + """, + folder_id, + position, + ) + + return EmptyResponse() 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"): From f388568323e7286887ed7ad27644c670c281a05f Mon Sep 17 00:00:00 2001 From: Martastain Date: Tue, 16 Dec 2025 11:19:49 +0100 Subject: [PATCH 04/12] refactor: split project folder router --- api/project_folders/__init__.py | 7 +++++++ api/{projects => project_folders}/project_folders.py | 0 api/project_folders/router.py | 3 +++ api/projects/__init__.py | 2 -- 4 files changed, 10 insertions(+), 2 deletions(-) create mode 100644 api/project_folders/__init__.py rename api/{projects => project_folders}/project_folders.py (100%) create mode 100644 api/project_folders/router.py 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/projects/project_folders.py b/api/project_folders/project_folders.py similarity index 100% rename from api/projects/project_folders.py rename to api/project_folders/project_folders.py 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/__init__.py b/api/projects/__init__.py index 8368c3ba6..d16ab47bc 100644 --- a/api/projects/__init__.py +++ b/api/projects/__init__.py @@ -6,7 +6,6 @@ "guests", "list_projects", "product_types", - "project_folders", "projects", "roots", "tags", @@ -20,7 +19,6 @@ guests, list_projects, product_types, - project_folders, projects, roots, tags, From 41652a6d447f9fb10bcd8440a086490ab2a3bc8a Mon Sep 17 00:00:00 2001 From: Martastain Date: Tue, 16 Dec 2025 12:40:00 +0100 Subject: [PATCH 05/12] refactor: project list, add library flag --- api/projects/list_projects.py | 153 +++++++++++++++++++++------------- 1 file changed, 97 insertions(+), 56 deletions(-) diff --git a/api/projects/list_projects.py b/api/projects/list_projects.py index 4ff83ebab..5d5204414 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,67 +14,102 @@ class ListProjectsItemModel(OPModel): - name: str = Field(..., title="Project name") - code: str = Field(..., title="Project code") - active: bool = Field(..., title="Project is active") - project_folder: str | None = Field(None, title="Project folder id") - 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 + 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. @@ -114,6 +149,7 @@ async def list_projects( COUNT(name) OVER () AS count, name, code, + library, created_at, updated_at, active, @@ -148,17 +184,22 @@ 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), ) ) 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", From ddb8b6528ce3d58f797891f7255fb8efad7b54fb Mon Sep 17 00:00:00 2001 From: Martastain Date: Tue, 16 Dec 2025 13:24:39 +0100 Subject: [PATCH 06/12] feat: add flag to list projects endpoint --- api/projects/list_projects.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/api/projects/list_projects.py b/api/projects/list_projects.py index 5d5204414..e91dad18c 100644 --- a/api/projects/list_projects.py +++ b/api/projects/list_projects.py @@ -18,6 +18,7 @@ class ListProjectsItemModel(OPModel): 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")] @@ -119,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: @@ -189,6 +192,7 @@ async def list_projects( active=row.get("active", True), project_folder=row["data"].get("projectFolder"), library=row.get("library", False), + pinned=row["name"] in pinned, ) ) From 023ecc4e4746b1c75fcbde8281e96cf42ecbfa7e Mon Sep 17 00:00:00 2001 From: Martastain Date: Tue, 16 Dec 2025 13:32:26 +0100 Subject: [PATCH 07/12] feat: add projectFolders/assign endpoint --- api/project_folders/project_folders.py | 48 ++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/api/project_folders/project_folders.py b/api/project_folders/project_folders.py index b4d6f36f3..f6e77f467 100644 --- a/api/project_folders/project_folders.py +++ b/api/project_folders/project_folders.py @@ -10,7 +10,9 @@ 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 @@ -205,3 +207,49 @@ async def set_entity_list_folders_order( ) 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) + + await Postgres.execute( + """ + UPDATE projects + SET data = jsonb_set( + COALESCE(data, '{}'::jsonb), + '{projectFolder}', + to_jsonb($2::text) + ) + WHERE name = $1 + """, + project_name, + ) + await Redis.delete("project-data", project_name) + + return EmptyResponse() From 342adb540dc101cdeda60326e84eff2f15a5d7dd Mon Sep 17 00:00:00 2001 From: Martastain Date: Tue, 16 Dec 2025 13:35:28 +0100 Subject: [PATCH 08/12] fix: validate folder id --- api/project_folders/project_folders.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/api/project_folders/project_folders.py b/api/project_folders/project_folders.py index f6e77f467..b9181780c 100644 --- a/api/project_folders/project_folders.py +++ b/api/project_folders/project_folders.py @@ -238,6 +238,10 @@ async def assign_projects_to_folder( for project_name_input in payload.project_names: project_name = await normalize_project_name(project_name_input) + # ensure id is valid (32 hex characters) + + folder_id = EntityID.parse(payload.folder_id, allow_nulls=True) + await Postgres.execute( """ UPDATE projects @@ -249,6 +253,7 @@ async def assign_projects_to_folder( WHERE name = $1 """, project_name, + folder_id, ) await Redis.delete("project-data", project_name) From 6735270ec122a211f652599ef969e2bd60b37a1b Mon Sep 17 00:00:00 2001 From: Martastain Date: Tue, 16 Dec 2025 13:37:00 +0100 Subject: [PATCH 09/12] fix: model names --- api/project_folders/project_folders.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/api/project_folders/project_folders.py b/api/project_folders/project_folders.py index b9181780c..6169f328c 100644 --- a/api/project_folders/project_folders.py +++ b/api/project_folders/project_folders.py @@ -85,7 +85,7 @@ async def get_project_folders(user: CurrentUser) -> ProjectFoldersResponseModel: @router.post("/projectFolders") -async def create_entity_list_folder( +async def create_project_folder( user: CurrentUser, payload: ProjectFolderPostModel, ) -> EntityIdResponse: @@ -176,7 +176,7 @@ async def delete_project_folder( return EmptyResponse() -class EntityListFolderOrderModel(OPModel): +class ProjectFolderOrderModel(OPModel): order: Annotated[ list[str], Field( @@ -187,9 +187,9 @@ class EntityListFolderOrderModel(OPModel): @router.post("/projectFolders/order") -async def set_entity_list_folders_order( +async def set_project_folders_order( user: CurrentUser, - payload: EntityListFolderOrderModel, + payload: ProjectFolderOrderModel, ) -> EmptyResponse: if not user.is_admin: raise ForbiddenException("You don't have permission to reorder project folders") @@ -198,7 +198,7 @@ async def set_entity_list_folders_order( for position, folder_id in enumerate(payload.order): await Postgres.execute( """ - UPDATE entity_list_folders + UPDATE project_folders SET position = $2 WHERE id = $1 """, From 980ab51d98df9518fe50503495e2b85e1b7ce789 Mon Sep 17 00:00:00 2001 From: Martin Wacker Date: Tue, 16 Dec 2025 16:18:59 +0100 Subject: [PATCH 10/12] Update api/project_folders/project_folders.py MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Filip Vnenčák --- api/project_folders/project_folders.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/project_folders/project_folders.py b/api/project_folders/project_folders.py index 6169f328c..1bf1ee789 100644 --- a/api/project_folders/project_folders.py +++ b/api/project_folders/project_folders.py @@ -97,7 +97,7 @@ async def create_project_folder( """ INSERT INTO project_folders (id, label, parent_id, data) - VALUES ($1, $2, $3, $4,) + VALUES ($1, $2, $3, $4) """, payload.id, payload.label, From d919235df547d16b68fdd2e882b7a935b3b78a69 Mon Sep 17 00:00:00 2001 From: Martastain Date: Tue, 16 Dec 2025 16:44:20 +0100 Subject: [PATCH 11/12] fix: clean-up --- api/project_folders/project_folders.py | 24 ++++++------------------ 1 file changed, 6 insertions(+), 18 deletions(-) diff --git a/api/project_folders/project_folders.py b/api/project_folders/project_folders.py index 1bf1ee789..0cd7b4ad2 100644 --- a/api/project_folders/project_folders.py +++ b/api/project_folders/project_folders.py @@ -157,22 +157,12 @@ async def delete_project_folder( user: CurrentUser, folder_id: FolderID, ) -> EmptyResponse: - async with Postgres.transaction(): - res = await Postgres.fetchrow( - "SELECT owner 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 delete this folder") - - await Postgres.execute( - "DELETE FROM project_folders WHERE id = $1", - folder_id, - ) - + 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() @@ -238,8 +228,6 @@ async def assign_projects_to_folder( for project_name_input in payload.project_names: project_name = await normalize_project_name(project_name_input) - # ensure id is valid (32 hex characters) - folder_id = EntityID.parse(payload.folder_id, allow_nulls=True) await Postgres.execute( From f8bbc2a57737b1386f42f4608945cc3c852d229b Mon Sep 17 00:00:00 2001 From: Martastain Date: Thu, 18 Dec 2025 12:04:10 +0100 Subject: [PATCH 12/12] fix: unlinking project folder --- api/project_folders/project_folders.py | 34 +++++++++++++++++--------- 1 file changed, 22 insertions(+), 12 deletions(-) diff --git a/api/project_folders/project_folders.py b/api/project_folders/project_folders.py index 0cd7b4ad2..8c60b5a0a 100644 --- a/api/project_folders/project_folders.py +++ b/api/project_folders/project_folders.py @@ -230,19 +230,29 @@ async def assign_projects_to_folder( folder_id = EntityID.parse(payload.folder_id, allow_nulls=True) - await Postgres.execute( - """ - UPDATE projects - SET data = jsonb_set( - COALESCE(data, '{}'::jsonb), - '{projectFolder}', - to_jsonb($2::text) + 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, ) - WHERE name = $1 - """, - project_name, - folder_id, - ) await Redis.delete("project-data", project_name) return EmptyResponse()