Skip to content

fix(api): pass project_lead_id (not User instance) when creating ProjectMember#8966

Open
jamartineztelecoengineer84-dotcom wants to merge 6 commits intomakeplane:previewfrom
jamartineztelecoengineer84-dotcom:fix/api-create-project-member-id
Open

fix(api): pass project_lead_id (not User instance) when creating ProjectMember#8966
jamartineztelecoengineer84-dotcom wants to merge 6 commits intomakeplane:previewfrom
jamartineztelecoengineer84-dotcom:fix/api-create-project-member-id

Conversation

@jamartineztelecoengineer84-dotcom
Copy link
Copy Markdown

@jamartineztelecoengineer84-dotcom jamartineztelecoengineer84-dotcom commented Apr 27, 2026

Description

The public REST API endpoint POST /api/v1/workspaces/{slug}/projects/
returned 400 "Please provide valid detail" whenever the payload
included a project_lead, even when the request was otherwise valid.
Worse, the Project row had already been persisted before the
exception bubbled up, leaving an unusable orphan: no workflow states,
no ProjectMember entries, no default cycles.

The bug had three contributing parts in
apps/api/plane/api/views/project.py:214 (def post):

  1. UUID vs User instance. The endpoint passed
    serializer.instance.project_lead (the related descriptor, which
    Django resolves to a User instance) where a UUID was expected:

    ProjectMember.objects.create(
        project_id=serializer.instance.id,
        member_id=serializer.instance.project_lead,  # ← User instance, not UUID
        role=20,
    )

    Django's UUIDField.to_python(value=<User>) then crashed with
    AttributeError: 'User' object has no attribute 'replace'. The
    generic exception handler in BaseAPIView.handle_exception mapped
    any uncaught exception to 400 "Please provide valid detail" — a
    misleading response that hid the real failure.

  2. Buggy lead-vs-creator guard. The guard compared str(<User>)
    against str(<UUID>), which never matched (different __str__
    outputs), so the buggy branch above was always entered when
    project_lead was provided — including the very common case where
    the creator is also the lead.

  3. No transaction wrapping. There was no transaction.atomic()
    around the post-save side effects (member creation, default state
    seeding, queryset re-fetch), so any partial failure left the
    database in an inconsistent state.

Reproduction (before fix)

curl -X POST \
  -H "X-API-Key: $PAT" -H "Content-Type: application/json" \
  -d '{"name": "Repro", "identifier": "RP", "project_lead": "<creator-uuid>"}' \
  https://your-plane.example/api/v1/workspaces/<slug>/projects/

# → HTTP 400 {"error": "Please provide valid detail"}
# → Project row exists in db, with 0 states, no ProjectMember.

Stack trace (with debug logging temporarily enabled in
BaseAPIView.handle_exception):

File "/code/plane/api/views/project.py", line 234, in post
    ProjectMember.objects.create(
File ".../django/db/models/query.py", line 660, in create
    obj.save(force_insert=True, using=self.db)
File ".../django/db/models/fields/__init__.py", line 2688, in to_python
    return uuid.UUID(**{input_form: value})
File ".../uuid.py", line 175, in __init__
    hex = hex.replace('urn:', '').replace('uuid:', '')
        ↳ AttributeError: 'User' object has no attribute 'replace'

Changes

apps/api/plane/api/views/project.py

  • Use project_lead_id (FK column directly) for both the guard
    comparison and ProjectMember.objects.create(member_id=...). No more
    descriptor-resolved User instance leaking into a UUID field.
  • Compare UUIDs directly (!= request.user.id) instead of via
    str() of two heterogeneous objects.
  • Wrap the post-is_valid() block in with transaction.atomic(): so
    the Project + ProjectMember + State.bulk_create all roll back
    as a unit if any future failure is added in between.
  • Defer model_activity.delay(...) with transaction.on_commit(...),
    so the activity-log task never fires on a rolled-back creation.
  • Add a final except Exception as e: branch that calls
    log_exception(e) before returning the generic 400 — keeps the
    public response stable but ensures unexpected regressions are
    visible in API logs going forward.

apps/api/plane/tests/contract/api/test_projects.py (new file)

Three contract tests covering the create-project endpoint:

  • test_create_project_with_lead_as_creator — regression for the bug
    (project_lead == creator).
  • test_create_project_with_lead_as_other_user — verifies both
    creator and lead become Admins of the project.
  • test_create_project_without_lead — baseline regression.

The first two FAIL on preview HEAD before this fix and PASS after.

Type of Change

  • Bug fix (non-breaking change which fixes an issue)

Test Scenarios

  • pytest plane/tests/contract/api/test_projects.py — 3/3 PASS on
    this branch. Same 3 tests on preview HEAD: 2 FAIL (the bug) + 1
    PASS (baseline).
  • ruff check apps/api/plane/api/views/project.py apps/api/plane/tests/contract/api/test_projects.py — All checks passed.
  • ruff format --check ... — both files already formatted.
  • Manual repro against a live Plane self-hosted (v1.3.0):
    POST /projects/ with project_lead = creator_id
    before fix: 400 + ghost project (0 states, no member);
    after fix: 201 + complete project (5 default states + creator as
    Admin).

Note on follow-up

A related data-integrity issue exists where
ProjectCreateSerializer.create()
(apps/api/plane/api/serializers/project.py:135) does not create
a corresponding ProjectIdentifier row, while its in-file sibling
ProjectSerializer.create() (line 276) and the frontend serializer
apps/api/plane/app/serializers/project.py:95 both do. This means
projects created via the public API don't register their identifier
in the project_identifiers table that tracks workspace-wide
identifier uniqueness. Out of scope for this PR; will follow up
separately.

References

The bug was not previously reported upstream (verified via issue +
PR search for "Please provide valid detail", "orphaned project",
"states not created", "project create 400", "transaction.atomic",
"create project api").

Summary by CodeRabbit

  • Bug Fixes

    • Project creation is now fully transactional; partial creations are rolled back, activity dispatch occurs only after commit, and unexpected server errors return a uniform HTTP 500 and are logged. Creator is always an admin; project lead is made admin only when distinct. Default project states are created atomically.
  • Validation

    • Project lead must reference an active workspace member and returns a field-specific error when invalid.
  • Tests

    • Added contract tests covering creation, lead-assignment scenarios, validation failures, rollback behavior, exact default-state creation, and post-commit dispatch errors.

Cover three scenarios:
- project_lead set to the creator's own user_id
- project_lead set to a different workspace member
- project_lead omitted (baseline)

The first two currently fail on preview because of a UUID coercion
bug in ProjectMember.objects.create — see follow-up commit.
…ectMember

The create-project endpoint built a ProjectMember row with
member_id=serializer.instance.project_lead, which resolves to a User
instance via Django's related descriptor instead of a UUID. Django's
UUIDField coercion then fails with AttributeError: 'User' object has
no attribute 'replace', which the generic exception handler converts
to a 400 "Please provide valid detail" — but only after the Project
row was already persisted, leaving an orphaned project without
default states.

Fix:
- Use project_lead_id (FK ID, no descriptor lookup) on both the guard
  comparison and the ProjectMember creation.
- Wrap the post-save flow in transaction.atomic() so any future
  exception triggers a clean rollback.
- Defer model_activity.delay() with transaction.on_commit() so the
  activity log only fires after a successful commit.
- Capture the exception with log_exception() in the generic catch so
  future regressions surface in api logs.

Note: a related data integrity issue exists where
ProjectCreateSerializer doesn't create a ProjectIdentifier row
(unlike its frontend counterpart). Out of scope here, will follow
up in a separate PR.
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Apr 27, 2026

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 41839259-5abb-4f20-abed-05693e72c3fb

📥 Commits

Reviewing files that changed from the base of the PR and between 03e052e and 821eb15.

📒 Files selected for processing (1)
  • apps/api/plane/api/views/project.py
🚧 Files skipped from review as they are similar to previous changes (1)
  • apps/api/plane/api/views/project.py

📝 Walkthrough

Walkthrough

Project creation now runs inside transaction.atomic(); Project, ProjectMember(s), and default States are created transactionally. model_activity.delay(...) is registered via transaction.on_commit(..., robust=True) so it runs only after commit. Unexpected IntegrityErrors and other exceptions are logged and return HTTP 500. Serializer enforces WorkspaceMember.is_active for project_lead. New contract tests added.

Changes

Cohort / File(s) Summary
Project creation view
apps/api/plane/api/views/project.py
Creation moved inside transaction.atomic(); creator always added as admin ProjectMember; optional lead admin created when project_lead_id present and differs from creator; default State rows bulk-created within the transaction; model_activity.delay(...) scheduled with transaction.on_commit(..., robust=True) to run post-commit; unexpected IntegrityErrors and other exceptions are logged via log_exception and result in HTTP 500; rollback ensures no task dispatch.
Serializer validation
apps/api/plane/api/serializers/project.py
ProjectCreateSerializer.validate now validates project_lead only when provided, requires the referenced WorkspaceMember.is_active=True, and raises a DRF field-specific error under project_lead on failure.
Contract tests for project creation
apps/api/plane/tests/contract/api/test_projects.py
New test module and fixtures covering: lead as creator, lead as other workspace member, no lead, lead outside workspace (expects 400 with project_lead field error and no persistence), rollback behavior by mocking State.objects.bulk_create to force error (expects 500, no persisted Project/ProjectMember/State, and no task dispatch), and behavior when broker dispatch fails after commit (expects 201, persisted scaffolding, and dispatch attempt recorded).

Sequence Diagram(s)

sequenceDiagram
    participant Client
    participant API as "API (ProjectListCreateAPIEndpoint)"
    participant DB as "Database\n(transaction)"
    participant OnCommit as "transaction.on_commit"
    participant Worker as "Task Broker / Worker"

    Client->>API: POST /workspaces/{slug}/projects/ (payload)
    API->>DB: begin atomic transaction
    API->>DB: serializer.save() -> create Project
    API->>DB: create ProjectMember (creator admin)
    alt project_lead_id present and != creator
        API->>DB: create ProjectMember (lead admin)
    end
    API->>DB: State.objects.bulk_create(...) (default States)
    DB-->>API: commit transaction
    API->>OnCommit: register model_activity.delay(...), robust=True
    OnCommit->>Worker: schedule task (after commit)
    Worker-->>API: async activity processing (later)
    alt error occurs before commit
        DB-->>API: rollback
        OnCommit--x Worker: no task dispatched
        API-->>Client: 500 Internal Server Error
    end
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Poem

🐰 I stitched a transaction snug and tight,
Tasks wait till commits ride into night,
Leads and creators seated just so,
Rollbacks hush the hops that never go,
Tests nibble proofs with carrot-light glow. 🥕

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 75.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately describes the primary fix: passing project_lead_id (UUID) instead of a User instance when creating ProjectMember records.
Description check ✅ Passed The PR description is comprehensive and well-structured, covering the bug details, root causes, reproduction steps, specific changes, test scenarios, and references. All key template sections are present and detailed.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

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

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

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
Review rate limit: 7/8 reviews remaining, refill in 7 minutes and 30 seconds.

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

Copy link
Copy Markdown
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: 1

🧹 Nitpick comments (3)
apps/api/plane/tests/contract/api/test_projects.py (2)

11-26: Nit: simplify the fixture with create_user and a top-level import.

User.objects.create(...) followed by set_password + save does two writes and leaves the password unhashed momentarily. User.objects.create_user(...) handles hashing in one step and is the idiomatic Django pattern. Also, the from uuid import uuid4 is more naturally a module-level import.

♻️ Optional cleanup
 import pytest
+from uuid import uuid4
 from rest_framework import status

 from plane.db.models import Project, ProjectMember, State, User, WorkspaceMember
@@
 `@pytest.fixture`
 def other_workspace_member(db, workspace):
     """Create another user that is a member of the workspace, distinct from the creator."""
-    from uuid import uuid4
-
     unique_id = uuid4().hex[:8]
-    other = User.objects.create(
+    other = User.objects.create_user(
         email=f"other-{unique_id}@plane.so",
         username=f"other_user_{unique_id}",
         first_name="Other",
         last_name="User",
+        password="test-password",
     )
-    other.set_password("test-password")
-    other.save()
     WorkspaceMember.objects.create(workspace=workspace, member=other, role=20)
     return other
🤖 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_projects.py` around lines 11 - 26, The
fixture other_workspace_member should use Django's User.objects.create_user to
set the password in one step (avoiding create + set_password + save) and move
the "from uuid import uuid4" to a top-level import; update the fixture to call
create_user(...) with the same email/username/first_name/last_name values and
use uuid4().hex[:8] for uniqueness, and keep WorkspaceMember.objects.create(...)
unchanged.

67-88: Optional: also assert the total admin count in the "other lead" case.

exists() on each user verifies both rows are present, but won't catch a regression where an extra/duplicate admin row gets inserted (e.g., a future change accidentally re-introduces the descriptor coercion path or adds a second admin grant). Pinning count() == 2 makes the invariant explicit.

🧪 Optional tightening
         # Both creator and other_workspace_member are admins.
         assert ProjectMember.objects.filter(project=project, member=create_user, role=20).exists()
         assert ProjectMember.objects.filter(project=project, member=other_workspace_member, role=20).exists()
+        assert ProjectMember.objects.filter(project=project, role=20).count() == 2
         assert State.objects.filter(project=project).count() == 5
🤖 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_projects.py` around lines 67 - 88, The
test test_create_project_with_lead_as_other_user currently asserts each admin
row exists but doesn't ensure there are exactly two admins; add an assertion
that checks ProjectMember entries for the created project with role=20 have
count() == 2 (e.g., using ProjectMember.objects.filter(project=project,
role=20).count() == 2) to guard against duplicates or extra admin grants while
keeping the existing per-user exists() checks.
apps/api/plane/api/views/project.py (1)

264-282: Minor: defensive check that requeried project is not None.

self.get_queryset() filters by is_member=request.user (active) OR network=2. The creator membership added on line 231 will satisfy this in practice, so .first() is expected to return the row. If anything ever changes that invariant (e.g., a future filter on archived_at, network defaults, or the queryset gaining a permission scope), project could be None — the transaction.on_commit lambda would then AttributeError at commit time (after the atomic block exits, so the project would already be persisted), and ProjectSerializer(project).data on line 281 would also misbehave.

A cheap guard via serializer.instance.id for the activity task removes the subtle dependency:

🪶 Optional tightening
-                    project = self.get_queryset().filter(pk=serializer.instance.id).first()
+                    project_id = serializer.instance.id
+                    project = self.get_queryset().filter(pk=project_id).first()

                     # Defer the activity-log task until the surrounding
                     # transaction commits, so it never fires on a rolled-back
                     # creation.
                     transaction.on_commit(
                         lambda: model_activity.delay(
                             model_name="project",
-                            model_id=str(project.id),
+                            model_id=str(project_id),
                             requested_data=request.data,
                             current_instance=None,
                             actor_id=request.user.id,
                             slug=slug,
                             origin=base_host(request=request, is_app=True),
                         )
                     )
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/api/plane/api/views/project.py` around lines 264 - 282, The code assumes
project = self.get_queryset().filter(pk=serializer.instance.id).first() is
non-null, but .first() can return None and later transaction.on_commit lambda
(and ProjectSerializer(project)) would raise AttributeError; fix by using
serializer.instance.id directly inside the on_commit call and avoid
dereferencing project there, and add a defensive fallback before serializing
(e.g., if project is None, re-fetch with get(pk=serializer.instance.id) or use
serializer.instance to build response) so model_activity() is scheduled with
model_id=str(serializer.instance.id) and ProjectSerializer is only called with a
non-None object (reference symbols: self.get_queryset(), project,
serializer.instance.id, transaction.on_commit, model_activity,
ProjectSerializer).
🤖 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/project.py`:
- Around line 297-304: The generic exception handler in the view currently
catches all exceptions, logs them via log_exception(e), and returns a 400 with
"Please provide valid detail" which misclassifies server errors as client
errors; update the fallback to return a 500 (use
status.HTTP_500_INTERNAL_SERVER_ERROR) with a similarly generic public message
while keeping log_exception(e) so unexpected AttributeError/KeyError/DB/Celery
errors surface as server errors; keep explicit validation paths returning 400
unchanged and ensure you modify the Response construction in the same except
block that calls log_exception(e).

---

Nitpick comments:
In `@apps/api/plane/api/views/project.py`:
- Around line 264-282: The code assumes project =
self.get_queryset().filter(pk=serializer.instance.id).first() is non-null, but
.first() can return None and later transaction.on_commit lambda (and
ProjectSerializer(project)) would raise AttributeError; fix by using
serializer.instance.id directly inside the on_commit call and avoid
dereferencing project there, and add a defensive fallback before serializing
(e.g., if project is None, re-fetch with get(pk=serializer.instance.id) or use
serializer.instance to build response) so model_activity() is scheduled with
model_id=str(serializer.instance.id) and ProjectSerializer is only called with a
non-None object (reference symbols: self.get_queryset(), project,
serializer.instance.id, transaction.on_commit, model_activity,
ProjectSerializer).

In `@apps/api/plane/tests/contract/api/test_projects.py`:
- Around line 11-26: The fixture other_workspace_member should use Django's
User.objects.create_user to set the password in one step (avoiding create +
set_password + save) and move the "from uuid import uuid4" to a top-level
import; update the fixture to call create_user(...) with the same
email/username/first_name/last_name values and use uuid4().hex[:8] for
uniqueness, and keep WorkspaceMember.objects.create(...) unchanged.
- Around line 67-88: The test test_create_project_with_lead_as_other_user
currently asserts each admin row exists but doesn't ensure there are exactly two
admins; add an assertion that checks ProjectMember entries for the created
project with role=20 have count() == 2 (e.g., using
ProjectMember.objects.filter(project=project, role=20).count() == 2) to guard
against duplicates or extra admin grants while keeping the existing per-user
exists() checks.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: bb7337ef-d7bb-49e8-a014-4ecc520ead31

📥 Commits

Reviewing files that changed from the base of the PR and between a40e064 and 4d606fe.

📒 Files selected for processing (2)
  • apps/api/plane/api/views/project.py
  • apps/api/plane/tests/contract/api/test_projects.py

Comment thread apps/api/plane/api/views/project.py
@sriramveeraghanta
Copy link
Copy Markdown
Member

Suggestions

Thanks for the thorough write-up and the reproduction — the root-cause analysis is exemplary. A few suggestions before merging:

Should address in this PR

  1. Reconsider except Exception → 400. A server-side crash returning 400 "Please provide valid detail" is exactly the behavior that hid this bug for so long. Either return 500, or at minimum change the message to something like "An unexpected error occurred" so future regressions surface as server faults, not client errors.

  2. Fix the IntegrityError fall-through (apps/api/plane/api/views/project.py:272-277). If the message doesn't contain "already exists", the handler returns None and the function falls off the end. Either raise so your new catch-all picks it up, or return an explicit response. Pre-existing, but you're already in this code.

  3. Validate project_lead is a workspace member in ProjectCreateSerializer.validate, alongside the existing default_assignee check (apps/api/plane/api/serializers/project.py:125-132). Otherwise the API silently creates a ProjectMember for someone outside the workspace. Worth a fourth test asserting a 400 when project_lead is a non-member.

Test improvements

  1. Use the response payload's id instead of Project.objects.first()Project.objects.get(id=response.data[\"id\"]). More defensive; the test fails for the right reason if the wrong project gets returned.

  2. Lock in the on_commit behavior with a test. Mock model_activity.delay and assert it is not called when the surrounding transaction rolls back (e.g., force a state-creation failure). Prevents a future refactor from silently regressing this.

  3. Hoist other_workspace_member into conftest.py if you anticipate other contract tests needing a second user. Inline is fine for now.

Style nit

  1. functools.partial over lambda for transaction.on_commit(...) reads slightly clearer and avoids closure semantics. Minor.

Follow-up (already noted in your description)

  1. ProjectCreateSerializer.create() doesn't create a ProjectIdentifier — confirmed against apps/api/plane/api/serializers/project.py:285. Good call splitting it out; worth filing as a separate ticket.

Already great — don't change

  • Root cause and the _id fix.
  • transaction.atomic() + transaction.on_commit() for the celery task.
  • Direct UUID comparison instead of stringly-typed compare.
  • Three contract tests with clear before/after pass-fail evidence.

Address review feedback from @sriramveeraghanta on PR makeplane#8966:

- The catch-all `except Exception` now returns 500 instead of 400.
  Reusing the generic 400 response on a server-side crash was the
  anti-pattern that hid the original ghost-create bug for nine months;
  a 500 lets clients distinguish between "bad input" and "server fault".
- The `IntegrityError` branch no longer falls through silently when the
  message is unrecognised. It re-raises so the catch-all `except` logs
  the exception and returns a 500.
- `transaction.on_commit()` now schedules `model_activity.delay` via
  `functools.partial` instead of a lambda, avoiding late-binding closure
  semantics.
- `ProjectCreateSerializer.validate()` now rejects `project_lead`
  values that are not active workspace members, surfacing the error
  under the `project_lead` field key (rather than as `non_field_errors`)
  so API clients can react programmatically.
Address review feedback from @sriramveeraghanta on PR makeplane#8966:

- The three existing tests now look up the created project via
  `Project.objects.get(id=response.data["id"])` instead of
  `.first()`. The assertion now fails for the right reason if the
  wrong project is returned by the endpoint.
- New `test_create_project_with_lead_not_in_workspace_returns_400`
  guards the workspace-membership validation added to
  `ProjectCreateSerializer.validate()`. Expects a 400 with a
  field-shaped error and zero rows persisted.
- New `test_model_activity_not_called_on_rollback` locks in the
  `transaction.on_commit()` semantics: when an exception is raised
  inside the atomic block (forced via mocking `State.objects.bulk_create`),
  the response is 500, no Project / ProjectMember / State rows are
  persisted, and the deferred `model_activity.delay` task is never
  dispatched. This prevents a future refactor from silently
  regressing the rollback contract.
Copy link
Copy Markdown
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.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
apps/api/plane/api/views/project.py (1)

267-314: ⚠️ Potential issue | 🟠 Major

Guard the post-commit task dispatch to prevent callback failures from masking successful creates.

The transaction.on_commit() callback executes synchronously after the atomic block commits, and by default (robust=False), any exception raised by model_activity.delay() propagates up and is caught by the outer except Exception handler, returning a 500 despite the project, members, and states already being persisted. Use robust=True when calling transaction.on_commit() so that broker errors or task dispatch failures are logged internally without surfacing as a server error to the client:

Suggested fix
transaction.on_commit(
    partial(
        model_activity.delay,
        model_name="project",
        model_id=str(project.id),
        requested_data=request.data,
        current_instance=None,
        actor_id=request.user.id,
        slug=slug,
        origin=base_host(request=request, is_app=True),
    ),
    robust=True,
)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/api/plane/api/views/project.py` around lines 267 - 314, The on_commit
callback currently uses transaction.on_commit(partial(model_activity.delay,
...)) without robust=True so exceptions from model_activity.delay can propagate
and trigger the outer Exception handler; update the call to pass robust=True to
transaction.on_commit (keep the same partial with model_activity.delay,
model_name="project", model_id=str(project.id), requested_data=request.data,
current_instance=None, actor_id=request.user.id, slug=slug,
origin=base_host(...)) so task dispatch failures are logged internally and do
not return 500 after a successful project create.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Outside diff comments:
In `@apps/api/plane/api/views/project.py`:
- Around line 267-314: The on_commit callback currently uses
transaction.on_commit(partial(model_activity.delay, ...)) without robust=True so
exceptions from model_activity.delay can propagate and trigger the outer
Exception handler; update the call to pass robust=True to transaction.on_commit
(keep the same partial with model_activity.delay, model_name="project",
model_id=str(project.id), requested_data=request.data, current_instance=None,
actor_id=request.user.id, slug=slug, origin=base_host(...)) so task dispatch
failures are logged internally and do not return 500 after a successful project
create.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: feb47152-577d-4f5d-ba6d-d6a4bb296cf5

📥 Commits

Reviewing files that changed from the base of the PR and between 4d606fe and acb9146.

📒 Files selected for processing (3)
  • apps/api/plane/api/serializers/project.py
  • apps/api/plane/api/views/project.py
  • apps/api/plane/tests/contract/api/test_projects.py
🚧 Files skipped from review as they are similar to previous changes (1)
  • apps/api/plane/tests/contract/api/test_projects.py

@jamartineztelecoengineer84-dotcom
Copy link
Copy Markdown
Author

Thanks @sriramveeraghanta — really appreciate the depth of the review. All seven points addressed in two follow-up commits:

Server-side (6a85b5f7):

  • The catch-all except Exception now returns 500 with "An unexpected error occurred", with a comment explicitly tying the change to the anti-pattern that hid this bug.
  • The IntegrityError branch now raises on unrecognized errors so the catch-all logs and surfaces them as 500 (rather than falling off the end).
  • ProjectCreateSerializer.validate() rejects non-member project_lead values via a field-shaped error under the project_lead key (not non_field_errors), and now also requires the membership to be active so revoked members don't slip through.
  • transaction.on_commit() uses functools.partial instead of a lambda — same effect, no late-binding closure surprises.

Tests (acb91466):

  • The three existing tests now look up the project via Project.objects.get(id=response.data["id"]) for defensive failure modes.
  • New test_create_project_with_lead_not_in_workspace_returns_400 covers the new validation rule (asserts the 400, the field-shaped error, and that no project gets persisted).
  • New test_model_activity_not_called_on_rollback mocks State.objects.bulk_create to force a failure inside the atomic block, then asserts a 500 + zero rows persisted + model_activity.delay never dispatched. This locks in the transaction.on_commit semantics against future refactors.

5/5 tests pass on this branch (3 original + 2 new). ruff check and ruff format --check clean. The ProjectCreateSerializer.create() ProjectIdentifier follow-up is noted for a separate PR.

Happy to address anything else.

Address coderabbit re-review feedback on PR makeplane#8966.

Without robust=True, an exception raised by model_activity.delay
(e.g., a Celery broker outage) propagates out of the on_commit
callback and is caught by the outer `except Exception` handler,
which returns a 500 despite the project, ProjectMember rows and
default States having already been committed. The client sees a
500 and assumes the create failed — the same class of mismatch
between actual state and reported status that the original bug
exhibited, just at the post-commit phase.

Set robust=True so Django logs the dispatch failure internally
via the standard transaction logger and the response stays 201,
reflecting the persisted state.

Switch from `functools.partial` to a nested function
(`_dispatch_model_activity`) for the on_commit callable. Django's
robust on_commit logging path reads `func.__qualname__` to format
the error message; `partial` objects lack that dunder by default,
and the `functools.update_wrapper` workaround turns out to be
brittle when the wrapped callable is replaced by a Mock (which
the new regression test relies on). A nested function exposes
`__qualname__` natively, and the locals it closes over are
bound at definition time and never rebound before the callback
fires, so the late-binding-closure motivation for `partial` over
`lambda` does not apply here.

A new test, test_response_still_201_when_broker_dispatch_fails,
mirrors test_model_activity_not_called_on_rollback to lock in the
post-commit branch. It uses `@pytest.mark.django_db(transaction=True)`
so the surrounding test transaction is actually committed and the
`on_commit` callback fires (the default wrapper suppresses it via
rollback).
Copy link
Copy Markdown
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: 1


ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: cb48d587-752e-4460-803e-122460f80483

📥 Commits

Reviewing files that changed from the base of the PR and between acb9146 and 03e052e.

📒 Files selected for processing (2)
  • apps/api/plane/api/views/project.py
  • apps/api/plane/tests/contract/api/test_projects.py

Comment thread apps/api/plane/api/views/project.py Outdated
Address coderabbit re-review feedback on PR makeplane#8966.

The previous fix used `raise` inside the IntegrityError handler with
the intent of "letting the catch-all `except Exception` below log it
and return 500". Coderabbit correctly flagged that `raise` exits the
try/except entirely — sibling except clauses don't fire — so
unrecognised integrity errors actually skipped `log_exception` and
the consistent 500 JSON shape, contradicting the stated intent.

Replicate the catch-all behaviour inline: log the exception via
`log_exception(e)` and return the same generic 500 response with
`{"error": "An unexpected error occurred"}`. The client now gets a
uniform error shape regardless of which `except` branch handled it.
@jamartineztelecoengineer84-dotcom
Copy link
Copy Markdown
Author

Re: the transaction.on_commit(robust=True) suggestion from coderabbit's review — addressed in 03e052e2.

Added robust=True so broker / dispatch failures are logged internally rather than surfacing as 500 after the atomic block has already committed.

Implementation note: while wiring the new regression test (test_response_still_201_when_broker_dispatch_fails) I discovered that Django's robust on_commit logging path reads func.__qualname__ to format the error message, and functools.partial doesn't expose that dunder by default. The update_wrapper workaround turns out to be brittle once the wrapped callable is replaced by a Mock. I switched the dispatcher from functools.partial to a small nested function — __qualname__ is then native, and the late-binding-closure motivation for partial-over-lambda doesn't apply here (the locals are bound at def-time and never rebound before the callback fires). Happy to revisit if you'd prefer a different shape.

The new test uses @pytest.mark.django_db(transaction=True) so the surrounding test transaction actually commits and the on_commit callback fires.

6/6 tests pass on this branch. ruff check + ruff format --check clean.

@jamartineztelecoengineer84-dotcom
Copy link
Copy Markdown
Author

Friendly ping @sriramveeraghanta — happy to address any further feedback when you have a moment. The two CodeRabbit follow-ups (robust=True for on_commit, and the raise fall-through fix) have also been addressed in commits 03e052e2 and 821eb150 respectively, with replies in the corresponding threads.

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.

2 participants