From 9473ad2bf6ddc081add451238bdbf866eedbc707 Mon Sep 17 00:00:00 2001 From: sunder Date: Mon, 20 Jul 2026 18:32:11 +0530 Subject: [PATCH 1/6] feat: add Collections resource client Adds a full Collections resource (create/list/retrieve/update/delete collections, add/list/search/move/remove pages within a collection, and collection member CRUD) matching the new external Collections API, plus `collection_id`/`parent_id` on CreatePage so pages can be created directly inside a collection or as a sub-page. --- plane/api/base_resource.py | 17 ++- plane/api/collections.py | 242 +++++++++++++++++++++++++++++++++ plane/client/plane_client.py | 2 + plane/models/collections.py | 155 +++++++++++++++++++++ plane/models/pages.py | 3 +- plane/models/query_params.py | 20 +++ tests/unit/test_collections.py | 205 ++++++++++++++++++++++++++++ 7 files changed, 638 insertions(+), 6 deletions(-) create mode 100644 plane/api/collections.py create mode 100644 plane/models/collections.py create mode 100644 tests/unit/test_collections.py diff --git a/plane/api/base_resource.py b/plane/api/base_resource.py index bd8bb4a..db4f2c0 100644 --- a/plane/api/base_resource.py +++ b/plane/api/base_resource.py @@ -36,9 +36,7 @@ def _get(self, endpoint: str, params: Mapping[str, Any] | None = None) -> Any: ) return self._handle_response(response) - def _post( - self, endpoint: str, data: Mapping[str, Any] | list[Any] | None = None - ) -> Any: + def _post(self, endpoint: str, data: Mapping[str, Any] | list[Any] | None = None) -> Any: url = self._build_url(endpoint) response = self.session.post( url, headers=self._headers(), json=data, timeout=self.config.timeout @@ -59,10 +57,19 @@ def _patch(self, endpoint: str, data: Mapping[str, Any] | None = None) -> Any: ) return self._handle_response(response) - def _delete(self, endpoint: str, data: Mapping[str, Any] | None = None) -> None: + def _delete( + self, + endpoint: str, + data: Mapping[str, Any] | None = None, + params: Mapping[str, Any] | None = None, + ) -> None: url = self._build_url(endpoint) response = self.session.delete( - url, headers=self._headers(), json=data, timeout=self.config.timeout + url, + headers=self._headers(), + json=data, + params=params, + timeout=self.config.timeout, ) self._handle_response(response) diff --git a/plane/api/collections.py b/plane/api/collections.py new file mode 100644 index 0000000..4caff31 --- /dev/null +++ b/plane/api/collections.py @@ -0,0 +1,242 @@ +from typing import Any + +from ..models.collections import ( + AddCollectionPages, + Collection, + CollectionMember, + CollectionPage, + CollectionPageSearchResult, + CreateCollection, + CreateCollectionMember, + PaginatedCollectionPageResponse, + UpdateCollection, + UpdateCollectionMember, + UpdateCollectionPage, +) +from ..models.query_params import CollectionPageQueryParams +from .base_resource import BaseResource + + +class Collections(BaseResource): + def __init__(self, config: Any) -> None: + super().__init__(config, "/workspaces/") + + # Collections + + def list_collections(self, workspace_slug: str) -> list[Collection]: + """List all collections in a workspace. + + Args: + workspace_slug: The workspace slug identifier + """ + response = self._get(f"{workspace_slug}/collections") + return [Collection.model_validate(item) for item in response] + + def create_collection(self, workspace_slug: str, data: CreateCollection) -> Collection: + """Create a new collection in a workspace. + + Args: + workspace_slug: The workspace slug identifier + data: Collection data + """ + response = self._post(f"{workspace_slug}/collections", data.model_dump(exclude_none=True)) + return Collection.model_validate(response) + + def retrieve_collection(self, workspace_slug: str, collection_id: str) -> Collection: + """Retrieve a collection by ID. + + Args: + workspace_slug: The workspace slug identifier + collection_id: UUID of the collection + """ + response = self._get(f"{workspace_slug}/collections/{collection_id}") + return Collection.model_validate(response) + + def update_collection( + self, workspace_slug: str, collection_id: str, data: UpdateCollection + ) -> Collection: + """Update a collection's name, logo, or sort order. + + Args: + workspace_slug: The workspace slug identifier + collection_id: UUID of the collection + data: Fields to update (access cannot be changed after creation) + """ + response = self._patch( + f"{workspace_slug}/collections/{collection_id}", + data.model_dump(exclude_none=True), + ) + return Collection.model_validate(response) + + def delete_collection( + self, + workspace_slug: str, + collection_id: str, + archive_pages: bool | None = None, + ) -> None: + """Delete a collection. + + Args: + workspace_slug: The workspace slug identifier + collection_id: UUID of the collection + archive_pages: Whether to archive the collection's pages instead of + leaving them unfiled. Omit to use the server's default (True). + Private collections always archive their pages regardless. + """ + params = None + if archive_pages is not None: + params = {"archive_pages": "true" if archive_pages else "false"} + return self._delete(f"{workspace_slug}/collections/{collection_id}", params=params) + + # Pages within a collection + + def list_collection_pages( + self, + workspace_slug: str, + collection_id: str, + params: CollectionPageQueryParams | None = None, + ) -> PaginatedCollectionPageResponse: + """List pages that belong to a collection. + + Args: + workspace_slug: The workspace slug identifier + collection_id: UUID of the collection + params: Optional search/parent_id/pagination filters + """ + query_params = params.model_dump(exclude_none=True) if params else None + response = self._get( + f"{workspace_slug}/collections/{collection_id}/pages", params=query_params + ) + return PaginatedCollectionPageResponse.model_validate(response) + + def add_collection_pages( + self, workspace_slug: str, collection_id: str, data: AddCollectionPages + ) -> list[CollectionPage]: + """Add existing page(s) to a collection. + + Args: + workspace_slug: The workspace slug identifier + collection_id: UUID of the collection + data: Page IDs to add, with optional sort_orders/placement + """ + response = self._post( + f"{workspace_slug}/collections/{collection_id}/pages", + data.model_dump(exclude_none=True), + ) + return [CollectionPage.model_validate(item) for item in response] + + def search_addable_collection_pages( + self, workspace_slug: str, collection_id: str, search: str | None = None + ) -> list[CollectionPageSearchResult]: + """Search pages that are not yet in a collection, to add them. + + Args: + workspace_slug: The workspace slug identifier + collection_id: UUID of the collection + search: Optional case-insensitive substring filter on page name + """ + query_params = {"search": search} if search else None + response = self._get( + f"{workspace_slug}/collections/{collection_id}/pages-search", + params=query_params, + ) + return [CollectionPageSearchResult.model_validate(item) for item in response] + + def update_collection_page( + self, + workspace_slug: str, + collection_id: str, + page_collection_id: str, + data: UpdateCollectionPage, + ) -> CollectionPage: + """Move a page to a different collection, or reorder it within the current one. + + Args: + workspace_slug: The workspace slug identifier + collection_id: UUID of the page's current collection + page_collection_id: UUID of the page-collection membership row + data: `collection` to move (omit/leave unset to just reorder), + and/or `sort_order`/`placement` to reorder + """ + response = self._patch( + f"{workspace_slug}/collections/{collection_id}/pages/{page_collection_id}", + data.model_dump(exclude_none=True), + ) + return CollectionPage.model_validate(response) + + def remove_collection_page( + self, workspace_slug: str, collection_id: str, page_collection_id: str + ) -> None: + """Remove a page from a collection (does not delete the page itself). + + Args: + workspace_slug: The workspace slug identifier + collection_id: UUID of the collection + page_collection_id: UUID of the page-collection membership row + """ + return self._delete( + f"{workspace_slug}/collections/{collection_id}/pages/{page_collection_id}" + ) + + # Collection members + + def list_collection_members( + self, workspace_slug: str, collection_id: str + ) -> list[CollectionMember]: + """List members of a (typically private) collection. + + Args: + workspace_slug: The workspace slug identifier + collection_id: UUID of the collection + """ + response = self._get(f"{workspace_slug}/collections/{collection_id}/members") + return [CollectionMember.model_validate(item) for item in response] + + def add_collection_member( + self, workspace_slug: str, collection_id: str, data: CreateCollectionMember + ) -> CollectionMember: + """Add a member to a collection. + + Args: + workspace_slug: The workspace slug identifier + collection_id: UUID of the collection + data: Member user id and access level + """ + response = self._post( + f"{workspace_slug}/collections/{collection_id}/members", + data.model_dump(exclude_none=True), + ) + return CollectionMember.model_validate(response) + + def update_collection_member( + self, + workspace_slug: str, + collection_id: str, + member_id: str, + data: UpdateCollectionMember, + ) -> CollectionMember: + """Update a collection member's access level. + + Args: + workspace_slug: The workspace slug identifier + collection_id: UUID of the collection + member_id: UUID of the CollectionMember row (not the user id) + data: New access level + """ + response = self._patch( + f"{workspace_slug}/collections/{collection_id}/members/{member_id}", + data.model_dump(exclude_none=True), + ) + return CollectionMember.model_validate(response) + + def remove_collection_member( + self, workspace_slug: str, collection_id: str, member_id: str + ) -> None: + """Remove a member from a collection. + + Args: + workspace_slug: The workspace slug identifier + collection_id: UUID of the collection + member_id: UUID of the CollectionMember row (not the user id) + """ + return self._delete(f"{workspace_slug}/collections/{collection_id}/members/{member_id}") diff --git a/plane/client/plane_client.py b/plane/client/plane_client.py index 121df15..4e565c1 100644 --- a/plane/client/plane_client.py +++ b/plane/client/plane_client.py @@ -1,4 +1,5 @@ from ..api.agent_runs import AgentRuns +from ..api.collections import Collections from ..api.customers import Customers from ..api.cycles import Cycles from ..api.epics import Epics @@ -61,6 +62,7 @@ def __init__( self.epics = Epics(self.config) self.work_items = WorkItems(self.config) self.pages = Pages(self.config) + self.collections = Collections(self.config) self.labels = Labels(self.config) self.states = States(self.config) self.milestones = Milestones(self.config) diff --git a/plane/models/collections.py b/plane/models/collections.py new file mode 100644 index 0000000..9407545 --- /dev/null +++ b/plane/models/collections.py @@ -0,0 +1,155 @@ +from typing import Any + +from pydantic import BaseModel, ConfigDict + +from .pagination import PaginatedResponse + + +class Collection(BaseModel): + """Collection model (a folder that groups workspace pages).""" + + model_config = ConfigDict(extra="allow", populate_by_name=True) + + id: str | None = None + name: str | None = None + owned_by_id: str | None = None + access: int | None = None + current_user_access: int | None = None + has_pages: bool | None = None + is_default: bool | None = None + is_global: bool | None = None + logo_props: Any | None = None + sort_order: float | None = None + workspace: str | None = None + created_at: str | None = None + updated_at: str | None = None + created_by: str | None = None + updated_by: str | None = None + + +class CreateCollection(BaseModel): + """Request model for creating a collection.""" + + model_config = ConfigDict(extra="ignore", populate_by_name=True) + + name: str + access: int | None = None + logo_props: Any | None = None + + +class UpdateCollection(BaseModel): + """Request model for updating a collection. + + Deliberately has no `access` field -- the API rejects (400) any attempt to + change a collection's access level after creation. + """ + + model_config = ConfigDict(extra="ignore", populate_by_name=True) + + name: str | None = None + logo_props: Any | None = None + sort_order: float | None = None + + +class CollectionMember(BaseModel): + """Collection membership record.""" + + model_config = ConfigDict(extra="allow", populate_by_name=True) + + id: str | None = None + collection: str | None = None + member: str | None = None + access: int | None = None + workspace: str | None = None + created_at: str | None = None + updated_at: str | None = None + created_by: str | None = None + updated_by: str | None = None + + +class CreateCollectionMember(BaseModel): + """Request model for adding a collection member.""" + + model_config = ConfigDict(extra="ignore", populate_by_name=True) + + member: str + access: int + + +class UpdateCollectionMember(BaseModel): + """Request model for updating a collection member's access level.""" + + model_config = ConfigDict(extra="ignore", populate_by_name=True) + + access: int + + +class CollectionPage(BaseModel): + """Flat page-collection membership record, returned by the move/reorder endpoint.""" + + model_config = ConfigDict(extra="allow", populate_by_name=True) + + id: str | None = None + collection: str | None = None + page: str | None = None + workspace: str | None = None + sort_order: float | None = None + created_at: str | None = None + updated_at: str | None = None + created_by: str | None = None + updated_by: str | None = None + + +class CollectionBranchPage(BaseModel): + """A row in the list-pages-in-collection response, with a nested page object.""" + + model_config = ConfigDict(extra="allow", populate_by_name=True) + + page_collection_id: str | None = None + collection_id: str | None = None + parent_id: str | None = None + sort_order: float | None = None + page: dict[str, Any] | None = None + + +class AddCollectionPages(BaseModel): + """Request model for adding existing page(s) to a collection.""" + + model_config = ConfigDict(extra="ignore", populate_by_name=True) + + page_ids: list[str] + sort_orders: dict[str, float] | None = None + placement: dict[str, Any] | None = None + + +class UpdateCollectionPage(BaseModel): + """Request model for moving/reordering a page within or between collections. + + Omit `collection` entirely (leave it unset, do not pass None) to reorder the + page within its current collection -- setting it triggers a move to that + target collection. + """ + + model_config = ConfigDict(extra="ignore", populate_by_name=True) + + collection: str | None = None + sort_order: float | None = None + placement: dict[str, Any] | None = None + + +class CollectionPageSearchResult(BaseModel): + """A page eligible to be added to a collection.""" + + model_config = ConfigDict(extra="allow", populate_by_name=True) + + id: str | None = None + name: str | None = None + logo_props: Any | None = None + + +class PaginatedCollectionPageResponse(PaginatedResponse): + """Paginated response for pages within a collection.""" + + model_config = ConfigDict(extra="allow", populate_by_name=True) + + results: list[CollectionBranchPage] diff --git a/plane/models/pages.py b/plane/models/pages.py index c19e3b7..1058fb1 100644 --- a/plane/models/pages.py +++ b/plane/models/pages.py @@ -40,6 +40,8 @@ class CreatePage(BaseModel): logo_props: Any | None = None external_id: str | None = None external_source: str | None = None + parent_id: str | None = None + collection_id: str | None = None class UpdatePage(BaseModel): @@ -65,4 +67,3 @@ class PaginatedPageResponse(PaginatedResponse): model_config = ConfigDict(extra="allow", populate_by_name=True) results: list[Page] - diff --git a/plane/models/query_params.py b/plane/models/query_params.py index 188835d..c8458d2 100644 --- a/plane/models/query_params.py +++ b/plane/models/query_params.py @@ -51,6 +51,25 @@ class PaginatedQueryParams(BaseQueryParams): ) +class CollectionPageQueryParams(PaginatedQueryParams): + """Query parameters for the list-pages-in-collection endpoint. + + Adds a name search filter and a parent-page filter on top of the standard + pagination params. + """ + + model_config = ConfigDict(extra="ignore", populate_by_name=True) + + search: str | None = Field(None, description="Case-insensitive substring filter on page name") + parent_id: str | None = Field( + None, + description=( + "Filter to direct children of this page within the collection. " + "Omit to return only top-level (non-sub) pages." + ), + ) + + class WorkItemQueryParams(PaginatedQueryParams): """Query parameters for work item list endpoints. @@ -327,6 +346,7 @@ class WorkItemCountQueryParams(BaseModel): __all__ = [ "BaseQueryParams", + "CollectionPageQueryParams", "CycleLiteListQueryParams", "CycleListQueryParams", "LiteListQueryParams", diff --git a/tests/unit/test_collections.py b/tests/unit/test_collections.py new file mode 100644 index 0000000..09a2ada --- /dev/null +++ b/tests/unit/test_collections.py @@ -0,0 +1,205 @@ +"""Unit tests for Collections API resource (smoke tests with real HTTP requests). + +Requires the target workspace to have the WORKSPACE_PAGES (and, for the +private-collection cases, PRIVATE_COLLECTIONS) feature flags enabled. +""" + +import time + +from plane.client import PlaneClient +from plane.models.collections import ( + AddCollectionPages, + Collection, + CreateCollection, + PaginatedCollectionPageResponse, + UpdateCollection, + UpdateCollectionMember, + UpdateCollectionPage, +) +from plane.models.pages import CreatePage + + +def _collection_name(prefix: str) -> str: + return f"{prefix} {int(time.time() * 1000)}" + + +class TestCollectionsAPI: + """Test Collections CRUD.""" + + def test_collection_crud(self, client: PlaneClient, workspace_slug: str) -> None: + created = client.collections.create_collection( + workspace_slug, CreateCollection(name=_collection_name("SDK Collection")) + ) + assert created is not None + assert created.id is not None + + try: + collections = client.collections.list_collections(workspace_slug) + assert isinstance(collections, list) + assert any(c.id == created.id for c in collections) + + retrieved = client.collections.retrieve_collection(workspace_slug, created.id) + assert isinstance(retrieved, Collection) + assert retrieved.id == created.id + + updated = client.collections.update_collection( + workspace_slug, + created.id, + UpdateCollection(name=f"{created.name} (updated)", sort_order=25000), + ) + assert updated.name == f"{created.name} (updated)" + finally: + client.collections.delete_collection(workspace_slug, created.id, archive_pages=False) + + collections_after = client.collections.list_collections(workspace_slug) + assert not any(c.id == created.id for c in collections_after) + + +class TestCollectionPagesAPI: + """Test page-in-collection operations: add, list, search, move, remove.""" + + def test_add_list_search_move_and_remove_pages( + self, client: PlaneClient, workspace_slug: str + ) -> None: + source = client.collections.create_collection( + workspace_slug, CreateCollection(name=_collection_name("Source Collection")) + ) + target = client.collections.create_collection( + workspace_slug, CreateCollection(name=_collection_name("Target Collection")) + ) + page = client.pages.create_workspace_page( + workspace_slug, + CreatePage( + name=_collection_name("Collection Page"), + description_html="

collection sdk test

", + ), + ) + + try: + search_results = client.collections.search_addable_collection_pages( + workspace_slug, source.id, search=page.name + ) + assert any(r.id == page.id for r in search_results) + + added = client.collections.add_collection_pages( + workspace_slug, source.id, AddCollectionPages(page_ids=[page.id]) + ) + assert any(pc.page == page.id for pc in added) + + listed = client.collections.list_collection_pages(workspace_slug, source.id) + assert isinstance(listed, PaginatedCollectionPageResponse) + matching = [row for row in listed.results if row.page and row.page.get("id") == page.id] + assert len(matching) == 1 + page_collection_id = matching[0].page_collection_id + assert page_collection_id is not None + + moved = client.collections.update_collection_page( + workspace_slug, + source.id, + page_collection_id, + UpdateCollectionPage(collection=target.id), + ) + assert moved.collection == target.id + + client.collections.remove_collection_page(workspace_slug, target.id, page_collection_id) + listed_after = client.collections.list_collection_pages(workspace_slug, target.id) + assert not any( + row.page and row.page.get("id") == page.id for row in listed_after.results + ) + finally: + try: + client.pages.delete_workspace_page(workspace_slug, page.id) + except Exception: + pass + for collection in (source, target): + try: + client.collections.delete_collection( + workspace_slug, collection.id, archive_pages=False + ) + except Exception: + pass + + def test_create_page_in_collection_and_sub_page( + self, client: PlaneClient, workspace_slug: str + ) -> None: + """Create page directly via CreatePage(collection_id=...), plus a sub-page.""" + collection = client.collections.create_collection( + workspace_slug, CreateCollection(name=_collection_name("Parent Collection")) + ) + parent_page = None + child_page = None + try: + parent_page = client.pages.create_workspace_page( + workspace_slug, + CreatePage( + name=_collection_name("Parent Page"), + description_html="

parent

", + collection_id=collection.id, + ), + ) + assert parent_page is not None + + child_page = client.pages.create_workspace_page( + workspace_slug, + CreatePage( + name=_collection_name("Child Page"), + description_html="

child

", + parent_id=parent_page.id, + ), + ) + assert child_page is not None + + listed = client.collections.list_collection_pages(workspace_slug, collection.id) + page_ids = {row.page.get("id") for row in listed.results if row.page} + assert parent_page.id in page_ids + assert child_page.id in page_ids + finally: + for created_page in (child_page, parent_page): + if created_page is not None: + try: + client.pages.delete_workspace_page(workspace_slug, created_page.id) + except Exception: + pass + try: + client.collections.delete_collection( + workspace_slug, collection.id, archive_pages=False + ) + except Exception: + pass + + +class TestCollectionMembersAPI: + """Test collection membership CRUD (private collections). + + Creating a private collection auto-adds its creator as an EDIT member + (server-side), so this test exercises list/update against that + auto-created row rather than adding a second membership for the same + user (which the API rejects as a duplicate). + """ + + def test_collection_member_crud(self, client: PlaneClient, workspace_slug: str) -> None: + collection = client.collections.create_collection( + workspace_slug, + CreateCollection(name=_collection_name("Private Collection"), access=1), + ) + try: + members = client.collections.list_collection_members(workspace_slug, collection.id) + assert isinstance(members, list) + assert len(members) == 1 + owner_member = members[0] + assert owner_member.access == 2 # EDIT, auto-assigned to the creator + + updated_member = client.collections.update_collection_member( + workspace_slug, + collection.id, + owner_member.id, + UpdateCollectionMember(access=0), + ) + assert updated_member.access == 0 + finally: + try: + client.collections.delete_collection( + workspace_slug, collection.id, archive_pages=False + ) + except Exception: + pass From 78a0304bb9f6ad3b6cdaadf7a763162a2b730698 Mon Sep 17 00:00:00 2001 From: sunder Date: Mon, 20 Jul 2026 19:30:04 +0530 Subject: [PATCH 2/6] =?UTF-8?q?fix(tests):=20assert=20sub-page=20via=20par?= =?UTF-8?q?ent=5Fid=20filter=20=E2=80=94=20unfiltered=20collection=20listi?= =?UTF-8?q?ng=20returns=20root-branch=20pages=20only?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/unit/test_collections.py | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/tests/unit/test_collections.py b/tests/unit/test_collections.py index 09a2ada..4c85f19 100644 --- a/tests/unit/test_collections.py +++ b/tests/unit/test_collections.py @@ -17,6 +17,7 @@ UpdateCollectionPage, ) from plane.models.pages import CreatePage +from plane.models.query_params import CollectionPageQueryParams def _collection_name(prefix: str) -> str: @@ -149,10 +150,20 @@ def test_create_page_in_collection_and_sub_page( ) assert child_page is not None + # The unfiltered listing returns only root-branch pages; sub-pages are + # listed under their parent via the parent_id filter. listed = client.collections.list_collection_pages(workspace_slug, collection.id) - page_ids = {row.page.get("id") for row in listed.results if row.page} - assert parent_page.id in page_ids - assert child_page.id in page_ids + root_page_ids = {row.page.get("id") for row in listed.results if row.page} + assert parent_page.id in root_page_ids + assert child_page.id not in root_page_ids + + children = client.collections.list_collection_pages( + workspace_slug, + collection.id, + params=CollectionPageQueryParams(parent_id=parent_page.id), + ) + child_page_ids = {row.page.get("id") for row in children.results if row.page} + assert child_page.id in child_page_ids finally: for created_page in (child_page, parent_page): if created_page is not None: From 3518c3c67381494cc7a0bb63709b864bcdca4a62 Mon Sep 17 00:00:00 2001 From: sunder Date: Tue, 21 Jul 2026 00:45:01 +0530 Subject: [PATCH 3/6] bumpup version to 0.2.21 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index b10ada4..b1a451c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "plane-sdk" -version = "0.2.20" +version = "0.2.21" description = "Python SDK for Plane API" readme = "README.md" requires-python = ">=3.10" From 0ca445932b7744f1551246d37a1ae410cc33cc54 Mon Sep 17 00:00:00 2001 From: sunder Date: Thu, 23 Jul 2026 16:15:26 +0530 Subject: [PATCH 4/6] resolve review comments --- plane/api/collections.py | 242 ------------------------------ plane/api/collections/__init__.py | 3 + plane/api/collections/base.py | 84 +++++++++++ plane/api/collections/members.py | 72 +++++++++ plane/api/collections/pages.py | 104 +++++++++++++ plane/models/collections.py | 28 +++- tests/unit/test_collections.py | 52 +++---- 7 files changed, 308 insertions(+), 277 deletions(-) delete mode 100644 plane/api/collections.py create mode 100644 plane/api/collections/__init__.py create mode 100644 plane/api/collections/base.py create mode 100644 plane/api/collections/members.py create mode 100644 plane/api/collections/pages.py diff --git a/plane/api/collections.py b/plane/api/collections.py deleted file mode 100644 index 4caff31..0000000 --- a/plane/api/collections.py +++ /dev/null @@ -1,242 +0,0 @@ -from typing import Any - -from ..models.collections import ( - AddCollectionPages, - Collection, - CollectionMember, - CollectionPage, - CollectionPageSearchResult, - CreateCollection, - CreateCollectionMember, - PaginatedCollectionPageResponse, - UpdateCollection, - UpdateCollectionMember, - UpdateCollectionPage, -) -from ..models.query_params import CollectionPageQueryParams -from .base_resource import BaseResource - - -class Collections(BaseResource): - def __init__(self, config: Any) -> None: - super().__init__(config, "/workspaces/") - - # Collections - - def list_collections(self, workspace_slug: str) -> list[Collection]: - """List all collections in a workspace. - - Args: - workspace_slug: The workspace slug identifier - """ - response = self._get(f"{workspace_slug}/collections") - return [Collection.model_validate(item) for item in response] - - def create_collection(self, workspace_slug: str, data: CreateCollection) -> Collection: - """Create a new collection in a workspace. - - Args: - workspace_slug: The workspace slug identifier - data: Collection data - """ - response = self._post(f"{workspace_slug}/collections", data.model_dump(exclude_none=True)) - return Collection.model_validate(response) - - def retrieve_collection(self, workspace_slug: str, collection_id: str) -> Collection: - """Retrieve a collection by ID. - - Args: - workspace_slug: The workspace slug identifier - collection_id: UUID of the collection - """ - response = self._get(f"{workspace_slug}/collections/{collection_id}") - return Collection.model_validate(response) - - def update_collection( - self, workspace_slug: str, collection_id: str, data: UpdateCollection - ) -> Collection: - """Update a collection's name, logo, or sort order. - - Args: - workspace_slug: The workspace slug identifier - collection_id: UUID of the collection - data: Fields to update (access cannot be changed after creation) - """ - response = self._patch( - f"{workspace_slug}/collections/{collection_id}", - data.model_dump(exclude_none=True), - ) - return Collection.model_validate(response) - - def delete_collection( - self, - workspace_slug: str, - collection_id: str, - archive_pages: bool | None = None, - ) -> None: - """Delete a collection. - - Args: - workspace_slug: The workspace slug identifier - collection_id: UUID of the collection - archive_pages: Whether to archive the collection's pages instead of - leaving them unfiled. Omit to use the server's default (True). - Private collections always archive their pages regardless. - """ - params = None - if archive_pages is not None: - params = {"archive_pages": "true" if archive_pages else "false"} - return self._delete(f"{workspace_slug}/collections/{collection_id}", params=params) - - # Pages within a collection - - def list_collection_pages( - self, - workspace_slug: str, - collection_id: str, - params: CollectionPageQueryParams | None = None, - ) -> PaginatedCollectionPageResponse: - """List pages that belong to a collection. - - Args: - workspace_slug: The workspace slug identifier - collection_id: UUID of the collection - params: Optional search/parent_id/pagination filters - """ - query_params = params.model_dump(exclude_none=True) if params else None - response = self._get( - f"{workspace_slug}/collections/{collection_id}/pages", params=query_params - ) - return PaginatedCollectionPageResponse.model_validate(response) - - def add_collection_pages( - self, workspace_slug: str, collection_id: str, data: AddCollectionPages - ) -> list[CollectionPage]: - """Add existing page(s) to a collection. - - Args: - workspace_slug: The workspace slug identifier - collection_id: UUID of the collection - data: Page IDs to add, with optional sort_orders/placement - """ - response = self._post( - f"{workspace_slug}/collections/{collection_id}/pages", - data.model_dump(exclude_none=True), - ) - return [CollectionPage.model_validate(item) for item in response] - - def search_addable_collection_pages( - self, workspace_slug: str, collection_id: str, search: str | None = None - ) -> list[CollectionPageSearchResult]: - """Search pages that are not yet in a collection, to add them. - - Args: - workspace_slug: The workspace slug identifier - collection_id: UUID of the collection - search: Optional case-insensitive substring filter on page name - """ - query_params = {"search": search} if search else None - response = self._get( - f"{workspace_slug}/collections/{collection_id}/pages-search", - params=query_params, - ) - return [CollectionPageSearchResult.model_validate(item) for item in response] - - def update_collection_page( - self, - workspace_slug: str, - collection_id: str, - page_collection_id: str, - data: UpdateCollectionPage, - ) -> CollectionPage: - """Move a page to a different collection, or reorder it within the current one. - - Args: - workspace_slug: The workspace slug identifier - collection_id: UUID of the page's current collection - page_collection_id: UUID of the page-collection membership row - data: `collection` to move (omit/leave unset to just reorder), - and/or `sort_order`/`placement` to reorder - """ - response = self._patch( - f"{workspace_slug}/collections/{collection_id}/pages/{page_collection_id}", - data.model_dump(exclude_none=True), - ) - return CollectionPage.model_validate(response) - - def remove_collection_page( - self, workspace_slug: str, collection_id: str, page_collection_id: str - ) -> None: - """Remove a page from a collection (does not delete the page itself). - - Args: - workspace_slug: The workspace slug identifier - collection_id: UUID of the collection - page_collection_id: UUID of the page-collection membership row - """ - return self._delete( - f"{workspace_slug}/collections/{collection_id}/pages/{page_collection_id}" - ) - - # Collection members - - def list_collection_members( - self, workspace_slug: str, collection_id: str - ) -> list[CollectionMember]: - """List members of a (typically private) collection. - - Args: - workspace_slug: The workspace slug identifier - collection_id: UUID of the collection - """ - response = self._get(f"{workspace_slug}/collections/{collection_id}/members") - return [CollectionMember.model_validate(item) for item in response] - - def add_collection_member( - self, workspace_slug: str, collection_id: str, data: CreateCollectionMember - ) -> CollectionMember: - """Add a member to a collection. - - Args: - workspace_slug: The workspace slug identifier - collection_id: UUID of the collection - data: Member user id and access level - """ - response = self._post( - f"{workspace_slug}/collections/{collection_id}/members", - data.model_dump(exclude_none=True), - ) - return CollectionMember.model_validate(response) - - def update_collection_member( - self, - workspace_slug: str, - collection_id: str, - member_id: str, - data: UpdateCollectionMember, - ) -> CollectionMember: - """Update a collection member's access level. - - Args: - workspace_slug: The workspace slug identifier - collection_id: UUID of the collection - member_id: UUID of the CollectionMember row (not the user id) - data: New access level - """ - response = self._patch( - f"{workspace_slug}/collections/{collection_id}/members/{member_id}", - data.model_dump(exclude_none=True), - ) - return CollectionMember.model_validate(response) - - def remove_collection_member( - self, workspace_slug: str, collection_id: str, member_id: str - ) -> None: - """Remove a member from a collection. - - Args: - workspace_slug: The workspace slug identifier - collection_id: UUID of the collection - member_id: UUID of the CollectionMember row (not the user id) - """ - return self._delete(f"{workspace_slug}/collections/{collection_id}/members/{member_id}") diff --git a/plane/api/collections/__init__.py b/plane/api/collections/__init__.py new file mode 100644 index 0000000..b1b5f3f --- /dev/null +++ b/plane/api/collections/__init__.py @@ -0,0 +1,3 @@ +from .base import Collections + +__all__ = ["Collections"] diff --git a/plane/api/collections/base.py b/plane/api/collections/base.py new file mode 100644 index 0000000..823cfa6 --- /dev/null +++ b/plane/api/collections/base.py @@ -0,0 +1,84 @@ +from __future__ import annotations + +from typing import Any + +from plane.api.base_resource import BaseResource +from plane.api.collections.members import CollectionMembers +from plane.api.collections.pages import CollectionPages +from plane.models.collections import ( + Collection, + CreateCollection, + UpdateCollection, +) + + +class Collections(BaseResource): + def __init__(self, config: Any) -> None: + super().__init__(config, "/workspaces/") + + # Initialize sub-resources + self.pages = CollectionPages(config) + self.members = CollectionMembers(config) + + def list(self, workspace_slug: str) -> list[Collection]: + """List all collections in a workspace. + + Args: + workspace_slug: The workspace slug identifier + """ + response = self._get(f"{workspace_slug}/collections") + return [Collection.model_validate(item) for item in response] + + def create(self, workspace_slug: str, data: CreateCollection) -> Collection: + """Create a new collection in a workspace. + + Args: + workspace_slug: The workspace slug identifier + data: Collection data + """ + response = self._post(f"{workspace_slug}/collections", data.model_dump(exclude_none=True)) + return Collection.model_validate(response) + + def retrieve(self, workspace_slug: str, collection_id: str) -> Collection: + """Retrieve a collection by ID. + + Args: + workspace_slug: The workspace slug identifier + collection_id: UUID of the collection + """ + response = self._get(f"{workspace_slug}/collections/{collection_id}") + return Collection.model_validate(response) + + def update(self, workspace_slug: str, collection_id: str, data: UpdateCollection) -> Collection: + """Update a collection's name, logo, or sort order. + + Args: + workspace_slug: The workspace slug identifier + collection_id: UUID of the collection + data: Fields to update (access cannot be changed after creation) + """ + response = self._patch( + f"{workspace_slug}/collections/{collection_id}", + data.model_dump(exclude_none=True), + ) + return Collection.model_validate(response) + + def delete( + self, + workspace_slug: str, + collection_id: str, + archive_pages: bool | None = None, + ) -> None: + """Delete a collection. + + Args: + workspace_slug: The workspace slug identifier + collection_id: UUID of the collection + archive_pages: Whether to archive the collection's pages instead of + leaving them unfiled. Omit to use the server's default (True). + Private collections always archive their pages regardless. + """ + params = None + if archive_pages is not None: + params = {"archive_pages": "true" if archive_pages else "false"} + return self._delete(f"{workspace_slug}/collections/{collection_id}", params=params) diff --git a/plane/api/collections/members.py b/plane/api/collections/members.py new file mode 100644 index 0000000..6c4c36c --- /dev/null +++ b/plane/api/collections/members.py @@ -0,0 +1,72 @@ +from __future__ import annotations + +from typing import Any + +from plane.api.base_resource import BaseResource +from plane.models.collections import ( + CollectionMember, + CreateCollectionMember, + UpdateCollectionMember, +) + + +class CollectionMembers(BaseResource): + def __init__(self, config: Any) -> None: + super().__init__(config, "/workspaces/") + + def list(self, workspace_slug: str, collection_id: str) -> list[CollectionMember]: + """List members of a (typically private) collection. + + Args: + workspace_slug: The workspace slug identifier + collection_id: UUID of the collection + """ + response = self._get(f"{workspace_slug}/collections/{collection_id}/members") + return [CollectionMember.model_validate(item) for item in response] + + def add( + self, workspace_slug: str, collection_id: str, data: CreateCollectionMember + ) -> CollectionMember: + """Add a member to a collection. + + Args: + workspace_slug: The workspace slug identifier + collection_id: UUID of the collection + data: Member user id and access level + """ + response = self._post( + f"{workspace_slug}/collections/{collection_id}/members", + data.model_dump(exclude_none=True), + ) + return CollectionMember.model_validate(response) + + def update( + self, + workspace_slug: str, + collection_id: str, + member_id: str, + data: UpdateCollectionMember, + ) -> CollectionMember: + """Update a collection member's access level. + + Args: + workspace_slug: The workspace slug identifier + collection_id: UUID of the collection + member_id: UUID of the CollectionMember row (not the user id) + data: New access level + """ + response = self._patch( + f"{workspace_slug}/collections/{collection_id}/members/{member_id}", + data.model_dump(exclude_none=True), + ) + return CollectionMember.model_validate(response) + + def remove(self, workspace_slug: str, collection_id: str, member_id: str) -> None: + """Remove a member from a collection. + + Args: + workspace_slug: The workspace slug identifier + collection_id: UUID of the collection + member_id: UUID of the CollectionMember row (not the user id) + """ + return self._delete(f"{workspace_slug}/collections/{collection_id}/members/{member_id}") diff --git a/plane/api/collections/pages.py b/plane/api/collections/pages.py new file mode 100644 index 0000000..9f8a625 --- /dev/null +++ b/plane/api/collections/pages.py @@ -0,0 +1,104 @@ +from __future__ import annotations + +from typing import Any + +from plane.api.base_resource import BaseResource +from plane.models.collections import ( + AddCollectionPages, + CollectionPage, + CollectionPageSearchResult, + PaginatedCollectionPageResponse, + UpdateCollectionPage, +) +from plane.models.query_params import CollectionPageQueryParams + + +class CollectionPages(BaseResource): + def __init__(self, config: Any) -> None: + super().__init__(config, "/workspaces/") + + def list( + self, + workspace_slug: str, + collection_id: str, + params: CollectionPageQueryParams | None = None, + ) -> PaginatedCollectionPageResponse: + """List pages that belong to a collection. + + Args: + workspace_slug: The workspace slug identifier + collection_id: UUID of the collection + params: Optional search/parent_id/pagination filters + """ + query_params = params.model_dump(exclude_none=True) if params else None + response = self._get( + f"{workspace_slug}/collections/{collection_id}/pages", params=query_params + ) + return PaginatedCollectionPageResponse.model_validate(response) + + def add( + self, workspace_slug: str, collection_id: str, data: AddCollectionPages + ) -> list[CollectionPage]: + """Add existing page(s) to a collection. + + Args: + workspace_slug: The workspace slug identifier + collection_id: UUID of the collection + data: Page IDs to add, with optional sort_orders/placement + """ + response = self._post( + f"{workspace_slug}/collections/{collection_id}/pages", + data.model_dump(exclude_none=True), + ) + return [CollectionPage.model_validate(item) for item in response] + + def search( + self, workspace_slug: str, collection_id: str, search: str | None = None + ) -> list[CollectionPageSearchResult]: + """Search pages that are not yet in a collection, to add them. + + Args: + workspace_slug: The workspace slug identifier + collection_id: UUID of the collection + search: Optional case-insensitive substring filter on page name + """ + query_params = {"search": search} if search else None + response = self._get( + f"{workspace_slug}/collections/{collection_id}/pages-search", + params=query_params, + ) + return [CollectionPageSearchResult.model_validate(item) for item in response] + + def update( + self, + workspace_slug: str, + collection_id: str, + page_collection_id: str, + data: UpdateCollectionPage, + ) -> CollectionPage: + """Move a page to a different collection, or reorder it within the current one. + + Args: + workspace_slug: The workspace slug identifier + collection_id: UUID of the page's current collection + page_collection_id: UUID of the page-collection membership row + data: `collection` to move (omit/leave unset to just reorder), + and/or `sort_order`/`placement` to reorder + """ + response = self._patch( + f"{workspace_slug}/collections/{collection_id}/pages/{page_collection_id}", + data.model_dump(exclude_none=True), + ) + return CollectionPage.model_validate(response) + + def remove(self, workspace_slug: str, collection_id: str, page_collection_id: str) -> None: + """Remove a page from a collection (does not delete the page itself). + + Args: + workspace_slug: The workspace slug identifier + collection_id: UUID of the collection + page_collection_id: UUID of the page-collection membership row + """ + return self._delete( + f"{workspace_slug}/collections/{collection_id}/pages/{page_collection_id}" + ) diff --git a/plane/models/collections.py b/plane/models/collections.py index 9407545..da76851 100644 --- a/plane/models/collections.py +++ b/plane/models/collections.py @@ -1,3 +1,4 @@ +from enum import IntEnum from typing import Any from pydantic import BaseModel, ConfigDict @@ -5,6 +6,21 @@ from .pagination import PaginatedResponse +class CollectionAccessEnum(IntEnum): + """Access level of a collection.""" + + PUBLIC = 0 + PRIVATE = 1 + + +class CollectionMemberAccessEnum(IntEnum): + """Access level of a member within a collection.""" + + VIEW = 0 + COMMENT = 1 + EDIT = 2 + + class Collection(BaseModel): """Collection model (a folder that groups workspace pages).""" @@ -13,8 +29,8 @@ class Collection(BaseModel): id: str | None = None name: str | None = None owned_by_id: str | None = None - access: int | None = None - current_user_access: int | None = None + access: CollectionAccessEnum | None = None + current_user_access: CollectionMemberAccessEnum | None = None has_pages: bool | None = None is_default: bool | None = None is_global: bool | None = None @@ -33,7 +49,7 @@ class CreateCollection(BaseModel): model_config = ConfigDict(extra="ignore", populate_by_name=True) name: str - access: int | None = None + access: CollectionAccessEnum | None = None logo_props: Any | None = None @@ -59,7 +75,7 @@ class CollectionMember(BaseModel): id: str | None = None collection: str | None = None member: str | None = None - access: int | None = None + access: CollectionMemberAccessEnum | None = None workspace: str | None = None created_at: str | None = None updated_at: str | None = None @@ -73,7 +89,7 @@ class CreateCollectionMember(BaseModel): model_config = ConfigDict(extra="ignore", populate_by_name=True) member: str - access: int + access: CollectionMemberAccessEnum class UpdateCollectionMember(BaseModel): @@ -81,7 +97,7 @@ class UpdateCollectionMember(BaseModel): model_config = ConfigDict(extra="ignore", populate_by_name=True) - access: int + access: CollectionMemberAccessEnum class CollectionPage(BaseModel): diff --git a/tests/unit/test_collections.py b/tests/unit/test_collections.py index 4c85f19..c908400 100644 --- a/tests/unit/test_collections.py +++ b/tests/unit/test_collections.py @@ -28,31 +28,31 @@ class TestCollectionsAPI: """Test Collections CRUD.""" def test_collection_crud(self, client: PlaneClient, workspace_slug: str) -> None: - created = client.collections.create_collection( + created = client.collections.create( workspace_slug, CreateCollection(name=_collection_name("SDK Collection")) ) assert created is not None assert created.id is not None try: - collections = client.collections.list_collections(workspace_slug) + collections = client.collections.list(workspace_slug) assert isinstance(collections, list) assert any(c.id == created.id for c in collections) - retrieved = client.collections.retrieve_collection(workspace_slug, created.id) + retrieved = client.collections.retrieve(workspace_slug, created.id) assert isinstance(retrieved, Collection) assert retrieved.id == created.id - updated = client.collections.update_collection( + updated = client.collections.update( workspace_slug, created.id, UpdateCollection(name=f"{created.name} (updated)", sort_order=25000), ) assert updated.name == f"{created.name} (updated)" finally: - client.collections.delete_collection(workspace_slug, created.id, archive_pages=False) + client.collections.delete(workspace_slug, created.id, archive_pages=False) - collections_after = client.collections.list_collections(workspace_slug) + collections_after = client.collections.list(workspace_slug) assert not any(c.id == created.id for c in collections_after) @@ -62,10 +62,10 @@ class TestCollectionPagesAPI: def test_add_list_search_move_and_remove_pages( self, client: PlaneClient, workspace_slug: str ) -> None: - source = client.collections.create_collection( + source = client.collections.create( workspace_slug, CreateCollection(name=_collection_name("Source Collection")) ) - target = client.collections.create_collection( + target = client.collections.create( workspace_slug, CreateCollection(name=_collection_name("Target Collection")) ) page = client.pages.create_workspace_page( @@ -77,24 +77,24 @@ def test_add_list_search_move_and_remove_pages( ) try: - search_results = client.collections.search_addable_collection_pages( + search_results = client.collections.pages.search( workspace_slug, source.id, search=page.name ) assert any(r.id == page.id for r in search_results) - added = client.collections.add_collection_pages( + added = client.collections.pages.add( workspace_slug, source.id, AddCollectionPages(page_ids=[page.id]) ) assert any(pc.page == page.id for pc in added) - listed = client.collections.list_collection_pages(workspace_slug, source.id) + listed = client.collections.pages.list(workspace_slug, source.id) assert isinstance(listed, PaginatedCollectionPageResponse) matching = [row for row in listed.results if row.page and row.page.get("id") == page.id] assert len(matching) == 1 page_collection_id = matching[0].page_collection_id assert page_collection_id is not None - moved = client.collections.update_collection_page( + moved = client.collections.pages.update( workspace_slug, source.id, page_collection_id, @@ -102,8 +102,8 @@ def test_add_list_search_move_and_remove_pages( ) assert moved.collection == target.id - client.collections.remove_collection_page(workspace_slug, target.id, page_collection_id) - listed_after = client.collections.list_collection_pages(workspace_slug, target.id) + client.collections.pages.remove(workspace_slug, target.id, page_collection_id) + listed_after = client.collections.pages.list(workspace_slug, target.id) assert not any( row.page and row.page.get("id") == page.id for row in listed_after.results ) @@ -114,9 +114,7 @@ def test_add_list_search_move_and_remove_pages( pass for collection in (source, target): try: - client.collections.delete_collection( - workspace_slug, collection.id, archive_pages=False - ) + client.collections.delete(workspace_slug, collection.id, archive_pages=False) except Exception: pass @@ -124,7 +122,7 @@ def test_create_page_in_collection_and_sub_page( self, client: PlaneClient, workspace_slug: str ) -> None: """Create page directly via CreatePage(collection_id=...), plus a sub-page.""" - collection = client.collections.create_collection( + collection = client.collections.create( workspace_slug, CreateCollection(name=_collection_name("Parent Collection")) ) parent_page = None @@ -152,12 +150,12 @@ def test_create_page_in_collection_and_sub_page( # The unfiltered listing returns only root-branch pages; sub-pages are # listed under their parent via the parent_id filter. - listed = client.collections.list_collection_pages(workspace_slug, collection.id) + listed = client.collections.pages.list(workspace_slug, collection.id) root_page_ids = {row.page.get("id") for row in listed.results if row.page} assert parent_page.id in root_page_ids assert child_page.id not in root_page_ids - children = client.collections.list_collection_pages( + children = client.collections.pages.list( workspace_slug, collection.id, params=CollectionPageQueryParams(parent_id=parent_page.id), @@ -172,9 +170,7 @@ def test_create_page_in_collection_and_sub_page( except Exception: pass try: - client.collections.delete_collection( - workspace_slug, collection.id, archive_pages=False - ) + client.collections.delete(workspace_slug, collection.id, archive_pages=False) except Exception: pass @@ -189,18 +185,18 @@ class TestCollectionMembersAPI: """ def test_collection_member_crud(self, client: PlaneClient, workspace_slug: str) -> None: - collection = client.collections.create_collection( + collection = client.collections.create( workspace_slug, CreateCollection(name=_collection_name("Private Collection"), access=1), ) try: - members = client.collections.list_collection_members(workspace_slug, collection.id) + members = client.collections.members.list(workspace_slug, collection.id) assert isinstance(members, list) assert len(members) == 1 owner_member = members[0] assert owner_member.access == 2 # EDIT, auto-assigned to the creator - updated_member = client.collections.update_collection_member( + updated_member = client.collections.members.update( workspace_slug, collection.id, owner_member.id, @@ -209,8 +205,6 @@ def test_collection_member_crud(self, client: PlaneClient, workspace_slug: str) assert updated_member.access == 0 finally: try: - client.collections.delete_collection( - workspace_slug, collection.id, archive_pages=False - ) + client.collections.delete(workspace_slug, collection.id, archive_pages=False) except Exception: pass From 0a7f62072a93284840f70d64a32337e63eafe5e2 Mon Sep 17 00:00:00 2001 From: sunder Date: Thu, 23 Jul 2026 17:03:10 +0530 Subject: [PATCH 5/6] resolve coderabbit comments --- tests/unit/test_collections.py | 40 +++++++++++++++++++--------------- 1 file changed, 23 insertions(+), 17 deletions(-) diff --git a/tests/unit/test_collections.py b/tests/unit/test_collections.py index c908400..5310305 100644 --- a/tests/unit/test_collections.py +++ b/tests/unit/test_collections.py @@ -65,18 +65,20 @@ def test_add_list_search_move_and_remove_pages( source = client.collections.create( workspace_slug, CreateCollection(name=_collection_name("Source Collection")) ) - target = client.collections.create( - workspace_slug, CreateCollection(name=_collection_name("Target Collection")) - ) - page = client.pages.create_workspace_page( - workspace_slug, - CreatePage( - name=_collection_name("Collection Page"), - description_html="

collection sdk test

", - ), - ) - + target = None + page = None try: + target = client.collections.create( + workspace_slug, CreateCollection(name=_collection_name("Target Collection")) + ) + page = client.pages.create_workspace_page( + workspace_slug, + CreatePage( + name=_collection_name("Collection Page"), + description_html="

collection sdk test

", + ), + ) + search_results = client.collections.pages.search( workspace_slug, source.id, search=page.name ) @@ -108,15 +110,19 @@ def test_add_list_search_move_and_remove_pages( row.page and row.page.get("id") == page.id for row in listed_after.results ) finally: - try: - client.pages.delete_workspace_page(workspace_slug, page.id) - except Exception: - pass - for collection in (source, target): + if page is not None: try: - client.collections.delete(workspace_slug, collection.id, archive_pages=False) + client.pages.delete_workspace_page(workspace_slug, page.id) except Exception: pass + for collection in (source, target): + if collection is not None: + try: + client.collections.delete( + workspace_slug, collection.id, archive_pages=False + ) + except Exception: + pass def test_create_page_in_collection_and_sub_page( self, client: PlaneClient, workspace_slug: str From 9ffc4f3d03e32051f073f8c254baa75f0318fb4a Mon Sep 17 00:00:00 2001 From: sunder Date: Thu, 23 Jul 2026 17:03:35 +0530 Subject: [PATCH 6/6] test: drop unsupported sub-page nesting assertions Sub-page creation is not supported by the public API (CreatePage.parent_id is accepted but not honored on creation), so the test now covers only the supported page-in-collection behavior. --- tests/unit/test_collections.py | 57 ++++++++++++---------------------- 1 file changed, 19 insertions(+), 38 deletions(-) diff --git a/tests/unit/test_collections.py b/tests/unit/test_collections.py index 5310305..6b04606 100644 --- a/tests/unit/test_collections.py +++ b/tests/unit/test_collections.py @@ -17,7 +17,6 @@ UpdateCollectionPage, ) from plane.models.pages import CreatePage -from plane.models.query_params import CollectionPageQueryParams def _collection_name(prefix: str) -> str: @@ -124,57 +123,39 @@ def test_add_list_search_move_and_remove_pages( except Exception: pass - def test_create_page_in_collection_and_sub_page( + def test_create_page_in_collection( self, client: PlaneClient, workspace_slug: str ) -> None: - """Create page directly via CreatePage(collection_id=...), plus a sub-page.""" + """Create a page directly in a collection via CreatePage(collection_id=...). + + Nesting a page under a parent (sub-pages) is not currently supported by the + public API: CreatePage.parent_id is accepted but not honored on creation, so + this test only covers the supported collection-membership behavior. + """ collection = client.collections.create( workspace_slug, CreateCollection(name=_collection_name("Parent Collection")) ) - parent_page = None - child_page = None + page = None try: - parent_page = client.pages.create_workspace_page( + page = client.pages.create_workspace_page( workspace_slug, CreatePage( - name=_collection_name("Parent Page"), - description_html="

parent

", + name=_collection_name("Collection Page"), + description_html="

page in collection

", collection_id=collection.id, ), ) - assert parent_page is not None + assert page is not None - child_page = client.pages.create_workspace_page( - workspace_slug, - CreatePage( - name=_collection_name("Child Page"), - description_html="

child

", - parent_id=parent_page.id, - ), - ) - assert child_page is not None - - # The unfiltered listing returns only root-branch pages; sub-pages are - # listed under their parent via the parent_id filter. listed = client.collections.pages.list(workspace_slug, collection.id) - root_page_ids = {row.page.get("id") for row in listed.results if row.page} - assert parent_page.id in root_page_ids - assert child_page.id not in root_page_ids - - children = client.collections.pages.list( - workspace_slug, - collection.id, - params=CollectionPageQueryParams(parent_id=parent_page.id), - ) - child_page_ids = {row.page.get("id") for row in children.results if row.page} - assert child_page.id in child_page_ids + page_ids = {row.page.get("id") for row in listed.results if row.page} + assert page.id in page_ids finally: - for created_page in (child_page, parent_page): - if created_page is not None: - try: - client.pages.delete_workspace_page(workspace_slug, created_page.id) - except Exception: - pass + if page is not None: + try: + client.pages.delete_workspace_page(workspace_slug, page.id) + except Exception: + pass try: client.collections.delete(workspace_slug, collection.id, archive_pages=False) except Exception: