From f7684a4dd0f63a5bf8cf0bca520690d05256a877 Mon Sep 17 00:00:00 2001 From: Edgar Date: Sat, 21 Feb 2026 12:38:38 +0100 Subject: [PATCH 1/6] chore: add .worktrees/ to .gitignore Co-Authored-By: Claude Sonnet 4.6 --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index e2e6441ba3c..c2b04825945 100644 --- a/.gitignore +++ b/.gitignore @@ -110,3 +110,4 @@ build/ .react-router/ temp/ scripts/ +.worktrees/ From 064bb40234f7c16e9d38f1f01aecaaafe977ec45 Mon Sep 17 00:00:00 2001 From: Edgar Date: Sat, 21 Feb 2026 12:43:15 +0100 Subject: [PATCH 2/6] feat: add api/v1 pages endpoint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implement four REST endpoint classes for pages under the api/v1 API-key-authenticated layer, mirroring the business logic of the existing app-layer PageViewSet: - PageListCreateAPIEndpoint — list (with archive/access/owned filters, external_id lookup, pagination) and create pages - PageDetailAPIEndpoint — retrieve, partial update (lock/access guards), and delete (archive-before-delete and owner/admin guard) - PageArchiveUnarchiveAPIEndpoint — archive/unarchive with recursive descendant update via unarchive_archive_page_and_descendants - PageLockUnlockAPIEndpoint — lock/unlock toggle Also adds PageSerializer (with label_ids/project_ids annotations and PageLabel bulk-create/update), URL patterns, and wires everything into the serializer, view, and URL __init__ exports. Co-Authored-By: Claude Sonnet 4.6 --- apps/api/plane/api/serializers/__init__.py | 1 + apps/api/plane/api/serializers/page.py | 104 +++++ apps/api/plane/api/urls/__init__.py | 2 + apps/api/plane/api/urls/page.py | 35 ++ apps/api/plane/api/views/__init__.py | 7 + apps/api/plane/api/views/page.py | 421 +++++++++++++++++++++ 6 files changed, 570 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 diff --git a/apps/api/plane/api/serializers/__init__.py b/apps/api/plane/api/serializers/__init__.py index 44e527a2dc5..d7640737dc3 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 PageSerializer diff --git a/apps/api/plane/api/serializers/page.py b/apps/api/plane/api/serializers/page.py new file mode 100644 index 00000000000..463c0c113e7 --- /dev/null +++ b/apps/api/plane/api/serializers/page.py @@ -0,0 +1,104 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + +from rest_framework import serializers +from django.db.models import Q +from plane.db.models import Page, PageLabel, Label, ProjectPage, Project +from .base import BaseSerializer + + +class PageSerializer(BaseSerializer): + label_ids = serializers.ListField(child=serializers.UUIDField(), read_only=True) + project_ids = serializers.ListField(child=serializers.UUIDField(), read_only=True) + labels = serializers.ListField( + child=serializers.PrimaryKeyRelatedField(queryset=Label.objects.all()), + write_only=True, + required=False, + ) + + class Meta: + model = Page + fields = [ + "id", + "name", + "description_html", + "description_json", + "owned_by", + "access", + "color", + "labels", + "label_ids", + "parent", + "is_locked", + "archived_at", + "view_props", + "logo_props", + "project_ids", + "workspace", + "external_id", + "external_source", + "sort_order", + "created_at", + "updated_at", + "created_by", + "updated_by", + ] + read_only_fields = ["workspace", "owned_by", "project_ids", "label_ids"] + + def create(self, validated_data): + labels = validated_data.pop("labels", None) + project_id = self.context["project_id"] + owned_by_id = self.context["owned_by_id"] + + project = Project.objects.get(pk=project_id) + + page = Page.objects.create( + **validated_data, + owned_by_id=owned_by_id, + workspace_id=project.workspace_id, + ) + + ProjectPage.objects.create( + workspace_id=page.workspace_id, + project_id=project_id, + page_id=page.id, + created_by_id=page.created_by_id, + updated_by_id=page.updated_by_id, + ) + + if labels is not None: + PageLabel.objects.bulk_create( + [ + PageLabel( + label=label, + page=page, + workspace_id=page.workspace_id, + created_by_id=page.created_by_id, + updated_by_id=page.updated_by_id, + ) + for label in labels + ], + batch_size=10, + ) + + return page + + def update(self, instance, validated_data): + labels = validated_data.pop("labels", None) + if labels is not None: + PageLabel.objects.filter(page=instance).delete() + PageLabel.objects.bulk_create( + [ + PageLabel( + label=label, + page=instance, + workspace_id=instance.workspace_id, + created_by_id=instance.created_by_id, + updated_by_id=instance.updated_by_id, + ) + for label in labels + ], + batch_size=10, + ) + return super().update(instance, validated_data) 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..7b666ebcea5 --- /dev/null +++ b/apps/api/plane/api/urls/page.py @@ -0,0 +1,35 @@ +# 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, + PageArchiveUnarchiveAPIEndpoint, + PageLockUnlockAPIEndpoint, +) + +urlpatterns = [ + path( + "workspaces//projects//pages/", + PageListCreateAPIEndpoint.as_view(http_method_names=["get", "post"]), + name="page-list", + ), + path( + "workspaces//projects//pages//", + PageDetailAPIEndpoint.as_view(http_method_names=["get", "patch", "delete"]), + name="page-detail", + ), + path( + "workspaces//projects//pages//archive/", + PageArchiveUnarchiveAPIEndpoint.as_view(http_method_names=["post", "delete"]), + name="page-archive", + ), + path( + "workspaces//projects//pages//lock/", + PageLockUnlockAPIEndpoint.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 305ebfdb39a..06df0015b5b 100644 --- a/apps/api/plane/api/views/__init__.py +++ b/apps/api/plane/api/views/__init__.py @@ -61,3 +61,10 @@ from .invite import WorkspaceInvitationsViewset from .sticky import StickyViewSet + +from .page import ( + PageListCreateAPIEndpoint, + PageDetailAPIEndpoint, + PageArchiveUnarchiveAPIEndpoint, + PageLockUnlockAPIEndpoint, +) diff --git a/apps/api/plane/api/views/page.py b/apps/api/plane/api/views/page.py new file mode 100644 index 00000000000..1e38d6432a9 --- /dev/null +++ b/apps/api/plane/api/views/page.py @@ -0,0 +1,421 @@ +# 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.models import Q, Value, UUIDField +from django.contrib.postgres.aggregates import ArrayAgg +from django.contrib.postgres.fields import ArrayField +from django.db.models.functions import Coalesce + +# Third party imports +from rest_framework import status +from rest_framework.response import Response + +# Module imports +from plane.api.serializers import PageSerializer +from plane.app.permissions import ProjectEntityPermission, ProjectPagePermission +from plane.app.views.page.base import unarchive_archive_page_and_descendants +from plane.bgtasks.page_transaction_task import page_transaction +from plane.db.models import Page, ProjectMember, UserFavorite, UserRecentVisit +from .base import BaseAPIView + + +class PageListCreateAPIEndpoint(BaseAPIView): + """Page List and Create Endpoint""" + + permission_classes = [ProjectEntityPermission] + + def get_queryset(self): + return ( + Page.objects.filter( + workspace__slug=self.kwargs.get("slug"), + projects__id=self.kwargs.get("project_id"), + project_pages__deleted_at__isnull=True, + ) + .annotate( + label_ids=Coalesce( + ArrayAgg( + "page_labels__label_id", + distinct=True, + filter=~Q(page_labels__label_id__isnull=True), + ), + Value([], output_field=ArrayField(UUIDField())), + ), + project_ids=Coalesce( + ArrayAgg( + "projects__id", + distinct=True, + filter=~Q(projects__id__isnull=True), + ), + Value([], output_field=ArrayField(UUIDField())), + ), + ) + .distinct() + ) + + def get(self, request, slug, project_id): + """List pages + + Retrieve all pages in a project with support for filtering and pagination. + """ + queryset = self.get_queryset() + + # External ID/source lookup + external_id = request.GET.get("external_id") + external_source = request.GET.get("external_source") + if external_id and external_source: + page = queryset.filter( + external_id=external_id, + external_source=external_source, + ).first() + if page is None: + return Response( + {"error": "The requested resource does not exist."}, + status=status.HTTP_404_NOT_FOUND, + ) + return Response( + PageSerializer(page).data, + status=status.HTTP_200_OK, + ) + + # Archived filter + archived = request.GET.get("archived", "false").lower() == "true" + if archived: + queryset = queryset.filter(archived_at__isnull=False) + else: + queryset = queryset.filter(archived_at__isnull=True) + + # Access filter + access = request.GET.get("access") + if access is not None: + queryset = queryset.filter(access=access) + + # Owned filter + owned = request.GET.get("owned", "false").lower() == "true" + if owned: + queryset = queryset.filter(owned_by=request.user) + else: + queryset = queryset.filter(Q(owned_by=request.user) | Q(access=0)) + + # Default: top-level pages only + queryset = queryset.filter(parent__isnull=True) + + return self.paginate( + request=request, + queryset=queryset, + on_results=lambda pages: PageSerializer(pages, many=True).data, + ) + + def post(self, request, slug, project_id): + """Create page + + Create a new page in the project. + """ + # Conflict check for external_id/external_source + external_id = request.data.get("external_id") + external_source = request.data.get("external_source") + if external_id and external_source: + existing_page = Page.objects.filter( + projects__id=project_id, + workspace__slug=slug, + external_id=external_id, + external_source=external_source, + ).first() + if existing_page: + return Response( + { + "error": "Page with the same external id and external source already exists", + "id": str(existing_page.id), + }, + status=status.HTTP_409_CONFLICT, + ) + + serializer = PageSerializer( + data=request.data, + context={ + "project_id": project_id, + "owned_by_id": request.user.id, + }, + ) + if serializer.is_valid(): + serializer.save() + # Capture the page transaction + page_transaction.delay( + new_description_html=request.data.get("description_html", "

"), + old_description_html=None, + page_id=serializer.data["id"], + ) + # Re-fetch with annotations + page = self.get_queryset().get(pk=serializer.data["id"]) + return Response( + PageSerializer(page).data, + status=status.HTTP_201_CREATED, + ) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + +class PageDetailAPIEndpoint(BaseAPIView): + """Page Detail Endpoint""" + + permission_classes = [ProjectPagePermission] + + def get_queryset(self): + return ( + Page.objects.filter( + workspace__slug=self.kwargs.get("slug"), + projects__id=self.kwargs.get("project_id"), + project_pages__deleted_at__isnull=True, + ) + .annotate( + label_ids=Coalesce( + ArrayAgg( + "page_labels__label_id", + distinct=True, + filter=~Q(page_labels__label_id__isnull=True), + ), + Value([], output_field=ArrayField(UUIDField())), + ), + project_ids=Coalesce( + ArrayAgg( + "projects__id", + distinct=True, + filter=~Q(projects__id__isnull=True), + ), + Value([], output_field=ArrayField(UUIDField())), + ), + ) + .distinct() + ) + + def get(self, request, slug, project_id, pk): + """Retrieve page + + Retrieve details of a specific page. + """ + page = self.get_queryset().get(pk=pk) + return Response(PageSerializer(page).data, status=status.HTTP_200_OK) + + def patch(self, request, slug, project_id, pk): + """Update page + + Partially update a page's properties. + """ + page = Page.objects.get( + pk=pk, + workspace__slug=slug, + projects__id=project_id, + project_pages__deleted_at__isnull=True, + ) + + if page.is_locked: + return Response( + {"error": "Page is locked"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + # Only update access if the page owner is the requesting user + 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_400_BAD_REQUEST, + ) + + page_description = page.description_html + serializer = PageSerializer(page, data=request.data, partial=True) + if serializer.is_valid(): + serializer.save() + # Capture the page transaction if description changed + if request.data.get("description_html"): + page_transaction.delay( + new_description_html=request.data.get("description_html", "

"), + old_description_html=page_description, + page_id=pk, + ) + # Re-fetch with annotations for the response + page = self.get_queryset().get(pk=pk) + return Response(PageSerializer(page).data, status=status.HTTP_200_OK) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + def delete(self, request, slug, project_id, pk): + """Delete page + + Permanently delete a page. The page must be archived before deletion. + """ + page = Page.objects.get( + pk=pk, + workspace__slug=slug, + projects__id=project_id, + project_pages__deleted_at__isnull=True, + ) + + 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=pk, + projects__id=project_id, + workspace__slug=slug, + project_pages__deleted_at__isnull=True, + ).update(parent=None) + + page.delete() + + # Delete the user favorite page + UserFavorite.objects.filter( + project=project_id, + workspace__slug=slug, + entity_identifier=pk, + entity_type="page", + ).delete() + + # Delete the page from recent visit + UserRecentVisit.objects.filter( + project_id=project_id, + workspace__slug=slug, + entity_identifier=pk, + entity_name="page", + ).delete(soft=False) + + return Response(status=status.HTTP_204_NO_CONTENT) + + +class PageArchiveUnarchiveAPIEndpoint(BaseAPIView): + """Page Archive and Unarchive Endpoint""" + + permission_classes = [ProjectPagePermission] + + def post(self, request, slug, project_id, page_id): + """Archive page + + Archive a page and all its descendants. + """ + page = Page.objects.get( + pk=page_id, + workspace__slug=slug, + projects__id=project_id, + project_pages__deleted_at__isnull=True, + ) + + # Only the owner or admin can archive the page + if ( + ProjectMember.objects.filter( + project_id=project_id, + member=request.user, + is_active=True, + role__lte=15, + ).exists() + and request.user.id != page.owned_by_id + ): + return Response( + {"error": "Only the owner or admin can archive the page"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + 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) + + def delete(self, request, slug, project_id, page_id): + """Unarchive page + + Restore an archived page and all its descendants. + """ + page = Page.objects.get( + pk=page_id, + workspace__slug=slug, + projects__id=project_id, + project_pages__deleted_at__isnull=True, + ) + + # Only the owner or admin can unarchive the page + if ( + ProjectMember.objects.filter( + project_id=project_id, + member=request.user, + is_active=True, + role__lte=15, + ).exists() + and request.user.id != page.owned_by_id + ): + return Response( + {"error": "Only the owner or admin can un archive the page"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + # If parent is archived, break hierarchy by clearing parent + 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 PageLockUnlockAPIEndpoint(BaseAPIView): + """Page Lock and Unlock Endpoint""" + + permission_classes = [ProjectPagePermission] + + def post(self, request, slug, project_id, page_id): + """Lock page + + Lock a page to prevent further edits. + """ + page = Page.objects.get( + pk=page_id, + workspace__slug=slug, + projects__id=project_id, + project_pages__deleted_at__isnull=True, + ) + + page.is_locked = True + page.save() + + return Response(status=status.HTTP_204_NO_CONTENT) + + def delete(self, request, slug, project_id, page_id): + """Unlock page + + Unlock a page to allow edits. + """ + page = Page.objects.get( + pk=page_id, + workspace__slug=slug, + projects__id=project_id, + project_pages__deleted_at__isnull=True, + ) + + page.is_locked = False + page.save() + + return Response(status=status.HTTP_204_NO_CONTENT) From 11dcf3b0c2533bd36ee8f5688f1c93d6eacfcb92 Mon Sep 17 00:00:00 2001 From: Edgar Date: Sat, 21 Feb 2026 13:04:13 +0100 Subject: [PATCH 3/6] fix: address CodeRabbit review comments on api/v1 pages endpoint - Remove unused Q import from serializer - Scope label queryset to workspace in serializer __init__ to prevent cross-workspace label assignment - Wrap create() and update() label operations in transaction.atomic() - Extract PageQuerySetMixin to eliminate duplicate get_queryset - Validate access query param before filtering (must be 0 or 1) - Fix archive/unarchive permission errors to return 403 instead of 400 - Replace datetime.now() with timezone.now().date() for archived_at, captured once to avoid double-call inconsistency - Move UserFavorite/UserRecentVisit cleanup before page.delete() - Use update_fields=["is_locked"] in lock/unlock to avoid full saves - Pass project_id context to patch serializer for label scoping Co-Authored-By: Edgar --- apps/api/plane/api/serializers/page.py | 112 +++++++++++++---------- apps/api/plane/api/views/page.py | 117 ++++++++++--------------- 2 files changed, 115 insertions(+), 114 deletions(-) diff --git a/apps/api/plane/api/serializers/page.py b/apps/api/plane/api/serializers/page.py index 463c0c113e7..5d4be63ef86 100644 --- a/apps/api/plane/api/serializers/page.py +++ b/apps/api/plane/api/serializers/page.py @@ -2,9 +2,11 @@ # SPDX-License-Identifier: AGPL-3.0-only # See the LICENSE file for details. +from django.db import transaction from rest_framework import serializers -from django.db.models import Q -from plane.db.models import Page, PageLabel, Label, ProjectPage, Project + +from plane.db.models import Label, Page, PageLabel, Project, ProjectPage + from .base import BaseSerializer @@ -12,7 +14,7 @@ class PageSerializer(BaseSerializer): label_ids = serializers.ListField(child=serializers.UUIDField(), read_only=True) project_ids = serializers.ListField(child=serializers.UUIDField(), read_only=True) labels = serializers.ListField( - child=serializers.PrimaryKeyRelatedField(queryset=Label.objects.all()), + child=serializers.PrimaryKeyRelatedField(queryset=Label.objects.none()), write_only=True, required=False, ) @@ -46,6 +48,24 @@ class Meta: ] read_only_fields = ["workspace", "owned_by", "project_ids", "label_ids"] + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + # Scope label choices to the current workspace to prevent cross-workspace + # label assignment. + workspace_id = None + project_id = self.context.get("project_id") + if project_id: + workspace_id = ( + Project.objects.filter(pk=project_id).values_list("workspace_id", flat=True).first() + ) + elif self.instance: + workspace_id = self.instance.workspace_id + + if workspace_id: + self.fields["labels"].child = serializers.PrimaryKeyRelatedField( + queryset=Label.objects.filter(workspace_id=workspace_id) + ) + def create(self, validated_data): labels = validated_data.pop("labels", None) project_id = self.context["project_id"] @@ -53,52 +73,54 @@ def create(self, validated_data): project = Project.objects.get(pk=project_id) - page = Page.objects.create( - **validated_data, - owned_by_id=owned_by_id, - workspace_id=project.workspace_id, - ) - - ProjectPage.objects.create( - workspace_id=page.workspace_id, - project_id=project_id, - page_id=page.id, - created_by_id=page.created_by_id, - updated_by_id=page.updated_by_id, - ) + with transaction.atomic(): + page = Page.objects.create( + **validated_data, + owned_by_id=owned_by_id, + workspace_id=project.workspace_id, + ) - if labels is not None: - PageLabel.objects.bulk_create( - [ - PageLabel( - label=label, - page=page, - workspace_id=page.workspace_id, - created_by_id=page.created_by_id, - updated_by_id=page.updated_by_id, - ) - for label in labels - ], - batch_size=10, + ProjectPage.objects.create( + workspace_id=page.workspace_id, + project_id=project_id, + page_id=page.id, + created_by_id=page.created_by_id, + updated_by_id=page.updated_by_id, ) + if labels is not None: + PageLabel.objects.bulk_create( + [ + PageLabel( + label=label, + page=page, + workspace_id=page.workspace_id, + created_by_id=page.created_by_id, + updated_by_id=page.updated_by_id, + ) + for label in labels + ], + batch_size=10, + ) + return page def update(self, instance, validated_data): labels = validated_data.pop("labels", None) - if labels is not None: - PageLabel.objects.filter(page=instance).delete() - PageLabel.objects.bulk_create( - [ - PageLabel( - label=label, - page=instance, - workspace_id=instance.workspace_id, - created_by_id=instance.created_by_id, - updated_by_id=instance.updated_by_id, - ) - for label in labels - ], - batch_size=10, - ) - return super().update(instance, validated_data) + with transaction.atomic(): + if labels is not None: + PageLabel.objects.filter(page=instance).delete() + PageLabel.objects.bulk_create( + [ + PageLabel( + label=label, + page=instance, + workspace_id=instance.workspace_id, + created_by_id=instance.created_by_id, + updated_by_id=instance.updated_by_id, + ) + for label in labels + ], + batch_size=10, + ) + return super().update(instance, validated_data) diff --git a/apps/api/plane/api/views/page.py b/apps/api/plane/api/views/page.py index 1e38d6432a9..c4a74a38df3 100644 --- a/apps/api/plane/api/views/page.py +++ b/apps/api/plane/api/views/page.py @@ -2,14 +2,12 @@ # SPDX-License-Identifier: AGPL-3.0-only # See the LICENSE file for details. -# Python imports -from datetime import datetime - # Django imports from django.db.models import Q, Value, UUIDField from django.contrib.postgres.aggregates import ArrayAgg from django.contrib.postgres.fields import ArrayField from django.db.models.functions import Coalesce +from django.utils import timezone # Third party imports from rest_framework import status @@ -21,13 +19,12 @@ from plane.app.views.page.base import unarchive_archive_page_and_descendants from plane.bgtasks.page_transaction_task import page_transaction from plane.db.models import Page, ProjectMember, UserFavorite, UserRecentVisit + from .base import BaseAPIView -class PageListCreateAPIEndpoint(BaseAPIView): - """Page List and Create Endpoint""" - - permission_classes = [ProjectEntityPermission] +class PageQuerySetMixin: + """Shared queryset with workspace/project filtering and label/project annotations.""" def get_queryset(self): return ( @@ -57,6 +54,12 @@ def get_queryset(self): .distinct() ) + +class PageListCreateAPIEndpoint(PageQuerySetMixin, BaseAPIView): + """Page List and Create Endpoint""" + + permission_classes = [ProjectEntityPermission] + def get(self, request, slug, project_id): """List pages @@ -64,7 +67,7 @@ def get(self, request, slug, project_id): """ queryset = self.get_queryset() - # External ID/source lookup + # External ID/source lookup returns a single object directly external_id = request.GET.get("external_id") external_source = request.GET.get("external_source") if external_id and external_source: @@ -77,24 +80,30 @@ def get(self, request, slug, project_id): {"error": "The requested resource does not exist."}, status=status.HTTP_404_NOT_FOUND, ) - return Response( - PageSerializer(page).data, - status=status.HTTP_200_OK, - ) + return Response(PageSerializer(page).data, status=status.HTTP_200_OK) - # Archived filter + # Archived filter (default: non-archived) archived = request.GET.get("archived", "false").lower() == "true" if archived: queryset = queryset.filter(archived_at__isnull=False) else: queryset = queryset.filter(archived_at__isnull=True) - # Access filter - access = request.GET.get("access") - if access is not None: - queryset = queryset.filter(access=access) + # Access filter — validate the value is 0 (public) or 1 (private) + access_param = request.GET.get("access") + if access_param is not None: + try: + access_value = int(access_param) + if access_value not in (Page.PUBLIC_ACCESS, Page.PRIVATE_ACCESS): + raise ValueError + queryset = queryset.filter(access=access_value) + except (ValueError, TypeError): + return Response( + {"error": "access must be 0 (public) or 1 (private)"}, + status=status.HTTP_400_BAD_REQUEST, + ) - # Owned filter + # Visibility filter: own pages only, or own + all public pages owned = request.GET.get("owned", "false").lower() == "true" if owned: queryset = queryset.filter(owned_by=request.user) @@ -143,54 +152,21 @@ def post(self, request, slug, project_id): ) if serializer.is_valid(): serializer.save() - # Capture the page transaction page_transaction.delay( new_description_html=request.data.get("description_html", "

"), old_description_html=None, page_id=serializer.data["id"], ) - # Re-fetch with annotations page = self.get_queryset().get(pk=serializer.data["id"]) - return Response( - PageSerializer(page).data, - status=status.HTTP_201_CREATED, - ) + return Response(PageSerializer(page).data, status=status.HTTP_201_CREATED) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) -class PageDetailAPIEndpoint(BaseAPIView): +class PageDetailAPIEndpoint(PageQuerySetMixin, BaseAPIView): """Page Detail Endpoint""" permission_classes = [ProjectPagePermission] - def get_queryset(self): - return ( - Page.objects.filter( - workspace__slug=self.kwargs.get("slug"), - projects__id=self.kwargs.get("project_id"), - project_pages__deleted_at__isnull=True, - ) - .annotate( - label_ids=Coalesce( - ArrayAgg( - "page_labels__label_id", - distinct=True, - filter=~Q(page_labels__label_id__isnull=True), - ), - Value([], output_field=ArrayField(UUIDField())), - ), - project_ids=Coalesce( - ArrayAgg( - "projects__id", - distinct=True, - filter=~Q(projects__id__isnull=True), - ), - Value([], output_field=ArrayField(UUIDField())), - ), - ) - .distinct() - ) - def get(self, request, slug, project_id, pk): """Retrieve page @@ -217,7 +193,7 @@ def patch(self, request, slug, project_id, pk): status=status.HTTP_400_BAD_REQUEST, ) - # Only update access if the page owner is the requesting user + # Only the page owner may change the access level 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"}, @@ -225,17 +201,20 @@ def patch(self, request, slug, project_id, pk): ) page_description = page.description_html - serializer = PageSerializer(page, data=request.data, partial=True) + serializer = PageSerializer( + page, + data=request.data, + partial=True, + context={"project_id": project_id}, + ) if serializer.is_valid(): serializer.save() - # Capture the page transaction if description changed if request.data.get("description_html"): page_transaction.delay( new_description_html=request.data.get("description_html", "

"), old_description_html=page_description, page_id=pk, ) - # Re-fetch with annotations for the response page = self.get_queryset().get(pk=pk) return Response(PageSerializer(page).data, status=status.HTTP_200_OK) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) @@ -272,7 +251,7 @@ def delete(self, request, slug, project_id, pk): status=status.HTTP_403_FORBIDDEN, ) - # Remove parent from all children + # Remove parent reference from all children before deletion Page.objects.filter( parent_id=pk, projects__id=project_id, @@ -280,9 +259,7 @@ def delete(self, request, slug, project_id, pk): project_pages__deleted_at__isnull=True, ).update(parent=None) - page.delete() - - # Delete the user favorite page + # Clean up related records before deleting the page UserFavorite.objects.filter( project=project_id, workspace__slug=slug, @@ -290,7 +267,6 @@ def delete(self, request, slug, project_id, pk): entity_type="page", ).delete() - # Delete the page from recent visit UserRecentVisit.objects.filter( project_id=project_id, workspace__slug=slug, @@ -298,6 +274,8 @@ def delete(self, request, slug, project_id, pk): entity_name="page", ).delete(soft=False) + page.delete() + return Response(status=status.HTTP_204_NO_CONTENT) @@ -330,7 +308,7 @@ def post(self, request, slug, project_id, page_id): ): return Response( {"error": "Only the owner or admin can archive the page"}, - status=status.HTTP_400_BAD_REQUEST, + status=status.HTTP_403_FORBIDDEN, ) UserFavorite.objects.filter( @@ -340,9 +318,10 @@ def post(self, request, slug, project_id, page_id): workspace__slug=slug, ).delete() - unarchive_archive_page_and_descendants(page_id, datetime.now()) + archived_at = timezone.now().date() + unarchive_archive_page_and_descendants(page_id, archived_at) - return Response({"archived_at": str(datetime.now())}, status=status.HTTP_200_OK) + return Response({"archived_at": archived_at.isoformat()}, status=status.HTTP_200_OK) def delete(self, request, slug, project_id, page_id): """Unarchive page @@ -367,8 +346,8 @@ def delete(self, request, slug, project_id, page_id): and request.user.id != page.owned_by_id ): return Response( - {"error": "Only the owner or admin can un archive the page"}, - status=status.HTTP_400_BAD_REQUEST, + {"error": "Only the owner or admin can unarchive the page"}, + status=status.HTTP_403_FORBIDDEN, ) # If parent is archived, break hierarchy by clearing parent @@ -399,7 +378,7 @@ def post(self, request, slug, project_id, page_id): ) page.is_locked = True - page.save() + page.save(update_fields=["is_locked"]) return Response(status=status.HTTP_204_NO_CONTENT) @@ -416,6 +395,6 @@ def delete(self, request, slug, project_id, page_id): ) page.is_locked = False - page.save() + page.save(update_fields=["is_locked"]) return Response(status=status.HTTP_204_NO_CONTENT) From 529c54a9f2c02b07a1733d5b81ea0719354ace9e Mon Sep 17 00:00:00 2001 From: Edgar Date: Sat, 21 Feb 2026 16:16:07 +0100 Subject: [PATCH 4/6] fix: address second round of CodeRabbit review comments - Add is_locked and archived_at to serializer read_only_fields to prevent bypassing the dedicated lock/archive endpoints via PATCH - Coerce access param to int before comparison in patch() to handle string values from form-encoded requests - Use select_related("parent") in unarchive lookup to avoid an extra query when checking page.parent.archived_at --- apps/api/plane/api/serializers/page.py | 2 +- apps/api/plane/api/views/page.py | 11 ++++++++--- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/apps/api/plane/api/serializers/page.py b/apps/api/plane/api/serializers/page.py index 5d4be63ef86..3f83e55739d 100644 --- a/apps/api/plane/api/serializers/page.py +++ b/apps/api/plane/api/serializers/page.py @@ -46,7 +46,7 @@ class Meta: "created_by", "updated_by", ] - read_only_fields = ["workspace", "owned_by", "project_ids", "label_ids"] + read_only_fields = ["workspace", "owned_by", "project_ids", "label_ids", "is_locked", "archived_at"] def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) diff --git a/apps/api/plane/api/views/page.py b/apps/api/plane/api/views/page.py index c4a74a38df3..ffc25013d69 100644 --- a/apps/api/plane/api/views/page.py +++ b/apps/api/plane/api/views/page.py @@ -193,8 +193,13 @@ def patch(self, request, slug, project_id, pk): status=status.HTTP_400_BAD_REQUEST, ) - # Only the page owner may change the access level - if page.access != request.data.get("access", page.access) and page.owned_by_id != request.user.id: + # Only the page owner may change the access level. + # Coerce to int before comparing to handle string values from form data. + try: + new_access = int(request.data.get("access", page.access)) + except (TypeError, ValueError): + new_access = page.access + if new_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_400_BAD_REQUEST, @@ -328,7 +333,7 @@ def delete(self, request, slug, project_id, page_id): Restore an archived page and all its descendants. """ - page = Page.objects.get( + page = Page.objects.select_related("parent").get( pk=page_id, workspace__slug=slug, projects__id=project_id, From d0647a3edf9ef930f032208e34f282f226895387 Mon Sep 17 00:00:00 2001 From: Edgar Date: Sat, 21 Feb 2026 16:34:20 +0100 Subject: [PATCH 5/6] fix: address nitpicks from third CodeRabbit review - Use Page.PUBLIC_ACCESS constant instead of magic integer 0 - Use project_id= shorthand instead of project= in UserFavorite filter Co-Authored-By: Claude Sonnet 4.6 --- apps/api/plane/api/views/page.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/api/plane/api/views/page.py b/apps/api/plane/api/views/page.py index ffc25013d69..d6770e635e2 100644 --- a/apps/api/plane/api/views/page.py +++ b/apps/api/plane/api/views/page.py @@ -108,7 +108,7 @@ def get(self, request, slug, project_id): if owned: queryset = queryset.filter(owned_by=request.user) else: - queryset = queryset.filter(Q(owned_by=request.user) | Q(access=0)) + queryset = queryset.filter(Q(owned_by=request.user) | Q(access=Page.PUBLIC_ACCESS)) # Default: top-level pages only queryset = queryset.filter(parent__isnull=True) @@ -266,7 +266,7 @@ def delete(self, request, slug, project_id, pk): # Clean up related records before deleting the page UserFavorite.objects.filter( - project=project_id, + project_id=project_id, workspace__slug=slug, entity_identifier=pk, entity_type="page", From d84dbb1e845a1821a3828e56f340b943f4dcbb5e Mon Sep 17 00:00:00 2001 From: Edgar Date: Sat, 21 Feb 2026 16:49:20 +0100 Subject: [PATCH 6/6] fix: exclude soft-deleted pages from external ID conflict check A page removed from a project sets ProjectPage.deleted_at but leaves Page.deleted_at null. Without this filter the conflict check would find it and return a spurious 409, permanently blocking re-creation under that external_id/external_source pair. Co-Authored-By: Claude Sonnet 4.6 --- apps/api/plane/api/views/page.py | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/api/plane/api/views/page.py b/apps/api/plane/api/views/page.py index d6770e635e2..4150e6b2c57 100644 --- a/apps/api/plane/api/views/page.py +++ b/apps/api/plane/api/views/page.py @@ -133,6 +133,7 @@ def post(self, request, slug, project_id): workspace__slug=slug, external_id=external_id, external_source=external_source, + project_pages__deleted_at__isnull=True, ).first() if existing_page: return Response(