Skip to content
19 changes: 13 additions & 6 deletions apps/api/plane/api/serializers/project.py
Original file line number Diff line number Diff line change
Expand Up @@ -114,13 +114,20 @@ def validate(self, data):
if project_identifier is not None and re.match(Project.FORBIDDEN_IDENTIFIER_CHARS_PATTERN, project_identifier):
raise serializers.ValidationError("Project identifier cannot contain special characters.")

if data.get("project_lead", None) is not None:
# Check if the project lead is a member of the workspace
if not WorkspaceMember.objects.filter(
project_lead = data.get("project_lead")
if (
project_lead
and not WorkspaceMember.objects.filter(
workspace_id=self.context["workspace_id"],
member_id=data.get("project_lead"),
).exists():
raise serializers.ValidationError("Project lead should be a user in the workspace")
member=project_lead,
is_active=True,
).exists()
):
# Field-shaped error so DRF surfaces it under the specific key
# rather than as non_field_errors. Also requires the membership
# to be active so that revoked / removed members can't slip
# through and trigger the FK error downstream.
raise serializers.ValidationError({"project_lead": "The provided user is not a member of this workspace."})

if data.get("default_assignee", None) is not None:
# Check if the default assignee is a member of the workspace
Expand Down
122 changes: 84 additions & 38 deletions apps/api/plane/api/views/project.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
import json

# Django imports
from django.db import IntegrityError
from django.db import IntegrityError, transaction
from django.db.models import Exists, F, Func, OuterRef, Prefetch, Q, Subquery, Count
from django.db.models.functions import Coalesce
from django.utils import timezone
Expand Down Expand Up @@ -38,6 +38,7 @@
ProjectPage,
)
from plane.bgtasks.webhook_task import model_activity, webhook_activity
from plane.utils.exception_logger import log_exception
from .base import BaseAPIView
from plane.utils.host import base_host
from plane.api.serializers import (
Expand Down Expand Up @@ -223,48 +224,72 @@ def post(self, request, slug):
serializer = ProjectCreateSerializer(data={**request.data}, context={"workspace_id": workspace.id})

if serializer.is_valid():
serializer.save()

# Add the user as Administrator to the project
_ = ProjectMember.objects.create(project_id=serializer.instance.id, member=request.user, role=20)
with transaction.atomic():
serializer.save()

# Add the creator as Administrator of the project.
_ = ProjectMember.objects.create(project_id=serializer.instance.id, member=request.user, role=20)

# If a different project_lead was provided, add them as
# Administrator too. Use project_lead_id (the FK column)
# rather than project_lead (the related descriptor, which
# would resolve to a User instance and break UUID coercion
# downstream in ProjectMember.objects.create).
if (
serializer.instance.project_lead_id is not None
and serializer.instance.project_lead_id != request.user.id
):
ProjectMember.objects.create(
project_id=serializer.instance.id,
member_id=serializer.instance.project_lead_id,
role=20,
)

if serializer.instance.project_lead is not None and str(serializer.instance.project_lead) != str(
request.user.id
):
ProjectMember.objects.create(
project_id=serializer.instance.id,
member_id=serializer.instance.project_lead,
role=20,
State.objects.bulk_create(
[
State(
name=state["name"],
color=state["color"],
project=serializer.instance,
sequence=state["sequence"],
workspace=serializer.instance.workspace,
group=state["group"],
default=state.get("default", False),
created_by=request.user,
)
for state in DEFAULT_STATES
]
)

State.objects.bulk_create(
[
State(
name=state["name"],
color=state["color"],
project=serializer.instance,
sequence=state["sequence"],
workspace=serializer.instance.workspace,
group=state["group"],
default=state.get("default", False),
created_by=request.user,
project = self.get_queryset().filter(pk=serializer.instance.id).first()

# Defer the activity-log task until the surrounding
# transaction commits, so it never fires on a rolled-back
# creation.
# robust=True so broker / dispatch failures are logged
# internally by Django and don't surface as 500 after a
# successful commit (the inverse of the rollback path
# covered by test_model_activity_not_called_on_rollback).
# A nested function (rather than functools.partial) is
# used here because Django's robust on_commit logging
# path reads ``func.__qualname__`` to format the error
# message; ``partial`` objects don't have that dunder
# by default and the workaround is brittle when the
# wrapped callable is a mock. The closure captures
# the locals at construction time and they are never
# rebound, so late-binding is not a hazard here.
def _dispatch_model_activity():
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),
)
for state in DEFAULT_STATES
]
)

project = self.get_queryset().filter(pk=serializer.instance.id).first()

# Model activity
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),
)
transaction.on_commit(_dispatch_model_activity, robust=True)

serializer = ProjectSerializer(project)
return Response(serializer.data, status=status.HTTP_201_CREATED)
Expand All @@ -275,13 +300,34 @@ def post(self, request, slug):
{"name": "The project name is already taken"},
status=status.HTTP_409_CONFLICT,
)
# Any other IntegrityError is unexpected: log it the same way
# the catch-all `except Exception` below would and return the
# same generic 500 so the client gets a uniform error shape.
# `raise` here would not fall through to a sibling except
# clause — it would exit the try/except entirely and bypass
# both the logging and the JSON response.
log_exception(e)
return Response(
{"error": "An unexpected error occurred"},
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
)
except Workspace.DoesNotExist:
return Response({"error": "Workspace does not exist"}, status=status.HTTP_404_NOT_FOUND)
except ValidationError:
return Response(
{"identifier": "The project identifier is already taken"},
status=status.HTTP_409_CONFLICT,
)
except Exception as e:
# Unexpected server-side failure: log the traceback and return a
# generic 500 so the client can distinguish it from a 4xx caused
# by bad input. Returning 400 here was the anti-pattern that
# masked the original ghost-create bug.
log_exception(e)
return Response(
{"error": "An unexpected error occurred"},
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
)
Comment thread
coderabbitai[bot] marked this conversation as resolved.


class ProjectDetailAPIEndpoint(BaseAPIView):
Expand Down
Loading