Skip to content

Comments

feat: add api/v1 pages endpoint#8649

Closed
Vierkandt wants to merge 3 commits intomakeplane:previewfrom
Vierkandt:feature/api-v1-pages
Closed

feat: add api/v1 pages endpoint#8649
Vierkandt wants to merge 3 commits intomakeplane:previewfrom
Vierkandt:feature/api-v1-pages

Conversation

@Vierkandt
Copy link

@Vierkandt Vierkandt commented Feb 21, 2026

Summary

  • Adds a full CRUD pages endpoint to the api/v1 (API-key-authenticated) layer, which previously had no page support
  • New endpoint follows all existing api/v1 conventions (naming, permissions, pagination, external_id/external_source upsert pattern)
  • Reuses existing ProjectPagePermission, page_transaction background task, and the unarchive_archive_page_and_descendants helper — no new business logic introduced

Endpoints added

GET    /api/v1/workspaces/{slug}/projects/{pid}/pages/                  list pages
POST   /api/v1/workspaces/{slug}/projects/{pid}/pages/                  create page
GET    /api/v1/workspaces/{slug}/projects/{pid}/pages/{id}/             retrieve page
PATCH  /api/v1/workspaces/{slug}/projects/{pid}/pages/{id}/             update page
DELETE /api/v1/workspaces/{slug}/projects/{pid}/pages/{id}/             delete page (requires archived)
POST   /api/v1/workspaces/{slug}/projects/{pid}/pages/{id}/archive/     archive page
DELETE /api/v1/workspaces/{slug}/projects/{pid}/pages/{id}/archive/     unarchive page
POST   /api/v1/workspaces/{slug}/projects/{pid}/pages/{id}/lock/        lock page
DELETE /api/v1/workspaces/{slug}/projects/{pid}/pages/{id}/lock/        unlock page

Files changed

File Change
apps/api/plane/api/serializers/page.py New — PageSerializer with label/project M2M handling
apps/api/plane/api/views/page.py New — 4 endpoint classes
apps/api/plane/api/urls/page.py New — URL patterns
apps/api/plane/api/serializers/__init__.py Export PageSerializer
apps/api/plane/api/views/__init__.py Export endpoint classes
apps/api/plane/api/urls/__init__.py Include page URL patterns

Test plan

  • GET /api/v1/workspaces/{slug}/projects/{pid}/pages/ returns paginated list of public + owned pages
  • POST creates a page and ProjectPage join record; returns 409 on duplicate external_id/external_source
  • PATCH is rejected on locked pages; access change rejected for non-owners
  • DELETE is rejected if page is not archived first
  • Archive/unarchive cascades to descendant pages (recursive SQL helper)
  • Lock/unlock returns 204
  • All endpoints return 403 for non-project-members

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features
    • Comprehensive page management APIs: list, create, retrieve, update, and delete pages within projects.
    • Page archiving and unarchiving.
    • Page locking and unlocking.
    • Create/update pages with associated labels and project linkage in a single request (preserves workspace and user attribution).
  • Chores
    • Added .worktrees/ to repository ignore rules.

@CLAassistant
Copy link

CLAassistant commented Feb 21, 2026

CLA assistant check
Thank you for your submission! We really appreciate it. Like many open source projects, we ask that you all sign our Contributor License Agreement before we can accept your contribution.
1 out of 2 committers have signed the CLA.

✅ Vierkandt
❌ edgarzitha
You have signed the CLA already but the status is still pending? Let us recheck it.

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Feb 21, 2026

Caution

Review failed

The pull request is closed.

📝 Walkthrough

Walkthrough

Adds page management: a new DRF PageSerializer with label/project handling, four page API endpoints (list/create, detail/patch/delete, archive/unarchive, lock/unlock), URL routing for page routes, export re-exports, and a .gitignore entry for .worktrees/. (48 words)

Changes

Cohort / File(s) Summary
Git Configuration
/.gitignore
Adds .worktrees/ to ignored patterns.
Serializers
apps/api/plane/api/serializers/page.py, apps/api/plane/api/serializers/__init__.py
Adds PageSerializer exposing label_ids, project_ids, and write-only labels; implements workspace-scoped __init__, and custom create/update to create Page, ProjectPage, and PageLabel relations; exports serializer via package __init__.
URL Routing
apps/api/plane/api/urls/page.py, apps/api/plane/api/urls/__init__.py
Adds page URL module with four routes (list/create, detail, archive/unarchive, lock/unlock) and includes page_patterns into package urlpatterns.
API Views
apps/api/plane/api/views/page.py, apps/api/plane/api/views/__init__.py
Introduces PageQuerySetMixin and four endpoint classes (PageListCreateAPIEndpoint, PageDetailAPIEndpoint, PageArchiveUnarchiveAPIEndpoint, PageLockUnlockAPIEndpoint) implementing listing, creation (conflict detection), detail/patch/delete (validations, cleanup), archive/unarchive, and lock/unlock behaviors; re-exported via views __init__.

Sequence Diagram

sequenceDiagram
    actor Client
    participant Endpoint as "PageListCreateAPIEndpoint"
    participant Serializer as "PageSerializer"
    participant Project as "Project (DB)"
    participant PageModel as "Page (DB)"
    participant ProjectPageRel as "ProjectPage (DB)"
    participant PageLabelRel as "PageLabel (DB)"

    Client->>Endpoint: POST /workspaces/{slug}/projects/{id}/pages/ (data + labels)
    Endpoint->>Serializer: validate(data)
    Serializer->>Project: fetch(project_id from context)
    Serializer->>PageModel: create(page with owned_by, workspace)
    Serializer->>ProjectPageRel: create(project-page link)
    alt labels provided
        Serializer->>PageLabelRel: bulk create page-label links
    end
    Serializer-->>Endpoint: return serialized page
    Endpoint-->>Client: 201 Created
Loading

Estimated Code Review Effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Poem

🐇 I hopped through code to bind each label true,

Linked page to project, stitched a workspace view.
Endpoints guard the gates, archive and lock,
I nibbled bugs away — a tidy little rock.
Hooray, the pages bloom beneath my dew.

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 69.23% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The title 'feat: add api/v1 pages endpoint' accurately and concisely summarizes the main change—adding a new pages endpoint to the API v1 layer with full CRUD operations.
Description check ✅ Passed The PR description is comprehensive and well-structured, covering summary, endpoints added, files changed, test plan, and implementation details. However, it does not follow the provided description template structure with explicit sections for Type of Change, Screenshots, and References.

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

✨ Finishing Touches
  • 📝 Generate docstrings (stacked PR)
  • 📝 Generate docstrings (commit on current branch)
🧪 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

@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: 6

🧹 Nitpick comments (4)
apps/api/plane/api/views/page.py (3)

32-58: Duplicate get_queryset implementations in PageListCreateAPIEndpoint and PageDetailAPIEndpoint.

These two methods (lines 32–58 and 166–192) are identical. Consider extracting a shared mixin or base class to avoid the duplication and simplify future maintenance.

♻️ Suggested approach
class PageQuerySetMixin:
    def get_queryset(self):
        return (
            Page.objects.filter(
                workspace__slug=self.kwargs.get("slug"),
                projects__id=self.kwargs.get("project_id"),
                project_pages__deleted_at__isnull=True,
            )
            .annotate(
                label_ids=Coalesce(
                    ArrayAgg(
                        "page_labels__label_id",
                        distinct=True,
                        filter=~Q(page_labels__label_id__isnull=True),
                    ),
                    Value([], output_field=ArrayField(UUIDField())),
                ),
                project_ids=Coalesce(
                    ArrayAgg(
                        "projects__id",
                        distinct=True,
                        filter=~Q(projects__id__isnull=True),
                    ),
                    Value([], output_field=ArrayField(UUIDField())),
                ),
            )
            .distinct()
        )

# Then:
class PageListCreateAPIEndpoint(PageQuerySetMixin, BaseAPIView):
    ...

class PageDetailAPIEndpoint(PageQuerySetMixin, BaseAPIView):
    ...

Also applies to: 166-192

🤖 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 32 - 58, Extract the
duplicated get_queryset implementation into a shared mixin (e.g.,
PageQuerySetMixin) that defines the existing filtering and annotate logic, then
have PageListCreateAPIEndpoint and PageDetailAPIEndpoint inherit from that mixin
and remove the duplicate get_queryset methods from both classes; ensure the
mixin is placed before the endpoint base class in the inheritance list so its
get_queryset is used, and keep all original query logic (filters,
Coalesce/ArrayAgg annotations and .distinct()) unchanged when moving it.

394-404: Lock/unlock page.save() saves all fields unnecessarily.

Use update_fields=["is_locked"] to limit the write to a single column, avoiding potential race conditions with concurrent updates to other page fields and reducing I/O.

♻️ Proposed fix
         page.is_locked = True
-        page.save()
+        page.save(update_fields=["is_locked"])
         page.is_locked = False
-        page.save()
+        page.save(update_fields=["is_locked"])

Also applies to: 411-421

🤖 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 394 - 404, The current code
sets page.is_locked = True (and elsewhere sets False) then calls page.save()
which writes all fields; change these to page.save(update_fields=["is_locked"])
to only persist the is_locked column and avoid stomping concurrent
updates—update the save call in the block where page.is_locked is set to True
and in the similar unlock block (the other page.is_locked assignment further
down) to use update_fields=["is_locked"] instead of a plain save().

274-300: Cleanup of favorites and recent visits happens after page.delete() — these records may already be cascade-deleted or the identifiers may no longer resolve.

Lines 286–299 delete UserFavorite and UserRecentVisit records after the page has been deleted on line 283. If these models have a FK to Page with CASCADE, the records are already gone. If they reference by UUID (entity_identifier), this is fine but the ordering is misleading. Consider moving cleanup before page.delete() for clarity and safety.

🤖 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 274 - 300, The cleanup for
UserFavorite and UserRecentVisit should run before calling page.delete() to
avoid relying on records that may have been cascade-removed or to make the
intent explicit; move the
UserFavorite.objects.filter(...entity_identifier=pk...,
entity_type="page").delete() and
UserRecentVisit.objects.filter(...entity_identifier=pk...,
entity_name="page").delete(soft=False) so they execute after the parent-null
update (Page.objects.filter(...).update(parent=None)) but before page.delete();
keep the same filters and delete(soft=False) call for recent visits to preserve
behavior.
apps/api/plane/api/serializers/page.py (1)

6-6: Unused import: Q.

Q is imported but never used in this serializer file.

🧹 Proposed fix
-from django.db.models import Q
🤖 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` at line 6, Remove the unused import
symbol Q from the serializer module (the line "from django.db.models import
Q")—locate the import in the serializers page file and delete it to avoid
unused-import lint errors; if any future logic needs complex queries, re-add Q
where it's actually used (no other changes required to functions or classes in
this file).
🤖 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 14-18: The labels ListField currently uses
PrimaryKeyRelatedField(queryset=Label.objects.all()), allowing labels from any
workspace to be attached; update the serializer to scope label lookups to the
current workspace by filtering the queryset at validation time using the
serializer context (use context['project_id'] to derive the workspace) — either
replace the child field with a SlugRelatedField/PrimaryKeyRelatedField whose
queryset is filtered in __init__ or override validate()/create()/update() in the
serializer to verify each Label belongs to the derived workspace (raise
ValidationError for cross-workspace labels) so both create() and update()
enforce the same scoped validation.
- Around line 49-85: The create() method does multiple DB writes
(Page.objects.create, ProjectPage.objects.create, PageLabel.objects.bulk_create)
that must be executed atomically to avoid orphaned rows on partial failure; wrap
the sequence of operations in a single transaction.atomic() block (import
transaction from django.db) so that Page creation, ProjectPage creation, and
PageLabel bulk_create are rolled back together on error, keeping the rest of the
logic (labels handling, context usage for project_id and owned_by_id) the same.
- Around line 87-103: Wrap the label replacement in the PageSerializer.update
method inside a transaction: when labels is not None, perform
PageLabel.objects.filter(page=instance).delete() and
PageLabel.objects.bulk_create(...) inside a transaction.atomic() block so the
delete and bulk_create are atomic; reference the update method and PageLabel
model to locate the code and import Django's transaction if not already
imported.

In `@apps/api/plane/api/views/page.py`:
- Around line 322-334: The archive/unarchive permission check currently returns
HTTP 400 for unauthorized users; change the Response status to 403 Forbidden to
match the delete method behavior. Locate the two permission branches using
ProjectMember.objects.filter(..., role__lte=15) and the condition
request.user.id != page.owned_by_id in the archive and unarchive handlers and
update their Response(...) status from status.HTTP_400_BAD_REQUEST to
status.HTTP_403_FORBIDDEN so unauthorized attempts return 403.
- Around line 92-102: The code passes request.GET.get("access") directly into
queryset.filter(access=access) which can raise on invalid strings; parse and
validate the parameter before filtering: retrieve access via
request.GET.get("access"), attempt to cast to int, ensure the value is one of
the allowed choices (0 or 1), and only then call queryset.filter(access=access);
if casting/validation fails, either skip applying the access filter (safe
default) or return a 400 Bad Request—update the logic around the existing
queryset.filter(access=access) usage to use the validated integer and reference
the same queryset variable and request.GET("access") call site.
- Around line 343-345: Replace the two naive datetime.now() calls with a single
timezone-aware timestamp captured once using django.utils.timezone.now(), pass
its .date() to unarchive_archive_page_and_descendants(page_id, ...) to match the
archived_at DateField, and return that same date (e.g., date.isoformat() or
str(date)) in the Response so the DB value and API response are identical; key
symbols: unarchive_archive_page_and_descendants, archived_at, and the Response
return.

---

Nitpick comments:
In `@apps/api/plane/api/serializers/page.py`:
- Line 6: Remove the unused import symbol Q from the serializer module (the line
"from django.db.models import Q")—locate the import in the serializers page file
and delete it to avoid unused-import lint errors; if any future logic needs
complex queries, re-add Q where it's actually used (no other changes required to
functions or classes in this file).

In `@apps/api/plane/api/views/page.py`:
- Around line 32-58: Extract the duplicated get_queryset implementation into a
shared mixin (e.g., PageQuerySetMixin) that defines the existing filtering and
annotate logic, then have PageListCreateAPIEndpoint and PageDetailAPIEndpoint
inherit from that mixin and remove the duplicate get_queryset methods from both
classes; ensure the mixin is placed before the endpoint base class in the
inheritance list so its get_queryset is used, and keep all original query logic
(filters, Coalesce/ArrayAgg annotations and .distinct()) unchanged when moving
it.
- Around line 394-404: The current code sets page.is_locked = True (and
elsewhere sets False) then calls page.save() which writes all fields; change
these to page.save(update_fields=["is_locked"]) to only persist the is_locked
column and avoid stomping concurrent updates—update the save call in the block
where page.is_locked is set to True and in the similar unlock block (the other
page.is_locked assignment further down) to use update_fields=["is_locked"]
instead of a plain save().
- Around line 274-300: The cleanup for UserFavorite and UserRecentVisit should
run before calling page.delete() to avoid relying on records that may have been
cascade-removed or to make the intent explicit; move the
UserFavorite.objects.filter(...entity_identifier=pk...,
entity_type="page").delete() and
UserRecentVisit.objects.filter(...entity_identifier=pk...,
entity_name="page").delete(soft=False) so they execute after the parent-null
update (Page.objects.filter(...).update(parent=None)) but before page.delete();
keep the same filters and delete(soft=False) call for recent visits to preserve
behavior.

Vierkandt and others added 2 commits February 21, 2026 13:00
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Implement four REST endpoint classes for pages under the api/v1
API-key-authenticated layer, mirroring the business logic of the
existing app-layer PageViewSet:

- PageListCreateAPIEndpoint — list (with archive/access/owned filters,
  external_id lookup, pagination) and create pages
- PageDetailAPIEndpoint — retrieve, partial update (lock/access guards),
  and delete (archive-before-delete and owner/admin guard)
- PageArchiveUnarchiveAPIEndpoint — archive/unarchive with recursive
  descendant update via unarchive_archive_page_and_descendants
- PageLockUnlockAPIEndpoint — lock/unlock toggle

Also adds PageSerializer (with label_ids/project_ids annotations and
PageLabel bulk-create/update), URL patterns, and wires everything into
the serializer, view, and URL __init__ exports.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@Vierkandt Vierkandt force-pushed the feature/api-v1-pages branch from e66e1de to 064bb40 Compare February 21, 2026 12:01
- Remove unused Q import from serializer
- Scope label queryset to workspace in serializer __init__ to prevent
  cross-workspace label assignment
- Wrap create() and update() label operations in transaction.atomic()
- Extract PageQuerySetMixin to eliminate duplicate get_queryset
- Validate access query param before filtering (must be 0 or 1)
- Fix archive/unarchive permission errors to return 403 instead of 400
- Replace datetime.now() with timezone.now().date() for archived_at,
  captured once to avoid double-call inconsistency
- Move UserFavorite/UserRecentVisit cleanup before page.delete()
- Use update_fields=["is_locked"] in lock/unlock to avoid full saves
- Pass project_id context to patch serializer for label scoping

Co-Authored-By: Edgar <e.zitha@hotmail.com>
@Vierkandt
Copy link
Author

Closing to reopen with corrected commit authorship.

@Vierkandt Vierkandt closed this Feb 21, 2026
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.

🧹 Nitpick comments (3)
apps/api/plane/api/views/page.py (3)

401-402: page.save() without update_fields in lock/unlock — risks overwriting concurrent field changes.

Calling page.save() without update_fields=["is_locked"] issues a full-row UPDATE. If another request modifies a different field between the fetch and this save, that change is silently overwritten.

♻️ Proposed fix
         page.is_locked = True
-        page.save()
+        page.save(update_fields=["is_locked"])

         ...

         page.is_locked = False
-        page.save()
+        page.save(update_fields=["is_locked"])

Also applies to: 418-419

🤖 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 401 - 402, The current
lock/unlock logic sets page.is_locked and calls page.save(), which issues a
full-row UPDATE and can overwrite concurrent changes; change both occurrences
that set page.is_locked to persist only that field by using a targeted update
(e.g., call save with update_fields=["is_locked"] or use
Page.objects.filter(pk=page.pk).update(is_locked=...) instead) so only the
is_locked column is modified and other concurrent field changes are preserved.

32-58: get_queryset() is duplicated verbatim across PageListCreateAPIEndpoint and PageDetailAPIEndpoint.

Both classes contain identical annotation logic. Extract it into a shared mixin or module-level helper to avoid divergence.

♻️ Suggested refactor
+def _page_queryset(slug, project_id):
+    return (
+        Page.objects.filter(
+            workspace__slug=slug,
+            projects__id=project_id,
+            project_pages__deleted_at__isnull=True,
+        )
+        .annotate(
+            label_ids=Coalesce(
+                ArrayAgg(
+                    "page_labels__label_id",
+                    distinct=True,
+                    filter=~Q(page_labels__label_id__isnull=True),
+                ),
+                Value([], output_field=ArrayField(UUIDField())),
+            ),
+            project_ids=Coalesce(
+                ArrayAgg(
+                    "projects__id",
+                    distinct=True,
+                    filter=~Q(projects__id__isnull=True),
+                ),
+                Value([], output_field=ArrayField(UUIDField())),
+            ),
+        )
+        .distinct()
+    )

 class PageListCreateAPIEndpoint(BaseAPIView):
     def get_queryset(self):
-        return (
-            Page.objects.filter(...)
-            .annotate(...)
-            .distinct()
-        )
+        return _page_queryset(self.kwargs["slug"], self.kwargs["project_id"])

 class PageDetailAPIEndpoint(BaseAPIView):
     def get_queryset(self):
-        return (
-            Page.objects.filter(...)
-            .annotate(...)
-            .distinct()
-        )
+        return _page_queryset(self.kwargs["slug"], self.kwargs["project_id"])

Also applies to: 166-192

🤖 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 32 - 58, get_queryset contains
identical annotation/filter logic duplicated in PageListCreateAPIEndpoint and
PageDetailAPIEndpoint; extract that logic into a shared helper or mixin and have
both classes use it. Create either a module-level function (e.g.,
build_annotated_page_queryset(kwargs) that returns the annotated QuerySet using
the same filters/Coalesce/ArrayAgg logic) or a PageQuerysetMixin that implements
get_queryset with that logic, then replace the duplicated get_queryset
implementations in PageListCreateAPIEndpoint and PageDetailAPIEndpoint to call
the new helper or inherit the mixin so annotation/filtering is defined in one
place.

248-301: page.delete() and its cleanup operations are not wrapped in transaction.atomic().

If page.delete() succeeds but the subsequent UserFavorite or UserRecentVisit deletion fails, those records become orphaned — users would continue to see the deleted page in their favorites or recent visits.

Consider wrapping lines 276–299 in a transaction.atomic() block, or at minimum ensuring the cleanup runs before hard-deleting the page row.

🤖 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 248 - 301, The delete flow for
Page (Page.objects.get(...), page.delete()) currently performs the page.delete()
before cleaning up UserFavorite and UserRecentVisit, and these operations are
not wrapped in a DB transaction; wrap the deletion and its related cleanup in a
single transaction.atomic() block (or move cleanup before the hard delete) so
that Page.delete(), UserFavorite.objects.filter(...).delete(), and
UserRecentVisit.objects.filter(...).delete(soft=False) succeed or roll back
together; update the handler surrounding the Page.delete() call and the
subsequent cleanup using transaction.atomic() and keep the permission checks
(ProjectMember query / owned_by_id) unchanged.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Duplicate comments:
In `@apps/api/plane/api/serializers/page.py`:
- Around line 14-18: The labels field uses
PrimaryKeyRelatedField(queryset=Label.objects.all()) which allows labels from
any workspace; update the serializer to scope that queryset to the current
project/workspace by filtering
Label.objects.filter(project_id=context["project_id"]) in the serializer's
__init__ (e.g., access self.context["project_id"] and set
self.fields["labels"].child.queryset accordingly), and as a belt-and-suspenders
measure also validate in create() and update() that each provided label belongs
to the same workspace (raise a ValidationError if any label.project_id !=
context["project_id"]).
- Around line 49-85: Wrap the multi-step DB writes in create() (the Page
creation, ProjectPage.objects.create, and PageLabel.objects.bulk_create) in a
single transaction.atomic() block so any failure rolls back all changes; update
the create method in the serializer to import and use
django.db.transaction.atomic and move Page.objects.create,
ProjectPage.objects.create, and the PageLabel bulk_create calls inside the
atomic context (including labels processing) so no orphaned records remain on
partial failure.
- Around line 87-103: The current update method deletes existing labels with
PageLabel.objects.filter(page=instance).delete() and then calls
PageLabel.objects.bulk_create(...), which can leave the page without labels if
bulk_create fails; wrap the delete + bulk_create sequence in a database
transaction so both operations are atomic: import transaction from django.db and
run the delete and bulk_create inside a with transaction.atomic(): block in the
update method (the block containing
PageLabel.objects.filter(page=instance).delete() and
PageLabel.objects.bulk_create(...)) so any bulk_create failure rolls back the
prior delete.

In `@apps/api/plane/api/views/page.py`:
- Around line 322-334: Change the authorization failure responses in the page
archive/unarchive handlers to use HTTP_403_FORBIDDEN instead of
HTTP_400_BAD_REQUEST and fix the unarchive message typo: locate the permission
checks inside the archive/unarchive methods (the block using
ProjectMember.objects.filter(..., role__lte=15) and comparing request.user.id to
page.owned_by_id) and replace the Response status from
status.HTTP_400_BAD_REQUEST to status.HTTP_403_FORBIDDEN and change any "un
archive" text to "unarchive" so the messages and status codes match the delete
handler's authorization behavior.
- Around line 343-345: Compute a single timezone-aware timestamp with
django.utils.timezone.now(), convert it to a date with .date(), and reuse that
value for both the call to unarchive_archive_page_and_descendants(page_id, ...)
and the API Response so DB and response match and the type fits the DateField;
i.e., replace the two datetime.now() calls with one tz-aware variable
(timezone.now()), derive date = tz_now.date(), pass date to
unarchive_archive_page_and_descendants and return str(date) (or
date.isoformat()) in Response.
- Around line 93-95: The raw request.GET.get("access") string is passed to
queryset.filter(access=access) which raises on non-integer input; change the
code to validate/convert the access value before filtering: retrieve access via
request.GET.get("access"), if not None attempt to cast to int in a try/except,
and on success call queryset.filter(access=converted_int); on ValueError raise a
DRF validation error (e.g. rest_framework.exceptions.ValidationError or
ParseError) with a clear message instead of letting the ValueError bubble up.
Refer to the access variable, request.GET.get("access"), and the
queryset.filter(access=...) call when making the change.

---

Nitpick comments:
In `@apps/api/plane/api/views/page.py`:
- Around line 401-402: The current lock/unlock logic sets page.is_locked and
calls page.save(), which issues a full-row UPDATE and can overwrite concurrent
changes; change both occurrences that set page.is_locked to persist only that
field by using a targeted update (e.g., call save with
update_fields=["is_locked"] or use
Page.objects.filter(pk=page.pk).update(is_locked=...) instead) so only the
is_locked column is modified and other concurrent field changes are preserved.
- Around line 32-58: get_queryset contains identical annotation/filter logic
duplicated in PageListCreateAPIEndpoint and PageDetailAPIEndpoint; extract that
logic into a shared helper or mixin and have both classes use it. Create either
a module-level function (e.g., build_annotated_page_queryset(kwargs) that
returns the annotated QuerySet using the same filters/Coalesce/ArrayAgg logic)
or a PageQuerysetMixin that implements get_queryset with that logic, then
replace the duplicated get_queryset implementations in PageListCreateAPIEndpoint
and PageDetailAPIEndpoint to call the new helper or inherit the mixin so
annotation/filtering is defined in one place.
- Around line 248-301: The delete flow for Page (Page.objects.get(...),
page.delete()) currently performs the page.delete() before cleaning up
UserFavorite and UserRecentVisit, and these operations are not wrapped in a DB
transaction; wrap the deletion and its related cleanup in a single
transaction.atomic() block (or move cleanup before the hard delete) so that
Page.delete(), UserFavorite.objects.filter(...).delete(), and
UserRecentVisit.objects.filter(...).delete(soft=False) succeed or roll back
together; update the handler surrounding the Page.delete() call and the
subsequent cleanup using transaction.atomic() and keep the permission checks
(ProjectMember query / owned_by_id) unchanged.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants