[SILO-1087] feat: add IssueRelations external API (#8763)
* add IssueRelations external API * update serializer methods and filter by slug
This commit is contained in:
parent
9851fe0b8f
commit
d7c80885fd
7 changed files with 451 additions and 12 deletions
|
|
@ -25,6 +25,10 @@ from .issue import (
|
||||||
IssueCommentCreateSerializer,
|
IssueCommentCreateSerializer,
|
||||||
IssueLinkCreateSerializer,
|
IssueLinkCreateSerializer,
|
||||||
IssueLinkUpdateSerializer,
|
IssueLinkUpdateSerializer,
|
||||||
|
IssueRelationCreateSerializer,
|
||||||
|
IssueRelationResponseSerializer,
|
||||||
|
IssueRelationSerializer,
|
||||||
|
RelatedIssueSerializer,
|
||||||
)
|
)
|
||||||
from .state import StateLiteSerializer, StateSerializer
|
from .state import StateLiteSerializer, StateSerializer
|
||||||
from .cycle import (
|
from .cycle import (
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ from plane.db.models import (
|
||||||
IssueComment,
|
IssueComment,
|
||||||
IssueLabel,
|
IssueLabel,
|
||||||
IssueLink,
|
IssueLink,
|
||||||
|
IssueRelation,
|
||||||
Label,
|
Label,
|
||||||
ProjectMember,
|
ProjectMember,
|
||||||
State,
|
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):
|
class IssueAttachmentSerializer(BaseSerializer):
|
||||||
"""
|
"""
|
||||||
Serializer for work item file attachments.
|
Serializer for work item file attachments.
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ from plane.api.views import (
|
||||||
IssueAttachmentDetailAPIEndpoint,
|
IssueAttachmentDetailAPIEndpoint,
|
||||||
WorkspaceIssueAPIEndpoint,
|
WorkspaceIssueAPIEndpoint,
|
||||||
IssueSearchEndpoint,
|
IssueSearchEndpoint,
|
||||||
|
IssueRelationListCreateAPIEndpoint,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Deprecated url patterns
|
# Deprecated url patterns
|
||||||
|
|
@ -145,6 +146,11 @@ new_url_patterns = [
|
||||||
IssueAttachmentDetailAPIEndpoint.as_view(http_method_names=["get", "patch", "delete"]),
|
IssueAttachmentDetailAPIEndpoint.as_view(http_method_names=["get", "patch", "delete"]),
|
||||||
name="work-item-attachment-detail",
|
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
|
urlpatterns = old_url_patterns + new_url_patterns
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,7 @@ from .issue import (
|
||||||
IssueAttachmentListCreateAPIEndpoint,
|
IssueAttachmentListCreateAPIEndpoint,
|
||||||
IssueAttachmentDetailAPIEndpoint,
|
IssueAttachmentDetailAPIEndpoint,
|
||||||
IssueSearchEndpoint,
|
IssueSearchEndpoint,
|
||||||
|
IssueRelationListCreateAPIEndpoint,
|
||||||
)
|
)
|
||||||
|
|
||||||
from .cycle import (
|
from .cycle import (
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,11 @@ from django.db.models import (
|
||||||
Value,
|
Value,
|
||||||
When,
|
When,
|
||||||
Subquery,
|
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.utils import timezone
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
|
|
||||||
|
|
@ -45,6 +49,9 @@ from plane.api.serializers import (
|
||||||
IssueActivitySerializer,
|
IssueActivitySerializer,
|
||||||
IssueCommentSerializer,
|
IssueCommentSerializer,
|
||||||
IssueLinkSerializer,
|
IssueLinkSerializer,
|
||||||
|
IssueRelationCreateSerializer,
|
||||||
|
IssueRelationResponseSerializer,
|
||||||
|
IssueRelationSerializer,
|
||||||
IssueSerializer,
|
IssueSerializer,
|
||||||
LabelSerializer,
|
LabelSerializer,
|
||||||
IssueAttachmentUploadSerializer,
|
IssueAttachmentUploadSerializer,
|
||||||
|
|
@ -53,6 +60,7 @@ from plane.api.serializers import (
|
||||||
IssueLinkCreateSerializer,
|
IssueLinkCreateSerializer,
|
||||||
IssueLinkUpdateSerializer,
|
IssueLinkUpdateSerializer,
|
||||||
LabelCreateUpdateSerializer,
|
LabelCreateUpdateSerializer,
|
||||||
|
RelatedIssueSerializer,
|
||||||
)
|
)
|
||||||
from plane.app.permissions import (
|
from plane.app.permissions import (
|
||||||
ProjectEntityPermission,
|
ProjectEntityPermission,
|
||||||
|
|
@ -66,6 +74,7 @@ from plane.db.models import (
|
||||||
FileAsset,
|
FileAsset,
|
||||||
IssueComment,
|
IssueComment,
|
||||||
IssueLink,
|
IssueLink,
|
||||||
|
IssueRelation,
|
||||||
Label,
|
Label,
|
||||||
Project,
|
Project,
|
||||||
ProjectMember,
|
ProjectMember,
|
||||||
|
|
@ -76,10 +85,12 @@ from plane.settings.storage import S3Storage
|
||||||
from plane.bgtasks.storage_metadata_task import get_asset_object_metadata
|
from plane.bgtasks.storage_metadata_task import get_asset_object_metadata
|
||||||
from .base import BaseAPIView
|
from .base import BaseAPIView
|
||||||
from plane.utils.host import base_host
|
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.bgtasks.webhook_task import model_activity
|
||||||
from plane.app.permissions import ROLE
|
from plane.app.permissions import ROLE
|
||||||
from plane.utils.openapi import (
|
from plane.utils.openapi import (
|
||||||
work_item_docs,
|
work_item_docs,
|
||||||
|
work_item_relation_docs,
|
||||||
label_docs,
|
label_docs,
|
||||||
issue_link_docs,
|
issue_link_docs,
|
||||||
issue_comment_docs,
|
issue_comment_docs,
|
||||||
|
|
@ -1119,9 +1130,9 @@ class IssueLinkListCreateAPIEndpoint(BaseAPIView):
|
||||||
return self.paginate(
|
return self.paginate(
|
||||||
request=request,
|
request=request,
|
||||||
queryset=(self.get_queryset()),
|
queryset=(self.get_queryset()),
|
||||||
on_results=lambda issue_links: IssueLinkSerializer(
|
on_results=lambda issue_links: (
|
||||||
issue_links, many=True, fields=self.fields, expand=self.expand
|
IssueLinkSerializer(issue_links, many=True, fields=self.fields, expand=self.expand).data
|
||||||
).data,
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
@issue_link_docs(
|
@issue_link_docs(
|
||||||
|
|
@ -1226,9 +1237,9 @@ class IssueLinkDetailAPIEndpoint(BaseAPIView):
|
||||||
return self.paginate(
|
return self.paginate(
|
||||||
request=request,
|
request=request,
|
||||||
queryset=(self.get_queryset()),
|
queryset=(self.get_queryset()),
|
||||||
on_results=lambda issue_links: IssueLinkSerializer(
|
on_results=lambda issue_links: (
|
||||||
issue_links, many=True, fields=self.fields, expand=self.expand
|
IssueLinkSerializer(issue_links, many=True, fields=self.fields, expand=self.expand).data
|
||||||
).data,
|
),
|
||||||
)
|
)
|
||||||
issue_link = self.get_queryset().get(pk=pk)
|
issue_link = self.get_queryset().get(pk=pk)
|
||||||
serializer = IssueLinkSerializer(issue_link, fields=self.fields, expand=self.expand)
|
serializer = IssueLinkSerializer(issue_link, fields=self.fields, expand=self.expand)
|
||||||
|
|
@ -1377,9 +1388,9 @@ class IssueCommentListCreateAPIEndpoint(BaseAPIView):
|
||||||
return self.paginate(
|
return self.paginate(
|
||||||
request=request,
|
request=request,
|
||||||
queryset=(self.get_queryset()),
|
queryset=(self.get_queryset()),
|
||||||
on_results=lambda issue_comments: IssueCommentSerializer(
|
on_results=lambda issue_comments: (
|
||||||
issue_comments, many=True, fields=self.fields, expand=self.expand
|
IssueCommentSerializer(issue_comments, many=True, fields=self.fields, expand=self.expand).data
|
||||||
).data,
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
@issue_comment_docs(
|
@issue_comment_docs(
|
||||||
|
|
@ -1688,9 +1699,9 @@ class IssueActivityListAPIEndpoint(BaseAPIView):
|
||||||
return self.paginate(
|
return self.paginate(
|
||||||
request=request,
|
request=request,
|
||||||
queryset=(issue_activities),
|
queryset=(issue_activities),
|
||||||
on_results=lambda issue_activity: IssueActivitySerializer(
|
on_results=lambda issue_activity: (
|
||||||
issue_activity, many=True, fields=self.fields, expand=self.expand
|
IssueActivitySerializer(issue_activity, many=True, fields=self.fields, expand=self.expand).data
|
||||||
).data,
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -2250,3 +2261,224 @@ class IssueSearchEndpoint(BaseAPIView):
|
||||||
)[: int(limit)]
|
)[: int(limit)]
|
||||||
|
|
||||||
return Response({"issues": issue_results}, status=status.HTTP_200_OK)
|
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,
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -157,6 +157,7 @@ from .decorators import (
|
||||||
user_docs,
|
user_docs,
|
||||||
cycle_docs,
|
cycle_docs,
|
||||||
work_item_docs,
|
work_item_docs,
|
||||||
|
work_item_relation_docs,
|
||||||
label_docs,
|
label_docs,
|
||||||
issue_link_docs,
|
issue_link_docs,
|
||||||
issue_comment_docs,
|
issue_comment_docs,
|
||||||
|
|
@ -307,6 +308,7 @@ __all__ = [
|
||||||
"user_docs",
|
"user_docs",
|
||||||
"cycle_docs",
|
"cycle_docs",
|
||||||
"work_item_docs",
|
"work_item_docs",
|
||||||
|
"work_item_relation_docs",
|
||||||
"label_docs",
|
"label_docs",
|
||||||
"issue_link_docs",
|
"issue_link_docs",
|
||||||
"issue_comment_docs",
|
"issue_comment_docs",
|
||||||
|
|
|
||||||
|
|
@ -223,6 +223,21 @@ def issue_attachment_docs(**kwargs):
|
||||||
return extend_schema(**_merge_schema_options(defaults, 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):
|
def module_docs(**kwargs):
|
||||||
"""Decorator for module management endpoints"""
|
"""Decorator for module management endpoints"""
|
||||||
defaults = {
|
defaults = {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue