Skip to content

Commit 4d606fe

Browse files
fix(api): pass project_lead_id (not User instance) when creating ProjectMember
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.
1 parent 88b751d commit 4d606fe

1 file changed

Lines changed: 59 additions & 39 deletions

File tree

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

Lines changed: 59 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
import json
77

88
# Django imports
9-
from django.db import IntegrityError
9+
from django.db import IntegrityError, transaction
1010
from django.db.models import Exists, F, Func, OuterRef, Prefetch, Q, Subquery, Count
1111
from django.db.models.functions import Coalesce
1212
from django.utils import timezone
@@ -38,6 +38,7 @@
3838
ProjectPage,
3939
)
4040
from plane.bgtasks.webhook_task import model_activity, webhook_activity
41+
from plane.utils.exception_logger import log_exception
4142
from .base import BaseAPIView
4243
from plane.utils.host import base_host
4344
from plane.api.serializers import (
@@ -223,48 +224,59 @@ def post(self, request, slug):
223224
serializer = ProjectCreateSerializer(data={**request.data}, context={"workspace_id": workspace.id})
224225

225226
if serializer.is_valid():
226-
serializer.save()
227-
228-
# Add the user as Administrator to the project
229-
_ = ProjectMember.objects.create(project_id=serializer.instance.id, member=request.user, role=20)
227+
with transaction.atomic():
228+
serializer.save()
229+
230+
# Add the creator as Administrator of the project.
231+
_ = ProjectMember.objects.create(project_id=serializer.instance.id, member=request.user, role=20)
232+
233+
# If a different project_lead was provided, add them as
234+
# Administrator too. Use project_lead_id (the FK column)
235+
# rather than project_lead (the related descriptor, which
236+
# would resolve to a User instance and break UUID coercion
237+
# downstream in ProjectMember.objects.create).
238+
if (
239+
serializer.instance.project_lead_id is not None
240+
and serializer.instance.project_lead_id != request.user.id
241+
):
242+
ProjectMember.objects.create(
243+
project_id=serializer.instance.id,
244+
member_id=serializer.instance.project_lead_id,
245+
role=20,
246+
)
230247

231-
if serializer.instance.project_lead is not None and str(serializer.instance.project_lead) != str(
232-
request.user.id
233-
):
234-
ProjectMember.objects.create(
235-
project_id=serializer.instance.id,
236-
member_id=serializer.instance.project_lead,
237-
role=20,
248+
State.objects.bulk_create(
249+
[
250+
State(
251+
name=state["name"],
252+
color=state["color"],
253+
project=serializer.instance,
254+
sequence=state["sequence"],
255+
workspace=serializer.instance.workspace,
256+
group=state["group"],
257+
default=state.get("default", False),
258+
created_by=request.user,
259+
)
260+
for state in DEFAULT_STATES
261+
]
238262
)
239263

240-
State.objects.bulk_create(
241-
[
242-
State(
243-
name=state["name"],
244-
color=state["color"],
245-
project=serializer.instance,
246-
sequence=state["sequence"],
247-
workspace=serializer.instance.workspace,
248-
group=state["group"],
249-
default=state.get("default", False),
250-
created_by=request.user,
264+
project = self.get_queryset().filter(pk=serializer.instance.id).first()
265+
266+
# Defer the activity-log task until the surrounding
267+
# transaction commits, so it never fires on a rolled-back
268+
# creation.
269+
transaction.on_commit(
270+
lambda: model_activity.delay(
271+
model_name="project",
272+
model_id=str(project.id),
273+
requested_data=request.data,
274+
current_instance=None,
275+
actor_id=request.user.id,
276+
slug=slug,
277+
origin=base_host(request=request, is_app=True),
251278
)
252-
for state in DEFAULT_STATES
253-
]
254-
)
255-
256-
project = self.get_queryset().filter(pk=serializer.instance.id).first()
257-
258-
# Model activity
259-
model_activity.delay(
260-
model_name="project",
261-
model_id=str(project.id),
262-
requested_data=request.data,
263-
current_instance=None,
264-
actor_id=request.user.id,
265-
slug=slug,
266-
origin=base_host(request=request, is_app=True),
267-
)
279+
)
268280

269281
serializer = ProjectSerializer(project)
270282
return Response(serializer.data, status=status.HTTP_201_CREATED)
@@ -282,6 +294,14 @@ def post(self, request, slug):
282294
{"identifier": "The project identifier is already taken"},
283295
status=status.HTTP_409_CONFLICT,
284296
)
297+
except Exception as e:
298+
# Surface unexpected failures in the api logs so future regressions
299+
# are debuggable. Keep the public response generic.
300+
log_exception(e)
301+
return Response(
302+
{"error": "Please provide valid detail"},
303+
status=status.HTTP_400_BAD_REQUEST,
304+
)
285305

286306

287307
class ProjectDetailAPIEndpoint(BaseAPIView):

0 commit comments

Comments
 (0)