280 lines
12 KiB
Python
280 lines
12 KiB
Python
# Python imports
|
|
import re
|
|
|
|
from django.conf import settings
|
|
|
|
# Django imports
|
|
from django.db.models import (
|
|
Q,
|
|
)
|
|
|
|
# drf-spectacular imports
|
|
from drf_spectacular.utils import (
|
|
OpenApiExample,
|
|
OpenApiRequest,
|
|
OpenApiResponse,
|
|
extend_schema,
|
|
)
|
|
|
|
# Third party imports
|
|
from rest_framework import status
|
|
from rest_framework.response import Response
|
|
|
|
# Module imports
|
|
from plane.api.serializers import (
|
|
WorkItemAdvancedSearchRequestSerializer,
|
|
WorkItemAdvancedSearchResponseSerializer,
|
|
)
|
|
from plane.db.models import (
|
|
Issue,
|
|
)
|
|
|
|
# Module imports
|
|
from plane.payment.flags.flag import FeatureFlag
|
|
from plane.payment.flags.flag_decorator import check_workspace_feature_flag
|
|
from plane.utils.filters import ComplexFilterBackend, IssueFilterSet
|
|
from plane.utils.openapi import (
|
|
INVALID_REQUEST_RESPONSE,
|
|
WORKSPACE_SLUG_PARAMETER,
|
|
)
|
|
|
|
from .base import BaseAPIView
|
|
|
|
if settings.OPENSEARCH_ENABLED:
|
|
from plane.ee.documents import IssueDocument
|
|
from plane.ee.utils.opensearch_helper import OpenSearchHelper
|
|
|
|
|
|
class WorkItemAdvancedSearchEndpoint(BaseAPIView):
|
|
"""Work Item Advanced Search Endpoint"""
|
|
|
|
filter_backends = (ComplexFilterBackend,)
|
|
filterset_class = IssueFilterSet
|
|
|
|
@extend_schema(
|
|
operation_id="work_item_advanced_search",
|
|
summary="Work Item Advanced Search",
|
|
description="Search for work items with advanced filters and search query.",
|
|
parameters=[
|
|
WORKSPACE_SLUG_PARAMETER,
|
|
],
|
|
request=OpenApiRequest(
|
|
request=WorkItemAdvancedSearchRequestSerializer,
|
|
examples=[
|
|
OpenApiExample(
|
|
name="Basic text search in project",
|
|
value={
|
|
"query": "login",
|
|
"project_id": "11111111-1111-1111-1111-111111111111",
|
|
"limit": 10,
|
|
},
|
|
),
|
|
OpenApiExample(
|
|
name="Workspace search by assignee and state",
|
|
value={
|
|
"filters": {
|
|
"and": [
|
|
{"assignee_id": "22222222-2222-2222-2222-222222222222"},
|
|
{"state_group__in": ["started", "unstarted"]},
|
|
{"priority__in": ["high", "urgent"]},
|
|
]
|
|
},
|
|
"workspace_search": True,
|
|
"limit": 20,
|
|
},
|
|
),
|
|
OpenApiExample(
|
|
name="Filter by date range and labels",
|
|
value={
|
|
"filters": {
|
|
"and": [
|
|
{"target_date__range": ["2025-01-01", "2025-01-31"]},
|
|
{
|
|
"label_id__in": [
|
|
"33333333-3333-3333-3333-333333333333",
|
|
"44444444-4444-4444-4444-444444444444",
|
|
]
|
|
},
|
|
{"module_id": "55555555-5555-5555-5555-555555555555"},
|
|
{"is_archived": False},
|
|
]
|
|
},
|
|
"project_id": "66666666-6666-6666-6666-666666666666",
|
|
"limit": 15,
|
|
},
|
|
),
|
|
OpenApiExample(
|
|
name="Nested and/or with not",
|
|
value={
|
|
"filters": {
|
|
"and": [
|
|
{
|
|
"or": [
|
|
{"assignee_id": "77777777-7777-7777-7777-777777777777"},
|
|
{"created_by_id": "88888888-8888-8888-8888-888888888888"},
|
|
]
|
|
},
|
|
{"not": {"state_group": "completed"}},
|
|
{"priority__in": ["high", "urgent"]},
|
|
]
|
|
},
|
|
"workspace_search": True,
|
|
"limit": 25,
|
|
},
|
|
),
|
|
OpenApiExample(
|
|
name="Deeply nested groups across cycle/module/date",
|
|
value={
|
|
"filters": {
|
|
"or": [
|
|
{
|
|
"and": [
|
|
{
|
|
"cycle_id__in": [
|
|
"99999999-9999-9999-9999-999999999999",
|
|
"aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa",
|
|
]
|
|
},
|
|
{"target_date__range": ["2025-02-01", "2025-02-28"]},
|
|
]
|
|
},
|
|
{
|
|
"and": [
|
|
{
|
|
"module_id__in": [
|
|
"bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb",
|
|
"cccccccc-cccc-cccc-cccc-cccccccccccc",
|
|
]
|
|
},
|
|
{
|
|
"state_id__in": [
|
|
"dddddddd-dddd-dddd-dddd-dddddddddddd",
|
|
"eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee",
|
|
]
|
|
},
|
|
{"is_archived": False},
|
|
]
|
|
},
|
|
]
|
|
},
|
|
"project_id": "ffffffff-ffff-ffff-ffff-ffffffffffff",
|
|
"limit": 30,
|
|
},
|
|
),
|
|
],
|
|
),
|
|
responses={
|
|
200: OpenApiResponse(
|
|
description="Work item advanced search results",
|
|
response=WorkItemAdvancedSearchResponseSerializer(many=True),
|
|
examples=[
|
|
OpenApiExample(
|
|
name="Work Item Advanced Search Results",
|
|
value=[
|
|
{
|
|
"id": "0f5e2c9a-1b2c-4d5e-8f01-23456789abcd",
|
|
"name": "Fix login redirect loop",
|
|
"sequence_id": 102,
|
|
"project_identifier": "WEB",
|
|
"project_id": "11111111-1111-1111-1111-111111111111",
|
|
"workspace_id": "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa",
|
|
"type_id": "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb",
|
|
"state_id": "cccccccc-cccc-cccc-cccc-cccccccccccc",
|
|
"priority": "high",
|
|
"target_date": "2025-01-15",
|
|
"start_date": "2025-01-10",
|
|
},
|
|
{
|
|
"id": "1a2b3c4d-5e6f-7081-92a3-b4c5d6e7f890",
|
|
"name": "Update billing email validation",
|
|
"sequence_id": 245,
|
|
"project_identifier": "API",
|
|
"project_id": "22222222-2222-2222-2222-222222222222",
|
|
"workspace_id": "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa",
|
|
"type_id": "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb",
|
|
"state_id": "dddddddd-dddd-dddd-dddd-dddddddddddd",
|
|
"priority": "medium",
|
|
"target_date": None,
|
|
"start_date": "2025-01-05",
|
|
},
|
|
],
|
|
)
|
|
],
|
|
),
|
|
400: INVALID_REQUEST_RESPONSE,
|
|
},
|
|
)
|
|
def post(self, request, slug):
|
|
"""Work Item Advanced Search"""
|
|
query = request.data.get("query", False)
|
|
filters = request.data.get("filters", False)
|
|
limit = request.data.get("limit", 25)
|
|
workspace_search = request.data.get("workspace_search", False)
|
|
project_id = request.data.get("project_id", False)
|
|
|
|
if not (query or filters):
|
|
return Response([], status=status.HTTP_200_OK)
|
|
|
|
q = Q()
|
|
|
|
if query:
|
|
opensearch_enabled = settings.OPENSEARCH_ENABLED and check_workspace_feature_flag(
|
|
FeatureFlag.ADVANCED_SEARCH, slug, self.request.user.id
|
|
)
|
|
if opensearch_enabled:
|
|
opensearch_filters = [
|
|
{"workspace_slug": slug},
|
|
{"active_project_member_user_ids": self.request.user.id},
|
|
{"project_is_archived": False},
|
|
]
|
|
if project_id:
|
|
opensearch_filters.append({"project_id": project_id})
|
|
fields_to_retrieve = ["id"]
|
|
boosts = {"name": 1.25, "description": 1.0}
|
|
search_fields = ["name", "description", "project_identifier", "pretty_sequence", "sequence_id"]
|
|
|
|
helper = OpenSearchHelper(
|
|
document_cls=IssueDocument,
|
|
filters=opensearch_filters,
|
|
query=query,
|
|
search_fields=search_fields,
|
|
source_fields=fields_to_retrieve,
|
|
page=1,
|
|
page_size=100,
|
|
boosts=boosts,
|
|
operator="and", # Use AND operator for stricter matching
|
|
)
|
|
issues = helper.execute()
|
|
q |= Q(**{"id__in": [issue["id"] for issue in issues]})
|
|
else:
|
|
# Build search query
|
|
fields = ["name", "sequence_id", "project__identifier"]
|
|
for field in fields:
|
|
if field == "sequence_id":
|
|
# Match whole integers only (exclude decimal numbers)
|
|
sequences = re.findall(r"\b\d+\b", query)
|
|
for sequence_id in sequences:
|
|
q |= Q(**{"sequence_id": sequence_id})
|
|
else:
|
|
q |= Q(**{f"{field}__icontains": query})
|
|
|
|
# Filter issues
|
|
issues = (
|
|
Issue.issue_and_epics_objects.filter(q).select_related("project").accessible_to(self.request.user.id, slug)
|
|
)
|
|
|
|
# Apply project filter if not searching across workspace
|
|
if (not workspace_search) and project_id:
|
|
issues = issues.filter(project_id=project_id)
|
|
|
|
if filters:
|
|
# Pass filters from request body explicitly to ComplexFilterBackend
|
|
backend = ComplexFilterBackend()
|
|
issues = backend.filter_queryset(request, issues, self, filter_data=filters)
|
|
|
|
issues = issues.distinct()[: int(limit)]
|
|
|
|
issue_results = WorkItemAdvancedSearchResponseSerializer(issues, many=True).data
|
|
|
|
return Response(issue_results, status=status.HTTP_200_OK)
|