Skip to content

Add Pages endpoints to v1 API#8669

Open
zimbatm wants to merge 4 commits intomakeplane:previewfrom
zimbatm:feat/v1-pages-crud
Open

Add Pages endpoints to v1 API#8669
zimbatm wants to merge 4 commits intomakeplane:previewfrom
zimbatm:feat/v1-pages-crud

Conversation

@zimbatm
Copy link

@zimbatm zimbatm commented Feb 27, 2026

Description

Adds full CRUD and archive/unarchive support for Pages in the v1 external API.

Type of Change

  • Bug fix (non-breaking change which fixes an issue)
  • Feature (non-breaking change which adds functionality)
  • Improvement (change that would cause existing functionality to not work as expected)
  • Code refactoring
  • Performance improvements
  • Documentation update

Screenshots and Media (if applicable)

Test Scenarios

Expanded the test suite to cover the new API.

References

Closes #4108, closes #7319, closes #8598

Summary by CodeRabbit

  • New Features

    • Added page management API endpoints for creating, retrieving, updating, and deleting pages within projects.
    • Implemented page archiving and unarchiving functionality with support for hierarchical page organization.
    • Added permission-based access controls for page operations (owner/admin restrictions).
    • Added support for external page source tracking.
  • Documentation

    • Updated API documentation with Pages feature information and endpoint details.
  • Tests

    • Added comprehensive contract tests for page API endpoints covering all operations and permission scenarios.

Copilot AI review requested due to automatic review settings February 27, 2026 00:10
@CLAassistant
Copy link

CLAassistant commented Feb 27, 2026

CLA assistant check
All committers have signed the CLA.

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Feb 27, 2026

📝 Walkthrough

Walkthrough

This change introduces a complete CRUD API feature for Pages. It adds three serializers (PageCreateSerializer, PageUpdateSerializer, PageSerializer) for model operations, three API endpoint classes handling list/create, detail retrieval/update/deletion, and archive/unarchive workflows, five new URL routes, permission controls, background task integration, and comprehensive contract tests.

Changes

Cohort / File(s) Summary
Serializers
apps/api/plane/api/serializers/__init__.py, apps/api/plane/api/serializers/page.py
Introduces PageCreateSerializer, PageUpdateSerializer, and PageSerializer with writable fields (name, description_html, color, access, parent, external_source, external_id) and read-only relationship fields (label_ids, project_ids). Exports new serializers to package public API.
API Endpoints
apps/api/plane/api/views/__init__.py, apps/api/plane/api/views/page.py
Implements PageListCreateAPIEndpoint (GET/POST), PageDetailAPIEndpoint (GET/PATCH/DELETE), and PageArchiveUnarchiveAPIEndpoint (GET/POST/DELETE) with permission checks, external_id uniqueness validation, background task triggering, cascade operations on archiving/deletion, and cleanup of related user favorites and visit records.
URL Routing
apps/api/plane/api/urls/__init__.py, apps/api/plane/api/urls/page.py
Wires five page-related routes including list/create, detail CRUD, and archive/unarchive endpoints. Aggregates page URL patterns into main urlpatterns.
Documentation & Configuration
apps/api/plane/settings/openapi.py
Adds "Pages" tag to OpenAPI schema with detailed description of page management features, hierarchical organization, and access control.
Tests
apps/api/plane/tests/contract/api/test_pages.py
Comprehensive contract test suite covering page creation with external source support, duplicate detection, listing with filtering, detail operations, update validation, deletion constraints, archiving cascades, permission enforcement, and hierarchy adjustments.

Sequence Diagram(s)

sequenceDiagram
    actor User
    participant API as PageListCreateAPIEndpoint
    participant DB as Database
    participant Celery as Background Task Queue
    
    User->>API: POST /pages/ with name, description_html, color
    API->>DB: Check for duplicate external_id/external_source
    DB-->>API: No conflicts found
    API->>DB: Create Page instance
    DB-->>API: Page object returned
    API->>DB: Create ProjectPage association
    DB-->>API: Association created
    API->>Celery: Enqueue page_transaction task
    Celery-->>API: Task queued
    API->>DB: Fetch page with annotations (labels, projects)
    DB-->>API: Annotated page data
    API-->>User: 201 Created with PageSerializer
Loading
sequenceDiagram
    actor User
    participant API as PageDetailAPIEndpoint
    participant PermCheck as Permission Validator
    participant DB as Database
    
    User->>API: DELETE /pages/{page_id}/ (non-archived page)
    API->>PermCheck: Verify user is owner or admin
    PermCheck-->>API: Permission denied / granted
    alt Not archived
        API-->>User: 400 Bad Request (archived-before-delete)
    else Insufficient permissions
        API-->>User: 403 Forbidden
    else Authorized & archived
        API->>DB: Find all descendants
        DB-->>API: List of child pages
        API->>DB: Clear parent reference from children
        DB-->>API: Updated
        API->>DB: Delete page record
        DB-->>API: Deleted
        API->>DB: Remove UserFavorite entries
        DB-->>API: Removed
        API->>DB: Remove UserRecentVisit entries
        DB-->>API: Removed
        API-->>User: 204 No Content
    end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Poem

🐰 Hop along, dear pages flow,
API routes help docs grow,
Create, update, archive with grace,
Hierarchy managed in cyberspace!

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title 'Add Pages endpoints to v1 API' clearly and concisely describes the main change—adding API endpoints for Pages to version 1 of the external API.
Description check ✅ Passed The PR description includes a clear summary of the feature (full CRUD and archive/unarchive support for Pages), identifies it as a feature change, mentions expanded test coverage, and includes references to related issues.
Linked Issues check ✅ Passed The PR fully addresses requirements from all linked issues: #4108 (CRUD endpoints), #7319 (POST/PATCH endpoints for create/edit), and #8598 (update API endpoints) are met with implemented GET, POST, PATCH, DELETE endpoints and archive/unarchive operations.
Out of Scope Changes check ✅ Passed All changes are directly aligned with the PR objectives: serializers, views, URL routing, and tests are all necessary to implement the Pages API endpoints with no extraneous modifications.
Docstring Coverage ✅ Passed Docstring coverage is 82.98% which is sufficient. The required threshold is 80.00%.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds Pages CRUD plus archive/unarchive support to the v1 external API, along with contract tests to validate the new endpoints.

Changes:

  • Introduce v1 API views for listing/creating pages, retrieving/updating/deleting a page, and archive/unarchive flows.
  • Add v1 serializers for Page create/update/read payloads.
  • Register new URLs and expand the contract test suite for Pages.

Reviewed changes

Copilot reviewed 8 out of 8 changed files in this pull request and generated 10 comments.

Show a summary per file
File Description
apps/api/plane/api/views/page.py Implements v1 Pages list/create, detail (get/patch/delete), and archive/unarchive endpoints.
apps/api/plane/api/urls/page.py Adds URL routes for the new Pages and archived-pages endpoints.
apps/api/plane/api/urls/init.py Registers the new page URL patterns with the v1 API router.
apps/api/plane/api/serializers/page.py Adds serializers for Page create/update and full read responses.
apps/api/plane/api/serializers/init.py Exposes the new Page serializers via the serializers package.
apps/api/plane/api/views/init.py Exposes the new Page API endpoints via the views package.
apps/api/plane/tests/contract/api/test_pages.py Adds contract tests covering Pages CRUD and archive/unarchive behavior.

Comment on lines +261 to +269
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()
Copy link

Copilot AI Feb 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The external-id conflict check in patch() only runs when external_id changes. Changing external_source while keeping the same external_id can still create a duplicate (external_source, external_id) pair but bypasses this check.

Trigger the conflict check when either external_id OR external_source changes (using the effective pair after defaults).

Suggested change
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()
effective_external_id = request.data.get("external_id", page.external_id)
effective_external_source = request.data.get("external_source", page.external_source)
if (
effective_external_id
and (
page.external_id != effective_external_id
or page.external_source != effective_external_source
)
and Page.objects.filter(
workspace__slug=slug,
projects__id=project_id,
external_source=effective_external_source,
external_id=effective_external_id,
)
.exclude(pk=page.pk)
.exists()

Copilot uses AI. Check for mistakes.
Comment on lines +261 to +277
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,
)
Copy link

Copilot AI Feb 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

On external-id conflict during patch(), the response returns "id": str(page.id) where page is the current page being updated. That doesn’t help callers find the existing conflicting page (and differs from the create endpoint behavior).

Return the id of the conflicting Page record found by the duplicate query instead.

Suggested change
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,
)
if request.data.get("external_id") and (page.external_id != request.data.get("external_id")):
conflicting_page = 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"),
).first()
if conflicting_page:
return Response(
{
"error": "Page with the same external id and external source already exists",
"id": str(conflicting_page.id),
},
status=status.HTTP_409_CONFLICT,
)

Copilot uses AI. Check for mistakes.
serializer.save()

# Fire page transaction on description change
if request.data.get("description_html"):
Copy link

Copilot AI Feb 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

page_transaction is only triggered when request.data.get("description_html") is truthy. If a client updates the description to an empty string, the description changes but the transaction task won’t run.

Check for field presence (e.g., 'description_html' in request.data) instead of truthiness so transactions fire on all description updates.

Suggested change
if request.data.get("description_html"):
if "description_html" in request.data:

Copilot uses AI. Check for mistakes.
Comment on lines +469 to +474
page = Page.objects.get(
pk=page_id,
workspace__slug=slug,
projects__id=project_id,
project_pages__deleted_at__isnull=True,
)
Copy link

Copilot AI Feb 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PageArchiveUnarchiveAPIEndpoint.delete() retrieves the page without applying the access filter used by list/retrieve. This allows unarchiving another user’s private page by id.

Apply the same access filter (or explicitly block non-owners for private pages) before the owner/admin authorization logic.

Suggested change
page = Page.objects.get(
pk=page_id,
workspace__slug=slug,
projects__id=project_id,
project_pages__deleted_at__isnull=True,
)
try:
page = self.get_queryset().get(pk=page_id)
except Page.DoesNotExist:
return Response(status=status.HTTP_404_NOT_FOUND)

Copilot uses AI. Check for mistakes.
Comment on lines +201 to +205
def test_list_pages_excludes_private_pages_of_other_users(self, api_key_client, workspace, project, create_user):
"""Test that private pages owned by other users are excluded"""
url = self.get_page_url(workspace.slug, project.id)

from plane.db.models import User
Copy link

Copilot AI Feb 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The suite verifies that private pages owned by other users are excluded from listing, but it doesn’t assert that non-owners also cannot PATCH/archive/unarchive/delete those private pages via v1.

Add negative tests ensuring these mutations are rejected and do not modify the page when access=1 and owned_by!=request.user.

Copilot uses AI. Check for mistakes.
Comment on lines +138 to +142

page = serializer.save(
workspace_id=project.workspace_id,
owned_by=request.user,
)
Copy link

Copilot AI Feb 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PageListCreateAPIEndpoint.post() allows setting parent but doesn’t validate that the parent page belongs to the same workspace/project (and isn’t soft-deleted). This can create cross-project/cross-workspace hierarchies.

Validate parent against the same project/workspace constraints before saving (similar to the PATCH endpoint).

Copilot uses AI. Check for mistakes.
Comment on lines +41 to +45
class PageListCreateAPIEndpoint(BaseAPIView):
"""Page List and Create Endpoint"""

serializer_class = PageSerializer
model = Page
Copy link

Copilot AI Feb 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All other v1 API views use @*_docs(...) decorators for OpenAPI (cycles/modules/issues/etc.), but this Pages view has none. This risks these endpoints being undocumented or inconsistent in the generated schema.

Add a page_docs decorator and annotate these methods (list/create/detail/archive/unarchive).

Copilot uses AI. Check for mistakes.
Comment on lines +303 to +307
page = Page.objects.get(
pk=pk,
workspace__slug=slug,
projects__id=project_id,
project_pages__deleted_at__isnull=True,
Copy link

Copilot AI Feb 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PageDetailAPIEndpoint.delete() retrieves the page without applying the access filter used elsewhere (owned_by=request.user or access=0). This lets a project admin delete another user’s private page by id, even though private pages are hidden from list/retrieve.

Apply the same access filter (or explicitly forbid deletes on private pages by non-owners) before deletion.

Copilot uses AI. Check for mistakes.
Comment on lines +426 to +430
page = Page.objects.get(
pk=page_id,
workspace__slug=slug,
projects__id=project_id,
project_pages__deleted_at__isnull=True,
Copy link

Copilot AI Feb 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PageArchiveUnarchiveAPIEndpoint.post() retrieves the page without applying the access filter used by list/retrieve. This allows archiving another user’s private page by id.

Apply the same access filter (or explicitly block non-owners for private pages) before the owner/admin authorization logic.

Copilot uses AI. Check for mistakes.
Comment on lines +227 to +232
page = Page.objects.get(
pk=pk,
workspace__slug=slug,
projects__id=project_id,
project_pages__deleted_at__isnull=True,
)
Copy link

Copilot AI Feb 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PageDetailAPIEndpoint.patch() fetches the page with Page.objects.get(...) without the access filter used by get_queryset() (owned_by=request.user or access=0). A project member can PATCH a private page they don’t own (update succeeds, then response re-fetch can 404), which is an authorization bypass.

Fetch via an access-filtered queryset (or explicitly block non-owners when access=PRIVATE) before saving.

Suggested change
page = Page.objects.get(
pk=pk,
workspace__slug=slug,
projects__id=project_id,
project_pages__deleted_at__isnull=True,
)
page = self.get_queryset().get(pk=pk)

Copilot uses AI. Check for mistakes.
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

🧹 Nitpick comments (1)
apps/api/plane/tests/contract/api/test_pages.py (1)

73-137: Add regression tests for three uncovered edge cases in the new endpoints.

Current coverage is good, but it does not assert:

  • create with parent outside current project is rejected,
  • update conflict when only external_source changes,
  • update with description_html="" still triggers transaction semantics.

These tests would lock in the intended contract and prevent silent regressions.

Also applies to: 292-306, 352-378

🤖 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 73 - 137, Add
three regression tests to the same test module to cover the missing edge cases:
(1) test_create_page_parent_outside_project_rejected — attempt to create a Page
with a parent that belongs to a different Project and assert the API rejects it
(expect HTTP 400/403) and that no Page or ProjectPage was created (check
Page.objects.count() and ProjectPage.filter(...).exists()); (2)
test_update_external_source_conflict — create a Page with an
external_id+external_source, then call the update endpoint changing only
external_source and assert the API returns HTTP_409_CONFLICT and that the Page
record was not partially updated (verify external_id and external_source remain
consistent); (3) test_update_empty_description_triggers_transaction — perform an
update that sets description_html = "" but triggers a validation error inside
the update transaction and assert the response is an error (HTTP 400) and that
no partial changes were persisted (check the Page record remains unchanged and
related ProjectPage/owned_by are intact). Use the existing fixtures and helpers
(api_key_client, get_page_url, Page, ProjectPage) and name the tests exactly as
above so they are easy to locate.
🤖 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 282-287: The current guard uses truthiness on
request.data.get("description_html") which skips updates when description_html
is set to an empty string; change the condition to check for key presence (e.g.,
if "description_html" in request.data) so page_transaction is invoked for
explicit clears too, passing
new_description_html=request.data.get("description_html", "<p></p>"),
old_description_html=page_description, page_id=pk to the page_transaction task.
- Around line 110-143: The create path allows attaching a parent from a
different project because it never verifies the parent page's scope; before
calling serializer.save in the PageCreateSerializer flow, if request.data
contains a "parent" PK, fetch that parent_id and verify
Page.objects.filter(pk=parent_id, workspace__slug=slug,
projects__id=project_id).exists(); if not, return a 400/409 response indicating
invalid parent scope. Ensure this check runs prior to serializer.save (and
before using Project/owned_by), referencing the PageCreateSerializer flow, the
existing Page.objects.filter(...) duplicate check, and the serializer.save call.
- Around line 261-277: The conflict check only runs when external_id changes,
missing cases where external_source alone changes; update the conditional around
the Page.objects.filter so it triggers when either external_id or
external_source in request.data differs from page (i.e., if
request.data.get("external_id") != page.external_id or
request.data.get("external_source") != page.external_source), then set local
variables external_id = request.data.get("external_id", page.external_id) and
external_source = request.data.get("external_source", page.external_source) and
run Page.objects.filter(workspace__slug=slug, projects__id=project_id,
external_source=external_source, external_id=external_id).exists() to detect
collisions and return the existing 409 response if true.
- Line 137: Replace unguarded model .get() calls with Django's get_object_or_404
to avoid raising DoesNotExist and returning 500s; specifically swap the
Project.objects.get(...) usage (e.g., the assignment project =
Project.objects.get(pk=project_id, workspace__slug=slug)) and the other .get()
usages at the noted locations (lines 227-232, 243-248, 303-308, 426-431,
469-474) to use get_object_or_404(Model, ...) instead and import
get_object_or_404 from django.shortcuts at the top of
apps/api/plane/api/views/page.py so missing records produce 404 responses.

---

Nitpick comments:
In `@apps/api/plane/tests/contract/api/test_pages.py`:
- Around line 73-137: Add three regression tests to the same test module to
cover the missing edge cases: (1)
test_create_page_parent_outside_project_rejected — attempt to create a Page with
a parent that belongs to a different Project and assert the API rejects it
(expect HTTP 400/403) and that no Page or ProjectPage was created (check
Page.objects.count() and ProjectPage.filter(...).exists()); (2)
test_update_external_source_conflict — create a Page with an
external_id+external_source, then call the update endpoint changing only
external_source and assert the API returns HTTP_409_CONFLICT and that the Page
record was not partially updated (verify external_id and external_source remain
consistent); (3) test_update_empty_description_triggers_transaction — perform an
update that sets description_html = "" but triggers a validation error inside
the update transaction and assert the response is an error (HTTP 400) and that
no partial changes were persisted (check the Page record remains unchanged and
related ProjectPage/owned_by are intact). Use the existing fixtures and helpers
(api_key_client, get_page_url, Page, ProjectPage) and name the tests exactly as
above so they are easy to locate.

ℹ️ Review info

Configuration used: defaults

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 9425c66 and a43cc26.

📒 Files selected for processing (8)
  • apps/api/plane/api/serializers/__init__.py
  • apps/api/plane/api/serializers/page.py
  • apps/api/plane/api/urls/__init__.py
  • apps/api/plane/api/urls/page.py
  • apps/api/plane/api/views/__init__.py
  • apps/api/plane/api/views/page.py
  • apps/api/plane/settings/openapi.py
  • apps/api/plane/tests/contract/api/test_pages.py

Comment on lines +110 to +143
serializer = PageCreateSerializer(data=request.data)
if serializer.is_valid():
# Check for duplicate external_id + external_source
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,
)

project = Project.objects.get(pk=project_id, workspace__slug=slug)

page = serializer.save(
workspace_id=project.workspace_id,
owned_by=request.user,
)

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Validate parent scope during create to prevent cross-project hierarchy links.

PageCreateSerializer validates that the parent PK exists, but Line 110-143 does not validate that the parent belongs to the same slug/project_id. That allows linking a newly created page to a parent outside the current project context.

Suggested fix
         serializer = PageCreateSerializer(data=request.data)
         if serializer.is_valid():
+            parent = serializer.validated_data.get("parent")
+            if parent and not Page.objects.filter(
+                pk=parent.id,
+                workspace__slug=slug,
+                projects__id=project_id,
+                project_pages__deleted_at__isnull=True,
+            ).exists():
+                return Response(
+                    {"error": "Parent page must belong to the same project"},
+                    status=status.HTTP_400_BAD_REQUEST,
+                )
+
             # Check for duplicate external_id + external_source
             if (
🤖 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 110 - 143, The create path
allows attaching a parent from a different project because it never verifies the
parent page's scope; before calling serializer.save in the PageCreateSerializer
flow, if request.data contains a "parent" PK, fetch that parent_id and verify
Page.objects.filter(pk=parent_id, workspace__slug=slug,
projects__id=project_id).exists(); if not, return a 400/409 response indicating
invalid parent scope. Ensure this check runs prior to serializer.save (and
before using Project/owned_by), referencing the PageCreateSerializer flow, the
existing Page.objects.filter(...) duplicate check, and the serializer.save call.

status=status.HTTP_409_CONFLICT,
)

project = Project.objects.get(pk=project_id, workspace__slug=slug)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

wc -l apps/api/plane/api/views/page.py

Repository: makeplane/plane

Length of output: 95


🏁 Script executed:

sed -n '130,145p' apps/api/plane/api/views/page.py | cat -n

Repository: makeplane/plane

Length of output: 744


🏁 Script executed:

sed -n '220,255p' apps/api/plane/api/views/page.py | cat -n

Repository: makeplane/plane

Length of output: 1556


🏁 Script executed:

sed -n '295,315p' apps/api/plane/api/views/page.py | cat -n

Repository: makeplane/plane

Length of output: 889


🏁 Script executed:

sed -n '420,440p' apps/api/plane/api/views/page.py | cat -n

Repository: makeplane/plane

Length of output: 886


🏁 Script executed:

sed -n '465,480p' apps/api/plane/api/views/page.py | cat -n

Repository: makeplane/plane

Length of output: 686


🏁 Script executed:

sed -n '1,50p' apps/api/plane/api/views/page.py | cat -n

Repository: makeplane/plane

Length of output: 1728


🏁 Script executed:

rg -n 'class.*APIView|def (post|patch|get|delete|put)' apps/api/plane/api/views/page.py | head -20

Repository: makeplane/plane

Length of output: 743


🏁 Script executed:

cat -n apps/api/plane/api/views/base.py | head -100

Repository: makeplane/plane

Length of output: 4037


🏁 Script executed:

sed -n '77,120p' apps/api/plane/api/views/base.py | cat -n

Repository: makeplane/plane

Length of output: 1957


🏁 Script executed:

sed -n '40,65p' apps/api/plane/api/views/base.py | cat -n

Repository: makeplane/plane

Length of output: 1046


🏁 Script executed:

rg -n 'def dispatch' apps/api/plane/api/views/base.py -A 10

Repository: makeplane/plane

Length of output: 984


🏁 Script executed:

sed -n '116,130p' apps/api/plane/api/views/base.py | cat -n

Repository: makeplane/plane

Length of output: 816


Replace direct .get() calls with get_object_or_404() to prevent 500 errors.

At lines 137, 227-232, 243-248, 303-308, 426-431, and 469-474, unguarded .get() calls will raise DoesNotExist exceptions that surface as 500 errors due to a bug in the dispatch() method (line 126 of base.py returns the exception instead of the handled response).

Use get_object_or_404() for all .get() calls:

Suggested fix pattern
+from django.shortcuts import get_object_or_404
 ...
-            project = Project.objects.get(pk=project_id, workspace__slug=slug)
+            project = get_object_or_404(Project, pk=project_id, workspace__slug=slug)
...
-        page = Page.objects.get(
+        page = get_object_or_404(
+            Page,
             pk=pk,
             workspace__slug=slug,
             projects__id=project_id,
             project_pages__deleted_at__isnull=True,
         )
🤖 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` at line 137, Replace unguarded model .get()
calls with Django's get_object_or_404 to avoid raising DoesNotExist and
returning 500s; specifically swap the Project.objects.get(...) usage (e.g., the
assignment project = Project.objects.get(pk=project_id, workspace__slug=slug))
and the other .get() usages at the noted locations (lines 227-232, 243-248,
303-308, 426-431, 469-474) to use get_object_or_404(Model, ...) instead and
import get_object_or_404 from django.shortcuts at the top of
apps/api/plane/api/views/page.py so missing records produce 404 responses.

Comment on lines +261 to +277
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,
)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Conflict detection misses updates where only external_source changes.

Line 261-270 only performs duplicate checking when external_id is in the payload and changed. If external_source changes alone to a conflicting pair, the collision is not blocked.

Suggested fix
-            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()
-            ):
+            incoming_external_id = request.data.get("external_id", page.external_id)
+            incoming_external_source = request.data.get("external_source", page.external_source)
+            if (
+                incoming_external_id
+                and incoming_external_source
+                and (
+                    incoming_external_id != page.external_id
+                    or incoming_external_source != page.external_source
+                )
+                and Page.objects.filter(
+                    workspace__slug=slug,
+                    projects__id=project_id,
+                    external_source=incoming_external_source,
+                    external_id=incoming_external_id,
+                )
+                .exclude(pk=page.pk)
+                .exists()
+            ):
                 return 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 261 - 277, The conflict check
only runs when external_id changes, missing cases where external_source alone
changes; update the conditional around the Page.objects.filter so it triggers
when either external_id or external_source in request.data differs from page
(i.e., if request.data.get("external_id") != page.external_id or
request.data.get("external_source") != page.external_source), then set local
variables external_id = request.data.get("external_id", page.external_id) and
external_source = request.data.get("external_source", page.external_source) and
run Page.objects.filter(workspace__slug=slug, projects__id=project_id,
external_source=external_source, external_id=external_id).exists() to detect
collisions and return the existing 409 response if true.

Comment on lines +282 to +287
if request.data.get("description_html"):
page_transaction.delay(
new_description_html=request.data.get("description_html", "<p></p>"),
old_description_html=page_description,
page_id=pk,
)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Description transaction hook is skipped when clearing content to empty string.

At Line 282, the task is gated by truthiness (if request.data.get("description_html")). An explicit empty string update is a valid change but won’t trigger page_transaction, leaving derived transaction state stale.

Suggested fix
-            if request.data.get("description_html"):
+            if "description_html" in request.data:
                 page_transaction.delay(
                     new_description_html=request.data.get("description_html", "<p></p>"),
                     old_description_html=page_description,
                     page_id=pk,
                 )
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if request.data.get("description_html"):
page_transaction.delay(
new_description_html=request.data.get("description_html", "<p></p>"),
old_description_html=page_description,
page_id=pk,
)
if "description_html" in request.data:
page_transaction.delay(
new_description_html=request.data.get("description_html", "<p></p>"),
old_description_html=page_description,
page_id=pk,
)
🤖 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 282 - 287, The current guard
uses truthiness on request.data.get("description_html") which skips updates when
description_html is set to an empty string; change the condition to check for
key presence (e.g., if "description_html" in request.data) so page_transaction
is invoked for explicit clears too, passing
new_description_html=request.data.get("description_html", "<p></p>"),
old_description_html=page_description, page_id=pk to the page_transaction task.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

3 participants