feat(api): expose project pages in the public REST API - #9528
Conversation
Pages exist in the product but are not reachable through the public /api/v1 API on self-hosted instances (makeplane#8986), which breaks integrations such as the official MCP server and SDK page tools (plane-mcp-server#163). Expose project pages under /api/v1 following the same conventions as the other project resources (states, cycles, modules): - GET/POST /workspaces/:slug/projects/:project_id/pages/ - GET/PATCH/DELETE /workspaces/:slug/projects/:project_id/pages/:pk/ - POST/DELETE /workspaces/:slug/projects/:project_id/pages/:pk/archive/ Behavior mirrors the internal app API: private pages are only visible to their owner, description_html is sanitized and captured via page_transaction, access changes are restricted to the page owner, archive cascades to nested pages, and deletion requires the page to be archived first and the requester to be the owner or a project admin. Supports external_id/external_source deduplication, cursor pagination, and fields/expand like the rest of the public API, with OpenAPI docs (page_docs) included. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Plus Run ID: 📒 Files selected for processing (4)
🚧 Files skipped from review as they are similar to previous changes (3)
📝 WalkthroughWalkthroughThis change exposes project-scoped Pages REST endpoints with serializers, CRUD operations, archive lifecycle handling, contract tests, and OpenAPI documentation. ChangesPages REST API
Estimated code review effort: 4 (Complex) | ~45 minutes Sequence Diagram(s)sequenceDiagram
participant Client
participant PageListCreateAPIEndpoint
participant PageDetailSerializer
participant Page
participant ProjectPage
Client->>PageListCreateAPIEndpoint: Send page request
PageListCreateAPIEndpoint->>PageDetailSerializer: Validate page data
PageDetailSerializer->>Page: Create workspace-owned page
PageDetailSerializer->>ProjectPage: Create project association
PageListCreateAPIEndpoint-->>Client: Return page response
Possibly related PRs
Suggested reviewers: 🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ 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 |
15 tests covering create (success, validation, external_id dedup), list (pagination envelope, private-page visibility), retrieve, update (locked pages, owner-only access changes), delete (archive-first rule) and archive/unarchive. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Actionable comments posted: 10
🧹 Nitpick comments (4)
apps/api/plane/api/serializers/page.py (1)
81-89: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueReturn the validation reason to the client.
validate_html_contentproduceserror_msg, but the code discards it and raises a fixed string. Clients cannot tell why the content failed. Includeerror_msgwhen it is present.♻️ Proposed change
- if not is_valid: - raise serializers.ValidationError("html content is not valid") + if not is_valid: + raise serializers.ValidationError(error_msg or "html content is not valid")🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@apps/api/plane/api/serializers/page.py` around lines 81 - 89, Update validate_description_html to include the error_msg returned by validate_html_content in serializers.ValidationError when validation fails, while retaining a fallback message when error_msg is absent. Preserve the existing sanitization and return behavior for valid content.apps/api/plane/tests/contract/api/test_pages.py (1)
266-297: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winAdd coverage for the archive cascade and the permission branches.
The archive tests cover only a single page owned by the caller. The endpoint documentation states that archive and unarchive apply to all nested pages, and both handlers reject callers whose role is 15 or lower and who do not own the page. No test exercises either behavior. The unarchive branch that detaches an archived parent at Line 453 of
apps/api/plane/api/views/page.pyis also untested.Add tests for:
- Archive of a parent page sets
archived_aton its children.- Unarchive of a page whose parent stays archived clears
parent.- A non-owner member with role 15 receives an error from archive and unarchive.
- A non-owner non-admin receives 403 from delete.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@apps/api/plane/tests/contract/api/test_pages.py` around lines 266 - 297, Extend TestPageArchiveUnarchiveAPIEndpoint with tests covering nested-page archive propagation and unarchive parent detachment: archiving a parent must set archived_at on its children, while unarchiving a page whose parent remains archived must clear its parent. Add permission-branch tests using a non-owner role-15 member and a non-owner non-admin, asserting the documented error responses for archive/unarchive and 403 for delete.apps/api/plane/api/views/page.py (2)
51-65: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winExtract the duplicated page queryset. Three endpoint classes define the same
get_querysetbody: workspace slug filter, project filter, active-membership filter, non-archived project filter,project_pages__deleted_at__isnull=True, the access filter, anddistinct(). A change to the visibility rule must be applied in three places, and a missed copy silently exposes private pages on one endpoint.
apps/api/plane/api/views/page.py#L51-L65: move this body into a shared mixin or module-level helper, for exampleproject_pages_queryset(slug, project_id, user), and call it fromPageListCreateAPIEndpoint.get_queryset.apps/api/plane/api/views/page.py#L171-L185: call the shared helper fromPageDetailAPIEndpoint.get_queryset, keeping theselect_relatedcalls.apps/api/plane/api/views/page.py#L363-L375: call the shared helper fromPageArchiveUnarchiveAPIEndpoint.get_queryset.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@apps/api/plane/api/views/page.py` around lines 51 - 65, Extract the duplicated project-page queryset into a shared helper or mixin in apps/api/plane/api/views/page.py, preserving all existing filters and distinct behavior. At apps/api/plane/api/views/page.py lines 51-65, update PageListCreateAPIEndpoint.get_queryset to call it; at lines 171-185, update PageDetailAPIEndpoint.get_queryset and retain its select_related calls; at lines 363-375, update PageArchiveUnarchiveAPIEndpoint.get_queryset to call the same helper.
316-328: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winUse the project role enum for page delete authorization.
apps/api/plane/api/views/page.pyusesrole=20, while the archive/unarchive checks userole__lte=15. Since the canonical role values are defined inapps/api/plane/db/models/project.py, import thatROLEenum and useROLE.ADMIN.value(orROLE.member.valueif deleting should allow lower roles) instead of literal thresholds.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@apps/api/plane/api/views/page.py` around lines 316 - 328, Update the delete authorization check in the page deletion view to import and use the canonical ROLE enum from the project model instead of the literal role=20 filter. Use ROLE.ADMIN.value for the existing admin-only behavior, or ROLE.member.value only if deletion is intended to allow lower project roles, while preserving the remaining ProjectMember filters.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@apps/api/plane/api/serializers/page.py`:
- Around line 25-53: Update the serializer’s read_only_fields to include
archived_at so clients cannot set lifecycle state directly. Resolve the
is_locked deadlock in PageDetailAPIEndpoint.patch by either adding a dedicated
lock endpoint or permitting PATCH requests that change only is_locked while
preserving rejection of other updates on locked pages.
- Around line 91-114: Update the PageSerializer.create method to wrap both
Page.objects.create and ProjectPage.objects.create in a transaction.atomic()
block, ensuring neither insert persists without the other. Set ProjectPage’s
created_by_id and updated_by_id directly from the owned_by_id context value
instead of page audit fields.
- Around line 55-65: Update validate_parent to reject value when it is the
current instance, then traverse value’s parent chain and raise
serializers.ValidationError if any ancestor matches self.instance.id. Preserve
the existing project-membership validation and return value only when no cycle
would be introduced.
In `@apps/api/plane/api/urls/page.py`:
- Around line 14-23: Rename the detail route in the URL patterns using
PageDetailAPIEndpoint to a distinct name such as page-detail, while keeping the
PageListCreateAPIEndpoint route named pages so reverse("pages") resolves with
only slug and project_id.
In `@apps/api/plane/api/views/page.py`:
- Around line 398-407: Update the archive permission failure in the page
handler, the analogous check in PageDetailAPIEndpoint.delete, and the unarchive
handler to return status.HTTP_403_FORBIDDEN instead of 400. Also update the
page_docs decorator responses map to document 403 for these permission failures.
- Around line 330-353: Wrap the full deletion sequence in the page delete
handler—including clearing child parents, page.delete(), UserFavorite deletion,
and UserRecentVisit hard deletion—in transaction.atomic(). Add the django.db
transaction import and ensure any failure rolls back all four writes.
- Around line 119-127: Update the page creation flow around serializer.save()
and page_transaction.delay() to pass the sanitized description_html from the
saved Page/serializer result instead of request.data. Reuse that saved value for
the task and response, removing the extra Page.objects.get lookup; apply the
same change to the patch flow’s page_transaction call.
- Around line 247-251: Update the access comparison in the page update logic to
coerce the incoming request.data access value to the same integer type as
page.access before comparing them. Preserve the existing owner check and error
response, while ensuring equivalent values such as 1 and "1" are treated as
unchanged.
- Around line 95-117: In the duplicate-page check, replace the repeated
filter/exists/first sequence with one `.first()` query and branch on the
returned page. To enforce deduplication under concurrent requests, add a
database-level unique constraint covering workspace, external_source, and
external_id, then catch and handle the resulting IntegrityError around page
creation while preserving the existing 409 response.
In `@apps/api/plane/tests/contract/api/test_pages.py`:
- Around line 253-263: Replace both datetime.now() assignments in the page
deletion tests, including test_delete_archived_page and the archived_at
assignment near the later test, with timezone.now(). Preserve the existing
assertions and test behavior.
---
Nitpick comments:
In `@apps/api/plane/api/serializers/page.py`:
- Around line 81-89: Update validate_description_html to include the error_msg
returned by validate_html_content in serializers.ValidationError when validation
fails, while retaining a fallback message when error_msg is absent. Preserve the
existing sanitization and return behavior for valid content.
In `@apps/api/plane/api/views/page.py`:
- Around line 51-65: Extract the duplicated project-page queryset into a shared
helper or mixin in apps/api/plane/api/views/page.py, preserving all existing
filters and distinct behavior. At apps/api/plane/api/views/page.py lines 51-65,
update PageListCreateAPIEndpoint.get_queryset to call it; at lines 171-185,
update PageDetailAPIEndpoint.get_queryset and retain its select_related calls;
at lines 363-375, update PageArchiveUnarchiveAPIEndpoint.get_queryset to call
the same helper.
- Around line 316-328: Update the delete authorization check in the page
deletion view to import and use the canonical ROLE enum from the project model
instead of the literal role=20 filter. Use ROLE.ADMIN.value for the existing
admin-only behavior, or ROLE.member.value only if deletion is intended to allow
lower project roles, while preserving the remaining ProjectMember filters.
In `@apps/api/plane/tests/contract/api/test_pages.py`:
- Around line 266-297: Extend TestPageArchiveUnarchiveAPIEndpoint with tests
covering nested-page archive propagation and unarchive parent detachment:
archiving a parent must set archived_at on its children, while unarchiving a
page whose parent remains archived must clear its parent. Add permission-branch
tests using a non-owner role-15 member and a non-owner non-admin, asserting the
documented error responses for archive/unarchive and 403 for delete.
🪄 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 Plus
Run ID: 39899394-930b-4ad5-8a26-b7b9cb85e8f1
📒 Files selected for processing (11)
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.pyapps/api/plane/utils/openapi/__init__.pyapps/api/plane/utils/openapi/decorators.pyapps/api/plane/utils/openapi/examples.pyapps/api/plane/utils/openapi/parameters.py
- make archived_at read-only; archive lifecycle goes through /archive/ - allow unlocking a locked page via PATCH restricted to is_locked - reject self/descendant parents to prevent hierarchy cycles - wrap page+ProjectPage creation and the deletion sequence in transactions; set audit fields on create - pass the sanitized description_html to page_transaction instead of the raw request body - coerce access to int before the owner-only comparison - collapse the duplicate external_id lookup into a single query - return 403 (not 400) for archive/unarchive permission failures - tests: timezone-aware archived_at, plus coverage for unlock, read-only archived_at and string access values (18 passing) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@apps/api/plane/api/serializers/page.py`:
- Around line 59-82: Update validate_parent to track visited ancestor IDs while
traversing value.parent. If an ancestor ID repeats, raise
serializers.ValidationError to reject the pre-existing parent cycle; preserve
the existing self/descendant cycle check and normal traversal termination.
🪄 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 Plus
Run ID: d108bb09-50e2-4c8b-bd03-6fac460ba160
📒 Files selected for processing (3)
apps/api/plane/api/serializers/page.pyapps/api/plane/api/views/page.pyapps/api/plane/tests/contract/api/test_pages.py
🚧 Files skipped from review as they are similar to previous changes (1)
- apps/api/plane/api/views/page.py
- guard validate_parent against pre-existing hierarchy cycles by tracking visited ids during ancestor traversal - give the detail route a distinct name (page-detail) so reverse() resolves both routes - surface the HTML validation reason to the client instead of a fixed message - extract the shared page visibility queryset into a module-level helper used by all three endpoints - replace literal role values with the ROLE enum - tests: archive cascade to children, unarchive detaching an archived parent, and 403 permission branches for a role-15 member (22 passing) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Description
Pages exist in the product but are not reachable through the public
/api/v1API on self-hosted instances (#8986), which breaks integrationssuch as the official MCP server and SDK page tools
(makeplane/plane-mcp-server#163).
Expose project pages under
/api/v1following the same conventions as theother project resources (states, cycles, modules):
GET/POST /workspaces/:slug/projects/:project_id/pages/GET/PATCH/DELETE /workspaces/:slug/projects/:project_id/pages/:pk/POST/DELETE /workspaces/:slug/projects/:project_id/pages/:pk/archive/Behavior mirrors the internal app API: private pages are only visible to
their owner,
description_htmlis sanitized and captured viapage_transaction, access changes are restricted to the page owner,archive cascades to nested pages, and deletion requires the page to be
archived first and the requester to be the owner or a project admin.
Supports
external_id/external_sourcededuplication, cursor pagination,and
fields/expandlike the rest of the public API, with OpenAPI docs(
page_docs) included. Purely additive — no existing endpoint or behavioris modified.
Type of Change
Test Scenarios
plane/tests/contract/api/test_pages.py— 15 contract tests, all passingagainst Postgres: create (success, validation errors,
external_iddedup → 409),list (pagination envelope, other users' private pages excluded), retrieve
(including 404 for other users' private pages), update (locked page → 400,
access change by non-owner → 400), delete (archive-first rule, owner/admin only)
and archive/unarchive.
(
manage.py spectacular): the 7 new operations are emitted with no new warnings.ruff check/ruff formatclean on all touched files.plane-sdk/MCP server payloads (
X-Api-Keyauth,{name, description_html}create body,int
accessvalues) to confirm contract compatibility.References
Closes #8986
Related: makeplane/plane-mcp-server#163
Summary by CodeRabbit