-
Notifications
You must be signed in to change notification settings - Fork 3.8k
feat(api): add Pages CRUD endpoints to v1 API #8800
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: preview
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,50 @@ | ||
| # Copyright (c) 2023-present Plane Software, Inc. and contributors | ||
| # SPDX-License-Identifier: AGPL-3.0-only | ||
| # See the LICENSE file for details. | ||
|
|
||
| # Third party imports | ||
| from rest_framework import serializers | ||
|
|
||
| # Module imports | ||
| from .base import BaseSerializer | ||
| from plane.db.models import Page | ||
|
|
||
|
|
||
| class PageAPISerializer(BaseSerializer): | ||
| """ | ||
| Serializer for Page model exposed via the public v1 API. | ||
| Provides read/write access to core page fields. | ||
| """ | ||
|
|
||
| # name is required when creating a page via the API | ||
| name = serializers.CharField(required=True, allow_blank=False) | ||
|
|
||
| class Meta: | ||
| model = Page | ||
| fields = [ | ||
| "id", | ||
| "name", | ||
| "description_html", | ||
| "description_stripped", | ||
| "owned_by", | ||
| "access", | ||
| "color", | ||
| "parent", | ||
| "is_locked", | ||
| "is_global", | ||
| "archived_at", | ||
| "workspace", | ||
| "created_at", | ||
| "updated_at", | ||
| "created_by", | ||
| "updated_by", | ||
| "view_props", | ||
| "logo_props", | ||
| ] | ||
| read_only_fields = [ | ||
| "workspace", | ||
| "owned_by", | ||
| "created_by", | ||
| "updated_by", | ||
| "archived_at", | ||
| ] | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,23 @@ | ||
| # Copyright (c) 2023-present Plane Software, Inc. and contributors | ||
| # SPDX-License-Identifier: AGPL-3.0-only | ||
| # See the LICENSE file for details. | ||
|
|
||
| from django.urls import path | ||
|
|
||
| from plane.api.views import ( | ||
| PageListCreateAPIEndpoint, | ||
| PageDetailAPIEndpoint, | ||
| ) | ||
|
|
||
| urlpatterns = [ | ||
| path( | ||
| "workspaces/<str:slug>/projects/<uuid:project_id>/pages/", | ||
| PageListCreateAPIEndpoint.as_view(), | ||
| name="pages", | ||
| ), | ||
| path( | ||
| "workspaces/<str:slug>/projects/<uuid:project_id>/pages/<uuid:page_id>/", | ||
| PageDetailAPIEndpoint.as_view(), | ||
| name="page-detail", | ||
| ), | ||
| ] |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,207 @@ | ||
| # Django imports | ||
| from django.db.models import Exists, OuterRef, Q | ||
|
|
||
| # Third party imports | ||
| from rest_framework import status | ||
| from rest_framework.response import Response | ||
|
|
||
| # drf-spectacular imports | ||
| from drf_spectacular.utils import extend_schema | ||
|
|
||
| # Module imports | ||
| from plane.api.serializers import PageAPISerializer | ||
| from plane.app.permissions import ProjectPagePermission | ||
| from plane.db.models import Page, ProjectPage, UserFavorite, Project | ||
| from .base import BaseAPIView | ||
|
|
||
|
|
||
| class PageListCreateAPIEndpoint(BaseAPIView): | ||
| """ | ||
| GET /api/v1/workspaces/{slug}/projects/{project_id}/pages/ | ||
| List all pages in a project visible to the current user. | ||
|
|
||
| POST /api/v1/workspaces/{slug}/projects/{project_id}/pages/ | ||
| Create a new page in the specified project. | ||
| """ | ||
|
|
||
| permission_classes = [ProjectPagePermission] | ||
| serializer_class = PageAPISerializer | ||
|
|
||
| def get_queryset(self, slug, project_id): | ||
| subquery = UserFavorite.objects.filter( | ||
| user=self.request.user, | ||
| entity_type="page", | ||
| entity_identifier=OuterRef("pk"), | ||
| workspace__slug=slug, | ||
| ) | ||
| return ( | ||
| Page.objects.filter(workspace__slug=slug) | ||
| .filter( | ||
| projects__project_projectmember__member=self.request.user, | ||
| projects__project_projectmember__is_active=True, | ||
| projects__archived_at__isnull=True, | ||
| ) | ||
| .filter(parent__isnull=True) | ||
| .filter(Q(owned_by=self.request.user) | Q(access=0)) | ||
|
Comment on lines
+44
to
+45
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
The 🤖 Prompt for AI Agents |
||
| .filter( | ||
| Exists( | ||
| ProjectPage.objects.filter( | ||
| page_id=OuterRef("id"), | ||
| project_id=project_id, | ||
| ) | ||
| ) | ||
| ) | ||
| .prefetch_related("projects") | ||
| .select_related("workspace", "owned_by") | ||
| .annotate(is_favorite=Exists(subquery)) | ||
| .order_by("-created_at") | ||
| ) | ||
|
|
||
| @extend_schema( | ||
| responses={200: PageAPISerializer(many=True)}, | ||
| summary="List project pages", | ||
| description="Returns all pages in a project visible to the current user.", | ||
| tags=["Pages"], | ||
| ) | ||
| def get(self, request, slug, project_id): | ||
| pages = self.get_queryset(slug, project_id) | ||
| serializer = PageAPISerializer(pages, many=True) | ||
| return Response(serializer.data, status=status.HTTP_200_OK) | ||
|
|
||
| @extend_schema( | ||
| request=PageAPISerializer, | ||
| responses={201: PageAPISerializer}, | ||
| summary="Create a page", | ||
| description="Creates a new page in the specified project.", | ||
| tags=["Pages"], | ||
| ) | ||
| def post(self, request, slug, project_id): | ||
| try: | ||
| project = Project.objects.get(pk=project_id, workspace__slug=slug) | ||
| except Project.DoesNotExist: | ||
| return Response( | ||
| {"error": "Project not found."}, | ||
| status=status.HTTP_404_NOT_FOUND, | ||
| ) | ||
|
Comment on lines
+78
to
+85
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. POST allows page creation in archived projects. The project lookup on line 80 doesn't verify that the project isn't archived. Users could create pages in archived projects, which contradicts the archive semantics. 🛡️ Proposed fix to reject archived projects def post(self, request, slug, project_id):
try:
- project = Project.objects.get(pk=project_id, workspace__slug=slug)
+ project = Project.objects.get(
+ pk=project_id,
+ workspace__slug=slug,
+ archived_at__isnull=True,
+ )
except Project.DoesNotExist:
return Response(
{"error": "Project not found."},
status=status.HTTP_404_NOT_FOUND,
)🤖 Prompt for AI Agents |
||
|
|
||
| serializer = PageAPISerializer(data=request.data) | ||
| if not serializer.is_valid(): | ||
| return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) | ||
|
|
||
| page = Page.objects.create( | ||
| name=serializer.validated_data.get("name", ""), | ||
| description_html=serializer.validated_data.get("description_html", ""), | ||
| description_stripped=serializer.validated_data.get("description_stripped", ""), | ||
| description=serializer.validated_data.get("description", {}), | ||
| access=serializer.validated_data.get("access", 0), | ||
| color=serializer.validated_data.get("color", ""), | ||
| view_props=serializer.validated_data.get("view_props", {}), | ||
| logo_props=serializer.validated_data.get("logo_props", {}), | ||
|
Comment on lines
+91
to
+99
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chain🏁 Script executed: # First, let's find and examine the PageAPISerializer
fd -t f "serializer" apps/api/plane/api | grep -i page | head -20Repository: makeplane/plane Length of output: 41 🏁 Script executed: # Also check the Page model directly
fd -t f page.py apps/api/plane/db/models/ -x cat {}Repository: makeplane/plane Length of output: 7324 🏁 Script executed: # Let's search for PageAPISerializer definition
rg "class PageAPISerializer" -A 30 apps/api/Repository: makeplane/plane Length of output: 2081 🏁 Script executed: # Get the page.py view file and check lines 95-103
rg "class.*PageAPI" apps/api/plane/api/views/page.py -A 100 | head -120Repository: makeplane/plane Length of output: 4573 🏁 Script executed: # Alternative: just get the file and read around line 95
cat -n apps/api/plane/api/views/page.py | sed -n '90,110p'Repository: makeplane/plane Length of output: 1237 Remove Line 99 passes 🤖 Prompt for AI Agents |
||
| # Explicitly clear binary so the collab server reads description_html on next load | ||
| description_binary=None, | ||
| owned_by=request.user, | ||
| workspace_id=project.workspace_id, | ||
| ) | ||
| ProjectPage.objects.create( | ||
| workspace_id=page.workspace_id, | ||
| project_id=project_id, | ||
| page_id=page.id, | ||
| created_by=request.user, | ||
| updated_by=request.user, | ||
| ) | ||
|
Comment on lines
+91
to
+111
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Create the page and its project link atomically.
🤖 Prompt for AI Agents |
||
| return Response(PageAPISerializer(page).data, status=status.HTTP_201_CREATED) | ||
|
|
||
|
|
||
| class PageDetailAPIEndpoint(BaseAPIView): | ||
| """ | ||
| GET /api/v1/workspaces/{slug}/projects/{project_id}/pages/{page_id}/ | ||
| PATCH /api/v1/workspaces/{slug}/projects/{project_id}/pages/{page_id}/ | ||
| DELETE /api/v1/workspaces/{slug}/projects/{project_id}/pages/{page_id}/ | ||
| """ | ||
|
|
||
| permission_classes = [ProjectPagePermission] | ||
| serializer_class = PageAPISerializer | ||
|
|
||
| def get_page(self, slug, project_id, page_id): | ||
| return ( | ||
| Page.objects.filter( | ||
| workspace__slug=slug, | ||
| projects__id=project_id, | ||
| pk=page_id, | ||
| ) | ||
| .filter(Q(owned_by=self.request.user) | Q(access=0)) | ||
| .first() | ||
| ) | ||
|
Comment on lines
+125
to
+134
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Detail operations can access pages in archived projects. Unlike 🛡️ Proposed fix to add archived project filter def get_page(self, slug, project_id, page_id):
return (
Page.objects.filter(
workspace__slug=slug,
projects__id=project_id,
pk=page_id,
)
+ .filter(projects__archived_at__isnull=True)
.filter(Q(owned_by=self.request.user) | Q(access=0))
.first()
)🤖 Prompt for AI Agents |
||
|
|
||
| @extend_schema( | ||
| responses={200: PageAPISerializer}, | ||
| summary="Get a page", | ||
| description="Retrieve a single page by ID.", | ||
| tags=["Pages"], | ||
| ) | ||
| def get(self, request, slug, project_id, page_id): | ||
| page = self.get_page(slug, project_id, page_id) | ||
| if not page: | ||
| return Response( | ||
| {"error": "Page not found."}, | ||
| status=status.HTTP_404_NOT_FOUND, | ||
| ) | ||
| return Response(PageAPISerializer(page).data) | ||
|
|
||
| @extend_schema( | ||
| request=PageAPISerializer, | ||
| responses={200: PageAPISerializer}, | ||
| summary="Update a page", | ||
| description=( | ||
| "Partially update a page. Cannot update locked pages. " | ||
| "When description_html is updated, description_binary is reset so the " | ||
| "collaborative editor reloads the content from the new HTML on next open." | ||
| ), | ||
| tags=["Pages"], | ||
| ) | ||
| def patch(self, request, slug, project_id, page_id): | ||
| page = self.get_page(slug, project_id, page_id) | ||
| if not page: | ||
| return Response( | ||
| {"error": "Page not found."}, | ||
| status=status.HTTP_404_NOT_FOUND, | ||
| ) | ||
| if page.is_locked: | ||
| return Response( | ||
| {"error": "Page is locked and cannot be modified."}, | ||
| status=status.HTTP_400_BAD_REQUEST, | ||
| ) | ||
| serializer = PageAPISerializer(page, data=request.data, partial=True) | ||
| if not serializer.is_valid(): | ||
| return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) | ||
|
|
||
| # If the caller is updating description_html, reset description_binary so | ||
| # the Tiptap/Yjs collab server picks up the new HTML on next document load | ||
| # instead of serving stale binary state. | ||
| save_kwargs = {"updated_by": request.user} | ||
| if "description_html" in request.data: | ||
| save_kwargs["description_binary"] = None | ||
|
|
||
| serializer.save(**save_kwargs) | ||
| return Response(serializer.data) | ||
|
|
||
| @extend_schema( | ||
| responses={204: None}, | ||
| summary="Delete a page", | ||
| description="Delete a page. Only the page owner can perform this action.", | ||
| tags=["Pages"], | ||
| ) | ||
| def delete(self, request, slug, project_id, page_id): | ||
| page = self.get_page(slug, project_id, page_id) | ||
| if not page: | ||
| return Response( | ||
| {"error": "Page not found."}, | ||
| status=status.HTTP_404_NOT_FOUND, | ||
| ) | ||
| if page.owned_by != request.user: | ||
| return Response( | ||
| {"error": "Only the page owner can delete this page."}, | ||
| status=status.HTTP_403_FORBIDDEN, | ||
| ) | ||
| page.delete() | ||
| return Response(status=status.HTTP_204_NO_CONTENT) | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
is_lockedis too permissive on this PATCH surface.ProjectPagePermissionallows members to PATCH public pages, andPageDetailAPIEndpoint.patch()only blocks edits when the page is already locked. Leavingis_lockedwritable here lets one member lock another user's public page and freeze future edits through this API. Please makeis_lockedread-only here or move lock toggles behind a stricter owner/admin-only flow.🤖 Prompt for AI Agents