[SILO-1087] feat: add IssueRelations external API (#8763)

* add IssueRelations external API

* update serializer methods and filter by slug
This commit is contained in:
Saurabh Kumar 2026-03-30 15:29:16 +05:30 committed by GitHub
parent 9851fe0b8f
commit d7c80885fd
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 451 additions and 12 deletions

View file

@ -25,6 +25,10 @@ from .issue import (
IssueCommentCreateSerializer,
IssueLinkCreateSerializer,
IssueLinkUpdateSerializer,
IssueRelationCreateSerializer,
IssueRelationResponseSerializer,
IssueRelationSerializer,
RelatedIssueSerializer,
)
from .state import StateLiteSerializer, StateSerializer
from .cycle import (

View file

@ -20,6 +20,7 @@ from plane.db.models import (
IssueComment,
IssueLabel,
IssueLink,
IssueRelation,
Label,
ProjectMember,
State,
@ -479,6 +480,184 @@ class IssueLinkSerializer(BaseSerializer):
]
class IssueRelationResponseSerializer(serializers.Serializer):
"""
Serializer for issue relations response showing grouped relation types.
Returns issue IDs organized by relation type for efficient client-side processing.
"""
blocking = serializers.ListField(
child=serializers.UUIDField(),
help_text="List of issue IDs that are blocking this issue",
)
blocked_by = serializers.ListField(
child=serializers.UUIDField(),
help_text="List of issue IDs that this issue is blocked by",
)
duplicate = serializers.ListField(
child=serializers.UUIDField(),
help_text="List of issue IDs that are duplicates of this issue",
)
relates_to = serializers.ListField(
child=serializers.UUIDField(),
help_text="List of issue IDs that relate to this issue",
)
start_after = serializers.ListField(
child=serializers.UUIDField(),
help_text="List of issue IDs that start after this issue",
)
start_before = serializers.ListField(
child=serializers.UUIDField(),
help_text="List of issue IDs that start before this issue",
)
finish_after = serializers.ListField(
child=serializers.UUIDField(),
help_text="List of issue IDs that finish after this issue",
)
finish_before = serializers.ListField(
child=serializers.UUIDField(),
help_text="List of issue IDs that finish before this issue",
)
class IssueRelationCreateSerializer(serializers.Serializer):
"""
Serializer for creating issue relations.
Creates issue relations with the specified relation type and issues.
Validates relation types and ensures proper issue ID format.
"""
RELATION_TYPE_CHOICES = [
("blocking", "Blocking"),
("blocked_by", "Blocked By"),
("duplicate", "Duplicate"),
("relates_to", "Relates To"),
("start_before", "Start Before"),
("start_after", "Start After"),
("finish_before", "Finish Before"),
("finish_after", "Finish After"),
]
relation_type = serializers.ChoiceField(
choices=RELATION_TYPE_CHOICES,
required=True,
help_text="Type of relationship between work items",
)
issues = serializers.ListField(
child=serializers.UUIDField(),
required=True,
min_length=1,
help_text="Array of work item IDs to create relations with",
)
def validate_issues(self, value):
"""Validate that issues list is not empty and contains valid UUIDs."""
if not value:
raise serializers.ValidationError("At least one issue ID is required.")
return value
class IssueRelationRemoveSerializer(serializers.Serializer):
"""
Serializer for removing issue relations.
Removes existing relationships between work items by specifying
the related issue ID.
"""
related_issue = serializers.UUIDField(
required=True, help_text="ID of the related work item to remove relation with"
)
class IssueRelationSerializer(BaseSerializer):
"""
Serializer for issue relationships showing related issue details.
Provides comprehensive information about related issues including
project context, sequence ID, and relationship type.
"""
id = serializers.UUIDField(source="related_issue.id", read_only=True)
project_id = serializers.UUIDField(source="related_issue.project_id", read_only=True)
sequence_id = serializers.IntegerField(source="related_issue.sequence_id", read_only=True)
name = serializers.CharField(source="related_issue.name", read_only=True)
relation_type = serializers.CharField(read_only=True)
state_id = serializers.UUIDField(source="related_issue.state.id", read_only=True)
priority = serializers.CharField(source="related_issue.priority", read_only=True)
class Meta:
model = IssueRelation
fields = [
"id",
"project_id",
"sequence_id",
"relation_type",
"name",
"state_id",
"priority",
"created_by",
"created_at",
"updated_at",
"updated_by",
]
read_only_fields = [
"workspace",
"project",
"created_by",
"created_at",
"updated_by",
"updated_at",
]
class RelatedIssueSerializer(BaseSerializer):
"""
Serializer for reverse issue relationships showing issue details.
Provides comprehensive information about the source issue in a relationship
including project context, sequence ID, and relationship type.
"""
id = serializers.UUIDField(source="issue.id", read_only=True)
project_id = serializers.PrimaryKeyRelatedField(source="issue.project_id", read_only=True)
sequence_id = serializers.IntegerField(source="issue.sequence_id", read_only=True)
name = serializers.CharField(source="issue.name", read_only=True)
type_id = serializers.UUIDField(source="issue.type.id", read_only=True)
relation_type = serializers.CharField(read_only=True)
is_epic = serializers.BooleanField(source="issue.type.is_epic", read_only=True)
state_id = serializers.UUIDField(source="issue.state.id", read_only=True)
priority = serializers.CharField(source="issue.priority", read_only=True)
class Meta:
model = IssueRelation
fields = [
"id",
"project_id",
"sequence_id",
"relation_type",
"name",
"type_id",
"is_epic",
"state_id",
"priority",
"created_by",
"created_at",
"updated_by",
"updated_at",
]
read_only_fields = [
"workspace",
"project",
"created_by",
"created_at",
"updated_by",
"updated_at",
]
class IssueAttachmentSerializer(BaseSerializer):
"""
Serializer for work item file attachments.

View file

@ -17,6 +17,7 @@ from plane.api.views import (
IssueAttachmentDetailAPIEndpoint,
WorkspaceIssueAPIEndpoint,
IssueSearchEndpoint,
IssueRelationListCreateAPIEndpoint,
)
# Deprecated url patterns
@ -145,6 +146,11 @@ new_url_patterns = [
IssueAttachmentDetailAPIEndpoint.as_view(http_method_names=["get", "patch", "delete"]),
name="work-item-attachment-detail",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/work-items/<uuid:issue_id>/relations/",
IssueRelationListCreateAPIEndpoint.as_view(http_method_names=["get", "post"]),
name="work-item-relation-list",
),
]
urlpatterns = old_url_patterns + new_url_patterns

View file

@ -29,6 +29,7 @@ from .issue import (
IssueAttachmentListCreateAPIEndpoint,
IssueAttachmentDetailAPIEndpoint,
IssueSearchEndpoint,
IssueRelationListCreateAPIEndpoint,
)
from .cycle import (

View file

@ -23,7 +23,11 @@ from django.db.models import (
Value,
When,
Subquery,
UUIDField,
)
from django.db.models.functions import Coalesce
from django.contrib.postgres.aggregates import ArrayAgg
from django.contrib.postgres.fields import ArrayField
from django.utils import timezone
from django.conf import settings
@ -45,6 +49,9 @@ from plane.api.serializers import (
IssueActivitySerializer,
IssueCommentSerializer,
IssueLinkSerializer,
IssueRelationCreateSerializer,
IssueRelationResponseSerializer,
IssueRelationSerializer,
IssueSerializer,
LabelSerializer,
IssueAttachmentUploadSerializer,
@ -53,6 +60,7 @@ from plane.api.serializers import (
IssueLinkCreateSerializer,
IssueLinkUpdateSerializer,
LabelCreateUpdateSerializer,
RelatedIssueSerializer,
)
from plane.app.permissions import (
ProjectEntityPermission,
@ -66,6 +74,7 @@ from plane.db.models import (
FileAsset,
IssueComment,
IssueLink,
IssueRelation,
Label,
Project,
ProjectMember,
@ -76,10 +85,12 @@ from plane.settings.storage import S3Storage
from plane.bgtasks.storage_metadata_task import get_asset_object_metadata
from .base import BaseAPIView
from plane.utils.host import base_host
from plane.utils.issue_relation_mapper import get_actual_relation
from plane.bgtasks.webhook_task import model_activity
from plane.app.permissions import ROLE
from plane.utils.openapi import (
work_item_docs,
work_item_relation_docs,
label_docs,
issue_link_docs,
issue_comment_docs,
@ -1119,9 +1130,9 @@ class IssueLinkListCreateAPIEndpoint(BaseAPIView):
return self.paginate(
request=request,
queryset=(self.get_queryset()),
on_results=lambda issue_links: IssueLinkSerializer(
issue_links, many=True, fields=self.fields, expand=self.expand
).data,
on_results=lambda issue_links: (
IssueLinkSerializer(issue_links, many=True, fields=self.fields, expand=self.expand).data
),
)
@issue_link_docs(
@ -1226,9 +1237,9 @@ class IssueLinkDetailAPIEndpoint(BaseAPIView):
return self.paginate(
request=request,
queryset=(self.get_queryset()),
on_results=lambda issue_links: IssueLinkSerializer(
issue_links, many=True, fields=self.fields, expand=self.expand
).data,
on_results=lambda issue_links: (
IssueLinkSerializer(issue_links, many=True, fields=self.fields, expand=self.expand).data
),
)
issue_link = self.get_queryset().get(pk=pk)
serializer = IssueLinkSerializer(issue_link, fields=self.fields, expand=self.expand)
@ -1377,9 +1388,9 @@ class IssueCommentListCreateAPIEndpoint(BaseAPIView):
return self.paginate(
request=request,
queryset=(self.get_queryset()),
on_results=lambda issue_comments: IssueCommentSerializer(
issue_comments, many=True, fields=self.fields, expand=self.expand
).data,
on_results=lambda issue_comments: (
IssueCommentSerializer(issue_comments, many=True, fields=self.fields, expand=self.expand).data
),
)
@issue_comment_docs(
@ -1688,9 +1699,9 @@ class IssueActivityListAPIEndpoint(BaseAPIView):
return self.paginate(
request=request,
queryset=(issue_activities),
on_results=lambda issue_activity: IssueActivitySerializer(
issue_activity, many=True, fields=self.fields, expand=self.expand
).data,
on_results=lambda issue_activity: (
IssueActivitySerializer(issue_activity, many=True, fields=self.fields, expand=self.expand).data
),
)
@ -2250,3 +2261,224 @@ class IssueSearchEndpoint(BaseAPIView):
)[: int(limit)]
return Response({"issues": issue_results}, status=status.HTTP_200_OK)
class IssueRelationListCreateAPIEndpoint(BaseAPIView):
"""Issue Relation List and Create Endpoint"""
serializer_class = IssueRelationSerializer
model = IssueRelation
permission_classes = [ProjectEntityPermission]
use_read_replica = True
@work_item_relation_docs(
operation_id="list_work_item_relations",
summary="List work item relations",
description="Retrieve all relationships for a work item including blocking, blocked_by, duplicate, relates_to, start_before, start_after, finish_before, and finish_after relations.", # noqa E501
parameters=[
ISSUE_ID_PARAMETER,
CURSOR_PARAMETER,
PER_PAGE_PARAMETER,
ORDER_BY_PARAMETER,
FIELDS_PARAMETER,
EXPAND_PARAMETER,
],
responses={
200: OpenApiResponse(
description="Work item relations grouped by relation type",
response=IssueRelationResponseSerializer,
examples=[
OpenApiExample(
name="Work Item Relations Response",
value={
"blocking": [
"550e8400-e29b-41d4-a716-446655440000",
"550e8400-e29b-41d4-a716-446655440001",
],
"blocked_by": ["550e8400-e29b-41d4-a716-446655440002"],
"duplicate": [],
"relates_to": ["550e8400-e29b-41d4-a716-446655440003"],
"start_after": [],
"start_before": ["550e8400-e29b-41d4-a716-446655440004"],
"finish_after": [],
"finish_before": [],
},
)
],
),
400: INVALID_REQUEST_RESPONSE,
404: ISSUE_NOT_FOUND_RESPONSE,
},
)
def get(self, request, slug, project_id, issue_id):
"""List work item relations
Retrieve all relationships for a work item organized by relation type.
Returns a structured response with relations grouped by type.
"""
empty_uuid_array = Value([], output_field=ArrayField(UUIDField()))
def _agg_ids(field, **filter_kwargs):
return Coalesce(
ArrayAgg(field, filter=Q(**filter_kwargs), distinct=True),
empty_uuid_array,
)
issue_relation_qs = IssueRelation.objects.filter(
Q(issue_id=issue_id) | Q(related_issue_id=issue_id),
workspace__slug=slug,
)
relation_ids = issue_relation_qs.aggregate(
blocking_ids=_agg_ids("issue_id", relation_type="blocked_by", related_issue_id=issue_id),
blocked_by_ids=_agg_ids("related_issue_id", relation_type="blocked_by", issue_id=issue_id),
duplicate_ids=_agg_ids("related_issue_id", relation_type="duplicate", issue_id=issue_id),
duplicate_ids_related=_agg_ids("issue_id", relation_type="duplicate", related_issue_id=issue_id),
relates_to_ids=_agg_ids("related_issue_id", relation_type="relates_to", issue_id=issue_id),
relates_to_ids_related=_agg_ids("issue_id", relation_type="relates_to", related_issue_id=issue_id),
start_after_ids=_agg_ids("issue_id", relation_type="start_before", related_issue_id=issue_id),
start_before_ids=_agg_ids("related_issue_id", relation_type="start_before", issue_id=issue_id),
finish_after_ids=_agg_ids("issue_id", relation_type="finish_before", related_issue_id=issue_id),
finish_before_ids=_agg_ids("related_issue_id", relation_type="finish_before", issue_id=issue_id),
)
response_data = {
"blocking": relation_ids["blocking_ids"],
"blocked_by": relation_ids["blocked_by_ids"],
"duplicate": list(set(relation_ids["duplicate_ids"] + relation_ids["duplicate_ids_related"])),
"relates_to": list(set(relation_ids["relates_to_ids"] + relation_ids["relates_to_ids_related"])),
"start_after": relation_ids["start_after_ids"],
"start_before": relation_ids["start_before_ids"],
"finish_after": relation_ids["finish_after_ids"],
"finish_before": relation_ids["finish_before_ids"],
}
return Response(response_data, status=status.HTTP_200_OK)
@work_item_relation_docs(
operation_id="create_work_item_relation",
summary="Create work item relation",
description="Create relationships between work items. Supports various relation types including blocking, blocked_by, duplicate, relates_to, start_before, start_after, finish_before, and finish_after.", # noqa E501
parameters=[
ISSUE_ID_PARAMETER,
],
request=OpenApiRequest(
request=IssueRelationCreateSerializer,
examples=[
OpenApiExample(
name="Create blocking relation",
value={
"relation_type": "blocking",
"issues": [
"550e8400-e29b-41d4-a716-446655440000",
"550e8400-e29b-41d4-a716-446655440001",
],
},
)
],
),
responses={
201: OpenApiResponse(
description="Work item relations created successfully",
response=IssueRelationSerializer(many=True),
examples=[
OpenApiExample(
name="Relations created",
value=[
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"name": "Fix authentication bug",
"sequence_id": 42,
"project_id": "550e8400-e29b-41d4-a716-446655440001",
"relation_type": "blocked_by",
"state_id": "550e8400-e29b-41d4-a716-446655440002",
"priority": "high",
"created_at": "2024-01-15T10:00:00Z",
"updated_at": "2024-01-15T10:00:00Z",
"created_by": "550e8400-e29b-41d4-a716-446655440004",
"updated_by": "550e8400-e29b-41d4-a716-446655440004",
}
],
)
],
),
400: INVALID_REQUEST_RESPONSE,
404: ISSUE_NOT_FOUND_RESPONSE,
},
)
def post(self, request, slug, project_id, issue_id):
"""Create work item relation
Create relationships between work items with specified relation type.
Automatically tracks relation creation activity.
"""
# Validate request data using serializer
serializer = IssueRelationCreateSerializer(data=request.data)
if not serializer.is_valid():
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
relation_type = serializer.validated_data["relation_type"]
issues = serializer.validated_data["issues"]
project = Project.objects.get(pk=project_id, workspace__slug=slug)
actual_relation = get_actual_relation(relation_type)
is_reverse = relation_type in ["blocking", "start_after", "finish_after"]
IssueRelation.objects.bulk_create(
[
IssueRelation(
issue_id=(issue if is_reverse else issue_id),
related_issue_id=(issue_id if is_reverse else issue),
relation_type=actual_relation,
project_id=project_id,
workspace_id=project.workspace_id,
created_by=request.user,
updated_by=request.user,
)
for issue in issues
],
batch_size=10,
ignore_conflicts=True,
)
issue_activity.delay(
type="issue_relation.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=None,
epoch=int(timezone.now().timestamp()),
notification=True,
origin=base_host(request=request, is_app=True),
)
# Re-fetch with select_related to avoid N+1 queries in serializers.
# bulk_create with ignore_conflicts=True may not return PKs,
# so query by the issue/related_issue pairs and relation type.
if is_reverse:
refetch_filter = Q(
issue_id__in=issues,
related_issue_id=issue_id,
relation_type=actual_relation,
)
else:
refetch_filter = Q(
issue_id=issue_id,
related_issue_id__in=issues,
relation_type=actual_relation,
)
refetched_relations = IssueRelation.objects.filter(
refetch_filter,
workspace__slug=slug,
).select_related(
"issue__state",
"related_issue__state",
)
serializer_class = RelatedIssueSerializer if is_reverse else IssueRelationSerializer
return Response(
serializer_class(refetched_relations, many=True).data,
status=status.HTTP_201_CREATED,
)

View file

@ -157,6 +157,7 @@ from .decorators import (
user_docs,
cycle_docs,
work_item_docs,
work_item_relation_docs,
label_docs,
issue_link_docs,
issue_comment_docs,
@ -307,6 +308,7 @@ __all__ = [
"user_docs",
"cycle_docs",
"work_item_docs",
"work_item_relation_docs",
"label_docs",
"issue_link_docs",
"issue_comment_docs",

View file

@ -223,6 +223,21 @@ def issue_attachment_docs(**kwargs):
return extend_schema(**_merge_schema_options(defaults, kwargs))
def work_item_relation_docs(**kwargs):
"""Decorator for work item relation endpoints"""
defaults = {
"tags": ["Work Item Relations"],
"parameters": [WORKSPACE_SLUG_PARAMETER, PROJECT_ID_PARAMETER],
"responses": {
401: UNAUTHORIZED_RESPONSE,
403: FORBIDDEN_RESPONSE,
404: NOT_FOUND_RESPONSE,
},
}
return extend_schema(**_merge_schema_options(defaults, kwargs))
def module_docs(**kwargs):
"""Decorator for module management endpoints"""
defaults = {