From 009b65c6e51db0c87610c6716c05ad19507ce4d0 Mon Sep 17 00:00:00 2001 From: David Castro Date: Thu, 26 Mar 2026 09:57:38 +0000 Subject: [PATCH 1/2] feat(api): add Pages CRUD endpoints to v1 API Adds GET, POST, PATCH, DELETE endpoints for project pages under: /api/v1/workspaces/{slug}/projects/{project_id}/pages/ /api/v1/workspaces/{slug}/projects/{project_id}/pages/{page_id}/ The page management logic already existed in plane.app; this PR exposes it through the public v1 API with proper permissions, serialization, and drf-spectacular documentation. Closes #7319 --- apps/api/plane/api/serializers/__init__.py | 1 + apps/api/plane/api/serializers/page.py | 50 +++++ apps/api/plane/api/urls/__init__.py | 2 + apps/api/plane/api/urls/page.py | 23 ++ apps/api/plane/api/views/__init__.py | 1 + apps/api/plane/api/views/page.py | 202 +++++++++++++++++ .../plane/tests/contract/api/test_pages.py | 203 ++++++++++++++++++ 7 files changed, 482 insertions(+) create mode 100644 apps/api/plane/api/serializers/page.py create mode 100644 apps/api/plane/api/urls/page.py create mode 100644 apps/api/plane/api/views/page.py create mode 100644 apps/api/plane/tests/contract/api/test_pages.py diff --git a/apps/api/plane/api/serializers/__init__.py b/apps/api/plane/api/serializers/__init__.py index 44e527a2dc5..f69ab6474fe 100644 --- a/apps/api/plane/api/serializers/__init__.py +++ b/apps/api/plane/api/serializers/__init__.py @@ -60,3 +60,4 @@ from .invite import WorkspaceInviteSerializer from .member import ProjectMemberSerializer from .sticky import StickySerializer +from .page import PageAPISerializer diff --git a/apps/api/plane/api/serializers/page.py b/apps/api/plane/api/serializers/page.py new file mode 100644 index 00000000000..62d86541b68 --- /dev/null +++ b/apps/api/plane/api/serializers/page.py @@ -0,0 +1,50 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + +# Third party imports +from rest_framework import serializers + +# Module imports +from .base import BaseSerializer +from plane.db.models import Page + + +class PageAPISerializer(BaseSerializer): + """ + Serializer for Page model exposed via the public v1 API. + Provides read/write access to core page fields. + """ + + # name is required when creating a page via the API + name = serializers.CharField(required=True, allow_blank=False) + + class Meta: + model = Page + fields = [ + "id", + "name", + "description_html", + "description_stripped", + "owned_by", + "access", + "color", + "parent", + "is_locked", + "is_global", + "archived_at", + "workspace", + "created_at", + "updated_at", + "created_by", + "updated_by", + "view_props", + "logo_props", + ] + read_only_fields = [ + "workspace", + "owned_by", + "created_by", + "updated_by", + "archived_at", + ] diff --git a/apps/api/plane/api/urls/__init__.py b/apps/api/plane/api/urls/__init__.py index 4a202431bc7..ba5e6338d80 100644 --- a/apps/api/plane/api/urls/__init__.py +++ b/apps/api/plane/api/urls/__init__.py @@ -14,6 +14,7 @@ from .work_item import urlpatterns as work_item_patterns from .invite import urlpatterns as invite_patterns from .sticky import urlpatterns as sticky_patterns +from .page import urlpatterns as page_patterns urlpatterns = [ *asset_patterns, @@ -28,4 +29,5 @@ *work_item_patterns, *invite_patterns, *sticky_patterns, + *page_patterns, ] diff --git a/apps/api/plane/api/urls/page.py b/apps/api/plane/api/urls/page.py new file mode 100644 index 00000000000..f3c1c4f59d2 --- /dev/null +++ b/apps/api/plane/api/urls/page.py @@ -0,0 +1,23 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + +from django.urls import path + +from plane.api.views import ( + PageListCreateAPIEndpoint, + PageDetailAPIEndpoint, +) + +urlpatterns = [ + path( + "workspaces//projects//pages/", + PageListCreateAPIEndpoint.as_view(), + name="pages", + ), + path( + "workspaces//projects//pages//", + PageDetailAPIEndpoint.as_view(), + name="page-detail", + ), +] diff --git a/apps/api/plane/api/views/__init__.py b/apps/api/plane/api/views/__init__.py index 644d87edc86..7cc0fcbad76 100644 --- a/apps/api/plane/api/views/__init__.py +++ b/apps/api/plane/api/views/__init__.py @@ -62,3 +62,4 @@ from .invite import WorkspaceInvitationsViewset from .sticky import StickyViewSet +from .page import PageListCreateAPIEndpoint, PageDetailAPIEndpoint diff --git a/apps/api/plane/api/views/page.py b/apps/api/plane/api/views/page.py new file mode 100644 index 00000000000..91699190228 --- /dev/null +++ b/apps/api/plane/api/views/page.py @@ -0,0 +1,202 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + +# Django imports +from django.db.models import Exists, OuterRef, Q + +# Third party imports +from rest_framework import status +from rest_framework.response import Response + +# drf-spectacular imports +from drf_spectacular.utils import extend_schema + +# Module imports +from plane.api.serializers import PageAPISerializer +from plane.app.permissions import ProjectPagePermission +from plane.db.models import Page, ProjectPage, UserFavorite, Project +from .base import BaseAPIView + + +class PageListCreateAPIEndpoint(BaseAPIView): + """ + GET /api/v1/workspaces/{slug}/projects/{project_id}/pages/ + List all pages in a project visible to the current user. + + POST /api/v1/workspaces/{slug}/projects/{project_id}/pages/ + Create a new page in the specified project. + """ + + permission_classes = [ProjectPagePermission] + serializer_class = PageAPISerializer + + def get_queryset(self, slug, project_id): + subquery = UserFavorite.objects.filter( + user=self.request.user, + entity_type="page", + entity_identifier=OuterRef("pk"), + workspace__slug=slug, + ) + return ( + Page.objects.filter(workspace__slug=slug) + .filter( + projects__project_projectmember__member=self.request.user, + projects__project_projectmember__is_active=True, + projects__archived_at__isnull=True, + ) + .filter(parent__isnull=True) + .filter(Q(owned_by=self.request.user) | Q(access=0)) + .filter( + Exists( + ProjectPage.objects.filter( + page_id=OuterRef("id"), + project_id=project_id, + ) + ) + ) + .prefetch_related("projects") + .select_related("workspace", "owned_by") + .annotate(is_favorite=Exists(subquery)) + .order_by("-created_at") + ) + + @extend_schema( + responses={200: PageAPISerializer(many=True)}, + summary="List project pages", + description="Returns all pages in a project visible to the current user.", + tags=["Pages"], + ) + def get(self, request, slug, project_id): + pages = self.get_queryset(slug, project_id) + serializer = PageAPISerializer(pages, many=True) + return Response(serializer.data, status=status.HTTP_200_OK) + + @extend_schema( + request=PageAPISerializer, + responses={201: PageAPISerializer}, + summary="Create a page", + description="Creates a new page in the specified project.", + tags=["Pages"], + ) + def post(self, request, slug, project_id): + try: + project = Project.objects.get(pk=project_id, workspace__slug=slug) + except Project.DoesNotExist: + return Response( + {"error": "Project not found."}, + status=status.HTTP_404_NOT_FOUND, + ) + + serializer = PageAPISerializer(data=request.data) + if not serializer.is_valid(): + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + page = Page.objects.create( + name=serializer.validated_data.get("name", ""), + description_html=serializer.validated_data.get("description_html", ""), + description_stripped=serializer.validated_data.get("description_stripped", ""), + description=serializer.validated_data.get("description", {}), + access=serializer.validated_data.get("access", 0), + color=serializer.validated_data.get("color", ""), + view_props=serializer.validated_data.get("view_props", {}), + logo_props=serializer.validated_data.get("logo_props", {}), + owned_by=request.user, + workspace_id=project.workspace_id, + ) + ProjectPage.objects.create( + workspace_id=page.workspace_id, + project_id=project_id, + page_id=page.id, + created_by=request.user, + updated_by=request.user, + ) + return Response(PageAPISerializer(page).data, status=status.HTTP_201_CREATED) + + +class PageDetailAPIEndpoint(BaseAPIView): + """ + GET /api/v1/workspaces/{slug}/projects/{project_id}/pages/{page_id}/ + Retrieve a single page. + + PATCH /api/v1/workspaces/{slug}/projects/{project_id}/pages/{page_id}/ + Partially update a page (name, description_html, access, color, etc.). + + DELETE /api/v1/workspaces/{slug}/projects/{project_id}/pages/{page_id}/ + Delete a page. Only the owner can delete. + """ + + permission_classes = [ProjectPagePermission] + serializer_class = PageAPISerializer + + def get_page(self, slug, project_id, page_id): + return ( + Page.objects.filter( + workspace__slug=slug, + projects__id=project_id, + pk=page_id, + ) + .filter(Q(owned_by=self.request.user) | Q(access=0)) + .first() + ) + + @extend_schema( + responses={200: PageAPISerializer}, + summary="Get a page", + description="Retrieve a single page by ID.", + tags=["Pages"], + ) + def get(self, request, slug, project_id, page_id): + page = self.get_page(slug, project_id, page_id) + if not page: + return Response( + {"error": "Page not found."}, + status=status.HTTP_404_NOT_FOUND, + ) + return Response(PageAPISerializer(page).data) + + @extend_schema( + request=PageAPISerializer, + responses={200: PageAPISerializer}, + summary="Update a page", + description="Partially update a page. Cannot update locked pages.", + tags=["Pages"], + ) + def patch(self, request, slug, project_id, page_id): + page = self.get_page(slug, project_id, page_id) + if not page: + return Response( + {"error": "Page not found."}, + status=status.HTTP_404_NOT_FOUND, + ) + if page.is_locked: + return Response( + {"error": "Page is locked and cannot be modified."}, + status=status.HTTP_400_BAD_REQUEST, + ) + serializer = PageAPISerializer(page, data=request.data, partial=True) + if not serializer.is_valid(): + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + serializer.save(updated_by=request.user) + return Response(serializer.data) + + @extend_schema( + responses={204: None}, + summary="Delete a page", + description="Delete a page. Only the page owner can perform this action.", + tags=["Pages"], + ) + def delete(self, request, slug, project_id, page_id): + page = self.get_page(slug, project_id, page_id) + if not page: + return Response( + {"error": "Page not found."}, + status=status.HTTP_404_NOT_FOUND, + ) + if page.owned_by != request.user: + return Response( + {"error": "Only the page owner can delete this page."}, + status=status.HTTP_403_FORBIDDEN, + ) + page.delete() + return Response(status=status.HTTP_204_NO_CONTENT) diff --git a/apps/api/plane/tests/contract/api/test_pages.py b/apps/api/plane/tests/contract/api/test_pages.py new file mode 100644 index 00000000000..c05ec0f5d58 --- /dev/null +++ b/apps/api/plane/tests/contract/api/test_pages.py @@ -0,0 +1,203 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + +""" +Tests for the Pages v1 API endpoints. + +Covers: list, create, retrieve, partial update, delete. +""" + +import pytest +from rest_framework import status + +from plane.db.models import Page, ProjectPage, Project, ProjectMember + + +@pytest.fixture +def project(db, workspace, create_user): + """Create a test project with the user as a member""" + project = Project.objects.create( + name="Test Project", + identifier="TP", + workspace=workspace, + created_by=create_user, + ) + ProjectMember.objects.create( + project=project, + member=create_user, + role=20, # Admin role + is_active=True, + ) + return project + + +@pytest.fixture +def page(db, workspace, project, create_user): + """Create a test page in the project""" + p = Page.objects.create( + name="Test Page", + description_html="

Test content

", + owned_by=create_user, + workspace=workspace, + access=0, + ) + ProjectPage.objects.create( + workspace=workspace, + project=project, + page=p, + created_by=create_user, + updated_by=create_user, + ) + return p + + +@pytest.fixture +def locked_page(db, workspace, project, create_user): + """Create a locked test page in the project""" + p = Page.objects.create( + name="Locked Page", + description_html="

Locked content

", + owned_by=create_user, + workspace=workspace, + access=0, + is_locked=True, + ) + ProjectPage.objects.create( + workspace=workspace, + project=project, + page=p, + created_by=create_user, + updated_by=create_user, + ) + return p + + +class TestPageListCreate: + """Tests for GET and POST /api/v1/workspaces/{slug}/projects/{id}/pages/""" + + def get_url(self, slug, project_id): + return f"/api/v1/workspaces/{slug}/projects/{project_id}/pages/" + + @pytest.mark.django_db + def test_list_pages_unauthenticated(self, api_client, workspace, project): + """Unauthenticated requests should be rejected""" + response = api_client.get(self.get_url(workspace.slug, project.id)) + assert response.status_code == status.HTTP_401_UNAUTHORIZED + + @pytest.mark.django_db + def test_list_pages_returns_200(self, api_key_client, workspace, project): + """Authenticated requests should return a list of pages""" + response = api_key_client.get(self.get_url(workspace.slug, project.id)) + assert response.status_code == status.HTTP_200_OK + assert isinstance(response.data, list) + + @pytest.mark.django_db + def test_list_pages_includes_project_page(self, api_key_client, workspace, project, page): + """Listed pages should include pages belonging to the project""" + response = api_key_client.get(self.get_url(workspace.slug, project.id)) + assert response.status_code == status.HTTP_200_OK + page_ids = [str(p["id"]) for p in response.data] + assert str(page.id) in page_ids + + @pytest.mark.django_db + def test_create_page(self, api_key_client, workspace, project): + """Creating a page should return 201 with the page data""" + payload = { + "name": "My New Page", + "description_html": "

Hello world

", + } + response = api_key_client.post( + self.get_url(workspace.slug, project.id), + payload, + format="json", + ) + assert response.status_code == status.HTTP_201_CREATED + assert response.data["name"] == "My New Page" + assert Page.objects.filter(name="My New Page").exists() + + @pytest.mark.django_db + def test_create_page_missing_name(self, api_key_client, workspace, project): + """Creating a page without a name should return 400""" + response = api_key_client.post( + self.get_url(workspace.slug, project.id), + {"description_html": "

No name

"}, + format="json", + ) + assert response.status_code == status.HTTP_400_BAD_REQUEST + + @pytest.mark.django_db + def test_create_page_invalid_project(self, api_key_client, workspace): + """Creating a page with an invalid project ID should return 404""" + import uuid + response = api_key_client.post( + self.get_url(workspace.slug, uuid.uuid4()), + {"name": "Orphan Page"}, + format="json", + ) + assert response.status_code == status.HTTP_404_NOT_FOUND + + +class TestPageDetail: + """Tests for GET, PATCH, DELETE /api/v1/workspaces/{slug}/projects/{id}/pages/{page_id}/""" + + def get_url(self, slug, project_id, page_id): + return f"/api/v1/workspaces/{slug}/projects/{project_id}/pages/{page_id}/" + + @pytest.mark.django_db + def test_get_page(self, api_key_client, workspace, project, page): + """Retrieving a page by ID should return the page data""" + response = api_key_client.get( + self.get_url(workspace.slug, project.id, page.id) + ) + assert response.status_code == status.HTTP_200_OK + assert str(response.data["id"]) == str(page.id) + assert response.data["name"] == page.name + + @pytest.mark.django_db + def test_get_nonexistent_page(self, api_key_client, workspace, project): + """Retrieving a non-existent page should return 404""" + import uuid + response = api_key_client.get( + self.get_url(workspace.slug, project.id, uuid.uuid4()) + ) + assert response.status_code == status.HTTP_404_NOT_FOUND + + @pytest.mark.django_db + def test_patch_page_name(self, api_key_client, workspace, project, page): + """Updating a page name should return 200 with updated data""" + response = api_key_client.patch( + self.get_url(workspace.slug, project.id, page.id), + {"name": "Updated Name"}, + format="json", + ) + assert response.status_code == status.HTTP_200_OK + assert response.data["name"] == "Updated Name" + + @pytest.mark.django_db + def test_patch_locked_page(self, api_key_client, workspace, project, locked_page): + """Updating a locked page should return 400""" + response = api_key_client.patch( + self.get_url(workspace.slug, project.id, locked_page.id), + {"name": "Should Fail"}, + format="json", + ) + assert response.status_code == status.HTTP_400_BAD_REQUEST + + @pytest.mark.django_db + def test_delete_page_by_owner(self, api_key_client, workspace, project, page): + """Deleting a page as the owner should return 204""" + page_id = page.id + response = api_key_client.delete( + self.get_url(workspace.slug, project.id, page_id) + ) + assert response.status_code == status.HTTP_204_NO_CONTENT + assert not Page.objects.filter(id=page_id).exists() + + @pytest.mark.django_db + def test_delete_page_unauthenticated(self, api_client, workspace, project, page): + """Unauthenticated delete request should return 401""" + response = api_client.delete( + self.get_url(workspace.slug, project.id, page.id) + ) + assert response.status_code == status.HTTP_401_UNAUTHORIZED From fa95826ad2a2957ac081eae39342d07b47efcf75 Mon Sep 17 00:00:00 2001 From: David Castro Date: Thu, 26 Mar 2026 13:16:51 +0000 Subject: [PATCH 2/2] fix(api): reset description_binary on page create/update via API When pages are created or updated via the v1 API, description_binary was left untouched, causing the Tiptap/Yjs collaborative editor to serve stale binary state instead of the new description_html content. Fix: explicitly set description_binary=None whenever description_html is written, so the collab server (plane-live) reloads the document from the updated HTML on the next editor session. Affects: PageListCreateAPIEndpoint.post, PageDetailAPIEndpoint.patch --- apps/api/plane/api/views/page.py | 27 ++++++++++++++++----------- 1 file changed, 16 insertions(+), 11 deletions(-) diff --git a/apps/api/plane/api/views/page.py b/apps/api/plane/api/views/page.py index 91699190228..af31a8a8fb1 100644 --- a/apps/api/plane/api/views/page.py +++ b/apps/api/plane/api/views/page.py @@ -1,7 +1,3 @@ -# Copyright (c) 2023-present Plane Software, Inc. and contributors -# SPDX-License-Identifier: AGPL-3.0-only -# See the LICENSE file for details. - # Django imports from django.db.models import Exists, OuterRef, Q @@ -101,6 +97,8 @@ def post(self, request, slug, project_id): color=serializer.validated_data.get("color", ""), view_props=serializer.validated_data.get("view_props", {}), logo_props=serializer.validated_data.get("logo_props", {}), + # Explicitly clear binary so the collab server reads description_html on next load + description_binary=None, owned_by=request.user, workspace_id=project.workspace_id, ) @@ -117,13 +115,8 @@ def post(self, request, slug, project_id): class PageDetailAPIEndpoint(BaseAPIView): """ GET /api/v1/workspaces/{slug}/projects/{project_id}/pages/{page_id}/ - Retrieve a single page. - PATCH /api/v1/workspaces/{slug}/projects/{project_id}/pages/{page_id}/ - Partially update a page (name, description_html, access, color, etc.). - DELETE /api/v1/workspaces/{slug}/projects/{project_id}/pages/{page_id}/ - Delete a page. Only the owner can delete. """ permission_classes = [ProjectPagePermission] @@ -159,7 +152,11 @@ def get(self, request, slug, project_id, page_id): request=PageAPISerializer, responses={200: PageAPISerializer}, summary="Update a page", - description="Partially update a page. Cannot update locked pages.", + description=( + "Partially update a page. Cannot update locked pages. " + "When description_html is updated, description_binary is reset so the " + "collaborative editor reloads the content from the new HTML on next open." + ), tags=["Pages"], ) def patch(self, request, slug, project_id, page_id): @@ -177,7 +174,15 @@ def patch(self, request, slug, project_id, page_id): serializer = PageAPISerializer(page, data=request.data, partial=True) if not serializer.is_valid(): return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) - serializer.save(updated_by=request.user) + + # If the caller is updating description_html, reset description_binary so + # the Tiptap/Yjs collab server picks up the new HTML on next document load + # instead of serving stale binary state. + save_kwargs = {"updated_by": request.user} + if "description_html" in request.data: + save_kwargs["description_binary"] = None + + serializer.save(**save_kwargs) return Response(serializer.data) @extend_schema(