From 6a696f4cade1439214b2428aad42a5f2fc5afbb7 Mon Sep 17 00:00:00 2001 From: edho08 Date: Thu, 3 Sep 2026 15:09:20 +0700 Subject: [PATCH 1/2] feat(api): expose project pages in v1 API --- apps/api/plane/api/urls/__init__.py | 2 + apps/api/plane/api/urls/page.py | 73 ++++++++++++++++++ apps/api/plane/api/views/__init__.py | 8 ++ apps/api/plane/api/views/page.py | 68 +++++++++++++++++ .../plane/tests/contract/api/test_pages.py | 74 +++++++++++++++++++ 5 files changed, 225 insertions(+) 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/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..58dcd93abe4 --- /dev/null +++ b/apps/api/plane/api/urls/page.py @@ -0,0 +1,73 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file in the repository root for details. + +from django.urls import path + +from plane.api.views import ( + PageDuplicateEndpoint, + PageFavoriteViewSet, + PageVersionEndpoint, + PageViewSet, + PagesDescriptionViewSet, +) + +urlpatterns = [ + path( + "workspaces//projects//pages-summary/", + PageViewSet.as_view({"get": "summary"}), + name="api-project-pages-summary", + ), + path( + "workspaces//projects//pages/", + PageViewSet.as_view({"get": "list", "post": "create"}), + name="api-project-pages", + ), + path( + "workspaces//projects//pages//", + PageViewSet.as_view( + {"get": "retrieve", "put": "partial_update", "patch": "partial_update", "delete": "destroy"} + ), + name="api-project-page", + ), + path( + "workspaces//projects//favorite-pages//", + PageFavoriteViewSet.as_view({"post": "create", "delete": "destroy"}), + name="api-user-favorite-pages", + ), + path( + "workspaces//projects//pages//archive/", + PageViewSet.as_view({"post": "archive", "delete": "unarchive"}), + name="api-project-page-archive-unarchive", + ), + path( + "workspaces//projects//pages//lock/", + PageViewSet.as_view({"post": "lock", "delete": "unlock"}), + name="api-project-page-lock-unlock", + ), + path( + "workspaces//projects//pages//access/", + PageViewSet.as_view({"post": "access"}), + name="api-project-page-access", + ), + path( + "workspaces//projects//pages//description/", + PagesDescriptionViewSet.as_view({"get": "retrieve", "patch": "partial_update"}), + name="api-page-description", + ), + path( + "workspaces//projects//pages//versions/", + PageVersionEndpoint.as_view(), + name="api-page-versions", + ), + path( + "workspaces//projects//pages//versions//", + PageVersionEndpoint.as_view(), + name="api-page-version", + ), + path( + "workspaces//projects//pages//duplicate/", + PageDuplicateEndpoint.as_view(), + name="api-page-duplicate", + ), +] diff --git a/apps/api/plane/api/views/__init__.py b/apps/api/plane/api/views/__init__.py index 5e4660a7b2b..4ff848ebf49 100644 --- a/apps/api/plane/api/views/__init__.py +++ b/apps/api/plane/api/views/__init__.py @@ -72,3 +72,11 @@ from .invite import WorkspaceInvitationsViewset from .sticky import StickyViewSet + +from .page import ( + PageDuplicateEndpoint, + PageFavoriteViewSet, + PageVersionEndpoint, + PageViewSet, + PagesDescriptionViewSet, +) diff --git a/apps/api/plane/api/views/page.py b/apps/api/plane/api/views/page.py new file mode 100644 index 00000000000..a6b8b803991 --- /dev/null +++ b/apps/api/plane/api/views/page.py @@ -0,0 +1,68 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + +"""API-key authenticated Page endpoints for the public v1 API.""" + +from plane.api.middleware.api_authentication import APIKeyAuthentication +from plane.app.serializers import PageSerializer +from plane.app.views.page.base import ( + PageDuplicateEndpoint as AppPageDuplicateEndpoint, + PageFavoriteViewSet as AppPageFavoriteViewSet, + PageViewSet as AppPageViewSet, + PagesDescriptionViewSet as AppPagesDescriptionViewSet, +) +from plane.app.views.page.version import PageVersionEndpoint as AppPageVersionEndpoint +from plane.db.models import Project, ProjectMember + + +class PageViewSet(AppPageViewSet): + """Expose the existing Page CRUD handlers through API-key authentication.""" + + authentication_classes = [APIKeyAuthentication] + + def list(self, request, slug, project_id): + """Return project pages in the pagination format used by the SDK.""" + queryset = self.get_queryset() + project = Project.objects.get(pk=project_id) + if ( + ProjectMember.objects.filter( + workspace__slug=slug, + project_id=project_id, + member=request.user, + role=5, + is_active=True, + ).exists() + and not project.guest_view_all_features + ): + queryset = queryset.filter(owned_by=request.user) + + return self.paginate( + request=request, + queryset=queryset, + on_results=lambda pages: PageSerializer(pages, many=True).data, + ) + + +class PageFavoriteViewSet(AppPageFavoriteViewSet): + """Expose Page favorite handlers through API-key authentication.""" + + authentication_classes = [APIKeyAuthentication] + + +class PagesDescriptionViewSet(AppPagesDescriptionViewSet): + """Expose Page description handlers through API-key authentication.""" + + authentication_classes = [APIKeyAuthentication] + + +class PageVersionEndpoint(AppPageVersionEndpoint): + """Expose Page version handlers through API-key authentication.""" + + authentication_classes = [APIKeyAuthentication] + + +class PageDuplicateEndpoint(AppPageDuplicateEndpoint): + """Expose Page duplication through API-key authentication.""" + + authentication_classes = [APIKeyAuthentication] 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..3660d0cafb5 --- /dev/null +++ b/apps/api/plane/tests/contract/api/test_pages.py @@ -0,0 +1,74 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file in the repository root for details. + +from uuid import uuid4 + +import pytest +from django.urls import resolve +from rest_framework import status + +from plane.db.models import Project, ProjectMember + + +def _project_url(slug, project_id): + return f"/api/v1/workspaces/{slug}/projects/{project_id}/" + + +@pytest.mark.contract +def test_page_v1_routes_are_registered(): + project_id = uuid4() + page_id = uuid4() + + base = _project_url("workspace", project_id) + urls = [ + f"{base}pages-summary/", + f"{base}pages/", + f"{base}pages/{page_id}/", + f"{base}favorite-pages/{page_id}/", + f"{base}pages/{page_id}/archive/", + f"{base}pages/{page_id}/lock/", + f"{base}pages/{page_id}/access/", + f"{base}pages/{page_id}/description/", + f"{base}pages/{page_id}/versions/", + f"{base}pages/{page_id}/versions/{uuid4()}/", + f"{base}pages/{page_id}/duplicate/", + ] + + for url in urls: + assert resolve(url).func.view_class.__module__ == "plane.api.views.page" + + +@pytest.mark.contract +@pytest.mark.django_db +def test_page_v1_rejects_requests_without_api_key(api_client): + url = f"{_project_url('workspace', uuid4())}pages/" + + response = api_client.get(url) + + assert response.status_code in (status.HTTP_401_UNAUTHORIZED, status.HTTP_403_FORBIDDEN) + + +@pytest.mark.contract +@pytest.mark.django_db +def test_page_v1_accepts_api_key(api_key_client, workspace, create_user): + project = Project.objects.create( + name="Page API Project", + identifier="PAPI", + workspace=workspace, + created_by=create_user, + ) + ProjectMember.objects.create( + workspace=workspace, + project=project, + member=create_user, + role=20, + ) + + response = api_key_client.get(f"{_project_url(workspace.slug, project.id)}pages/") + + assert response.status_code == status.HTTP_200_OK + payload = response.json() + assert payload["results"] == [] + assert payload["total_count"] == 0 + assert payload["next_page_results"] is False From 915e6dae47ef6bb8dccb8da138d25fcb92ce3c7d Mon Sep 17 00:00:00 2001 From: edho08 Date: Fri, 4 Sep 2026 10:12:03 +0700 Subject: [PATCH 2/2] test(api): cover project page routes and CRUD --- apps/api/plane/api/views/page.py | 2 +- .../plane/tests/contract/api/test_pages.py | 82 +++++++++++++++---- 2 files changed, 66 insertions(+), 18 deletions(-) diff --git a/apps/api/plane/api/views/page.py b/apps/api/plane/api/views/page.py index a6b8b803991..5487b294f93 100644 --- a/apps/api/plane/api/views/page.py +++ b/apps/api/plane/api/views/page.py @@ -22,7 +22,7 @@ class PageViewSet(AppPageViewSet): authentication_classes = [APIKeyAuthentication] def list(self, request, slug, project_id): - """Return project pages in the pagination format used by the SDK.""" + """Return project pages in the v1 cursor-pagination envelope.""" queryset = self.get_queryset() project = Project.objects.get(pk=project_id) if ( diff --git a/apps/api/plane/tests/contract/api/test_pages.py b/apps/api/plane/tests/contract/api/test_pages.py index 3660d0cafb5..4cdbc9cbf00 100644 --- a/apps/api/plane/tests/contract/api/test_pages.py +++ b/apps/api/plane/tests/contract/api/test_pages.py @@ -2,19 +2,37 @@ # SPDX-License-Identifier: AGPL-3.0-only # See the LICENSE file in the repository root for details. +from unittest.mock import patch from uuid import uuid4 import pytest from django.urls import resolve from rest_framework import status -from plane.db.models import Project, ProjectMember +from plane.db.models import Page, Project, ProjectMember, ProjectPage def _project_url(slug, project_id): return f"/api/v1/workspaces/{slug}/projects/{project_id}/" +@pytest.fixture +def page_project(workspace, create_user): + project = Project.objects.create( + name="Page API Project", + identifier=f"P{uuid4().hex[:5].upper()}", + workspace=workspace, + created_by=create_user, + ) + ProjectMember.objects.create( + workspace=workspace, + project=project, + member=create_user, + role=20, + ) + return project + + @pytest.mark.contract def test_page_v1_routes_are_registered(): project_id = uuid4() @@ -36,11 +54,10 @@ def test_page_v1_routes_are_registered(): ] for url in urls: - assert resolve(url).func.view_class.__module__ == "plane.api.views.page" + assert resolve(url).func.cls.__module__ == "plane.api.views.page" @pytest.mark.contract -@pytest.mark.django_db def test_page_v1_rejects_requests_without_api_key(api_client): url = f"{_project_url('workspace', uuid4())}pages/" @@ -51,20 +68,8 @@ def test_page_v1_rejects_requests_without_api_key(api_client): @pytest.mark.contract @pytest.mark.django_db -def test_page_v1_accepts_api_key(api_key_client, workspace, create_user): - project = Project.objects.create( - name="Page API Project", - identifier="PAPI", - workspace=workspace, - created_by=create_user, - ) - ProjectMember.objects.create( - workspace=workspace, - project=project, - member=create_user, - role=20, - ) - +def test_page_v1_accepts_api_key(api_key_client, workspace, page_project): + project = page_project response = api_key_client.get(f"{_project_url(workspace.slug, project.id)}pages/") assert response.status_code == status.HTTP_200_OK @@ -72,3 +77,46 @@ def test_page_v1_accepts_api_key(api_key_client, workspace, create_user): assert payload["results"] == [] assert payload["total_count"] == 0 assert payload["next_page_results"] is False + + +@pytest.mark.contract +@pytest.mark.django_db +def test_existing_session_page_route_remains_available(session_client, workspace, page_project): + url = f"/api/workspaces/{workspace.slug}/projects/{page_project.id}/pages/" + + response = session_client.get(url) + + assert response.status_code == status.HTTP_200_OK + assert response.json() == [] + + +@pytest.mark.contract +@pytest.mark.django_db +def test_page_v1_supports_create_list_and_put_update(api_key_client, workspace, page_project): + pages_url = f"{_project_url(workspace.slug, page_project.id)}pages/" + + with patch("plane.app.views.page.base.page_transaction.delay"): + create_response = api_key_client.post( + pages_url, + {"name": "API Page", "description_html": "

Initial body

"}, + format="json", + ) + + assert create_response.status_code == status.HTTP_201_CREATED, create_response.data + page_id = create_response.data["id"] + page = Page.objects.get(id=page_id) + assert ProjectPage.objects.filter(project=page_project, page=page, deleted_at__isnull=True).exists() + + list_response = api_key_client.get(pages_url) + assert list_response.status_code == status.HTTP_200_OK + assert [result["id"] for result in list_response.json()["results"]] == [str(page_id)] + + with patch("plane.app.views.page.base.page_transaction.delay"): + update_response = api_key_client.put( + f"{pages_url}{page_id}/", + {"name": "Updated API Page"}, + format="json", + ) + + assert update_response.status_code == status.HTTP_200_OK, update_response.data + assert Page.objects.get(id=page_id).name == "Updated API Page"