feat(api): add Pages CRUD endpoints to v1 API#8800
feat(api): add Pages CRUD endpoints to v1 API#8800dvdcastro wants to merge 2 commits intomakeplane:previewfrom
Conversation
Adds GET, POST, PATCH, DELETE endpoints for project pages under:
/api/v1/workspaces/{slug}/projects/{project_id}/pages/
/api/v1/workspaces/{slug}/projects/{project_id}/pages/{page_id}/
The page management logic already existed in plane.app; this PR
exposes it through the public v1 API with proper permissions,
serialization, and drf-spectacular documentation.
Closes makeplane#7319
📝 WalkthroughWalkthroughAdds Page CRUD API: a new Changes
Sequence Diagram(s)sequenceDiagram
participant Client
participant Endpoint as PageListCreateAPIEndpoint
participant DetailEP as PageDetailAPIEndpoint
participant Serializer as PageAPISerializer
participant ORM as ORM/Models
participant DB as Database
rect rgba(100,150,200,0.5)
Note over Client,DB: POST - Create Page
Client->>Endpoint: POST /workspaces/{slug}/projects/{project_id}/pages/ {name, description_html}
Endpoint->>ORM: lookup Project by project_id & workspace
ORM-->>Endpoint: Project found / 404
Endpoint->>Serializer: validate input
Serializer-->>Endpoint: validated data
Endpoint->>ORM: create Page (description_binary=None)
ORM->>DB: INSERT Page
ORM-->>Endpoint: Page instance
Endpoint->>ORM: create ProjectPage linkage (created_by/updated_by=user)
ORM->>DB: INSERT ProjectPage
Endpoint-->>Client: 201 Created {page}
end
rect rgba(150,100,200,0.5)
Note over Client,DB: GET - List Pages
Client->>Endpoint: GET /workspaces/{slug}/projects/{project_id}/pages/
Endpoint->>ORM: query Pages (workspace, project mapping Exists, access/owned_by filters, not archived)
ORM->>ORM: annotate is_favorite (Exists subquery)
ORM->>DB: SELECT with prefetch/select_related
DB-->>ORM: Page rows
ORM-->>Endpoint: queryset
Endpoint-->>Client: 200 OK [pages...]
end
rect rgba(200,150,100,0.5)
Note over Client,DB: PATCH / DELETE - Detail Operations
Client->>DetailEP: PATCH/DELETE /workspaces/{slug}/projects/{project_id}/pages/{page_id}/
DetailEP->>ORM: lookup Page by id with access filters
ORM->>DB: SELECT Page
DB-->>ORM: Page or 404
alt PATCH and Page.is_locked
DetailEP-->>Client: 400 Bad Request
else PATCH valid
DetailEP->>Serializer: validate partial data
Serializer-->>DetailEP: validated data
DetailEP->>ORM: update Page (reset description_binary if description_html present) with updated_by=user
ORM->>DB: UPDATE Page
DetailEP-->>Client: 200 OK {page}
else DELETE and owner
DetailEP->>ORM: delete Page
ORM->>DB: DELETE
DetailEP-->>Client: 204 No Content
end
end
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~35 minutes Poem
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 4
🧹 Nitpick comments (2)
apps/api/plane/tests/contract/api/test_pages.py (2)
104-117: Assert theProjectPagelink on create.This endpoint is project-scoped, but the test only proves that a
Pagerow exists. If the through record is skipped or attached to the wrong project, this contract still passes.Suggested assertion
assert response.status_code == status.HTTP_201_CREATED assert response.data["name"] == "My New Page" assert Page.objects.filter(name="My New Page").exists() + assert ProjectPage.objects.filter( + project=project, + page__name="My New Page", + ).exists()🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/api/plane/tests/contract/api/test_pages.py` around lines 104 - 117, The test_create_page currently only asserts that a Page row exists; ensure it also verifies the project-scoped link by asserting a ProjectPage through-record exists and is associated with the created Page and the correct Project: after calling api_key_client.post(self.get_url(workspace.slug, project.id), ...) and checking response.data and Page, query ProjectPage (or the through model used to link Page to Project) for a record with page_id equal to the created page id (or response.data["id"]) and project_id equal to project.id, and assert it exists and/or has expected fields to guarantee the page was attached to the intended project.
187-203: Add the authenticated non-owner delete case.Owner-only delete is part of the contract, but the suite only covers owner success and anonymous
401. A logged-in second user with project access should assert the403path.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/api/plane/tests/contract/api/test_pages.py` around lines 187 - 203, Add a new test that asserts an authenticated non-owner with project access gets 403 when deleting a page: create a second user (or use an existing test fixture for a non-owner client), ensure this user has access to the workspace/project (but is not the page owner), call the same delete endpoint used in test_delete_page_by_owner using that non-owner authenticated client, assert response.status_code == status.HTTP_403_FORBIDDEN and that Page.objects.filter(id=page.id).exists() remains True; name the test something like test_delete_page_by_non_owner_returns_403 to match the existing naming.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@apps/api/plane/api/serializers/page.py`:
- Around line 33-50: The serializer currently exposes "is_locked" as writable
which lets non-owners lock other users' public pages; update the page serializer
in apps/api/plane/api/serializers/page.py (the serializer that defines
read_only_fields for pages) to make "is_locked" read-only by adding "is_locked"
to the read_only_fields list (or otherwise remove it from the writable fields),
ensuring lock state can only be changed via owner/admin-only flows (e.g., the
PageDetailAPIEndpoint or a dedicated lock/unlock endpoint).
In `@apps/api/plane/api/views/page.py`:
- Around line 95-113: The Page and ProjectPage creations must be done atomically
to avoid orphan Pages if the ProjectPage insert fails; wrap the
Page.objects.create and ProjectPage.objects.create calls inside a single
transaction (use django.db.transaction.atomic) so both are committed or both
rolled back, and keep references to the same page instance (created by
Page.objects.create) when creating the ProjectPage; update the view function
that calls Page.objects.create and ProjectPage.objects.create to use
transaction.atomic() and ensure any exceptions propagate so the transaction will
roll back.
- Around line 48-49: The query currently filters out nested pages by calling
.filter(parent__isnull=True); remove that filter so the view's queryset no
longer restricts results to root pages (keep the permission filters that use
Q(owned_by=self.request.user) | Q(access=0) intact) so GET /pages/ returns all
pages within the scope instead of only top-level ones; if you need project
scoping, ensure a project filter (e.g., parent__project or project field) is
applied elsewhere rather than parent__isnull.
- Around line 95-103: The Page.objects.create(...) call is passing a
non-existent field "description" which will raise a FieldError; remove the
description=serializer.validated_data.get("description", {}) entry from the
Page.objects.create invocation (located where Page.objects.create is called in
page view) so the create call only includes valid model fields (name,
description_html, description_stripped, access, color, view_props, logo_props,
etc.).
---
Nitpick comments:
In `@apps/api/plane/tests/contract/api/test_pages.py`:
- Around line 104-117: The test_create_page currently only asserts that a Page
row exists; ensure it also verifies the project-scoped link by asserting a
ProjectPage through-record exists and is associated with the created Page and
the correct Project: after calling
api_key_client.post(self.get_url(workspace.slug, project.id), ...) and checking
response.data and Page, query ProjectPage (or the through model used to link
Page to Project) for a record with page_id equal to the created page id (or
response.data["id"]) and project_id equal to project.id, and assert it exists
and/or has expected fields to guarantee the page was attached to the intended
project.
- Around line 187-203: Add a new test that asserts an authenticated non-owner
with project access gets 403 when deleting a page: create a second user (or use
an existing test fixture for a non-owner client), ensure this user has access to
the workspace/project (but is not the page owner), call the same delete endpoint
used in test_delete_page_by_owner using that non-owner authenticated client,
assert response.status_code == status.HTTP_403_FORBIDDEN and that
Page.objects.filter(id=page.id).exists() remains True; name the test something
like test_delete_page_by_non_owner_returns_403 to match the existing naming.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: 360a9fd9-d62c-4e09-8a67-36e6f72b6bf0
📒 Files selected for processing (7)
apps/api/plane/api/serializers/__init__.pyapps/api/plane/api/serializers/page.pyapps/api/plane/api/urls/__init__.pyapps/api/plane/api/urls/page.pyapps/api/plane/api/views/__init__.pyapps/api/plane/api/views/page.pyapps/api/plane/tests/contract/api/test_pages.py
| "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", | ||
| ] |
There was a problem hiding this comment.
is_locked is too permissive on this PATCH surface.
ProjectPagePermission allows members to PATCH public pages, and PageDetailAPIEndpoint.patch() only blocks edits when the page is already locked. Leaving is_locked writable here lets one member lock another user's public page and freeze future edits through this API. Please make is_locked read-only here or move lock toggles behind a stricter owner/admin-only flow.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@apps/api/plane/api/serializers/page.py` around lines 33 - 50, The serializer
currently exposes "is_locked" as writable which lets non-owners lock other
users' public pages; update the page serializer in
apps/api/plane/api/serializers/page.py (the serializer that defines
read_only_fields for pages) to make "is_locked" read-only by adding "is_locked"
to the read_only_fields list (or otherwise remove it from the writable fields),
ensuring lock state can only be changed via owner/admin-only flows (e.g., the
PageDetailAPIEndpoint or a dedicated lock/unlock endpoint).
| .filter(parent__isnull=True) | ||
| .filter(Q(owned_by=self.request.user) | Q(access=0)) |
There was a problem hiding this comment.
GET /pages/ currently hides child pages.
The parent__isnull=True filter on Line 48 makes this route return only root pages, even though the endpoint/docs describe a project pages listing. Nested pages become undiscoverable unless the caller already knows their IDs.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@apps/api/plane/api/views/page.py` around lines 48 - 49, The query currently
filters out nested pages by calling .filter(parent__isnull=True); remove that
filter so the view's queryset no longer restricts results to root pages (keep
the permission filters that use Q(owned_by=self.request.user) | Q(access=0)
intact) so GET /pages/ returns all pages within the scope instead of only
top-level ones; if you need project scoping, ensure a project filter (e.g.,
parent__project or project field) is applied elsewhere rather than
parent__isnull.
| 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", {}), | ||
| 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, | ||
| ) |
There was a problem hiding this comment.
Create the page and its project link atomically.
Page and ProjectPage are both required for a successful POST, but they are written independently here. If the second insert fails, this leaves behind an orphaned Page that the project-scoped API cannot reach anymore.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@apps/api/plane/api/views/page.py` around lines 95 - 113, The Page and
ProjectPage creations must be done atomically to avoid orphan Pages if the
ProjectPage insert fails; wrap the Page.objects.create and
ProjectPage.objects.create calls inside a single transaction (use
django.db.transaction.atomic) so both are committed or both rolled back, and
keep references to the same page instance (created by Page.objects.create) when
creating the ProjectPage; update the view function that calls
Page.objects.create and ProjectPage.objects.create to use transaction.atomic()
and ensure any exceptions propagate so the transaction will roll back.
| 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", {}), |
There was a problem hiding this comment.
🧩 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 description parameter from Page.objects.create() call.
Line 99 passes description=serializer.validated_data.get("description", {}) to Page.objects.create(), but the Page model has no "description" field. The PageAPISerializer also does not validate a "description" field. This will raise a FieldError at runtime instead of returning a 201 response.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@apps/api/plane/api/views/page.py` around lines 95 - 103, The
Page.objects.create(...) call is passing a non-existent field "description"
which will raise a FieldError; remove the
description=serializer.validated_data.get("description", {}) entry from the
Page.objects.create invocation (located where Page.objects.create is called in
page view) so the create call only includes valid model fields (name,
description_html, description_stripped, access, color, view_props, logo_props,
etc.).
When pages are created or updated via the v1 API, description_binary was left untouched, causing the Tiptap/Yjs collaborative editor to serve stale binary state instead of the new description_html content. Fix: explicitly set description_binary=None whenever description_html is written, so the collab server (plane-live) reloads the document from the updated HTML on the next editor session. Affects: PageListCreateAPIEndpoint.post, PageDetailAPIEndpoint.patch
There was a problem hiding this comment.
Actionable comments posted: 2
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@apps/api/plane/api/views/page.py`:
- Around line 78-85: The POST handler currently fetches Project via
Project.objects.get(pk=project_id, workspace__slug=slug) but doesn't prevent
creating pages on archived projects; after retrieving the Project in post,
verify the archive flag (e.g., project.archived or project.is_archived) or
change the query to exclude archived projects (e.g., add archived=False) and
return an appropriate Response (403 Forbidden with an error message like
"Project is archived") when the project is archived so page creation is
rejected.
- Around line 125-134: get_page currently returns pages without excluding those
in archived projects, unlike get_queryset which filters with
projects__archived_at__isnull=True; update the Page query in get_page to include
the same archived-project filter (add projects__archived_at__isnull=True) so
GET/PATCH/DELETE respect archived status, keeping the existing Q(owned_by=...) |
Q(access=0) logic and other filters in get_page.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: 5e621226-aa3f-4b95-96e8-e97a0af1e622
📒 Files selected for processing (1)
apps/api/plane/api/views/page.py
| 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, | ||
| ) |
There was a problem hiding this comment.
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
Verify each finding against the current code and only fix it if needed.
In `@apps/api/plane/api/views/page.py` around lines 78 - 85, The POST handler
currently fetches Project via Project.objects.get(pk=project_id,
workspace__slug=slug) but doesn't prevent creating pages on archived projects;
after retrieving the Project in post, verify the archive flag (e.g.,
project.archived or project.is_archived) or change the query to exclude archived
projects (e.g., add archived=False) and return an appropriate Response (403
Forbidden with an error message like "Project is archived") when the project is
archived so page creation is rejected.
| 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() | ||
| ) |
There was a problem hiding this comment.
Detail operations can access pages in archived projects.
Unlike get_queryset() which filters by projects__archived_at__isnull=True (line 42), get_page() omits this check. This allows GET, PATCH, and DELETE operations on pages belonging to archived projects, which is inconsistent with the list endpoint behavior.
🛡️ 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
Verify each finding against the current code and only fix it if needed.
In `@apps/api/plane/api/views/page.py` around lines 125 - 134, get_page currently
returns pages without excluding those in archived projects, unlike get_queryset
which filters with projects__archived_at__isnull=True; update the Page query in
get_page to include the same archived-project filter (add
projects__archived_at__isnull=True) so GET/PATCH/DELETE respect archived status,
keeping the existing Q(owned_by=...) | Q(access=0) logic and other filters in
get_page.
|
|
Summary
Adds
GET,POST,PATCH, andDELETEendpoints for project pages to the public v1 API.Endpoints added
/api/v1/workspaces/{slug}/projects/{project_id}/pages//api/v1/workspaces/{slug}/projects/{project_id}/pages//api/v1/workspaces/{slug}/projects/{project_id}/pages/{page_id}//api/v1/workspaces/{slug}/projects/{project_id}/pages/{page_id}//api/v1/workspaces/{slug}/projects/{project_id}/pages/{page_id}/Context
The page management logic already existed in
plane.app; this PR exposes it through the public v1 API with proper permissions (ProjectPagePermission), serialization (PageAPISerializer), and drf-spectacular documentation annotations.Locked pages return 400 on PATCH. Only the page owner can DELETE.
Tests
Unit tests added in
apps/api/plane/tests/contract/api/test_pages.pycovering all endpoints, authentication, locked page guard, and owner-only delete.Closes #7319
Summary by CodeRabbit
New Features
Tests