Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/api/plane/api/serializers/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
IssueLinkCreateSerializer,
IssueLinkUpdateSerializer,
IssueRelationCreateSerializer,
IssueRelationRemoveSerializer,
IssueRelationResponseSerializer,
IssueRelationSerializer,
RelatedIssueSerializer,
Expand Down
6 changes: 6 additions & 0 deletions apps/api/plane/api/urls/work_item.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
WorkspaceIssueAPIEndpoint,
IssueSearchEndpoint,
IssueRelationListCreateAPIEndpoint,
IssueRelationRemoveAPIEndpoint,
)

# Deprecated url patterns
Expand Down Expand Up @@ -151,6 +152,11 @@
IssueRelationListCreateAPIEndpoint.as_view(http_method_names=["get", "post"]),
name="work-item-relation-list",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/work-items/<uuid:issue_id>/relations/remove/",
IssueRelationRemoveAPIEndpoint.as_view(http_method_names=["post"]),
name="work-item-relation-remove",
),
]

urlpatterns = old_url_patterns + new_url_patterns
1 change: 1 addition & 0 deletions apps/api/plane/api/views/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
IssueAttachmentDetailAPIEndpoint,
IssueSearchEndpoint,
IssueRelationListCreateAPIEndpoint,
IssueRelationRemoveAPIEndpoint,
)

from .cycle import (
Expand Down
96 changes: 95 additions & 1 deletion apps/api/plane/api/views/issue.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@
IssueCommentSerializer,
IssueLinkSerializer,
IssueRelationCreateSerializer,
IssueRelationRemoveSerializer,
IssueRelationResponseSerializer,
IssueRelationSerializer,
IssueSerializer,
Expand Down Expand Up @@ -88,7 +89,7 @@
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.utils.issue_relation_mapper import get_actual_relation, get_inverse_relation
from plane.bgtasks.webhook_task import model_activity
from plane.app.permissions import ROLE
from plane.utils.openapi import (
Expand Down Expand Up @@ -2587,3 +2588,96 @@ def post(self, request, slug, project_id, issue_id):
serializer_class(refetched_relations, many=True).data,
status=status.HTTP_201_CREATED,
)


class IssueRelationRemoveAPIEndpoint(BaseAPIView):
"""Issue Relation Remove Endpoint"""

serializer_class = IssueRelationRemoveSerializer
model = IssueRelation
permission_classes = [ProjectEntityPermission]

@work_item_relation_docs(
operation_id="remove_work_item_relation",
summary="Remove work item relation",
description="Remove an existing relationship between two work items. The relation is matched in either direction, so the same request works whether it was created from this work item or from the related one.", # noqa E501
parameters=[
ISSUE_ID_PARAMETER,
],
request=OpenApiRequest(
request=IssueRelationRemoveSerializer,
examples=[
OpenApiExample(
name="Remove relation",
value={"related_issue": "550e8400-e29b-41d4-a716-446655440000"},
)
],
),
responses={
204: OpenApiResponse(description="Work item relation removed successfully"),
400: INVALID_REQUEST_RESPONSE,
404: ISSUE_NOT_FOUND_RESPONSE,
},
)
def post(self, request, slug, project_id, issue_id):
"""Remove work item relation

Remove the relation between a work item and a related work item.
Automatically tracks relation removal activity for both work items.
"""
# Validate request data using serializer
serializer = IssueRelationRemoveSerializer(data=request.data)
if not serializer.is_valid():
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)

related_issue_id = serializer.validated_data["related_issue"]

# The work item has to live in the project the request is scoped to,
# otherwise membership of that project would not authorize the removal.
if not Issue.objects.filter(pk=issue_id, project_id=project_id, workspace__slug=slug).exists():
return Response({"error": "Work item not found"}, status=status.HTTP_404_NOT_FOUND)

# Relations can cross projects so only workspace scope is enforced.
# The pair is matched in both directions since either work item may be
# the source of the stored relation.
issue_relation = (
IssueRelation.objects.filter(
Q(issue_id=issue_id, related_issue_id=related_issue_id)
| Q(issue_id=related_issue_id, related_issue_id=issue_id),
workspace__slug=slug,
)
.select_related("related_issue__state")
.first()
)

if issue_relation is None:
return Response(
{"error": "Work item relation not found"},
status=status.HTTP_404_NOT_FOUND,
)

# Stored relations are directional. Report the type as seen from the
# work item in the path so the activity feed reads the right way round.
relation_type = issue_relation.relation_type
if str(issue_relation.related_issue_id) == str(issue_id):
relation_type = get_inverse_relation(relation_type)

current_instance = json.dumps(IssueRelationSerializer(issue_relation).data, cls=DjangoJSONEncoder)
issue_relation.delete()

issue_activity.delay(
type="issue_relation.activity.deleted",
requested_data=json.dumps(
{"related_issue": str(related_issue_id), "relation_type": relation_type},
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=True,
origin=base_host(request=request, is_app=True),
)

return Response(status=status.HTTP_204_NO_CONTENT)
5 changes: 5 additions & 0 deletions apps/api/plane/app/views/issue/relation.py
Original file line number Diff line number Diff line change
Expand Up @@ -277,6 +277,11 @@ def remove_relation(self, request, slug, project_id, issue_id):
Q(issue_id=related_issue, related_issue_id=issue_id) | Q(issue_id=issue_id, related_issue_id=related_issue)
)
issue_relations = issue_relations.first()
if issue_relations is None:
return Response(
{"error": "Work item relation not found"},
status=status.HTTP_404_NOT_FOUND,
)
current_instance = json.dumps(IssueRelationSerializer(issue_relations).data, cls=DjangoJSONEncoder)
issue_relations.delete()
issue_activity.delay(
Expand Down
7 changes: 7 additions & 0 deletions apps/api/plane/tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,11 @@
# See the LICENSE file for details.

import pytest
from django.core.cache import cache
from rest_framework.test import APIClient
from pytest_django.fixtures import django_db_setup

from plane.api.rate_limit import ApiKeyRateThrottle
from plane.db.models import User, Workspace, WorkspaceMember
from plane.db.models.api import APIToken

Expand Down Expand Up @@ -60,6 +62,11 @@ def api_token(db, create_user):
@pytest.fixture
def api_key_client(api_client, api_token):
"""Return an API key authenticated client for external API testing"""
# ApiKeyRateThrottle counts requests per token in the shared cache, which
# outlives the test that made them. Every test reuses the same token, so
# the history accumulates until later tests are rate limited into 429s.
# Give each test the full budget.
cache.delete(f"{ApiKeyRateThrottle.scope}:{api_token.token}")
api_client.credentials(HTTP_X_API_KEY=api_token.token)
return api_client

Expand Down
Loading