From e5f2c7ad0240c7e44871eee59d322dc28f132965 Mon Sep 17 00:00:00 2001 From: Ojajajajaja Date: Tue, 5 May 2026 08:28:59 +0200 Subject: [PATCH 1/2] feat(api): expose project pages in the public REST API Adds Pages endpoints to the public API namespace (`/api/v1/`) so external clients (integrations, MCP servers, automation) can manage pages without the session-only app API. Routes (project-scoped, X-API-Key auth): - GET /workspaces//projects//pages/ list - POST /workspaces//projects//pages/ create - GET /workspaces//projects//pages// retrieve - PATCH /workspaces//projects//pages// update - DELETE /workspaces//projects//pages// delete - GET /workspaces//projects//pages-summary/ stats - GET /workspaces//projects//archived-pages/ list archived - POST /workspaces//projects//pages//archive/ archive - DELETE /workspaces//projects//pages//archive/ unarchive - POST /workspaces//projects//pages//lock/ lock - DELETE /workspaces//projects//pages//lock/ unlock - POST /workspaces//projects//pages//access/ toggle public/private - POST /workspaces//projects//pages//duplicate/ duplicate Reuses the existing Page / ProjectPage models and ProjectEntityPermission. Mirrors conventions from cycle / state public endpoints (BaseAPIView, APIKeyAuthentication, pagination, external_id idempotency). Refs #7319 Co-Authored-By: Claude Opus 4.7 --- apps/api/plane/api/serializers/__init__.py | 1 + apps/api/plane/api/serializers/page.py | 73 ++++ apps/api/plane/api/urls/__init__.py | 2 + apps/api/plane/api/urls/page.py | 64 ++++ apps/api/plane/api/views/__init__.py | 10 + apps/api/plane/api/views/page.py | 410 +++++++++++++++++++++ 6 files changed, 560 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 2ab639d5466..9776edbabe7 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 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..d7649146bba --- /dev/null +++ b/apps/api/plane/api/serializers/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 for details. + +# Module imports +from .base import BaseSerializer +from plane.db.models import Page, ProjectPage, Project + + +class PageSerializer(BaseSerializer): + """ + Serializer for project pages. + + Handles creation of a Page along with its ProjectPage join row so the + public API can create, list, retrieve and update pages scoped to a + project. Labels and revisions are not exposed in the MVP serializer. + """ + + class Meta: + model = Page + fields = [ + "id", + "name", + "description_html", + "description_json", + "owned_by", + "access", + "color", + "parent", + "is_locked", + "archived_at", + "view_props", + "logo_props", + "sort_order", + "external_id", + "external_source", + "workspace", + "created_at", + "updated_at", + "created_by", + "updated_by", + ] + read_only_fields = [ + "id", + "owned_by", + "workspace", + "created_at", + "updated_at", + "created_by", + "updated_by", + ] + + def create(self, validated_data): + 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, + ) + + return page 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..7134b7a86aa --- /dev/null +++ b/apps/api/plane/api/urls/page.py @@ -0,0 +1,64 @@ +# 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 ( + ProjectPageListCreateAPIEndpoint, + ProjectPageDetailAPIEndpoint, + ProjectPageArchiveAPIEndpoint, + ProjectPageLockAPIEndpoint, + ProjectPageAccessAPIEndpoint, + ProjectPageDuplicateAPIEndpoint, + ProjectPageSummaryAPIEndpoint, +) + +urlpatterns = [ + # CRUD + path( + "workspaces//projects//pages/", + ProjectPageListCreateAPIEndpoint.as_view(http_method_names=["get", "post"]), + name="project-pages", + ), + path( + "workspaces//projects//pages//", + ProjectPageDetailAPIEndpoint.as_view(http_method_names=["get", "patch", "delete"]), + name="project-pages", + ), + # Summary + path( + "workspaces//projects//pages-summary/", + ProjectPageSummaryAPIEndpoint.as_view(http_method_names=["get"]), + name="project-pages-summary", + ), + # Archive / unarchive + path( + "workspaces//projects//pages//archive/", + ProjectPageArchiveAPIEndpoint.as_view(http_method_names=["post", "delete"]), + name="project-page-archive-unarchive", + ), + path( + "workspaces//projects//archived-pages/", + ProjectPageArchiveAPIEndpoint.as_view(http_method_names=["get"]), + name="project-archived-pages", + ), + # Lock / unlock + path( + "workspaces//projects//pages//lock/", + ProjectPageLockAPIEndpoint.as_view(http_method_names=["post", "delete"]), + name="project-pages-lock-unlock", + ), + # Access toggle + path( + "workspaces//projects//pages//access/", + ProjectPageAccessAPIEndpoint.as_view(http_method_names=["post"]), + name="project-pages-access", + ), + # Duplicate + path( + "workspaces//projects//pages//duplicate/", + ProjectPageDuplicateAPIEndpoint.as_view(http_method_names=["post"]), + name="project-pages-duplicate", + ), +] diff --git a/apps/api/plane/api/views/__init__.py b/apps/api/plane/api/views/__init__.py index e8549afb437..489a92bc3b5 100644 --- a/apps/api/plane/api/views/__init__.py +++ b/apps/api/plane/api/views/__init__.py @@ -63,3 +63,13 @@ from .invite import WorkspaceInvitationsViewset from .sticky import StickyViewSet + +from .page import ( + ProjectPageListCreateAPIEndpoint, + ProjectPageDetailAPIEndpoint, + ProjectPageArchiveAPIEndpoint, + ProjectPageLockAPIEndpoint, + ProjectPageAccessAPIEndpoint, + ProjectPageDuplicateAPIEndpoint, + ProjectPageSummaryAPIEndpoint, +) diff --git a/apps/api/plane/api/views/page.py b/apps/api/plane/api/views/page.py new file mode 100644 index 00000000000..7fd8e48953f --- /dev/null +++ b/apps/api/plane/api/views/page.py @@ -0,0 +1,410 @@ +# 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 import connection +from django.db.models import Case, Count, IntegerField, Q, When +from django.utils import timezone + +# 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 +from plane.db.models import Page, ProjectPage +from .base import BaseAPIView + + +def _archive_page_and_descendants(page_id, archived_at): + """Recursively archive (or unarchive) a page and all its sub-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 ProjectPageListCreateAPIEndpoint(BaseAPIView): + """List and create project pages via the public API.""" + + serializer_class = PageSerializer + model = Page + permission_classes = [ProjectEntityPermission] + 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")) + .filter(project_pages__deleted_at__isnull=True) + .filter( + projects__project_projectmember__member=self.request.user, + projects__project_projectmember__is_active=True, + ) + .select_related("workspace") + .select_related("owned_by") + .order_by(self.kwargs.get("order_by", "-created_at")) + .distinct() + ) + + def get(self, request, slug, project_id): + return self.paginate( + request=request, + queryset=self.get_queryset().filter(archived_at__isnull=True), + on_results=lambda pages: PageSerializer( + pages, many=True, fields=self.fields, expand=self.expand + ).data, + ) + + def post(self, request, slug, project_id): + if ( + request.data.get("external_id") + and request.data.get("external_source") + and Page.objects.filter( + workspace__slug=slug, + projects__id=project_id, + external_source=request.data.get("external_source"), + external_id=request.data.get("external_id"), + ).exists() + ): + page = 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(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() + page = self.get_queryset().get(pk=serializer.instance.id) + return Response( + PageSerializer(page).data, + status=status.HTTP_201_CREATED, + ) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + +class ProjectPageDetailAPIEndpoint(BaseAPIView): + """Retrieve, update and delete a project page via the public API.""" + + serializer_class = PageSerializer + model = Page + permission_classes = [ProjectEntityPermission] + 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")) + .filter(project_pages__deleted_at__isnull=True) + .filter( + projects__project_projectmember__member=self.request.user, + projects__project_projectmember__is_active=True, + ) + .select_related("workspace") + .select_related("owned_by") + .distinct() + ) + + def get(self, request, slug, project_id, page_id): + page = self.get_queryset().get(pk=page_id) + serializer = PageSerializer(page, fields=self.fields, expand=self.expand) + return Response(serializer.data, status=status.HTTP_200_OK) + + def patch(self, request, slug, project_id, page_id): + 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.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, + ) + + serializer = PageSerializer(page, data=request.data, partial=True) + if serializer.is_valid(): + if ( + request.data.get("external_id") + and (page.external_id != request.data.get("external_id")) + and Page.objects.filter( + workspace__slug=slug, + projects__id=project_id, + external_source=request.data.get("external_source", page.external_source), + external_id=request.data.get("external_id"), + ).exists() + ): + return Response( + { + "error": "Page with the same external id and external source already exists", + "id": str(page.id), + }, + status=status.HTTP_409_CONFLICT, + ) + serializer.save() + return Response(serializer.data, status=status.HTTP_200_OK) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + def delete(self, request, slug, project_id, page_id): + 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 delete the page"}, + status=status.HTTP_403_FORBIDDEN, + ) + + ProjectPage.objects.filter( + page_id=page.id, + project_id=project_id, + workspace__slug=slug, + ).delete() + page.delete() + return Response(status=status.HTTP_204_NO_CONTENT) + + +class ProjectPageArchiveAPIEndpoint(BaseAPIView): + """Archive, unarchive and list archived project pages via the public API.""" + + serializer_class = PageSerializer + model = Page + permission_classes = [ProjectEntityPermission] + 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")) + .filter(project_pages__deleted_at__isnull=True) + .filter( + projects__project_projectmember__member=self.request.user, + projects__project_projectmember__is_active=True, + ) + .filter(archived_at__isnull=False) + .select_related("workspace") + .select_related("owned_by") + .order_by("-archived_at") + .distinct() + ) + + def get(self, request, slug, project_id): + return self.paginate( + request=request, + queryset=self.get_queryset(), + on_results=lambda pages: PageSerializer( + pages, many=True, fields=self.fields, expand=self.expand + ).data, + ) + + def post(self, request, slug, project_id, page_id): + page = Page.objects.get( + pk=page_id, + workspace__slug=slug, + projects__id=project_id, + project_pages__deleted_at__isnull=True, + ) + if page.archived_at is not None: + return Response( + {"error": "Page is already archived"}, + status=status.HTTP_400_BAD_REQUEST, + ) + now = timezone.now() + _archive_page_and_descendants(page_id, now) + return Response({"archived_at": str(now)}, status=status.HTTP_200_OK) + + def delete(self, request, slug, project_id, page_id): + 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, detach to avoid resurrecting under archived parent + if page.parent_id and page.parent.archived_at: + page.parent = None + page.save(update_fields=["parent"]) + _archive_page_and_descendants(page_id, None) + return Response(status=status.HTTP_204_NO_CONTENT) + + +class ProjectPageLockAPIEndpoint(BaseAPIView): + """Lock and unlock a project page via the public API.""" + + permission_classes = [ProjectEntityPermission] + + def post(self, request, slug, project_id, page_id): + 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(update_fields=["is_locked"]) + return Response(status=status.HTTP_204_NO_CONTENT) + + def delete(self, request, slug, project_id, page_id): + 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(update_fields=["is_locked"]) + return Response(status=status.HTTP_204_NO_CONTENT) + + +class ProjectPageAccessAPIEndpoint(BaseAPIView): + """Toggle access (public/private) on a project page via the public API.""" + + permission_classes = [ProjectEntityPermission] + + def post(self, request, slug, project_id, page_id): + page = Page.objects.get( + pk=page_id, + workspace__slug=slug, + projects__id=project_id, + project_pages__deleted_at__isnull=True, + ) + access = request.data.get("access") + if access is None: + return Response( + {"error": "'access' is required (0=public, 1=private)"}, + status=status.HTTP_400_BAD_REQUEST, + ) + try: + access = int(access) + except (TypeError, ValueError): + return Response( + {"error": "'access' must be an integer (0=public, 1=private)"}, + status=status.HTTP_400_BAD_REQUEST, + ) + if access not in (Page.PUBLIC_ACCESS, Page.PRIVATE_ACCESS): + return Response( + {"error": "'access' must be 0 (public) or 1 (private)"}, + status=status.HTTP_400_BAD_REQUEST, + ) + if page.access != access and page.owned_by_id != request.user.id: + return Response( + {"error": "Only the page owner can change access"}, + status=status.HTTP_403_FORBIDDEN, + ) + page.access = access + page.save(update_fields=["access"]) + return Response(status=status.HTTP_204_NO_CONTENT) + + +class ProjectPageDuplicateAPIEndpoint(BaseAPIView): + """Duplicate a project page via the public API.""" + + permission_classes = [ProjectEntityPermission] + + def post(self, request, slug, project_id, page_id): + page = Page.objects.get( + pk=page_id, + workspace__slug=slug, + projects__id=project_id, + project_pages__deleted_at__isnull=True, + ) + if page.access == Page.PRIVATE_ACCESS and page.owned_by_id != request.user.id: + return Response({"error": "Permission denied"}, status=status.HTTP_403_FORBIDDEN) + + project_ids = list( + ProjectPage.objects.filter(page_id=page.id).values_list("project_id", flat=True) + ) + + page.pk = None + page.name = f"{page.name} (Copy)" + page.description_binary = None + page.owned_by = request.user + page.created_by = request.user + page.updated_by = request.user + page.archived_at = None + page.save() + + for pid in project_ids: + ProjectPage.objects.create( + workspace_id=page.workspace_id, + project_id=pid, + page_id=page.id, + created_by_id=page.created_by_id, + updated_by_id=page.updated_by_id, + ) + + return Response(PageSerializer(page).data, status=status.HTTP_201_CREATED) + + +class ProjectPageSummaryAPIEndpoint(BaseAPIView): + """Aggregate page counts (public, private, archived) for a project.""" + + permission_classes = [ProjectEntityPermission] + use_read_replica = True + + def get(self, request, slug, project_id): + queryset = ( + Page.objects.filter(workspace__slug=slug) + .filter(projects__id=project_id) + .filter(project_pages__deleted_at__isnull=True) + .filter( + projects__project_projectmember__member=request.user, + projects__project_projectmember__is_active=True, + ) + .filter(Q(owned_by=request.user) | Q(access=Page.PUBLIC_ACCESS)) + .distinct() + ) + stats = queryset.aggregate( + public_pages=Count( + Case( + When(access=Page.PUBLIC_ACCESS, archived_at__isnull=True, then=1), + output_field=IntegerField(), + ) + ), + private_pages=Count( + Case( + When(access=Page.PRIVATE_ACCESS, archived_at__isnull=True, then=1), + output_field=IntegerField(), + ) + ), + archived_pages=Count( + Case( + When(archived_at__isnull=False, then=1), + output_field=IntegerField(), + ) + ), + ) + return Response(stats, status=status.HTTP_200_OK) From 325d69ebb892e855b924182a0e65c39285a200ed Mon Sep 17 00:00:00 2001 From: Ojajajajaja Date: Tue, 5 May 2026 08:49:09 +0200 Subject: [PATCH 2/2] refactor(api): tighten public Pages API per code review Address CodeRabbit findings on #9020: - Validate `parent` belongs to the same project (PageSerializer.validate_parent) - Wrap Page+ProjectPage create in transaction.atomic() and use get_object_or_404 for the project lookup so a stale project_id surfaces as 404, not 500 - Rename detail URL from `project-pages` to `project-pages-detail` so the two routes don't collide on `reverse()` lookups - Scope the recursive archive CTE by project_id (and exclude soft-deleted ProjectPage rows) so an (un)archive can never cascade across projects or workspaces - Replace the dead `self.kwargs.get("order_by")` with a `_safe_order_by` helper that reads from `request.query_params` and validates against an allow-list (defends against information-disclosure via arbitrary ordering columns) - Replace bare `Page.objects.get(...)` with `get_object_or_404` so missing/ out-of-scope page_ids return clean 404s - Wrap the duplicate handler in transaction.atomic() and link the clone only to the project the request targeted (linking to other projects the source page belongs to would bypass per-project membership checks) - Switch summary aggregation to `Count("id", distinct=True, filter=Q(...))` so counts are not multiplied by the number of project members in the JOIN Co-Authored-By: Claude Opus 4.7 --- apps/api/plane/api/serializers/page.py | 24 ++++- apps/api/plane/api/urls/page.py | 2 +- apps/api/plane/api/views/page.py | 136 +++++++++++++++---------- 3 files changed, 109 insertions(+), 53 deletions(-) diff --git a/apps/api/plane/api/serializers/page.py b/apps/api/plane/api/serializers/page.py index d7649146bba..82af9023854 100644 --- a/apps/api/plane/api/serializers/page.py +++ b/apps/api/plane/api/serializers/page.py @@ -2,6 +2,13 @@ # SPDX-License-Identifier: AGPL-3.0-only # See the LICENSE file for details. +# Django imports +from django.db import transaction +from django.shortcuts import get_object_or_404 + +# Third party imports +from rest_framework import serializers + # Module imports from .base import BaseSerializer from plane.db.models import Page, ProjectPage, Project @@ -50,11 +57,26 @@ class Meta: "updated_by", ] + def validate_parent(self, value): + if value is None: + return value + project_id = self.context.get("project_id") + if project_id and not ProjectPage.objects.filter( + page_id=value.id, + project_id=project_id, + deleted_at__isnull=True, + ).exists(): + raise serializers.ValidationError( + "Parent page must belong to the same project." + ) + return value + + @transaction.atomic def create(self, validated_data): project_id = self.context["project_id"] owned_by_id = self.context["owned_by_id"] - project = Project.objects.get(pk=project_id) + project = get_object_or_404(Project, pk=project_id) page = Page.objects.create( **validated_data, diff --git a/apps/api/plane/api/urls/page.py b/apps/api/plane/api/urls/page.py index 7134b7a86aa..cca9bae3941 100644 --- a/apps/api/plane/api/urls/page.py +++ b/apps/api/plane/api/urls/page.py @@ -24,7 +24,7 @@ path( "workspaces//projects//pages//", ProjectPageDetailAPIEndpoint.as_view(http_method_names=["get", "patch", "delete"]), - name="project-pages", + name="project-pages-detail", ), # Summary path( diff --git a/apps/api/plane/api/views/page.py b/apps/api/plane/api/views/page.py index 7fd8e48953f..3b70335c8d4 100644 --- a/apps/api/plane/api/views/page.py +++ b/apps/api/plane/api/views/page.py @@ -3,8 +3,9 @@ # See the LICENSE file for details. # Django imports -from django.db import connection -from django.db.models import Case, Count, IntegerField, Q, When +from django.db import connection, transaction +from django.db.models import Count, Q +from django.shortcuts import get_object_or_404 from django.utils import timezone # Third party imports @@ -18,18 +19,43 @@ from .base import BaseAPIView -def _archive_page_and_descendants(page_id, archived_at): - """Recursively archive (or unarchive) a page and all its sub-pages.""" +# Order keys accepted on list endpoints — anything else falls back to default. +ALLOWED_ORDER_BY = { + "created_at", "-created_at", + "updated_at", "-updated_at", + "name", "-name", + "sort_order", "-sort_order", +} + + +def _safe_order_by(request, default="-created_at"): + requested = request.query_params.get("order_by") + return requested if requested in ALLOWED_ORDER_BY else default + + +def _archive_page_and_descendants(page_id, project_id, archived_at): + """ + Archive (or unarchive) a page and its descendants, scoped to a single + project so the recursion can never cross workspace/project boundaries. + Soft-deleted ProjectPage rows are excluded. + """ sql = """ WITH RECURSIVE descendants AS ( - SELECT id FROM pages WHERE id = %s + SELECT p.id + FROM pages p + INNER JOIN project_pages pp ON pp.page_id = p.id + WHERE p.id = %s AND pp.project_id = %s AND pp.deleted_at IS NULL UNION ALL - SELECT pages.id FROM pages, descendants WHERE pages.parent_id = descendants.id + SELECT child.id + FROM pages child + INNER JOIN project_pages pp ON pp.page_id = child.id + INNER JOIN descendants d ON child.parent_id = d.id + WHERE pp.project_id = %s AND pp.deleted_at IS NULL ) 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]) + cursor.execute(sql, [page_id, project_id, project_id, archived_at]) class ProjectPageListCreateAPIEndpoint(BaseAPIView): @@ -51,7 +77,7 @@ def get_queryset(self): ) .select_related("workspace") .select_related("owned_by") - .order_by(self.kwargs.get("order_by", "-created_at")) + .order_by(_safe_order_by(self.request)) .distinct() ) @@ -129,12 +155,12 @@ def get_queryset(self): ) def get(self, request, slug, project_id, page_id): - page = self.get_queryset().get(pk=page_id) + page = get_object_or_404(self.get_queryset(), pk=page_id) serializer = PageSerializer(page, fields=self.fields, expand=self.expand) return Response(serializer.data, status=status.HTTP_200_OK) def patch(self, request, slug, project_id, page_id): - page = self.get_queryset().get(pk=page_id) + page = get_object_or_404(self.get_queryset(), pk=page_id) if page.is_locked: return Response( @@ -175,7 +201,8 @@ def patch(self, request, slug, project_id, page_id): return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) def delete(self, request, slug, project_id, page_id): - page = Page.objects.get( + page = get_object_or_404( + Page, pk=page_id, workspace__slug=slug, projects__id=project_id, @@ -188,12 +215,13 @@ def delete(self, request, slug, project_id, page_id): status=status.HTTP_403_FORBIDDEN, ) - ProjectPage.objects.filter( - page_id=page.id, - project_id=project_id, - workspace__slug=slug, - ).delete() - page.delete() + with transaction.atomic(): + ProjectPage.objects.filter( + page_id=page.id, + project_id=project_id, + workspace__slug=slug, + ).delete() + page.delete() return Response(status=status.HTTP_204_NO_CONTENT) @@ -231,7 +259,8 @@ def get(self, request, slug, project_id): ) def post(self, request, slug, project_id, page_id): - page = Page.objects.get( + page = get_object_or_404( + Page, pk=page_id, workspace__slug=slug, projects__id=project_id, @@ -243,11 +272,12 @@ def post(self, request, slug, project_id, page_id): status=status.HTTP_400_BAD_REQUEST, ) now = timezone.now() - _archive_page_and_descendants(page_id, now) + _archive_page_and_descendants(page_id, project_id, now) return Response({"archived_at": str(now)}, status=status.HTTP_200_OK) def delete(self, request, slug, project_id, page_id): - page = Page.objects.get( + page = get_object_or_404( + Page, pk=page_id, workspace__slug=slug, projects__id=project_id, @@ -257,7 +287,7 @@ def delete(self, request, slug, project_id, page_id): if page.parent_id and page.parent.archived_at: page.parent = None page.save(update_fields=["parent"]) - _archive_page_and_descendants(page_id, None) + _archive_page_and_descendants(page_id, project_id, None) return Response(status=status.HTTP_204_NO_CONTENT) @@ -267,7 +297,8 @@ class ProjectPageLockAPIEndpoint(BaseAPIView): permission_classes = [ProjectEntityPermission] def post(self, request, slug, project_id, page_id): - page = Page.objects.get( + page = get_object_or_404( + Page, pk=page_id, workspace__slug=slug, projects__id=project_id, @@ -278,7 +309,8 @@ def post(self, request, slug, project_id, page_id): return Response(status=status.HTTP_204_NO_CONTENT) def delete(self, request, slug, project_id, page_id): - page = Page.objects.get( + page = get_object_or_404( + Page, pk=page_id, workspace__slug=slug, projects__id=project_id, @@ -295,7 +327,8 @@ class ProjectPageAccessAPIEndpoint(BaseAPIView): permission_classes = [ProjectEntityPermission] def post(self, request, slug, project_id, page_id): - page = Page.objects.get( + page = get_object_or_404( + Page, pk=page_id, workspace__slug=slug, projects__id=project_id, @@ -335,7 +368,8 @@ class ProjectPageDuplicateAPIEndpoint(BaseAPIView): permission_classes = [ProjectEntityPermission] def post(self, request, slug, project_id, page_id): - page = Page.objects.get( + page = get_object_or_404( + Page, pk=page_id, workspace__slug=slug, projects__id=project_id, @@ -344,23 +378,23 @@ def post(self, request, slug, project_id, page_id): if page.access == Page.PRIVATE_ACCESS and page.owned_by_id != request.user.id: return Response({"error": "Permission denied"}, status=status.HTTP_403_FORBIDDEN) - project_ids = list( - ProjectPage.objects.filter(page_id=page.id).values_list("project_id", flat=True) - ) - - page.pk = None - page.name = f"{page.name} (Copy)" - page.description_binary = None - page.owned_by = request.user - page.created_by = request.user - page.updated_by = request.user - page.archived_at = None - page.save() + # The duplicate is linked only to the project the request targeted; the + # caller's permission was validated against that single project. Linking + # to other projects the source page belongs to would bypass per-project + # membership checks. + with transaction.atomic(): + page.pk = None + page.name = f"{page.name} (Copy)" + page.description_binary = None + page.owned_by = request.user + page.created_by = request.user + page.updated_by = request.user + page.archived_at = None + page.save() - for pid in project_ids: ProjectPage.objects.create( workspace_id=page.workspace_id, - project_id=pid, + project_id=project_id, page_id=page.id, created_by_id=page.created_by_id, updated_by_id=page.updated_by_id, @@ -387,24 +421,24 @@ def get(self, request, slug, project_id): .filter(Q(owned_by=request.user) | Q(access=Page.PUBLIC_ACCESS)) .distinct() ) + # Use Count("id", distinct=True, filter=...) — the queryset has multiple + # JOINs (projects, project members) so each Page row is duplicated; a + # plain Count(Case(...)) would scale with project membership. stats = queryset.aggregate( public_pages=Count( - Case( - When(access=Page.PUBLIC_ACCESS, archived_at__isnull=True, then=1), - output_field=IntegerField(), - ) + "id", + distinct=True, + filter=Q(access=Page.PUBLIC_ACCESS, archived_at__isnull=True), ), private_pages=Count( - Case( - When(access=Page.PRIVATE_ACCESS, archived_at__isnull=True, then=1), - output_field=IntegerField(), - ) + "id", + distinct=True, + filter=Q(access=Page.PRIVATE_ACCESS, archived_at__isnull=True), ), archived_pages=Count( - Case( - When(archived_at__isnull=False, then=1), - output_field=IntegerField(), - ) + "id", + distinct=True, + filter=Q(archived_at__isnull=False), ), ) return Response(stats, status=status.HTTP_200_OK)