diff --git a/apps/api/plane/api/serializers/intake.py b/apps/api/plane/api/serializers/intake.py
index 5fd846d1d..d2cdd5a31 100644
--- a/apps/api/plane/api/serializers/intake.py
+++ b/apps/api/plane/api/serializers/intake.py
@@ -103,6 +103,54 @@ class IntakeIssueUpdateSerializer(BaseSerializer):
"updated_at",
]
+ def validate(self, attrs):
+ """
+ Validate that if status is being changed to accepted (1),
+ the project has a default state to transition to.
+ """
+ from plane.db.models import State
+
+ # Check if status is being updated to accepted
+ if attrs.get("status") == 1:
+ intake_issue = self.instance
+ issue = intake_issue.issue
+
+ # Check if issue is in TRIAGE state
+ if issue.state and issue.state.group == State.TRIAGE:
+ # Verify default state exists before allowing the update
+ default_state = State.objects.filter(
+ workspace=intake_issue.workspace, project=intake_issue.project, default=True
+ ).first()
+
+ if not default_state:
+ raise serializers.ValidationError(
+ {"status": "Cannot accept intake issue: No default state found for the project"}
+ )
+
+ return attrs
+
+ def update(self, instance, validated_data):
+ """
+ Update intake issue and transition associated issue state if accepted.
+ """
+ from plane.db.models import State
+
+ # Update the intake issue with validated data
+ instance = super().update(instance, validated_data)
+
+ # If status is accepted (1), update the associated issue state from TRIAGE to default
+ if validated_data.get("status") == 1:
+ issue = instance.issue
+ if issue.state and issue.state.group == State.TRIAGE:
+ # Get the default project state
+ default_state = State.objects.filter(
+ workspace=instance.workspace, project=instance.project, default=True
+ ).first()
+ if default_state:
+ issue.state = default_state
+ issue.save()
+ return instance
+
class IssueDataSerializer(serializers.Serializer):
"""
diff --git a/apps/api/plane/api/serializers/state.py b/apps/api/plane/api/serializers/state.py
index fc6aac15e..10b3e84f9 100644
--- a/apps/api/plane/api/serializers/state.py
+++ b/apps/api/plane/api/serializers/state.py
@@ -1,6 +1,7 @@
# Module imports
from .base import BaseSerializer
from plane.db.models import State
+from rest_framework import serializers
class StateSerializer(BaseSerializer):
@@ -15,6 +16,9 @@ class StateSerializer(BaseSerializer):
# If the default is being provided then make all other states default False
if data.get("default", False):
State.objects.filter(project_id=self.context.get("project_id")).update(default=False)
+
+ if data.get("group", None) == State.TRIAGE:
+ raise serializers.ValidationError("Cannot create triage state")
return data
class Meta:
diff --git a/apps/api/plane/api/views/intake.py b/apps/api/plane/api/views/intake.py
index 7a00fa431..081c2f9f2 100644
--- a/apps/api/plane/api/views/intake.py
+++ b/apps/api/plane/api/views/intake.py
@@ -165,6 +165,20 @@ class IntakeIssueListCreateAPIEndpoint(BaseAPIView):
]:
return Response({"error": "Invalid priority"}, status=status.HTTP_400_BAD_REQUEST)
+ # get the triage state
+ triage_state = State.triage_objects.filter(project_id=project_id, workspace__slug=slug).first()
+
+ if not triage_state:
+ triage_state = State.objects.create(
+ name="Intake Triage",
+ group=State.TRIAGE,
+ project_id=project_id,
+ workspace_id=project.workspace_id,
+ color="#4E5355",
+ sequence=65000,
+ default=False,
+ )
+
# create an issue
issue = Issue.objects.create(
name=request.data.get("issue", {}).get("name"),
@@ -172,6 +186,7 @@ class IntakeIssueListCreateAPIEndpoint(BaseAPIView):
description_html=request.data.get("issue", {}).get("description_html", "
"),
priority=request.data.get("issue", {}).get("priority", "none"),
project_id=project_id,
+ state_id=triage_state.id,
)
# create an intake issue
@@ -320,7 +335,10 @@ class IntakeIssueDetailAPIEndpoint(BaseAPIView):
# Get issue data
issue_data = request.data.pop("issue", False)
+ issue_serializer = None
+ intake_serializer = None
+ # Validate issue data if provided
if bool(issue_data):
issue = Issue.objects.annotate(
label_ids=Coalesce(
@@ -344,6 +362,7 @@ class IntakeIssueDetailAPIEndpoint(BaseAPIView):
Value([], output_field=ArrayField(UUIDField())),
),
).get(pk=issue_id, workspace__slug=slug, project_id=project_id)
+
# Only allow guests to edit name and description
if project_member.role <= 5:
issue_data = {
@@ -354,71 +373,55 @@ class IntakeIssueDetailAPIEndpoint(BaseAPIView):
issue_serializer = IssueSerializer(issue, data=issue_data, partial=True)
- if issue_serializer.is_valid():
- current_instance = issue
- # Log all the updates
- requested_data = json.dumps(issue_data, cls=DjangoJSONEncoder)
- if issue is not None:
- issue_activity.delay(
- type="issue.activity.updated",
- requested_data=requested_data,
- actor_id=str(request.user.id),
- issue_id=str(issue_id),
- project_id=str(project_id),
- current_instance=json.dumps(
- IssueSerializer(current_instance).data,
- cls=DjangoJSONEncoder,
- ),
- epoch=int(timezone.now().timestamp()),
- intake=(intake_issue.id),
- )
- issue_serializer.save()
- else:
+ if not issue_serializer.is_valid():
return Response(issue_serializer.errors, status=status.HTTP_400_BAD_REQUEST)
# Only project admins and members can edit intake issue attributes
if project_member.role > 15:
- serializer = IntakeIssueUpdateSerializer(intake_issue, data=request.data, partial=True)
+ intake_serializer = IntakeIssueUpdateSerializer(intake_issue, data=request.data, partial=True)
+
+ if not intake_serializer.is_valid():
+ return Response(intake_serializer.errors, status=status.HTTP_400_BAD_REQUEST)
+
+ # Both serializers are valid, now save them
+ if issue_serializer:
+ current_instance = issue
+ # Log all the updates
+ requested_data = json.dumps(issue_data, cls=DjangoJSONEncoder)
+ issue_activity.delay(
+ type="issue.activity.updated",
+ requested_data=requested_data,
+ actor_id=str(request.user.id),
+ issue_id=str(issue_id),
+ project_id=str(project_id),
+ current_instance=json.dumps(
+ IssueSerializer(current_instance).data,
+ cls=DjangoJSONEncoder,
+ ),
+ epoch=int(timezone.now().timestamp()),
+ intake=str(intake_issue.id),
+ )
+ issue_serializer.save()
+
+ # Save intake issue (state transition happens in serializer's update method)
+ if intake_serializer:
current_instance = json.dumps(IntakeIssueSerializer(intake_issue).data, cls=DjangoJSONEncoder)
+ intake_serializer.save()
- if serializer.is_valid():
- serializer.save()
- # Update the issue state if the issue is rejected or marked as duplicate
- if serializer.data["status"] in [-1, 2]:
- issue = Issue.objects.get(pk=issue_id, workspace__slug=slug, project_id=project_id)
- state = State.objects.filter(group="cancelled", workspace__slug=slug, project_id=project_id).first()
- if state is not None:
- issue.state = state
- issue.save()
-
- # Update the issue state if it is accepted
- if serializer.data["status"] in [1]:
- issue = Issue.objects.get(pk=issue_id, workspace__slug=slug, project_id=project_id)
-
- # Update the issue state only if it is in triage state
- if issue.state.is_triage:
- # Move to default state
- state = State.objects.filter(workspace__slug=slug, project_id=project_id, default=True).first()
- if state is not None:
- issue.state = state
- issue.save()
-
- # create a activity for status change
- issue_activity.delay(
- type="intake.activity.created",
- requested_data=json.dumps(request.data, cls=DjangoJSONEncoder),
- actor_id=str(request.user.id),
- issue_id=str(issue_id),
- project_id=str(project_id),
- current_instance=current_instance,
- epoch=int(timezone.now().timestamp()),
- notification=False,
- origin=base_host(request=request, is_app=True),
- intake=str(intake_issue.id),
- )
- serializer = IntakeIssueSerializer(intake_issue)
- return Response(serializer.data, status=status.HTTP_200_OK)
- return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
+ # create a activity for status change
+ issue_activity.delay(
+ type="intake.activity.created",
+ requested_data=json.dumps(request.data, cls=DjangoJSONEncoder),
+ actor_id=str(request.user.id),
+ issue_id=str(issue_id),
+ project_id=str(project_id),
+ current_instance=current_instance,
+ epoch=int(timezone.now().timestamp()),
+ notification=False,
+ origin=base_host(request=request, is_app=True),
+ intake=str(intake_issue.id),
+ )
+ return Response(IntakeIssueSerializer(intake_issue).data, status=status.HTTP_200_OK)
else:
return Response(IntakeIssueSerializer(intake_issue).data, status=status.HTTP_200_OK)
diff --git a/apps/api/plane/api/views/project.py b/apps/api/plane/api/views/project.py
index 131932bf2..2f3db4237 100644
--- a/apps/api/plane/api/views/project.py
+++ b/apps/api/plane/api/views/project.py
@@ -265,6 +265,12 @@ class ProjectListCreateAPIEndpoint(BaseAPIView):
"sequence": 55000,
"group": "cancelled",
},
+ {
+ "name": "Intake Triage",
+ "color": "#4E5355",
+ "sequence": 65000,
+ "group": State.TRIAGE,
+ },
]
State.objects.bulk_create(
diff --git a/apps/api/plane/app/serializers/intake.py b/apps/api/plane/app/serializers/intake.py
index 7bc258220..9ed3494a7 100644
--- a/apps/api/plane/app/serializers/intake.py
+++ b/apps/api/plane/app/serializers/intake.py
@@ -36,6 +36,54 @@ class IntakeIssueSerializer(BaseSerializer):
]
read_only_fields = ["project", "workspace"]
+ def validate(self, attrs):
+ """
+ Validate that if status is being changed to accepted (1),
+ the project has a default state to transition to.
+ """
+ from plane.db.models import State
+
+ # Check if status is being updated to accepted
+ if attrs.get("status") == 1:
+ intake_issue = self.instance
+ issue = intake_issue.issue
+
+ # Check if issue is in TRIAGE state
+ if issue.state and issue.state.group == State.TRIAGE:
+ # Verify default state exists before allowing the update
+ default_state = State.objects.filter(
+ workspace=intake_issue.workspace, project=intake_issue.project, default=True
+ ).first()
+
+ if not default_state:
+ raise serializers.ValidationError(
+ {"status": "Cannot accept intake issue: No default state found for the project"}
+ )
+
+ return attrs
+
+ def update(self, instance, validated_data):
+ from plane.db.models import State
+
+ # Update the intake issue
+ instance = super().update(instance, validated_data)
+
+ # If status is accepted (1), transition the issue state from TRIAGE to default
+ if validated_data.get("status") == 1:
+ issue = instance.issue
+ if issue.state and issue.state.group == State.TRIAGE:
+ # Get the default project state
+ default_state = State.objects.filter(
+ workspace=instance.workspace,
+ project=instance.project,
+ default=True
+ ).first()
+ if default_state:
+ issue.state = default_state
+ issue.save()
+
+ return instance
+
def to_representation(self, instance):
# Pass the annotated fields to the Issue instance if they exist
if hasattr(instance, "label_ids"):
diff --git a/apps/api/plane/app/serializers/issue.py b/apps/api/plane/app/serializers/issue.py
index 21a286d5e..5e3b93ab6 100644
--- a/apps/api/plane/app/serializers/issue.py
+++ b/apps/api/plane/app/serializers/issue.py
@@ -78,7 +78,7 @@ class IssueProjectLiteSerializer(BaseSerializer):
class IssueCreateSerializer(BaseSerializer):
# ids
state_id = serializers.PrimaryKeyRelatedField(
- source="state", queryset=State.objects.all(), required=False, allow_null=True
+ source="state", queryset=State.all_state_objects.all(), required=False, allow_null=True
)
parent_id = serializers.PrimaryKeyRelatedField(
source="parent", queryset=Issue.objects.all(), required=False, allow_null=True
@@ -117,6 +117,9 @@ class IssueCreateSerializer(BaseSerializer):
return data
def validate(self, attrs):
+ allow_triage = self.context.get("allow_triage_state", False)
+ state_manager = State.triage_objects if allow_triage else State.objects
+
if (
attrs.get("start_date", None) is not None
and attrs.get("target_date", None) is not None
@@ -160,7 +163,7 @@ class IssueCreateSerializer(BaseSerializer):
# Check state is from the project only else raise validation error
if (
attrs.get("state")
- and not State.objects.filter(
+ and not state_manager.filter(
project_id=self.context.get("project_id"),
pk=attrs.get("state").id,
).exists()
@@ -795,6 +798,14 @@ class IssueSerializer(DynamicBaseSerializer):
]
read_only_fields = fields
+ def validate(self, data):
+ if (
+ data.get("state_id")
+ and not State.objects.filter(project_id=self.context.get("project_id"), pk=data.get("state_id")).exists()
+ ):
+ raise serializers.ValidationError("State is not valid please pass a valid state_id")
+ return data
+
class IssueListDetailSerializer(serializers.Serializer):
def __init__(self, *args, **kwargs):
diff --git a/apps/api/plane/app/serializers/state.py b/apps/api/plane/app/serializers/state.py
index 29d8cf302..f7ad053fd 100644
--- a/apps/api/plane/app/serializers/state.py
+++ b/apps/api/plane/app/serializers/state.py
@@ -24,6 +24,12 @@ class StateSerializer(BaseSerializer):
]
read_only_fields = ["workspace", "project"]
+ def validate(self, attrs):
+
+ if attrs.get("group") == State.TRIAGE:
+ raise serializers.ValidationError("Cannot create triage state")
+ return attrs
+
class StateLiteSerializer(BaseSerializer):
class Meta:
diff --git a/apps/api/plane/app/urls/state.py b/apps/api/plane/app/urls/state.py
index 7dcf01d62..b6135ca95 100644
--- a/apps/api/plane/app/urls/state.py
+++ b/apps/api/plane/app/urls/state.py
@@ -1,7 +1,7 @@
from django.urls import path
-from plane.app.views import StateViewSet
+from plane.app.views import StateViewSet, IntakeStateEndpoint
urlpatterns = [
@@ -15,6 +15,11 @@ urlpatterns = [
StateViewSet.as_view({"get": "retrieve", "patch": "partial_update", "delete": "destroy"}),
name="project-state",
),
+ path(
+ "workspaces//projects//intake-state/",
+ IntakeStateEndpoint.as_view(),
+ name="intake-state",
+ ),
path(
"workspaces//projects//states//mark-default/",
StateViewSet.as_view({"post": "mark_as_default"}),
diff --git a/apps/api/plane/app/views/__init__.py b/apps/api/plane/app/views/__init__.py
index 500bf0505..5f848a5ba 100644
--- a/apps/api/plane/app/views/__init__.py
+++ b/apps/api/plane/app/views/__init__.py
@@ -80,7 +80,7 @@ from .workspace.cycle import WorkspaceCyclesEndpoint
from .workspace.quick_link import QuickLinkViewSet
from .workspace.sticky import WorkspaceStickyViewSet
-from .state.base import StateViewSet
+from .state.base import StateViewSet, IntakeStateEndpoint
from .view.base import (
WorkspaceViewViewSet,
WorkspaceViewIssuesViewSet,
diff --git a/apps/api/plane/app/views/intake/base.py b/apps/api/plane/app/views/intake/base.py
index cc6379131..d2dd920c8 100644
--- a/apps/api/plane/app/views/intake/base.py
+++ b/apps/api/plane/app/views/intake/base.py
@@ -228,14 +228,30 @@ class IntakeIssueViewSet(BaseViewSet):
]:
return Response({"error": "Invalid priority"}, status=status.HTTP_400_BAD_REQUEST)
- # create an issue
project = Project.objects.get(pk=project_id)
+
+ # get the triage state
+ triage_state = State.triage_objects.filter(project_id=project_id, workspace__slug=slug).first()
+ if not triage_state:
+ triage_state = State.objects.create(
+ name="Intake Triage",
+ group=State.TRIAGE,
+ project_id=project_id,
+ workspace_id=project.workspace_id,
+ color="#4E5355",
+ sequence=65000,
+ default=False,
+ )
+ request.data["issue"]["state_id"] = triage_state.id
+
+ # create an issue
serializer = IssueCreateSerializer(
data=request.data.get("issue"),
context={
"project_id": project_id,
"workspace_id": project.workspace_id,
"default_assignee_id": project.default_assignee_id,
+ "allow_triage_state": True,
},
)
if serializer.is_valid():
@@ -344,6 +360,12 @@ class IntakeIssueViewSet(BaseViewSet):
# Get issue data
issue_data = request.data.pop("issue", False)
+ issue_serializer = None
+ issue = None
+ issue_current_instance = None
+ issue_requested_data = None
+
+ # Validate issue data if provided
if bool(issue_data):
issue = Issue.objects.annotate(
label_ids=Coalesce(
@@ -371,119 +393,95 @@ class IntakeIssueViewSet(BaseViewSet):
"description": issue_data.get("description", issue.description),
}
- current_instance = json.dumps(IssueDetailSerializer(issue).data, cls=DjangoJSONEncoder)
+ issue_current_instance = json.dumps(IssueDetailSerializer(issue).data, cls=DjangoJSONEncoder)
+ issue_requested_data = json.dumps(issue_data, cls=DjangoJSONEncoder)
issue_serializer = IssueCreateSerializer(
- issue, data=issue_data, partial=True, context={"project_id": project_id}
+ issue, data=issue_data, partial=True, context={"project_id": project_id, "allow_triage_state": True}
)
- if issue_serializer.is_valid():
- # Log all the updates
- requested_data = json.dumps(issue_data, cls=DjangoJSONEncoder)
- if issue is not None:
- issue_activity.delay(
- type="issue.activity.updated",
- requested_data=requested_data,
- actor_id=str(request.user.id),
- issue_id=str(issue.id),
- project_id=str(project_id),
- current_instance=current_instance,
- epoch=int(timezone.now().timestamp()),
- notification=True,
- origin=base_host(request=request, is_app=True),
- intake=str(intake_issue.id),
- )
- # updated issue description version
- issue_description_version_task.delay(
- updated_issue=current_instance,
- issue_id=str(pk),
- user_id=request.user.id,
- )
- issue_serializer.save()
- else:
+ if not issue_serializer.is_valid():
return Response(issue_serializer.errors, status=status.HTTP_400_BAD_REQUEST)
- # Only project admins can edit intake issue attributes
+ # Validate intake issue data if user has permission
+ intake_serializer = None
+ intake_current_instance = None
+
if (project_member and project_member.role > ROLE.MEMBER.value) or is_workspace_admin:
- serializer = IntakeIssueSerializer(intake_issue, data=request.data, partial=True)
- current_instance = json.dumps(IntakeIssueSerializer(intake_issue).data, cls=DjangoJSONEncoder)
- if serializer.is_valid():
- serializer.save()
- # Update the issue state if the issue is rejected or marked as duplicate
- if serializer.data["status"] in [-1, 2]:
- issue = Issue.objects.get(
- pk=intake_issue.issue_id,
- workspace__slug=slug,
- project_id=project_id,
- )
- state = State.objects.filter(group="cancelled", workspace__slug=slug, project_id=project_id).first()
- if state is not None:
- issue.state = state
- issue.save()
+ intake_current_instance = json.dumps(IntakeIssueSerializer(intake_issue).data, cls=DjangoJSONEncoder)
+ intake_serializer = IntakeIssueSerializer(intake_issue, data=request.data, partial=True)
- # Update the issue state if it is accepted
- if serializer.data["status"] in [1]:
- issue = Issue.objects.get(
- pk=intake_issue.issue_id,
- workspace__slug=slug,
- project_id=project_id,
- )
+ if not intake_serializer.is_valid():
+ return Response(intake_serializer.errors, status=status.HTTP_400_BAD_REQUEST)
- # Update the issue state only if it is in triage state
- if issue.state.is_triage:
- # Move to default state
- state = State.objects.filter(workspace__slug=slug, project_id=project_id, default=True).first()
- if state is not None:
- issue.state = state
- issue.save()
- # create a activity for status change
+ # Both serializers are valid, now save them
+ if issue_serializer:
+ issue_serializer.save()
+ # Log all the updates
+ if issue is not None:
issue_activity.delay(
- type="intake.activity.created",
- requested_data=json.dumps(request.data, cls=DjangoJSONEncoder),
+ type="issue.activity.updated",
+ requested_data=issue_requested_data,
actor_id=str(request.user.id),
- issue_id=str(pk),
+ issue_id=str(issue.id),
project_id=str(project_id),
- current_instance=current_instance,
+ current_instance=issue_current_instance,
epoch=int(timezone.now().timestamp()),
- notification=False,
+ notification=True,
origin=base_host(request=request, is_app=True),
- intake=(intake_issue.id),
+ intake=str(intake_issue.id),
+ )
+ # updated issue description version
+ issue_description_version_task.delay(
+ updated_issue=issue_current_instance,
+ issue_id=str(pk),
+ user_id=request.user.id,
)
- intake_issue = (
- IntakeIssue.objects.select_related("issue")
- .prefetch_related("issue__labels", "issue__assignees")
- .annotate(
- label_ids=Coalesce(
- ArrayAgg(
- "issue__labels__id",
- distinct=True,
- filter=Q(
- ~Q(issue__labels__id__isnull=True) & Q(issue__label_issue__deleted_at__isnull=True)
- ),
- ),
- Value([], output_field=ArrayField(UUIDField())),
+ if intake_serializer:
+ intake_serializer.save()
+ # create a activity for status change
+ issue_activity.delay(
+ type="intake.activity.created",
+ requested_data=json.dumps(request.data, cls=DjangoJSONEncoder),
+ actor_id=str(request.user.id),
+ issue_id=str(pk),
+ project_id=str(project_id),
+ current_instance=intake_current_instance,
+ epoch=int(timezone.now().timestamp()),
+ notification=False,
+ origin=base_host(request=request, is_app=True),
+ intake=str(intake_issue.id),
+ )
+
+ # Fetch and return the updated intake issue
+ intake_issue = (
+ IntakeIssue.objects.select_related("issue")
+ .prefetch_related("issue__labels", "issue__assignees")
+ .annotate(
+ label_ids=Coalesce(
+ ArrayAgg(
+ "issue__labels__id",
+ distinct=True,
+ filter=Q(~Q(issue__labels__id__isnull=True) & Q(issue__label_issue__deleted_at__isnull=True)),
+ ),
+ Value([], output_field=ArrayField(UUIDField())),
+ ),
+ assignee_ids=Coalesce(
+ ArrayAgg(
+ "issue__assignees__id",
+ distinct=True,
+ filter=Q(
+ ~Q(issue__assignees__id__isnull=True) & Q(issue__issue_assignee__deleted_at__isnull=True)
),
- assignee_ids=Coalesce(
- ArrayAgg(
- "issue__assignees__id",
- distinct=True,
- filter=Q(
- ~Q(issue__assignees__id__isnull=True)
- & Q(issue__issue_assignee__deleted_at__isnull=True)
- ),
- ),
- Value([], output_field=ArrayField(UUIDField())),
- ),
- )
- .get(intake_id=intake_id.id, issue_id=pk, project_id=project_id)
- )
- serializer = IntakeIssueDetailSerializer(intake_issue).data
- return Response(serializer, status=status.HTTP_200_OK)
- return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
- else:
- serializer = IntakeIssueDetailSerializer(intake_issue).data
- return Response(serializer, status=status.HTTP_200_OK)
+ ),
+ Value([], output_field=ArrayField(UUIDField())),
+ ),
+ )
+ .get(intake_id=intake_id.id, issue_id=pk, project_id=project_id)
+ )
+ serializer = IntakeIssueDetailSerializer(intake_issue).data
+ return Response(serializer, status=status.HTTP_200_OK)
@allow_permission(allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], creator=True, model=Issue)
def retrieve(self, request, slug, project_id, pk):
diff --git a/apps/api/plane/app/views/project/base.py b/apps/api/plane/app/views/project/base.py
index 84b2a5629..85207059a 100644
--- a/apps/api/plane/app/views/project/base.py
+++ b/apps/api/plane/app/views/project/base.py
@@ -297,6 +297,12 @@ class ProjectViewSet(BaseViewSet):
"sequence": 55000,
"group": "cancelled",
},
+ {
+ "name": "Intake Triage",
+ "color": "#4E5355",
+ "sequence": 65000,
+ "group": State.TRIAGE,
+ },
]
State.objects.bulk_create(
diff --git a/apps/api/plane/app/views/state/base.py b/apps/api/plane/app/views/state/base.py
index bb5026b0f..d5e876f94 100644
--- a/apps/api/plane/app/views/state/base.py
+++ b/apps/api/plane/app/views/state/base.py
@@ -10,7 +10,7 @@ from rest_framework.response import Response
from rest_framework import status
# Module imports
-from .. import BaseViewSet
+from .. import BaseViewSet, BaseAPIView
from plane.app.serializers import StateSerializer
from plane.app.permissions import ROLE, allow_permission
from plane.db.models import State, Issue
@@ -127,3 +127,16 @@ class StateViewSet(BaseViewSet):
state.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
+
+
+class IntakeStateEndpoint(BaseAPIView):
+ @allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
+ def get(self, request, slug, project_id):
+ state = State.triage_objects.filter(workspace__slug=slug, project_id=project_id).first()
+ if not state:
+ return Response(
+ {"error": "Intake triage state not found"},
+ status=status.HTTP_404_NOT_FOUND,
+ )
+
+ return Response(StateSerializer(state).data, status=status.HTTP_200_OK)
diff --git a/apps/api/plane/bgtasks/dummy_data_task.py b/apps/api/plane/bgtasks/dummy_data_task.py
index 3220ef0c0..dff8239c4 100644
--- a/apps/api/plane/bgtasks/dummy_data_task.py
+++ b/apps/api/plane/bgtasks/dummy_data_task.py
@@ -264,7 +264,7 @@ def create_issues(workspace, project, user_id, issue_count):
Faker.seed(0)
states = (
- State.objects.filter(workspace=workspace, project=project).exclude(group="Triage").values_list("id", flat=True)
+ State.objects.filter(workspace=workspace, project=project).exclude(group=State.TRIAGE).values_list("id", flat=True)
)
creators = ProjectMember.objects.filter(workspace=workspace, project=project).values_list("member_id", flat=True)
diff --git a/apps/api/plane/db/migrations/0112_auto_20251124_0603.py b/apps/api/plane/db/migrations/0112_auto_20251124_0603.py
new file mode 100644
index 000000000..6d5d4b9da
--- /dev/null
+++ b/apps/api/plane/db/migrations/0112_auto_20251124_0603.py
@@ -0,0 +1,92 @@
+# Generated by Django 4.2.25 on 2025-11-24 06:03
+
+from django.db import migrations, transaction
+from django.db.models import OuterRef, Subquery
+
+import logging
+logger = logging.getLogger("plane.migrations")
+
+BATCH_SIZE = 4000
+
+
+def create_triage_state(apps, _schema_editor):
+ Project = apps.get_model("db", "Project")
+ State = apps.get_model("db", "State")
+ Issue = apps.get_model("db", "Issue")
+
+
+ # 1) Bulk-update existing triage states
+ triage_qs = State.objects.filter(group="triage")
+ projects_with_triage_state = list(triage_qs.values_list("project_id", flat=True))
+ triage_qs.update(
+ name="Triage",
+ color="#4E5355",
+ sequence=65000,
+ default=False,
+ )
+ logger.info(f"Updated {triage_qs.count()} triage states.")
+
+ # 2) Projects that already have a 'Triage' name but not in triage group
+ projects_to_update_qs = (
+ State.objects.exclude(group="triage")
+ .filter(name="Triage")
+ .values_list("project_id", flat=True)
+ )
+ projects_to_update = set(projects_to_update_qs)
+ logger.info(f"Projects to update: {len(projects_to_update)}")
+
+ # 3) Create missing triage states in chunks to avoid memory spike
+ states_to_create = []
+ project_iter = Project.objects.all().values_list("id", "workspace_id").iterator()
+ for proj_id, workspace_id in project_iter:
+ if proj_id in projects_with_triage_state:
+ continue
+ if proj_id in projects_to_update:
+ name = f"Triage-{str(proj_id)[:5]}"
+ else:
+ name = "Triage"
+ states_to_create.append(
+ State(
+ name=name,
+ group="triage",
+ project_id=proj_id,
+ workspace_id=workspace_id,
+ color="#4E5355",
+ sequence=65000,
+ default=False,
+ )
+ )
+ if len(states_to_create) >= BATCH_SIZE:
+ State.objects.bulk_create(states_to_create, batch_size=BATCH_SIZE)
+ states_to_create = []
+
+ if states_to_create:
+ State.objects.bulk_create(states_to_create, batch_size=BATCH_SIZE)
+
+ # 4) Update issues: use deterministic subquery and only update issues that will get a triage state.
+ with transaction.atomic():
+ triage_state_subquery = (
+ State.objects.filter(
+ group="triage",
+ project_id=OuterRef("project_id"),
+ workspace_id=OuterRef("workspace_id"),
+ )
+ .values("id")[:1]
+ )
+
+ updated_count = Issue._default_manager.filter(
+ issue_intake__status__in=[-2, 0],
+ ).update(state_id=Subquery(triage_state_subquery))
+ logger.info(f"Updated {updated_count} issues.")
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('db', '0111_notification_notif_receiver_status_idx_and_more'),
+ ]
+
+ operations = [
+ migrations.RunPython(create_triage_state,
+ reverse_code=migrations.RunPython.noop),
+ ]
diff --git a/apps/api/plane/db/models/issue.py b/apps/api/plane/db/models/issue.py
index 83376c00d..9e040303c 100644
--- a/apps/api/plane/db/models/issue.py
+++ b/apps/api/plane/db/models/issue.py
@@ -19,6 +19,7 @@ from .project import ProjectBaseModel
from plane.utils.uuid import convert_uuid_to_integer
from .description import Description
from plane.db.mixins import ChangeTrackerMixin
+from .state import State
def get_default_properties():
@@ -97,6 +98,7 @@ class IssueManager(SoftDeletionManager):
)
.filter(deleted_at__isnull=True)
.filter(state__is_triage=False)
+ .exclude(state__group=State.TRIAGE)
.exclude(archived_at__isnull=False)
.exclude(project__archived_at__isnull=False)
.exclude(is_draft=True)
diff --git a/apps/api/plane/db/models/state.py b/apps/api/plane/db/models/state.py
index e9d56acf9..104954490 100644
--- a/apps/api/plane/db/models/state.py
+++ b/apps/api/plane/db/models/state.py
@@ -7,7 +7,36 @@ from django.db.models import Q
from .project import ProjectBaseModel
+class StateManager(models.Manager):
+ """Default manager - excludes triage states"""
+
+ def get_queryset(self):
+ return super().get_queryset().exclude(group=State.TRIAGE)
+
+
+class TriageStateManager(models.Manager):
+ """Manager for triage states only"""
+
+ def get_queryset(self):
+ return super().get_queryset().filter(group=State.TRIAGE)
+
+
class State(ProjectBaseModel):
+ BACKLOG = "backlog"
+ UNSTARTED = "unstarted"
+ STARTED = "started"
+ COMPLETED = "completed"
+ CANCELLED = "cancelled"
+ TRIAGE = "triage"
+
+ GROUP_CHOICES = (
+ (BACKLOG, "Backlog"),
+ (UNSTARTED, "Unstarted"),
+ (STARTED, "Started"),
+ (COMPLETED, "Completed"),
+ (CANCELLED, "Cancelled"),
+ (TRIAGE, "Triage"),
+ )
name = models.CharField(max_length=255, verbose_name="State Name")
description = models.TextField(verbose_name="State Description", blank=True)
color = models.CharField(max_length=255, verbose_name="State Color")
@@ -30,6 +59,10 @@ class State(ProjectBaseModel):
external_source = models.CharField(max_length=255, null=True, blank=True)
external_id = models.CharField(max_length=255, blank=True, null=True)
+ objects = StateManager()
+ all_state_objects = models.Manager()
+ triage_objects = TriageStateManager()
+
def __str__(self):
"""Return name of the state"""
return f"{self.name} <{self.project.name}>"
diff --git a/apps/api/plane/settings/local.py b/apps/api/plane/settings/local.py
index 84737712b..15f05aa3d 100644
--- a/apps/api/plane/settings/local.py
+++ b/apps/api/plane/settings/local.py
@@ -76,5 +76,10 @@ LOGGING = {
"handlers": ["console"],
"propagate": False,
},
+ "plane.migrations": {
+ "level": "INFO",
+ "handlers": ["console"],
+ "propagate": False,
+ },
},
}
diff --git a/apps/api/plane/settings/production.py b/apps/api/plane/settings/production.py
index 4725db38a..8df7ae906 100644
--- a/apps/api/plane/settings/production.py
+++ b/apps/api/plane/settings/production.py
@@ -86,5 +86,10 @@ LOGGING = {
"handlers": ["console"],
"propagate": False,
},
+ "plane.migrations": {
+ "level": "DEBUG" if DEBUG else "INFO",
+ "handlers": ["console"],
+ "propagate": False,
+ },
},
}
diff --git a/apps/api/plane/space/views/intake.py b/apps/api/plane/space/views/intake.py
index 60d4443b5..e82d0523e 100644
--- a/apps/api/plane/space/views/intake.py
+++ b/apps/api/plane/space/views/intake.py
@@ -12,7 +12,7 @@ from rest_framework.response import Response
# Module imports
from .base import BaseViewSet
-from plane.db.models import IntakeIssue, Issue, IssueLink, FileAsset, DeployBoard
+from plane.db.models import IntakeIssue, Issue, IssueLink, FileAsset, DeployBoard, State
from plane.app.serializers import (
IssueSerializer,
IntakeIssueSerializer,
@@ -121,6 +121,22 @@ class IntakeIssuePublicViewSet(BaseViewSet):
]:
return Response({"error": "Invalid priority"}, status=status.HTTP_400_BAD_REQUEST)
+ # get the triage state
+ triage_state = State.triage_objects.filter(
+ project_id=project_deploy_board.project_id, workspace_id=project_deploy_board.workspace_id
+ ).first()
+
+ if not triage_state:
+ triage_state = State.objects.create(
+ name="Intake Triage",
+ group=State.TRIAGE,
+ project_id=project_deploy_board.project_id,
+ workspace_id=project_deploy_board.workspace_id,
+ color="#4E5355",
+ sequence=65000,
+ default=False,
+ )
+
# create an issue
issue = Issue.objects.create(
name=request.data.get("issue", {}).get("name"),
@@ -128,6 +144,7 @@ class IntakeIssuePublicViewSet(BaseViewSet):
description_html=request.data.get("issue", {}).get("description_html", ""),
priority=request.data.get("issue", {}).get("priority", "low"),
project_id=project_deploy_board.project_id,
+ state_id=triage_state.id,
)
# Create an Issue Activity
@@ -191,7 +208,7 @@ class IntakeIssuePublicViewSet(BaseViewSet):
issue,
data=issue_data,
partial=True,
- context={"project_id": project_deploy_board.project_id},
+ context={"project_id": project_deploy_board.project_id, "allow_triage_state": True},
)
if issue_serializer.is_valid():
diff --git a/apps/web/core/components/dropdowns/intake-state/base.tsx b/apps/web/core/components/dropdowns/intake-state/base.tsx
new file mode 100644
index 000000000..c51c7cbf7
--- /dev/null
+++ b/apps/web/core/components/dropdowns/intake-state/base.tsx
@@ -0,0 +1,256 @@
+"use client";
+
+import type { ReactNode } from "react";
+import { useRef, useState } from "react";
+import { observer } from "mobx-react";
+import { usePopper } from "react-popper";
+import { Search } from "lucide-react";
+import { Combobox } from "@headlessui/react";
+// plane imports
+import { useTranslation } from "@plane/i18n";
+import { IntakeStateGroupIcon, ChevronDownIcon } from "@plane/propel/icons";
+import type { IIntakeState } from "@plane/types";
+import { ComboDropDown, Spinner } from "@plane/ui";
+import { cn } from "@plane/utils";
+// components
+import { DropdownButton } from "@/components/dropdowns/buttons";
+import { BUTTON_VARIANTS_WITH_TEXT } from "@/components/dropdowns/constants";
+import type { TDropdownProps } from "@/components/dropdowns/types";
+// hooks
+import { useDropdown } from "@/hooks/use-dropdown";
+// plane web imports
+import { StateOption } from "@/plane-web/components/workflow";
+
+export type TWorkItemStateDropdownBaseProps = TDropdownProps & {
+ alwaysAllowStateChange?: boolean;
+ button?: ReactNode;
+ dropdownArrow?: boolean;
+ dropdownArrowClassName?: string;
+ filterAvailableStateIds?: boolean;
+ getStateById: (stateId: string | null | undefined) => IIntakeState | undefined;
+ iconSize?: string;
+ isForWorkItemCreation?: boolean;
+ isInitializing?: boolean;
+ onChange: (val: string) => void;
+ onClose?: () => void;
+ onDropdownOpen?: () => void;
+ projectId: string | undefined;
+ renderByDefault?: boolean;
+ showDefaultState?: boolean;
+ stateIds: string[];
+ value: string | undefined | null;
+};
+
+export const WorkItemStateDropdownBase: React.FC = observer((props) => {
+ const {
+ button,
+ buttonClassName,
+ buttonContainerClassName,
+ buttonVariant,
+ className = "",
+ disabled = false,
+ dropdownArrow = false,
+ dropdownArrowClassName = "",
+ getStateById,
+ hideIcon = false,
+ iconSize = "size-4",
+ isInitializing = false,
+ onChange,
+ onClose,
+ onDropdownOpen,
+ placement,
+ renderByDefault = true,
+ showDefaultState = true,
+ showTooltip = false,
+ stateIds,
+ tabIndex,
+ value,
+ } = props;
+ // refs
+ const dropdownRef = useRef(null);
+ const inputRef = useRef(null);
+ // popper-js refs
+ const [referenceElement, setReferenceElement] = useState(null);
+ const [popperElement, setPopperElement] = useState(null);
+ // states
+ const [query, setQuery] = useState("");
+ const [isOpen, setIsOpen] = useState(false);
+ // store hooks
+ const { t } = useTranslation();
+ const statesList = stateIds.map((stateId) => getStateById(stateId)).filter((state) => !!state);
+ const defaultState = statesList?.find((state) => state?.default) || statesList[0];
+ const stateValue = !!value ? value : showDefaultState ? defaultState?.id : undefined;
+ // popper-js init
+ const { styles, attributes } = usePopper(referenceElement, popperElement, {
+ placement: placement ?? "bottom-start",
+ modifiers: [
+ {
+ name: "preventOverflow",
+ options: {
+ padding: 12,
+ },
+ },
+ ],
+ });
+ // dropdown init
+ const { handleClose, handleKeyDown, handleOnClick, searchInputKeyDown } = useDropdown({
+ dropdownRef,
+ inputRef,
+ isOpen,
+ onClose,
+ onOpen: onDropdownOpen,
+ query,
+ setIsOpen,
+ setQuery,
+ });
+
+ // derived values
+ const options = statesList?.map((state) => ({
+ value: state?.id,
+ query: `${state?.name}`,
+ content: (
+