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/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..da76851 --- /dev/null +++ b/plane/models/collections.py @@ -0,0 +1,171 @@ +from enum import IntEnum +from typing import Any + +from pydantic import BaseModel, ConfigDict + +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).""" + + model_config = ConfigDict(extra="allow", populate_by_name=True) + + id: str | None = None + name: str | None = None + owned_by_id: str | 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 + 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: CollectionAccessEnum | 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: CollectionMemberAccessEnum | 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: CollectionMemberAccessEnum + + +class UpdateCollectionMember(BaseModel): + """Request model for updating a collection member's access level.""" + + model_config = ConfigDict(extra="ignore", populate_by_name=True) + + access: CollectionMemberAccessEnum + + +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/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" diff --git a/tests/unit/test_collections.py b/tests/unit/test_collections.py new file mode 100644 index 0000000..6b04606 --- /dev/null +++ b/tests/unit/test_collections.py @@ -0,0 +1,197 @@ +"""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( + workspace_slug, CreateCollection(name=_collection_name("SDK Collection")) + ) + assert created is not None + assert created.id is not None + + try: + collections = client.collections.list(workspace_slug) + assert isinstance(collections, list) + assert any(c.id == created.id for c in collections) + + retrieved = client.collections.retrieve(workspace_slug, created.id) + assert isinstance(retrieved, Collection) + assert retrieved.id == created.id + + 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(workspace_slug, created.id, archive_pages=False) + + collections_after = client.collections.list(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( + workspace_slug, CreateCollection(name=_collection_name("Source Collection")) + ) + 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 + ) + assert any(r.id == page.id for r in search_results) + + 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.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.pages.update( + workspace_slug, + source.id, + page_collection_id, + UpdateCollectionPage(collection=target.id), + ) + assert moved.collection == 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 + ) + finally: + if page is not None: + try: + 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( + self, client: PlaneClient, workspace_slug: str + ) -> None: + """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")) + ) + page = None + try: + page = client.pages.create_workspace_page( + workspace_slug, + CreatePage( + name=_collection_name("Collection Page"), + description_html="page in collection
", + collection_id=collection.id, + ), + ) + assert page is not None + + listed = client.collections.pages.list(workspace_slug, collection.id) + page_ids = {row.page.get("id") for row in listed.results if row.page} + assert page.id in page_ids + finally: + 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: + 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( + workspace_slug, + CreateCollection(name=_collection_name("Private Collection"), access=1), + ) + try: + 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.members.update( + workspace_slug, + collection.id, + owner_member.id, + UpdateCollectionMember(access=0), + ) + assert updated_member.access == 0 + finally: + try: + client.collections.delete(workspace_slug, collection.id, archive_pages=False) + except Exception: + pass