From 011cae17bdf2c0d4efa3d3fc01771e94e36f2c49 Mon Sep 17 00:00:00 2001 From: Claude-C Date: Tue, 7 Apr 2026 10:21:05 -0700 Subject: [PATCH] feat(api): add complete Pages CRUD endpoints to v1 API Adds list, create, retrieve, update, delete, archive/unarchive, and lock/unlock endpoints for project pages in the public v1 API. Combines the best approaches from PRs #8650, #8669, and #8800: - Pagination on list endpoint (from #8650/#8669) - external_id/external_source support (from #8650/#8669) - description_binary reset on create/update (from #8800) - drf-spectacular OpenAPI annotations (from #8800) - transaction.atomic for data integrity (from #8650/#8669) - page_transaction task for version history (from #8650/#8669) - Archive/unarchive and lock/unlock endpoints (from #8650) - Comprehensive test suite Closes #8598, closes #7319 Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/api/plane/api/serializers/__init__.py | 1 + apps/api/plane/api/serializers/page.py | 52 ++ apps/api/plane/api/urls/__init__.py | 2 + apps/api/plane/api/urls/page.py | 39 ++ apps/api/plane/api/views/__init__.py | 7 + apps/api/plane/api/views/page.py | 511 ++++++++++++++++++ .../plane/tests/contract/api/test_pages.py | 460 ++++++++++++++++ 7 files changed, 1072 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 2ab639d5466..dc43caaf095 100644 --- a/apps/api/plane/api/serializers/__init__.py +++ b/apps/api/plane/api/serializers/__init__.py @@ -64,3 +64,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..e373f6aa5ae --- /dev/null +++ b/apps/api/plane/api/serializers/page.py @@ -0,0 +1,52 @@ +# 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 pages in the public v1 API. + + Provides page data including metadata, access control, and + external integration fields for third-party sync workflows. + """ + + class Meta: + model = Page + fields = [ + "id", + "name", + "description_html", + "access", + "color", + "is_locked", + "archived_at", + "view_props", + "logo_props", + "external_id", + "external_source", + "owned_by", + "parent", + "sort_order", + "created_at", + "updated_at", + "created_by", + "updated_by", + ] + read_only_fields = [ + "id", + "is_locked", + "archived_at", + "owned_by", + "created_at", + "updated_at", + "created_by", + "updated_by", + ] diff --git a/apps/api/plane/api/urls/__init__.py b/apps/api/plane/api/urls/__init__.py index 4a202431bc7..fc98a9454ab 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, @@ -22,6 +23,7 @@ *label_patterns, *member_patterns, *module_patterns, + *page_patterns, *project_patterns, *state_patterns, *user_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..53a1e7096a9 --- /dev/null +++ b/apps/api/plane/api/urls/page.py @@ -0,0 +1,39 @@ +# 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.page import ( + PageListCreateAPIEndpoint, + PageDetailAPIEndpoint, + PageArchiveAPIEndpoint, + PageLockAPIEndpoint, +) + +urlpatterns = [ + path( + "workspaces//projects//pages/", + PageListCreateAPIEndpoint.as_view(http_method_names=["get", "post"]), + name="page-list-create", + ), + path( + "workspaces//projects//pages//", + PageDetailAPIEndpoint.as_view( + http_method_names=["get", "patch", "delete"] + ), + name="page-detail", + ), + path( + "workspaces//projects//pages//archive/", + PageArchiveAPIEndpoint.as_view( + http_method_names=["post", "delete"] + ), + name="page-archive", + ), + path( + "workspaces//projects//pages//lock/", + PageLockAPIEndpoint.as_view(http_method_names=["post", "delete"]), + name="page-lock", + ), +] diff --git a/apps/api/plane/api/views/__init__.py b/apps/api/plane/api/views/__init__.py index e8549afb437..5b8a575877f 100644 --- a/apps/api/plane/api/views/__init__.py +++ b/apps/api/plane/api/views/__init__.py @@ -63,3 +63,10 @@ from .invite import WorkspaceInvitationsViewset from .sticky import StickyViewSet + +from .page import ( + PageListCreateAPIEndpoint, + PageDetailAPIEndpoint, + PageArchiveAPIEndpoint, + PageLockAPIEndpoint, +) diff --git a/apps/api/plane/api/views/page.py b/apps/api/plane/api/views/page.py new file mode 100644 index 00000000000..ad6d029cd38 --- /dev/null +++ b/apps/api/plane/api/views/page.py @@ -0,0 +1,511 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + +# Python imports +from datetime import datetime + +# Django imports +from django.db import connection, transaction +from django.utils import timezone + +# Third party imports +from rest_framework import status +from rest_framework.response import Response +from drf_spectacular.utils import extend_schema, OpenApiResponse + +# Module imports +from plane.api.serializers import PageAPISerializer +from plane.app.permissions import ProjectPagePermission +from plane.db.models import ( + Page, + Project, + ProjectMember, + ProjectPage, + UserFavorite, + UserRecentVisit, +) +from plane.bgtasks.page_transaction_task import page_transaction + +from .base import BaseAPIView + + +def unarchive_archive_page_and_descendants(page_id, archived_at): + """Archive or unarchive a page and all its descendant pages.""" + sql = """ + WITH RECURSIVE descendants AS ( + SELECT id FROM pages WHERE id = %s + UNION ALL + SELECT pages.id FROM pages, descendants WHERE pages.parent_id = descendants.id + ) + UPDATE pages SET archived_at = %s WHERE id IN (SELECT id FROM descendants); + """ + with connection.cursor() as cursor: + cursor.execute(sql, [page_id, archived_at]) + + +class PageListCreateAPIEndpoint(BaseAPIView): + """Page List and Create Endpoint for the public v1 API.""" + + serializer_class = PageAPISerializer + model = Page + permission_classes = [ProjectPagePermission] + use_read_replica = True + + def get_queryset(self): + return ( + Page.objects.filter(workspace__slug=self.kwargs.get("slug")) + .filter( + projects__id=self.kwargs.get("project_id"), + projects__archived_at__isnull=True, + ) + .filter(project_pages__deleted_at__isnull=True) + .select_related("workspace", "owned_by") + .order_by("-created_at") + .distinct() + ) + + @extend_schema( + operation_id="list_pages", + summary="List pages", + description="Retrieve a paginated list of all pages in a project.", + tags=["Pages"], + responses={ + 200: OpenApiResponse( + description="Paginated list of pages", + response=PageAPISerializer(many=True), + ), + }, + ) + def get(self, request, slug, project_id): + """List pages + + Retrieve a paginated list of all pages in a project. + Excludes archived pages by default. + """ + queryset = self.get_queryset().filter(archived_at__isnull=True) + + return self.paginate( + request=request, + queryset=queryset, + on_results=lambda pages: PageAPISerializer( + pages, + many=True, + fields=self.fields, + expand=self.expand, + ).data, + ) + + @extend_schema( + operation_id="create_page", + summary="Create page", + description="Create a new page in the specified project.", + tags=["Pages"], + request=PageAPISerializer, + responses={ + 201: OpenApiResponse( + description="Page created", + response=PageAPISerializer, + ), + 409: OpenApiResponse(description="Duplicate external_id"), + }, + ) + def post(self, request, slug, project_id): + """Create page + + Create a new page in the specified project. + Supports external_id/external_source for third-party integrations. + """ + project = Project.objects.get(pk=project_id) + + serializer = PageAPISerializer(data=request.data) + if serializer.is_valid(): + # Check for duplicate external_id + if ( + request.data.get("external_id") + and request.data.get("external_source") + and Page.objects.filter( + projects__id=project_id, + workspace__slug=slug, + external_source=request.data.get("external_source"), + external_id=request.data.get("external_id"), + ).exists() + ): + existing = Page.objects.filter( + workspace__slug=slug, + projects__id=project_id, + external_source=request.data.get("external_source"), + external_id=request.data.get("external_id"), + ).first() + return Response( + { + "error": "Page with the same external id and external source already exists", + "id": str(existing.id), + }, + status=status.HTTP_409_CONFLICT, + ) + + with transaction.atomic(): + page = serializer.save( + owned_by=request.user, + workspace_id=project.workspace_id, + description_html=request.data.get( + "description_html", "

" + ), + description_binary=None, + ) + + ProjectPage.objects.create( + workspace_id=project.workspace_id, + project_id=project_id, + page_id=page.id, + created_by_id=request.user.id, + updated_by_id=request.user.id, + ) + + # Track page transaction for version history + page_transaction.delay( + new_description_html=request.data.get( + "description_html", "

" + ), + old_description_html=None, + page_id=page.id, + ) + + return Response( + PageAPISerializer(page).data, + status=status.HTTP_201_CREATED, + ) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + +class PageDetailAPIEndpoint(BaseAPIView): + """Page Retrieve, Update, and Delete Endpoint for the public v1 API.""" + + serializer_class = PageAPISerializer + model = Page + permission_classes = [ProjectPagePermission] + use_read_replica = True + + def get_queryset(self): + return ( + Page.objects.filter(workspace__slug=self.kwargs.get("slug")) + .filter( + projects__id=self.kwargs.get("project_id"), + projects__archived_at__isnull=True, + ) + .filter(project_pages__deleted_at__isnull=True) + .select_related("workspace", "owned_by") + .distinct() + ) + + @extend_schema( + operation_id="retrieve_page", + summary="Retrieve page", + description="Retrieve a specific page by its ID.", + tags=["Pages"], + responses={ + 200: OpenApiResponse( + description="Page details", + response=PageAPISerializer, + ), + }, + ) + def get(self, request, slug, project_id, page_id): + """Retrieve page + + Retrieve a specific page by its ID. + """ + page = self.get_queryset().get(pk=page_id) + return Response( + PageAPISerializer(page, fields=self.fields, expand=self.expand).data, + status=status.HTTP_200_OK, + ) + + @extend_schema( + operation_id="update_page", + summary="Update page", + description="Update a page's properties. Locked and archived pages cannot be updated.", + tags=["Pages"], + request=PageAPISerializer, + responses={ + 200: OpenApiResponse( + description="Page updated", + response=PageAPISerializer, + ), + 400: OpenApiResponse(description="Page is locked or archived"), + }, + ) + def patch(self, request, slug, project_id, page_id): + """Update page + + Update a page's properties. Locked and archived pages cannot be updated. + Only the page owner can change the access level. + """ + page = self.get_queryset().get(pk=page_id) + + if page.is_locked: + return Response( + {"error": "Page is locked"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + if page.archived_at: + return Response( + {"error": "Archived page cannot be edited"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + # Only the owner can change access + if ( + page.access != request.data.get("access", page.access) + and page.owned_by_id != request.user.id + ): + return Response( + { + "error": "Access cannot be updated since this page is owned by someone else" + }, + status=status.HTTP_403_FORBIDDEN, + ) + + old_description_html = page.description_html + + serializer = PageAPISerializer(page, data=request.data, partial=True) + if serializer.is_valid(): + # Reset description_binary when description_html changes + if request.data.get("description_html"): + serializer.save(description_binary=None) + else: + serializer.save() + + # Track page transaction for version history + if request.data.get("description_html"): + page_transaction.delay( + new_description_html=request.data.get( + "description_html", "

" + ), + old_description_html=old_description_html, + page_id=page_id, + ) + + return Response(serializer.data, status=status.HTTP_200_OK) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + @extend_schema( + operation_id="delete_page", + summary="Delete page", + description="Permanently delete a page. The page must be archived first.", + tags=["Pages"], + responses={ + 204: OpenApiResponse(description="Page deleted"), + 400: OpenApiResponse(description="Page must be archived first"), + 403: OpenApiResponse(description="Only owner or admin can delete"), + }, + ) + def delete(self, request, slug, project_id, page_id): + """Delete page + + Permanently delete a page. The page must be archived first. + Only the page owner or a project admin can delete a page. + """ + page = self.get_queryset().get(pk=page_id) + + if page.archived_at is None: + return Response( + {"error": "The page should be archived before deleting"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + if page.owned_by_id != request.user.id and ( + not ProjectMember.objects.filter( + workspace__slug=slug, + member=request.user, + role=20, + project_id=project_id, + is_active=True, + ).exists() + ): + return Response( + {"error": "Only admin or owner can delete the page"}, + status=status.HTTP_403_FORBIDDEN, + ) + + # Remove parent from all children + Page.objects.filter( + parent_id=page_id, + projects__id=project_id, + workspace__slug=slug, + project_pages__deleted_at__isnull=True, + ).update(parent=None) + + page.delete() + + # Delete user favorites for this page + UserFavorite.objects.filter( + project=project_id, + workspace__slug=slug, + entity_identifier=page_id, + entity_type="page", + ).delete() + + # Delete from recent visits + UserRecentVisit.objects.filter( + project_id=project_id, + workspace__slug=slug, + entity_identifier=page_id, + entity_name="page", + ).delete(soft=False) + + return Response(status=status.HTTP_204_NO_CONTENT) + + +class PageArchiveAPIEndpoint(BaseAPIView): + """Page Archive and Unarchive Endpoint for the public v1 API.""" + + permission_classes = [ProjectPagePermission] + + @extend_schema( + operation_id="archive_page", + summary="Archive page", + description="Archive a page and all its descendant pages.", + tags=["Pages"], + request=None, + responses={ + 200: OpenApiResponse(description="Page archived"), + }, + ) + def post(self, request, slug, project_id, page_id): + """Archive page + + Archive a page and all its descendant pages. + """ + page = Page.objects.get( + pk=page_id, + workspace__slug=slug, + projects__id=project_id, + project_pages__deleted_at__isnull=True, + ) + + UserFavorite.objects.filter( + entity_type="page", + entity_identifier=page_id, + project_id=project_id, + workspace__slug=slug, + ).delete() + + unarchive_archive_page_and_descendants(page_id, datetime.now()) + + return Response( + {"archived_at": str(datetime.now())}, + status=status.HTTP_200_OK, + ) + + @extend_schema( + operation_id="unarchive_page", + summary="Unarchive page", + description="Unarchive a page and all its descendant pages.", + tags=["Pages"], + request=None, + responses={ + 204: OpenApiResponse(description="Page unarchived"), + }, + ) + def delete(self, request, slug, project_id, page_id): + """Unarchive page + + Unarchive a page and all its descendant pages. + If the parent page is still archived, the parent reference is removed. + """ + page = Page.objects.get( + pk=page_id, + workspace__slug=slug, + projects__id=project_id, + project_pages__deleted_at__isnull=True, + ) + + # If parent is still archived, break the hierarchy + if page.parent_id and page.parent.archived_at: + page.parent = None + page.save(update_fields=["parent"]) + + unarchive_archive_page_and_descendants(page_id, None) + + return Response(status=status.HTTP_204_NO_CONTENT) + + +class PageLockAPIEndpoint(BaseAPIView): + """Page Lock and Unlock Endpoint for the public v1 API.""" + + permission_classes = [ProjectPagePermission] + + @extend_schema( + operation_id="lock_page", + summary="Lock page", + description="Lock a page to prevent editing. Only the page owner can lock a page.", + tags=["Pages"], + request=None, + responses={ + 200: OpenApiResponse(description="Page locked"), + 403: OpenApiResponse(description="Only page owner can lock"), + }, + ) + def post(self, request, slug, project_id, page_id): + """Lock page + + Lock a page to prevent editing. Only the page owner can lock a page. + """ + page = Page.objects.get( + pk=page_id, + workspace__slug=slug, + projects__id=project_id, + project_pages__deleted_at__isnull=True, + ) + + if page.owned_by_id != request.user.id: + return Response( + {"error": "Only the page owner can lock the page"}, + status=status.HTTP_403_FORBIDDEN, + ) + + page.is_locked = True + page.save() + return Response( + {"is_locked": True}, + status=status.HTTP_200_OK, + ) + + @extend_schema( + operation_id="unlock_page", + summary="Unlock page", + description="Unlock a page to allow editing. Only the page owner can unlock a page.", + tags=["Pages"], + request=None, + responses={ + 200: OpenApiResponse(description="Page unlocked"), + 403: OpenApiResponse(description="Only page owner can unlock"), + }, + ) + def delete(self, request, slug, project_id, page_id): + """Unlock page + + Unlock a page to allow editing. Only the page owner can unlock a page. + """ + page = Page.objects.get( + pk=page_id, + workspace__slug=slug, + projects__id=project_id, + project_pages__deleted_at__isnull=True, + ) + + if page.owned_by_id != request.user.id: + return Response( + {"error": "Only the page owner can unlock the page"}, + status=status.HTTP_403_FORBIDDEN, + ) + + page.is_locked = False + page.save() + return Response( + {"is_locked": False}, + status=status.HTTP_200_OK, + ) 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..5b20423b484 --- /dev/null +++ b/apps/api/plane/tests/contract/api/test_pages.py @@ -0,0 +1,460 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + +import pytest +from rest_framework import status +from uuid import uuid4 + +from plane.db.models import Page, Project, ProjectMember, ProjectPage, User + + +@pytest.fixture +def project(db, workspace, create_user): + """Create a test project with the user as an admin 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 other_user(db): + """Create a second user for ownership tests.""" + user = User.objects.create( + email="other@plane.so", + first_name="Other", + last_name="User", + ) + user.set_password("other-password") + user.save() + return user + + +@pytest.fixture +def create_page(db, project, create_user): + """Create a test page linked to the project.""" + page = Page.objects.create( + name="Existing Page", + description_html="

Test content

", + owned_by=create_user, + workspace=project.workspace, + ) + ProjectPage.objects.create( + workspace=project.workspace, + project=project, + page=page, + created_by_id=create_user.id, + updated_by_id=create_user.id, + ) + return page + + +@pytest.fixture +def archived_page(db, project, create_user): + """Create an archived test page.""" + from datetime import date + + page = Page.objects.create( + name="Archived Page", + description_html="

Archived content

", + owned_by=create_user, + workspace=project.workspace, + archived_at=date.today(), + ) + ProjectPage.objects.create( + workspace=project.workspace, + project=project, + page=page, + created_by_id=create_user.id, + updated_by_id=create_user.id, + ) + return page + + +@pytest.fixture +def locked_page(db, project, create_user): + """Create a locked test page.""" + page = Page.objects.create( + name="Locked Page", + description_html="

Locked content

", + owned_by=create_user, + workspace=project.workspace, + is_locked=True, + ) + ProjectPage.objects.create( + workspace=project.workspace, + project=project, + page=page, + created_by_id=create_user.id, + updated_by_id=create_user.id, + ) + return page + + +@pytest.mark.contract +class TestPageListCreateAPIEndpoint: + """Test Page List and Create API Endpoint.""" + + def get_url(self, workspace_slug, project_id): + return f"/api/v1/workspaces/{workspace_slug}/projects/{project_id}/pages/" + + @pytest.mark.django_db + def test_unauthenticated_request(self, api_client, workspace, project): + """401 for unauthenticated requests.""" + url = self.get_url(workspace.slug, project.id) + response = api_client.get(url) + assert response.status_code == status.HTTP_403_FORBIDDEN + + @pytest.mark.django_db + def test_list_pages_success(self, api_key_client, workspace, project, create_page): + """200 with paginated results on list.""" + url = self.get_url(workspace.slug, project.id) + response = api_key_client.get(url) + + assert response.status_code == status.HTTP_200_OK + assert "results" in response.data + assert len(response.data["results"]) >= 1 + + @pytest.mark.django_db + def test_list_pages_excludes_archived( + self, api_key_client, workspace, project, create_page, archived_page + ): + """Archived pages are excluded from the default list.""" + url = self.get_url(workspace.slug, project.id) + response = api_key_client.get(url) + + assert response.status_code == status.HTTP_200_OK + page_ids = [str(p["id"]) for p in response.data["results"]] + assert str(create_page.id) in page_ids + assert str(archived_page.id) not in page_ids + + @pytest.mark.django_db + def test_create_page_success(self, api_key_client, workspace, project): + """201 on successful page creation with ProjectPage created.""" + url = self.get_url(workspace.slug, project.id) + data = { + "name": "New Page", + "description_html": "

Hello world

", + } + + response = api_key_client.post(url, data, format="json") + + assert response.status_code == status.HTTP_201_CREATED + assert response.data["name"] == "New Page" + + page = Page.objects.get(pk=response.data["id"]) + assert page.description_binary is None + assert ProjectPage.objects.filter( + page=page, project=project + ).exists() + + @pytest.mark.django_db + def test_create_page_with_external_id( + self, api_key_client, workspace, project + ): + """201 on creation with external_id, 409 on duplicate.""" + url = self.get_url(workspace.slug, project.id) + data = { + "name": "External Page", + "external_id": "ext-page-1", + "external_source": "notion", + } + + response = api_key_client.post(url, data, format="json") + assert response.status_code == status.HTTP_201_CREATED + assert response.data["external_id"] == "ext-page-1" + + # Duplicate should return 409 + dup_data = { + "name": "Duplicate Page", + "external_id": "ext-page-1", + "external_source": "notion", + } + response2 = api_key_client.post(url, dup_data, format="json") + assert response2.status_code == status.HTTP_409_CONFLICT + assert "same external id" in response2.data["error"] + + +@pytest.mark.contract +class TestPageDetailAPIEndpoint: + """Test Page Detail API Endpoint.""" + + def get_url(self, workspace_slug, project_id, page_id): + return f"/api/v1/workspaces/{workspace_slug}/projects/{project_id}/pages/{page_id}/" + + @pytest.mark.django_db + def test_retrieve_page(self, api_key_client, workspace, project, create_page): + """200 on successful retrieval.""" + url = self.get_url(workspace.slug, project.id, create_page.id) + response = api_key_client.get(url) + + assert response.status_code == status.HTTP_200_OK + assert str(response.data["id"]) == str(create_page.id) + assert response.data["name"] == create_page.name + + @pytest.mark.django_db + def test_retrieve_page_not_found(self, api_key_client, workspace, project): + """404 for non-existent page.""" + fake_id = uuid4() + url = self.get_url(workspace.slug, project.id, fake_id) + response = api_key_client.get(url) + assert response.status_code == status.HTTP_404_NOT_FOUND + + @pytest.mark.django_db + def test_update_page_success( + self, api_key_client, workspace, project, create_page + ): + """200 on successful update with description_html.""" + url = self.get_url(workspace.slug, project.id, create_page.id) + data = { + "name": "Updated Page Name", + "description_html": "

Updated content

", + } + + response = api_key_client.patch(url, data, format="json") + + assert response.status_code == status.HTTP_200_OK + create_page.refresh_from_db() + assert create_page.name == "Updated Page Name" + assert create_page.description_html == "

Updated content

" + assert create_page.description_binary is None + + @pytest.mark.django_db + def test_update_locked_page( + self, api_key_client, workspace, project, locked_page + ): + """400 when trying to update a locked page.""" + url = self.get_url(workspace.slug, project.id, locked_page.id) + data = {"name": "Should Fail"} + + response = api_key_client.patch(url, data, format="json") + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert "locked" in response.data["error"].lower() + + @pytest.mark.django_db + def test_update_archived_page( + self, api_key_client, workspace, project, archived_page + ): + """400 when trying to update an archived page.""" + url = self.get_url(workspace.slug, project.id, archived_page.id) + data = {"name": "Should Fail"} + + response = api_key_client.patch(url, data, format="json") + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert "archived" in response.data["error"].lower() + + @pytest.mark.django_db + def test_non_owner_cannot_change_access( + self, + api_key_client, + workspace, + project, + other_user, + create_user, + ): + """403 when non-owner tries to change page access.""" + # Create a page owned by other_user + page = Page.objects.create( + name="Other's Page", + description_html="

content

", + owned_by=other_user, + workspace=project.workspace, + access=0, # Public + ) + ProjectPage.objects.create( + workspace=project.workspace, + project=project, + page=page, + created_by_id=other_user.id, + updated_by_id=other_user.id, + ) + + url = self.get_url(workspace.slug, project.id, page.id) + data = {"access": 1} # Try to make it private + + response = api_key_client.patch(url, data, format="json") + assert response.status_code == status.HTTP_403_FORBIDDEN + + @pytest.mark.django_db + def test_delete_requires_archived( + self, api_key_client, workspace, project, create_page + ): + """400 when trying to delete a non-archived page.""" + url = self.get_url(workspace.slug, project.id, create_page.id) + response = api_key_client.delete(url) + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert "archived" in response.data["error"].lower() + + @pytest.mark.django_db + def test_delete_archived_page_success( + self, api_key_client, workspace, project, archived_page + ): + """204 when deleting an archived page owned by the user.""" + url = self.get_url(workspace.slug, project.id, archived_page.id) + response = api_key_client.delete(url) + assert response.status_code == status.HTTP_204_NO_CONTENT + assert not Page.objects.filter(id=archived_page.id).exists() + + @pytest.mark.django_db + def test_delete_by_non_owner_non_admin( + self, + api_key_client, + workspace, + project, + other_user, + create_user, + ): + """403 when non-owner non-admin tries to delete.""" + from datetime import date + + page = Page.objects.create( + name="Other's Archived Page", + description_html="

content

", + owned_by=other_user, + workspace=project.workspace, + archived_at=date.today(), + ) + ProjectPage.objects.create( + workspace=project.workspace, + project=project, + page=page, + created_by_id=other_user.id, + updated_by_id=other_user.id, + ) + + # Make the authenticated user a Member (not Admin) + ProjectMember.objects.filter( + project=project, member=create_user + ).update(role=15) + + url = self.get_url(workspace.slug, project.id, page.id) + response = api_key_client.delete(url) + assert response.status_code == status.HTTP_403_FORBIDDEN + + +@pytest.mark.contract +class TestPageArchiveAPIEndpoint: + """Test Page Archive and Unarchive API Endpoint.""" + + def get_url(self, workspace_slug, project_id, page_id): + return f"/api/v1/workspaces/{workspace_slug}/projects/{project_id}/pages/{page_id}/archive/" + + @pytest.mark.django_db + def test_archive_page(self, api_key_client, workspace, project, create_page): + """200 on successful archive.""" + url = self.get_url(workspace.slug, project.id, create_page.id) + response = api_key_client.post(url, format="json") + + assert response.status_code == status.HTTP_200_OK + assert "archived_at" in response.data + create_page.refresh_from_db() + assert create_page.archived_at is not None + + @pytest.mark.django_db + def test_unarchive_page( + self, api_key_client, workspace, project, archived_page + ): + """204 on successful unarchive.""" + url = self.get_url(workspace.slug, project.id, archived_page.id) + response = api_key_client.delete(url, format="json") + + assert response.status_code == status.HTTP_204_NO_CONTENT + archived_page.refresh_from_db() + assert archived_page.archived_at is None + + +@pytest.mark.contract +class TestPageLockAPIEndpoint: + """Test Page Lock and Unlock API Endpoint.""" + + def get_url(self, workspace_slug, project_id, page_id): + return f"/api/v1/workspaces/{workspace_slug}/projects/{project_id}/pages/{page_id}/lock/" + + @pytest.mark.django_db + def test_lock_page(self, api_key_client, workspace, project, create_page): + """200 on successful lock by owner.""" + url = self.get_url(workspace.slug, project.id, create_page.id) + response = api_key_client.post(url, format="json") + + assert response.status_code == status.HTTP_200_OK + assert response.data["is_locked"] is True + create_page.refresh_from_db() + assert create_page.is_locked is True + + @pytest.mark.django_db + def test_unlock_page( + self, api_key_client, workspace, project, locked_page + ): + """200 on successful unlock by owner.""" + url = self.get_url(workspace.slug, project.id, locked_page.id) + response = api_key_client.delete(url, format="json") + + assert response.status_code == status.HTTP_200_OK + assert response.data["is_locked"] is False + locked_page.refresh_from_db() + assert locked_page.is_locked is False + + @pytest.mark.django_db + def test_lock_by_non_owner( + self, + api_key_client, + workspace, + project, + other_user, + ): + """403 when non-owner tries to lock a page.""" + page = Page.objects.create( + name="Other's Page", + description_html="

content

", + owned_by=other_user, + workspace=project.workspace, + ) + ProjectPage.objects.create( + workspace=project.workspace, + project=project, + page=page, + created_by_id=other_user.id, + updated_by_id=other_user.id, + ) + + url = self.get_url(workspace.slug, project.id, page.id) + response = api_key_client.post(url, format="json") + assert response.status_code == status.HTTP_403_FORBIDDEN + + @pytest.mark.django_db + def test_unlock_by_non_owner( + self, + api_key_client, + workspace, + project, + other_user, + ): + """403 when non-owner tries to unlock a page.""" + page = Page.objects.create( + name="Other's Locked Page", + description_html="

content

", + owned_by=other_user, + workspace=project.workspace, + is_locked=True, + ) + ProjectPage.objects.create( + workspace=project.workspace, + project=project, + page=page, + created_by_id=other_user.id, + updated_by_id=other_user.id, + ) + + url = self.get_url(workspace.slug, project.id, page.id) + response = api_key_client.delete(url, format="json") + assert response.status_code == status.HTTP_403_FORBIDDEN