From 906fd2db7de7d37923d16c2977079986ad4792a6 Mon Sep 17 00:00:00 2001 From: akhil-vamshi-konam Date: Sat, 15 Aug 2026 23:39:34 +0530 Subject: [PATCH 1/3] feat: add update and archive functionality for workspace and project pages --- plane/api/pages.py | 102 +++++++++++++++++++++++++++- plane/models/workspace_templates.py | 12 +++- pyproject.toml | 2 +- tests/unit/test_pages.py | 85 ++++++++++++++++++++++- 4 files changed, 195 insertions(+), 6 deletions(-) diff --git a/plane/api/pages.py b/plane/api/pages.py index dabc58d..fa97998 100644 --- a/plane/api/pages.py +++ b/plane/api/pages.py @@ -1,6 +1,6 @@ from typing import Any -from ..models.pages import CreatePage, Page, PaginatedPageResponse +from ..models.pages import CreatePage, Page, PaginatedPageResponse, UpdatePage from ..models.query_params import PaginatedQueryParams, RetrieveQueryParams from .base_resource import BaseResource @@ -117,12 +117,112 @@ def create_project_page( ) return Page.model_validate(response) + def update_workspace_page( + self, + workspace_slug: str, + page_id: str, + data: UpdatePage, + ) -> Page: + """Update a workspace page. + + Args: + workspace_slug: The workspace slug identifier + page_id: UUID of the page + data: Fields to change. Send name, description_html, or both; a page that + is locked or archived is refused. + + Note: + Content is written through Plane's live collaboration service, which owns + the document. If it is unreachable the API answers 502 and nothing is + written, rather than leaving the editor showing the old text. + """ + response = self._put( + f"{workspace_slug}/pages/{page_id}", + data.model_dump(exclude_none=True, mode="json"), + ) + return Page.model_validate(response) + + def update_project_page( + self, + workspace_slug: str, + project_id: str, + page_id: str, + data: UpdatePage, + ) -> Page: + """Update a project page. + + Args: + workspace_slug: The workspace slug identifier + project_id: UUID of the project + page_id: UUID of the page + data: Fields to change. Send name, description_html, or both; a page that + is locked or archived is refused. + + Note: + Content is written through Plane's live collaboration service, which owns + the document. If it is unreachable the API answers 502 and nothing is + written, rather than leaving the editor showing the old text. + """ + response = self._put( + f"{workspace_slug}/projects/{project_id}/pages/{page_id}", + data.model_dump(exclude_none=True, mode="json"), + ) + return Page.model_validate(response) + + def archive_workspace_page(self, workspace_slug: str, page_id: str) -> None: + """Archive a workspace page. + + Args: + workspace_slug: The workspace slug identifier + page_id: UUID of the page + + Note: + Archiving is the reversible step `delete_workspace_page` requires. + """ + self._post(f"{workspace_slug}/pages/{page_id}/archive") + + def unarchive_workspace_page(self, workspace_slug: str, page_id: str) -> None: + """Restore an archived workspace page. + + Args: + workspace_slug: The workspace slug identifier + page_id: UUID of the page + """ + self._delete(f"{workspace_slug}/pages/{page_id}/archive") + + def archive_project_page(self, workspace_slug: str, project_id: str, page_id: str) -> None: + """Archive a project page. + + Args: + workspace_slug: The workspace slug identifier + project_id: UUID of the project + page_id: UUID of the page + + Note: + Archiving is the reversible step `delete_project_page` requires. + """ + self._post(f"{workspace_slug}/projects/{project_id}/pages/{page_id}/archive") + + def unarchive_project_page(self, workspace_slug: str, project_id: str, page_id: str) -> None: + """Restore an archived project page. + + Args: + workspace_slug: The workspace slug identifier + project_id: UUID of the project + page_id: UUID of the page + """ + self._delete(f"{workspace_slug}/projects/{project_id}/pages/{page_id}/archive") + def delete_workspace_page(self, workspace_slug: str, page_id: str) -> None: """Delete a workspace page by ID. Args: workspace_slug: The workspace slug identifier page_id: UUID of the page + + Note: + The page must be archived first; the API answers 400 + "The page should be archived before deleting" otherwise. """ return self._delete(f"{workspace_slug}/pages/{page_id}") diff --git a/plane/models/workspace_templates.py b/plane/models/workspace_templates.py index e680afc..03fb19c 100644 --- a/plane/models/workspace_templates.py +++ b/plane/models/workspace_templates.py @@ -16,7 +16,9 @@ class WorkItemTemplate(BaseModel): id: str | None = None name: str | None = None - description: str | None = None + # The editor stores a JSON document here and returns {} when empty; only + # description_html is text. Matches Page.description. + description: dict | str | None = None description_html: str | None = None template_data: Any | None = None logo_props: Any | None = None @@ -56,7 +58,9 @@ class ProjectTemplate(BaseModel): id: str | None = None name: str | None = None - description: str | None = None + # The editor stores a JSON document here and returns {} when empty; only + # description_html is text. Matches Page.description. + description: dict | str | None = None logo_props: Any | None = None template_data: Any | None = None created_at: str | None = None @@ -93,7 +97,9 @@ class PageTemplate(BaseModel): id: str | None = None name: str | None = None - description: str | None = None + # The editor stores a JSON document here and returns {} when empty; only + # description_html is text. Matches Page.description. + description: dict | str | None = None description_html: str | None = None template_data: Any | None = None logo_props: Any | None = None diff --git a/pyproject.toml b/pyproject.toml index 03afcb4..4183980 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "plane-sdk" -version = "0.2.22" +version = "0.2.23" description = "Python SDK for Plane API" readme = "README.md" requires-python = ">=3.10" diff --git a/tests/unit/test_pages.py b/tests/unit/test_pages.py index 6f2ea43..941607d 100644 --- a/tests/unit/test_pages.py +++ b/tests/unit/test_pages.py @@ -2,11 +2,20 @@ import time +import pytest + from plane.client import PlaneClient -from plane.models.pages import CreatePage, PaginatedPageResponse +from plane.models.pages import CreatePage, PaginatedPageResponse, UpdatePage from plane.models.projects import Project +def _requires_live(exc: Exception) -> None: + """Page content is written by the live collaboration service; skip without it.""" + if "502" in str(exc) or "Failed to update page document" in str(exc): + pytest.skip("requires Plane's live collaboration service") + raise exc + + class TestPagesAPI: """Test Pages API resource.""" @@ -92,3 +101,77 @@ def test_list_project_pages_contains_created_page( except Exception: pass + def test_update_project_page( + self, client: PlaneClient, workspace_slug: str, project: Project + ) -> None: + """Test updating a project page's name and content.""" + page = client.pages.create_project_page( + workspace_slug, + project.id, + CreatePage(name=f"Test Update {int(time.time())}", description_html="

first draft

"), + ) + + try: + updated = client.pages.update_project_page( + workspace_slug, + project.id, + page.id, + UpdatePage(name=f"{page.name} (edited)", description_html="

revised

"), + ) + except Exception as exc: # noqa: BLE001 - a missing live server is a skip, not a failure + _requires_live(exc) + + assert updated.id == page.id + assert updated.name == f"{page.name} (edited)" + assert "revised" in (updated.description_html or "") + + def test_update_workspace_page(self, client: PlaneClient, workspace_slug: str) -> None: + """Test updating a workspace page.""" + page = client.pages.create_workspace_page( + workspace_slug, + CreatePage(name=f"Test WS Update {int(time.time())}", description_html="

first draft

"), + ) + + try: + updated = client.pages.update_workspace_page( + workspace_slug, page.id, UpdatePage(name=f"{page.name} (edited)") + ) + except Exception as exc: # noqa: BLE001 - a missing live server is a skip, not a failure + _requires_live(exc) + + assert updated.id == page.id + assert updated.name == f"{page.name} (edited)" + + def test_update_project_page_needs_a_field_to_change( + self, client: PlaneClient, workspace_slug: str, project: Project + ) -> None: + """An update carrying nothing is refused rather than reported as a no-op.""" + page = client.pages.create_project_page( + workspace_slug, + project.id, + CreatePage(name=f"Test Empty {int(time.time())}", description_html="

draft

"), + ) + + try: + client.pages.update_project_page(workspace_slug, project.id, page.id, UpdatePage()) + except Exception as exc: # noqa: BLE001 - the message is the assertion + assert "name or description_html" in str(exc) + else: + raise AssertionError("an empty update was accepted") + + def test_delete_project_page_requires_archiving_first( + self, client: PlaneClient, workspace_slug: str, project: Project + ) -> None: + """The API refuses to delete a live page; the message says what to do.""" + page = client.pages.create_project_page( + workspace_slug, + project.id, + CreatePage(name=f"Test Delete {int(time.time())}", description_html="

draft

"), + ) + + try: + client.pages.delete_project_page(workspace_slug, project.id, page.id) + except Exception as exc: # noqa: BLE001 - the message is the assertion + assert "archived" in str(exc) + else: + raise AssertionError("an unarchived page was deleted") From bbb2fb9c6404454b013f0521899474e41e97983c Mon Sep 17 00:00:00 2001 From: akhil-vamshi-konam Date: Sun, 16 Aug 2026 00:13:55 +0530 Subject: [PATCH 2/3] docs: update API documentation for page deletion and enhance test descriptions --- plane/api/pages.py | 4 ++++ tests/unit/test_pages.py | 18 ++++++++++++++---- 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/plane/api/pages.py b/plane/api/pages.py index fa97998..0a9d268 100644 --- a/plane/api/pages.py +++ b/plane/api/pages.py @@ -233,5 +233,9 @@ def delete_project_page(self, workspace_slug: str, project_id: str, page_id: str workspace_slug: The workspace slug identifier project_id: UUID of the project page_id: UUID of the page + + Note: + The page must be archived first; the API answers 400 + "The page should be archived before deleting" otherwise. """ return self._delete(f"{workspace_slug}/projects/{project_id}/pages/{page_id}") diff --git a/tests/unit/test_pages.py b/tests/unit/test_pages.py index 941607d..dbc1ca7 100644 --- a/tests/unit/test_pages.py +++ b/tests/unit/test_pages.py @@ -10,8 +10,12 @@ def _requires_live(exc: Exception) -> None: - """Page content is written by the live collaboration service; skip without it.""" - if "502" in str(exc) or "Failed to update page document" in str(exc): + """Skip when the live collaboration service is absent, and only then. + + It answers one identified failure. Skipping on the status alone would swallow any + other 502 -- a proxy fault or an API regression -- as "environment not available". + """ + if "Failed to update page document" in str(exc): pytest.skip("requires Plane's live collaboration service") raise exc @@ -108,7 +112,10 @@ def test_update_project_page( page = client.pages.create_project_page( workspace_slug, project.id, - CreatePage(name=f"Test Update {int(time.time())}", description_html="

first draft

"), + CreatePage( + name=f"Test Update {int(time.time())}", + description_html="

first draft

", + ), ) try: @@ -129,7 +136,10 @@ def test_update_workspace_page(self, client: PlaneClient, workspace_slug: str) - """Test updating a workspace page.""" page = client.pages.create_workspace_page( workspace_slug, - CreatePage(name=f"Test WS Update {int(time.time())}", description_html="

first draft

"), + CreatePage( + name=f"Test WS Update {int(time.time())}", + description_html="

first draft

", + ), ) try: From 0571179ee40adbe1cafd0022d23b057a50dbfcd1 Mon Sep 17 00:00:00 2001 From: akhil-vamshi-konam Date: Mon, 17 Aug 2026 12:53:04 +0530 Subject: [PATCH 3/3] feat: extend Page model with additional fields for access control and collection management --- plane/models/pages.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/plane/models/pages.py b/plane/models/pages.py index 1058fb1..62cc565 100644 --- a/plane/models/pages.py +++ b/plane/models/pages.py @@ -23,6 +23,15 @@ class Page(BaseModel): anchor: str | None = None workspace: str | None = None projects: list[str] | None = None + access: int | None = None + is_locked: bool | None = None + archived_at: str | None = None + parent_id: str | None = None + # Where the page is filed, and the id of the row that files it there -- the + # second is what addresses the membership when moving the page between + # collections. Both are null for a page in no collection. + collection_id: str | None = None + page_collection_id: str | None = None class CreatePage(BaseModel):