Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/api/plane/api/serializers/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,3 +64,4 @@
from .invite import WorkspaceInviteSerializer
from .member import ProjectMemberSerializer
from .sticky import StickySerializer
from .page import PageSerializer
95 changes: 95 additions & 0 deletions apps/api/plane/api/serializers/page.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
# Copyright (c) 2023-present Plane Software, Inc. and contributors
# SPDX-License-Identifier: AGPL-3.0-only
# See the LICENSE file for details.

# Django imports
from django.db import transaction
from django.shortcuts import get_object_or_404

# Third party imports
from rest_framework import serializers

# Module imports
from .base import BaseSerializer
from plane.db.models import Page, ProjectPage, Project


class PageSerializer(BaseSerializer):
"""
Serializer for project pages.

Handles creation of a Page along with its ProjectPage join row so the
public API can create, list, retrieve and update pages scoped to a
project. Labels and revisions are not exposed in the MVP serializer.
"""

class Meta:
model = Page
fields = [
"id",
"name",
"description_html",
"description_json",
"owned_by",
"access",
"color",
"parent",
"is_locked",
"archived_at",
"view_props",
"logo_props",
"sort_order",
"external_id",
"external_source",
"workspace",
"created_at",
"updated_at",
"created_by",
"updated_by",
]
read_only_fields = [
"id",
"owned_by",
"workspace",
"created_at",
"updated_at",
"created_by",
"updated_by",
]
Comment on lines +26 to +58
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

is_locked and archived_at should be read-only.

Both fields are managed exclusively by the dedicated /lock/ and /archive/ endpoints (which enforce ownership checks, recursive cascade, etc.). Exposing them as writable in the serializer lets a client silently set is_locked=true or archived_at=<timestamp> on a POST /pages/ or PATCH /pages/<id>/ call, completely bypassing that business logic.

🛡️ Proposed fix
         read_only_fields = [
             "id",
             "owned_by",
             "workspace",
             "created_at",
             "updated_at",
             "created_by",
             "updated_by",
+            "is_locked",
+            "archived_at",
         ]
🤖 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 26 - 58, The
serializer's Meta currently exposes is_locked and archived_at as writable;
update the Meta.read_only_fields to include "is_locked" and "archived_at" so
these attributes cannot be set via Page creation or update (leave them listed in
Meta.fields but add both "is_locked" and "archived_at" to the read_only_fields
list in the Page serializer's Meta class).


def validate_parent(self, value):
if value is None:
return value
project_id = self.context.get("project_id")
if project_id and not ProjectPage.objects.filter(
page_id=value.id,
project_id=project_id,
deleted_at__isnull=True,
).exists():
raise serializers.ValidationError(
"Parent page must belong to the same project."
)
return value
Comment on lines +60 to +72
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

validate_parent resolves the previous cross-project assignment concern — but is missing a self-reference guard.

The past review concern about cross-project parent assignment has been addressed. However, validate_parent does not check whether value is the page being updated (i.e., value.id == self.instance.pk on PATCH), which would create a circular parent chain.

🛡️ Proposed fix
     def validate_parent(self, value):
         if value is None:
             return value
+        if self.instance and value.id == self.instance.pk:
+            raise serializers.ValidationError(
+                "A page cannot be its own parent."
+            )
         project_id = self.context.get("project_id")
         if project_id and not ProjectPage.objects.filter(
             page_id=value.id,
             project_id=project_id,
             deleted_at__isnull=True,
         ).exists():
             raise serializers.ValidationError(
                 "Parent page must belong to the same project."
             )
         return value
🤖 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 60 - 72, validate_parent
currently allows assigning a page as its own parent; add a self-reference guard
in validate_parent by checking if self.instance exists and if value.id ==
getattr(self.instance, "pk", None) and, if so, raise
serializers.ValidationError("Page cannot be its own parent.") before the
project-membership check; ensure this runs only when updating (self.instance is
not None) so creating new pages still allows None parent.


@transaction.atomic
def create(self, validated_data):
project_id = self.context["project_id"]
owned_by_id = self.context["owned_by_id"]

project = get_object_or_404(Project, pk=project_id)

page = Page.objects.create(
**validated_data,
owned_by_id=owned_by_id,
workspace_id=project.workspace_id,
)

ProjectPage.objects.create(
workspace_id=page.workspace_id,
project_id=project_id,
page_id=page.id,
created_by_id=page.created_by_id,
updated_by_id=page.updated_by_id,
)

return page
Comment thread
coderabbitai[bot] marked this conversation as resolved.
2 changes: 2 additions & 0 deletions apps/api/plane/api/urls/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
from .work_item import urlpatterns as work_item_patterns
from .invite import urlpatterns as invite_patterns
from .sticky import urlpatterns as sticky_patterns
from .page import urlpatterns as page_patterns

urlpatterns = [
*asset_patterns,
Expand All @@ -28,4 +29,5 @@
*work_item_patterns,
*invite_patterns,
*sticky_patterns,
*page_patterns,
]
64 changes: 64 additions & 0 deletions apps/api/plane/api/urls/page.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
# Copyright (c) 2023-present Plane Software, Inc. and contributors
# SPDX-License-Identifier: AGPL-3.0-only
# See the LICENSE file for details.

from django.urls import path

from plane.api.views import (
ProjectPageListCreateAPIEndpoint,
ProjectPageDetailAPIEndpoint,
ProjectPageArchiveAPIEndpoint,
ProjectPageLockAPIEndpoint,
ProjectPageAccessAPIEndpoint,
ProjectPageDuplicateAPIEndpoint,
ProjectPageSummaryAPIEndpoint,
)

urlpatterns = [
# CRUD
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/pages/",
ProjectPageListCreateAPIEndpoint.as_view(http_method_names=["get", "post"]),
name="project-pages",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/pages/<uuid:page_id>/",
ProjectPageDetailAPIEndpoint.as_view(http_method_names=["get", "patch", "delete"]),
name="project-pages-detail",
),
Comment thread
coderabbitai[bot] marked this conversation as resolved.
# Summary
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/pages-summary/",
ProjectPageSummaryAPIEndpoint.as_view(http_method_names=["get"]),
name="project-pages-summary",
),
# Archive / unarchive
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/pages/<uuid:page_id>/archive/",
ProjectPageArchiveAPIEndpoint.as_view(http_method_names=["post", "delete"]),
name="project-page-archive-unarchive",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/archived-pages/",
ProjectPageArchiveAPIEndpoint.as_view(http_method_names=["get"]),
name="project-archived-pages",
),
# Lock / unlock
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/pages/<uuid:page_id>/lock/",
ProjectPageLockAPIEndpoint.as_view(http_method_names=["post", "delete"]),
name="project-pages-lock-unlock",
),
# Access toggle
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/pages/<uuid:page_id>/access/",
ProjectPageAccessAPIEndpoint.as_view(http_method_names=["post"]),
name="project-pages-access",
),
# Duplicate
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/pages/<uuid:page_id>/duplicate/",
ProjectPageDuplicateAPIEndpoint.as_view(http_method_names=["post"]),
name="project-pages-duplicate",
),
]
10 changes: 10 additions & 0 deletions apps/api/plane/api/views/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,3 +63,13 @@
from .invite import WorkspaceInvitationsViewset

from .sticky import StickyViewSet

from .page import (
ProjectPageListCreateAPIEndpoint,
ProjectPageDetailAPIEndpoint,
ProjectPageArchiveAPIEndpoint,
ProjectPageLockAPIEndpoint,
ProjectPageAccessAPIEndpoint,
ProjectPageDuplicateAPIEndpoint,
ProjectPageSummaryAPIEndpoint,
)
Loading