Skip to content

Commit 78fbdde

Browse files
[WEB-5282] chore: triage state in intake (#8135)
* chore: traige state in intake * chore: triage state changes * feat: implement intake state dropdown component and integrate into issue properties * chore: added the triage state validation * chore: added triage state filter * chore: added workspace filter * fix: migration file * chore: added triage group state check * chore: updated the filters * chore: updated the filters * chore: added variables for intake state * fix: import error * refactor: improve project intake state retrieval logic and update TriageGroupIcon component * chore: changed the intake validation logic * refactor: update intake state types and clean up unused interfaces * chore: changed the state color * chore: changed the update serializer * chore: updated with current instance * chore: update TriageGroupIcon color to match new intake state group color * chore: stringified value * chore: added validation in serializer * chore: added logger instead of print * fix: correct component closing syntax in ActiveProjectItem * chore: updated the migration file * chore: added noop in migation --------- Co-authored-by: b-saikrishnakanth <[email protected]>
1 parent dbc5a63 commit 78fbdde

File tree

36 files changed

+955
-184
lines changed

36 files changed

+955
-184
lines changed

apps/api/plane/api/serializers/intake.py

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,54 @@ class Meta:
103103
"updated_at",
104104
]
105105

106+
def validate(self, attrs):
107+
"""
108+
Validate that if status is being changed to accepted (1),
109+
the project has a default state to transition to.
110+
"""
111+
from plane.db.models import State
112+
113+
# Check if status is being updated to accepted
114+
if attrs.get("status") == 1:
115+
intake_issue = self.instance
116+
issue = intake_issue.issue
117+
118+
# Check if issue is in TRIAGE state
119+
if issue.state and issue.state.group == State.TRIAGE:
120+
# Verify default state exists before allowing the update
121+
default_state = State.objects.filter(
122+
workspace=intake_issue.workspace, project=intake_issue.project, default=True
123+
).first()
124+
125+
if not default_state:
126+
raise serializers.ValidationError(
127+
{"status": "Cannot accept intake issue: No default state found for the project"}
128+
)
129+
130+
return attrs
131+
132+
def update(self, instance, validated_data):
133+
"""
134+
Update intake issue and transition associated issue state if accepted.
135+
"""
136+
from plane.db.models import State
137+
138+
# Update the intake issue with validated data
139+
instance = super().update(instance, validated_data)
140+
141+
# If status is accepted (1), update the associated issue state from TRIAGE to default
142+
if validated_data.get("status") == 1:
143+
issue = instance.issue
144+
if issue.state and issue.state.group == State.TRIAGE:
145+
# Get the default project state
146+
default_state = State.objects.filter(
147+
workspace=instance.workspace, project=instance.project, default=True
148+
).first()
149+
if default_state:
150+
issue.state = default_state
151+
issue.save()
152+
return instance
153+
106154

107155
class IssueDataSerializer(serializers.Serializer):
108156
"""

apps/api/plane/api/serializers/state.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
# Module imports
22
from .base import BaseSerializer
33
from plane.db.models import State
4+
from rest_framework import serializers
45

56

67
class StateSerializer(BaseSerializer):
@@ -15,6 +16,9 @@ def validate(self, data):
1516
# If the default is being provided then make all other states default False
1617
if data.get("default", False):
1718
State.objects.filter(project_id=self.context.get("project_id")).update(default=False)
19+
20+
if data.get("group", None) == State.TRIAGE:
21+
raise serializers.ValidationError("Cannot create triage state")
1822
return data
1923

2024
class Meta:

apps/api/plane/api/views/intake.py

Lines changed: 63 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -165,13 +165,28 @@ def post(self, request, slug, project_id):
165165
]:
166166
return Response({"error": "Invalid priority"}, status=status.HTTP_400_BAD_REQUEST)
167167

168+
# get the triage state
169+
triage_state = State.triage_objects.filter(project_id=project_id, workspace__slug=slug).first()
170+
171+
if not triage_state:
172+
triage_state = State.objects.create(
173+
name="Intake Triage",
174+
group=State.TRIAGE,
175+
project_id=project_id,
176+
workspace_id=project.workspace_id,
177+
color="#4E5355",
178+
sequence=65000,
179+
default=False,
180+
)
181+
168182
# create an issue
169183
issue = Issue.objects.create(
170184
name=request.data.get("issue", {}).get("name"),
171185
description=request.data.get("issue", {}).get("description", {}),
172186
description_html=request.data.get("issue", {}).get("description_html", "<p></p>"),
173187
priority=request.data.get("issue", {}).get("priority", "none"),
174188
project_id=project_id,
189+
state_id=triage_state.id,
175190
)
176191

177192
# create an intake issue
@@ -320,7 +335,10 @@ def patch(self, request, slug, project_id, issue_id):
320335

321336
# Get issue data
322337
issue_data = request.data.pop("issue", False)
338+
issue_serializer = None
339+
intake_serializer = None
323340

341+
# Validate issue data if provided
324342
if bool(issue_data):
325343
issue = Issue.objects.annotate(
326344
label_ids=Coalesce(
@@ -344,6 +362,7 @@ def patch(self, request, slug, project_id, issue_id):
344362
Value([], output_field=ArrayField(UUIDField())),
345363
),
346364
).get(pk=issue_id, workspace__slug=slug, project_id=project_id)
365+
347366
# Only allow guests to edit name and description
348367
if project_member.role <= 5:
349368
issue_data = {
@@ -354,71 +373,55 @@ def patch(self, request, slug, project_id, issue_id):
354373

355374
issue_serializer = IssueSerializer(issue, data=issue_data, partial=True)
356375

357-
if issue_serializer.is_valid():
358-
current_instance = issue
359-
# Log all the updates
360-
requested_data = json.dumps(issue_data, cls=DjangoJSONEncoder)
361-
if issue is not None:
362-
issue_activity.delay(
363-
type="issue.activity.updated",
364-
requested_data=requested_data,
365-
actor_id=str(request.user.id),
366-
issue_id=str(issue_id),
367-
project_id=str(project_id),
368-
current_instance=json.dumps(
369-
IssueSerializer(current_instance).data,
370-
cls=DjangoJSONEncoder,
371-
),
372-
epoch=int(timezone.now().timestamp()),
373-
intake=(intake_issue.id),
374-
)
375-
issue_serializer.save()
376-
else:
376+
if not issue_serializer.is_valid():
377377
return Response(issue_serializer.errors, status=status.HTTP_400_BAD_REQUEST)
378378

379379
# Only project admins and members can edit intake issue attributes
380380
if project_member.role > 15:
381-
serializer = IntakeIssueUpdateSerializer(intake_issue, data=request.data, partial=True)
382-
current_instance = json.dumps(IntakeIssueSerializer(intake_issue).data, cls=DjangoJSONEncoder)
381+
intake_serializer = IntakeIssueUpdateSerializer(intake_issue, data=request.data, partial=True)
382+
383+
if not intake_serializer.is_valid():
384+
return Response(intake_serializer.errors, status=status.HTTP_400_BAD_REQUEST)
385+
386+
# Both serializers are valid, now save them
387+
if issue_serializer:
388+
current_instance = issue
389+
# Log all the updates
390+
requested_data = json.dumps(issue_data, cls=DjangoJSONEncoder)
391+
issue_activity.delay(
392+
type="issue.activity.updated",
393+
requested_data=requested_data,
394+
actor_id=str(request.user.id),
395+
issue_id=str(issue_id),
396+
project_id=str(project_id),
397+
current_instance=json.dumps(
398+
IssueSerializer(current_instance).data,
399+
cls=DjangoJSONEncoder,
400+
),
401+
epoch=int(timezone.now().timestamp()),
402+
intake=str(intake_issue.id),
403+
)
404+
issue_serializer.save()
383405

384-
if serializer.is_valid():
385-
serializer.save()
386-
# Update the issue state if the issue is rejected or marked as duplicate
387-
if serializer.data["status"] in [-1, 2]:
388-
issue = Issue.objects.get(pk=issue_id, workspace__slug=slug, project_id=project_id)
389-
state = State.objects.filter(group="cancelled", workspace__slug=slug, project_id=project_id).first()
390-
if state is not None:
391-
issue.state = state
392-
issue.save()
393-
394-
# Update the issue state if it is accepted
395-
if serializer.data["status"] in [1]:
396-
issue = Issue.objects.get(pk=issue_id, workspace__slug=slug, project_id=project_id)
397-
398-
# Update the issue state only if it is in triage state
399-
if issue.state.is_triage:
400-
# Move to default state
401-
state = State.objects.filter(workspace__slug=slug, project_id=project_id, default=True).first()
402-
if state is not None:
403-
issue.state = state
404-
issue.save()
405-
406-
# create a activity for status change
407-
issue_activity.delay(
408-
type="intake.activity.created",
409-
requested_data=json.dumps(request.data, cls=DjangoJSONEncoder),
410-
actor_id=str(request.user.id),
411-
issue_id=str(issue_id),
412-
project_id=str(project_id),
413-
current_instance=current_instance,
414-
epoch=int(timezone.now().timestamp()),
415-
notification=False,
416-
origin=base_host(request=request, is_app=True),
417-
intake=str(intake_issue.id),
418-
)
419-
serializer = IntakeIssueSerializer(intake_issue)
420-
return Response(serializer.data, status=status.HTTP_200_OK)
421-
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
406+
# Save intake issue (state transition happens in serializer's update method)
407+
if intake_serializer:
408+
current_instance = json.dumps(IntakeIssueSerializer(intake_issue).data, cls=DjangoJSONEncoder)
409+
intake_serializer.save()
410+
411+
# create a activity for status change
412+
issue_activity.delay(
413+
type="intake.activity.created",
414+
requested_data=json.dumps(request.data, cls=DjangoJSONEncoder),
415+
actor_id=str(request.user.id),
416+
issue_id=str(issue_id),
417+
project_id=str(project_id),
418+
current_instance=current_instance,
419+
epoch=int(timezone.now().timestamp()),
420+
notification=False,
421+
origin=base_host(request=request, is_app=True),
422+
intake=str(intake_issue.id),
423+
)
424+
return Response(IntakeIssueSerializer(intake_issue).data, status=status.HTTP_200_OK)
422425
else:
423426
return Response(IntakeIssueSerializer(intake_issue).data, status=status.HTTP_200_OK)
424427

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

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -265,6 +265,12 @@ def post(self, request, slug):
265265
"sequence": 55000,
266266
"group": "cancelled",
267267
},
268+
{
269+
"name": "Intake Triage",
270+
"color": "#4E5355",
271+
"sequence": 65000,
272+
"group": State.TRIAGE,
273+
},
268274
]
269275

270276
State.objects.bulk_create(

apps/api/plane/app/serializers/intake.py

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,54 @@ class Meta:
3636
]
3737
read_only_fields = ["project", "workspace"]
3838

39+
def validate(self, attrs):
40+
"""
41+
Validate that if status is being changed to accepted (1),
42+
the project has a default state to transition to.
43+
"""
44+
from plane.db.models import State
45+
46+
# Check if status is being updated to accepted
47+
if attrs.get("status") == 1:
48+
intake_issue = self.instance
49+
issue = intake_issue.issue
50+
51+
# Check if issue is in TRIAGE state
52+
if issue.state and issue.state.group == State.TRIAGE:
53+
# Verify default state exists before allowing the update
54+
default_state = State.objects.filter(
55+
workspace=intake_issue.workspace, project=intake_issue.project, default=True
56+
).first()
57+
58+
if not default_state:
59+
raise serializers.ValidationError(
60+
{"status": "Cannot accept intake issue: No default state found for the project"}
61+
)
62+
63+
return attrs
64+
65+
def update(self, instance, validated_data):
66+
from plane.db.models import State
67+
68+
# Update the intake issue
69+
instance = super().update(instance, validated_data)
70+
71+
# If status is accepted (1), transition the issue state from TRIAGE to default
72+
if validated_data.get("status") == 1:
73+
issue = instance.issue
74+
if issue.state and issue.state.group == State.TRIAGE:
75+
# Get the default project state
76+
default_state = State.objects.filter(
77+
workspace=instance.workspace,
78+
project=instance.project,
79+
default=True
80+
).first()
81+
if default_state:
82+
issue.state = default_state
83+
issue.save()
84+
85+
return instance
86+
3987
def to_representation(self, instance):
4088
# Pass the annotated fields to the Issue instance if they exist
4189
if hasattr(instance, "label_ids"):

apps/api/plane/app/serializers/issue.py

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,7 @@ class Meta:
7878
class IssueCreateSerializer(BaseSerializer):
7979
# ids
8080
state_id = serializers.PrimaryKeyRelatedField(
81-
source="state", queryset=State.objects.all(), required=False, allow_null=True
81+
source="state", queryset=State.all_state_objects.all(), required=False, allow_null=True
8282
)
8383
parent_id = serializers.PrimaryKeyRelatedField(
8484
source="parent", queryset=Issue.objects.all(), required=False, allow_null=True
@@ -117,6 +117,9 @@ def to_representation(self, instance):
117117
return data
118118

119119
def validate(self, attrs):
120+
allow_triage = self.context.get("allow_triage_state", False)
121+
state_manager = State.triage_objects if allow_triage else State.objects
122+
120123
if (
121124
attrs.get("start_date", None) is not None
122125
and attrs.get("target_date", None) is not None
@@ -160,7 +163,7 @@ def validate(self, attrs):
160163
# Check state is from the project only else raise validation error
161164
if (
162165
attrs.get("state")
163-
and not State.objects.filter(
166+
and not state_manager.filter(
164167
project_id=self.context.get("project_id"),
165168
pk=attrs.get("state").id,
166169
).exists()
@@ -795,6 +798,14 @@ class Meta:
795798
]
796799
read_only_fields = fields
797800

801+
def validate(self, data):
802+
if (
803+
data.get("state_id")
804+
and not State.objects.filter(project_id=self.context.get("project_id"), pk=data.get("state_id")).exists()
805+
):
806+
raise serializers.ValidationError("State is not valid please pass a valid state_id")
807+
return data
808+
798809

799810
class IssueListDetailSerializer(serializers.Serializer):
800811
def __init__(self, *args, **kwargs):

apps/api/plane/app/serializers/state.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,12 @@ class Meta:
2424
]
2525
read_only_fields = ["workspace", "project"]
2626

27+
def validate(self, attrs):
28+
29+
if attrs.get("group") == State.TRIAGE:
30+
raise serializers.ValidationError("Cannot create triage state")
31+
return attrs
32+
2733

2834
class StateLiteSerializer(BaseSerializer):
2935
class Meta:

apps/api/plane/app/urls/state.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
from django.urls import path
22

33

4-
from plane.app.views import StateViewSet
4+
from plane.app.views import StateViewSet, IntakeStateEndpoint
55

66

77
urlpatterns = [
@@ -15,6 +15,11 @@
1515
StateViewSet.as_view({"get": "retrieve", "patch": "partial_update", "delete": "destroy"}),
1616
name="project-state",
1717
),
18+
path(
19+
"workspaces/<str:slug>/projects/<uuid:project_id>/intake-state/",
20+
IntakeStateEndpoint.as_view(),
21+
name="intake-state",
22+
),
1823
path(
1924
"workspaces/<str:slug>/projects/<uuid:project_id>/states/<uuid:pk>/mark-default/",
2025
StateViewSet.as_view({"post": "mark_as_default"}),

apps/api/plane/app/views/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,7 @@
8080
from .workspace.quick_link import QuickLinkViewSet
8181
from .workspace.sticky import WorkspaceStickyViewSet
8282

83-
from .state.base import StateViewSet
83+
from .state.base import StateViewSet, IntakeStateEndpoint
8484
from .view.base import (
8585
WorkspaceViewViewSet,
8686
WorkspaceViewIssuesViewSet,

0 commit comments

Comments
 (0)