Skip to content
Closed
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
5 changes: 4 additions & 1 deletion .gitattributes
Original file line number Diff line number Diff line change
@@ -1 +1,4 @@
*.sh text eol=lf
*.sh text eol=lf
apps/api/plane/db/models/__init__.py merge=ours
apps/api/plane/app/urls/__init__.py merge=ours
apps/api/plane/api/urls/__init__.py merge=ours
2 changes: 2 additions & 0 deletions apps/api/plane/app/serializers/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -133,3 +133,5 @@
DraftIssueSerializer,
DraftIssueDetailSerializer,
)

from .case_entity_link import CaseEntityLinkSerializer, CaseEntityLinkCreateSerializer
31 changes: 31 additions & 0 deletions apps/api/plane/app/serializers/case_entity_link.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
from rest_framework import serializers
from plane.app.serializers.base import BaseSerializer
from plane.db.models import CaseEntityLink


class CaseEntityLinkSerializer(BaseSerializer):
label = serializers.CharField(read_only=True, required=False)

class Meta:
model = CaseEntityLink
fields = [
"id",
"issue",
"entity_type",
"entity_id",
"role",
"label",
"created_at",
"updated_at",
]
read_only_fields = ["id", "issue", "label", "created_at", "updated_at"]


class CaseEntityLinkCreateSerializer(BaseSerializer):
class Meta:
model = CaseEntityLink
fields = [
"entity_type",
"entity_id",
"role",
]
2 changes: 2 additions & 0 deletions apps/api/plane/app/urls/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
from .workspace import urlpatterns as workspace_urls
from .timezone import urlpatterns as timezone_urls
from .exporter import urlpatterns as exporter_urls
from .case_entity_link import urlpatterns as case_entity_link_urls

urlpatterns = [
*analytic_urls,
Expand All @@ -44,4 +45,5 @@
*webhook_urls,
*timezone_urls,
*exporter_urls,
*case_entity_link_urls,
]
25 changes: 25 additions & 0 deletions apps/api/plane/app/urls/case_entity_link.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
from django.urls import path
from plane.app.views import CaseEntityLinkViewSet, ResolveEntityEndpoint, EntitySearchEndpoint

urlpatterns = [
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:issue_id>/entity-links/",
CaseEntityLinkViewSet.as_view({"get": "list", "post": "create"}),
name="case-entity-links",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:issue_id>/entity-links/<uuid:pk>/",
CaseEntityLinkViewSet.as_view({"patch": "partial_update", "delete": "destroy"}),
name="case-entity-link-detail",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/resolve-entity/",
ResolveEntityEndpoint.as_view(),
name="resolve-entity",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/search-entities/",
EntitySearchEndpoint.as_view(),
name="search-entities",
),
]
2 changes: 2 additions & 0 deletions apps/api/plane/app/views/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -238,3 +238,5 @@
from .user.base import AccountEndpoint, ProfileEndpoint, UserSessionEndpoint

from .timezone.base import TimezoneEndpoint

from .case import CaseEntityLinkViewSet, ResolveEntityEndpoint, EntitySearchEndpoint
2 changes: 2 additions & 0 deletions apps/api/plane/app/views/case/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
from .entity_link import CaseEntityLinkViewSet, ResolveEntityEndpoint
from .entity_search import EntitySearchEndpoint
172 changes: 172 additions & 0 deletions apps/api/plane/app/views/case/entity_link.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
from rest_framework import status
from rest_framework.response import Response

from plane.app.permissions import ROLE, allow_permission
from plane.app.serializers import (
CaseEntityLinkSerializer,
CaseEntityLinkCreateSerializer,
)
from plane.app.views.base import BaseViewSet, BaseAPIView
from plane.db.models import CaseEntityLink, Issue
from plane.utils.core_db_resolver import (
resolve_case_links,
resolve_entity,
resolve_entity_label,
get_claim_policy_id,
)


class CaseEntityLinkViewSet(BaseViewSet):
serializer_class = CaseEntityLinkSerializer
model = CaseEntityLink

def get_queryset(self):
return CaseEntityLink.objects.filter(
workspace__slug=self.kwargs.get("slug"),
project_id=self.kwargs.get("project_id"),
issue_id=self.kwargs.get("issue_id"),
)

@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
def list(self, request, slug, project_id, issue_id):
links = self.get_queryset()
resolved = resolve_case_links(links)
return Response(resolved, status=status.HTTP_200_OK)

@allow_permission([ROLE.ADMIN, ROLE.MEMBER])
def create(self, request, slug, project_id, issue_id):
serializer = CaseEntityLinkCreateSerializer(data=request.data)
serializer.is_valid(raise_exception=True)

entity_type = serializer.validated_data["entity_type"]
entity_id = serializer.validated_data["entity_id"]
role = serializer.validated_data.get("role", "primary")

resolved = resolve_entity(entity_type, entity_id)
if not resolved:
return Response(
{"error": f"{entity_type} {entity_id} not found in cima"},
status=status.HTTP_400_BAD_REQUEST,
)

issue = Issue.objects.get(id=issue_id)

if entity_type == "claim":
claim_policy_id = get_claim_policy_id(entity_id)
if claim_policy_id:
existing_policy = self.get_queryset().filter(
entity_type="policy", role="primary"
).first()
if existing_policy and str(existing_policy.entity_id) != claim_policy_id:
existing_policy.delete()
CaseEntityLink.objects.create(
issue_id=issue_id,
project_id=project_id,
workspace=issue.workspace,
entity_type="policy",
entity_id=claim_policy_id,
role="primary",
)
elif not existing_policy:
CaseEntityLink.objects.create(
issue_id=issue_id,
project_id=project_id,
workspace=issue.workspace,
entity_type="policy",
entity_id=claim_policy_id,
role="primary",
)

link = CaseEntityLink.objects.create(
issue_id=issue_id,
project_id=project_id,
workspace=issue.workspace,
entity_type=entity_type,
entity_id=entity_id,
role=role,
)

return Response(
{
"id": str(link.id),
"entity_type": link.entity_type,
"entity_id": str(link.entity_id),
"role": link.role,
"label": resolve_entity_label(entity_type, entity_id),
"detail": resolved,
},
status=status.HTTP_201_CREATED,
)

@allow_permission([ROLE.ADMIN, ROLE.MEMBER])
def partial_update(self, request, slug, project_id, issue_id, pk):
link = self.get_queryset().filter(id=pk).first()
if not link:
return Response(status=status.HTTP_404_NOT_FOUND)

serializer = CaseEntityLinkCreateSerializer(link, data=request.data, partial=True)
serializer.is_valid(raise_exception=True)
serializer.save()

return Response(
{
"id": str(link.id),
"entity_type": link.entity_type,
"entity_id": str(link.entity_id),
"role": link.role,
"label": resolve_entity_label(link.entity_type, link.entity_id),
"detail": resolve_entity(link.entity_type, link.entity_id),
},
status=status.HTTP_200_OK,
)

@allow_permission([ROLE.ADMIN, ROLE.MEMBER])
def destroy(self, request, slug, project_id, issue_id, pk):
link = self.get_queryset().filter(id=pk).first()
if not link:
return Response(status=status.HTTP_404_NOT_FOUND)

sub_issues = Issue.issue_objects.filter(parent_id=issue_id)
started_states = ["started", "completed"]
has_started_work = sub_issues.filter(
state__group__in=started_states
).exists()

if has_started_work:
return Response(
{"error": "Cannot remove entity link after work has started on sub-items"},
status=status.HTTP_400_BAD_REQUEST,
)

link.delete()
return Response(status=status.HTTP_204_NO_CONTENT)


class ResolveEntityEndpoint(BaseAPIView):
@allow_permission([ROLE.ADMIN, ROLE.MEMBER])
def get(self, request, slug, project_id):
entity_type = request.query_params.get("entity_type")
entity_id = request.query_params.get("entity_id")

if not entity_type or not entity_id:
return Response(
{"error": "entity_type and entity_id are required"},
status=status.HTTP_400_BAD_REQUEST,
)

resolved = resolve_entity(entity_type, entity_id)
if not resolved:
return Response(
{"error": f"{entity_type} {entity_id} not found"},
status=status.HTTP_404_NOT_FOUND,
)

return Response(
{
"entity_type": entity_type,
"entity_id": entity_id,
"label": resolve_entity_label(entity_type, entity_id),
"detail": resolved,
},
status=status.HTTP_200_OK,
)
26 changes: 26 additions & 0 deletions apps/api/plane/app/views/case/entity_search.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
from rest_framework import status
from rest_framework.response import Response

from plane.app.permissions import ROLE, allow_permission
from plane.app.views.base import BaseAPIView
from plane.utils.entity_search import search_policies, search_claims


class EntitySearchEndpoint(BaseAPIView):
@allow_permission([ROLE.ADMIN, ROLE.MEMBER])
def get(self, request, slug, project_id):
entity_type = request.query_params.get("type", "policy")
query = request.query_params.get("q", "").strip()
limit = min(int(request.query_params.get("limit", "10")), 20)

if len(query) < 2:
return Response({"results": []}, status=status.HTTP_200_OK)

if entity_type == "policy":
results = search_policies(query, limit)
elif entity_type == "claim":
results = search_claims(query, limit)
else:
return Response({"results": []}, status=status.HTTP_200_OK)

return Response({"results": results}, status=status.HTTP_200_OK)
8 changes: 8 additions & 0 deletions apps/api/plane/app/views/issue/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -269,6 +269,14 @@ def list(self, request, slug, project_id):
# Apply legacy filters
issue_queryset = issue_queryset.filter(**filters, **extra_filters)

# Filter by linked entity (CaseEntityLink)
entity_id = request.GET.get("entity_id")
if entity_id:
issue_queryset = issue_queryset.filter(
entity_links__entity_id=entity_id,
entity_links__deleted_at__isnull=True,
)

# Keeping a copy of the queryset before applying annotations
filtered_issue_queryset = copy.deepcopy(issue_queryset)

Expand Down
Loading