# Copyright (c) 2023-present Plane Software, Inc. and contributors # SPDX-License-Identifier: AGPL-3.0-only # See the LICENSE file for details. # Python imports import re # Django imports from django.db import models from django.db.models import ( Q, OuterRef, Subquery, Value, UUIDField, CharField, When, Case, ) from django.contrib.postgres.aggregates import ArrayAgg from django.contrib.postgres.fields import ArrayField from django.db.models.functions import Coalesce, Concat from django.utils import timezone # Third party imports from rest_framework import status from rest_framework.response import Response # Module imports from plane.app.views.base import BaseAPIView from plane.db.models import ( Workspace, Project, Issue, Cycle, Module, Page, IssueView, ProjectMember, ProjectPage, WorkspaceMember, ) class GlobalSearchEndpoint(BaseAPIView): """Endpoint to search across multiple fields in the workspace and also show related workspace if found """ def filter_workspaces(self, query, _slug, _project_id, _workspace_search): fields = ["name"] q = Q() if query: for field in fields: q |= Q(**{f"{field}__icontains": query}) return ( Workspace.objects.filter(q, workspace_member__member=self.request.user) .order_by("-created_at") .distinct() .values("name", "id", "slug") ) def filter_projects(self, query, slug, _project_id, _workspace_search): fields = ["name", "identifier"] q = Q() if query: for field in fields: q |= Q(**{f"{field}__icontains": query}) return ( Project.objects.filter( q, project_projectmember__member=self.request.user, project_projectmember__is_active=True, archived_at__isnull=True, workspace__slug=slug, ) .order_by("-created_at") .distinct() .values("name", "id", "identifier", "workspace__slug") ) def filter_issues(self, query, slug, project_id, workspace_search): fields = ["name", "sequence_id", "project__identifier"] q = Q() if query: 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}) issues = Issue.issue_objects.filter( q, project__project_projectmember__member=self.request.user, project__project_projectmember__is_active=True, project__archived_at__isnull=True, workspace__slug=slug, ) if workspace_search == "false" and project_id: issues = issues.filter(project_id=project_id) return issues.distinct().values( "name", "id", "sequence_id", "project__identifier", "project_id", "workspace__slug", )[:100] def filter_cycles(self, query, slug, project_id, workspace_search): fields = ["name"] q = Q() if query: for field in fields: q |= Q(**{f"{field}__icontains": query}) cycles = Cycle.objects.filter( q, project__project_projectmember__member=self.request.user, project__project_projectmember__is_active=True, project__archived_at__isnull=True, workspace__slug=slug, ) if workspace_search == "false" and project_id: cycles = cycles.filter(project_id=project_id) return ( cycles.order_by("-created_at") .distinct() .values("name", "id", "project_id", "project__identifier", "workspace__slug") ) def filter_modules(self, query, slug, project_id, workspace_search): fields = ["name"] q = Q() if query: for field in fields: q |= Q(**{f"{field}__icontains": query}) modules = Module.objects.filter( q, project__project_projectmember__member=self.request.user, project__project_projectmember__is_active=True, project__archived_at__isnull=True, workspace__slug=slug, ) if workspace_search == "false" and project_id: modules = modules.filter(project_id=project_id) return ( modules.order_by("-created_at") .distinct() .values("name", "id", "project_id", "project__identifier", "workspace__slug") ) def filter_pages(self, query, slug, project_id, workspace_search): fields = ["name"] q = Q() if query: for field in fields: q |= Q(**{f"{field}__icontains": query}) pages = ( Page.objects.filter( q, projects__project_projectmember__member=self.request.user, projects__project_projectmember__is_active=True, projects__archived_at__isnull=True, workspace__slug=slug, ) .annotate( project_ids=Coalesce( ArrayAgg("projects__id", distinct=True, filter=~Q(projects__id=True)), Value([], output_field=ArrayField(UUIDField())), ) ) .annotate( project_identifiers=Coalesce( ArrayAgg( "projects__identifier", distinct=True, filter=~Q(projects__id=True), ), Value([], output_field=ArrayField(CharField())), ) ) ) if workspace_search == "false" and project_id: project_subquery = ProjectPage.objects.filter(page_id=OuterRef("id"), project_id=project_id).values_list( "project_id", flat=True )[:1] pages = pages.annotate(project_id=Subquery(project_subquery)).filter(project_id=project_id) return ( pages.order_by("-created_at") .distinct() .values("name", "id", "project_ids", "project_identifiers", "workspace__slug") ) def filter_views(self, query, slug, project_id, workspace_search): fields = ["name"] q = Q() if query: for field in fields: q |= Q(**{f"{field}__icontains": query}) issue_views = IssueView.objects.filter( q, project__project_projectmember__member=self.request.user, project__project_projectmember__is_active=True, project__archived_at__isnull=True, workspace__slug=slug, ) if workspace_search == "false" and project_id: issue_views = issue_views.filter(project_id=project_id) return ( issue_views.order_by("-created_at") .distinct() .values("name", "id", "project_id", "project__identifier", "workspace__slug") ) def filter_intakes(self, query, slug, project_id, workspace_search): fields = ["name", "sequence_id", "project__identifier"] q = Q() if query: 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}) issues = Issue.objects.filter( q, project__project_projectmember__member=self.request.user, project__project_projectmember__is_active=True, project__archived_at__isnull=True, workspace__slug=slug, ).filter(models.Q(issue_intake__status=0) | models.Q(issue_intake__status=-2)) if workspace_search == "false" and project_id: issues = issues.filter(project_id=project_id) return ( issues.order_by("-created_at") .distinct() .values( "name", "id", "sequence_id", "project__identifier", "project_id", "workspace__slug", )[:100] ) def get(self, request, slug): query = request.query_params.get("search", False) entities_param = request.query_params.get("entities") workspace_search = request.query_params.get("workspace_search", "false") project_id = request.query_params.get("project_id", False) MODELS_MAPPER = { "workspace": self.filter_workspaces, "project": self.filter_projects, "issue": self.filter_issues, "cycle": self.filter_cycles, "module": self.filter_modules, "issue_view": self.filter_views, "page": self.filter_pages, "intake": self.filter_intakes, } # Determine which entities to search if entities_param: requested_entities = [e.strip() for e in entities_param.split(",") if e.strip()] requested_entities = [e for e in requested_entities if e in MODELS_MAPPER] else: requested_entities = list(MODELS_MAPPER.keys()) results = {} for entity in requested_entities: func = MODELS_MAPPER.get(entity) if func: results[entity] = func(query or None, slug, project_id, workspace_search) return Response({"results": results}, status=status.HTTP_200_OK) class SearchEndpoint(BaseAPIView): def get(self, request, slug): query = request.query_params.get("query", False) query_types = request.query_params.get("query_type", "user_mention").split(",") query_types = [qt.strip() for qt in query_types] count = int(request.query_params.get("count", 5)) project_id = request.query_params.get("project_id", None) response_data = {} if project_id: for query_type in query_types: if query_type == "user_mention": fields = [ "member__first_name", "member__last_name", "member__display_name", ] q = Q() if query: for field in fields: q |= Q(**{f"{field}__icontains": query}) users = ( ProjectMember.objects.filter( q, is_active=True, workspace__slug=slug, member__is_bot=False, project_id=project_id, ) .annotate( member__avatar_url=Case( When( member__avatar_asset__isnull=False, then=Concat( Value("/api/assets/v2/static/"), "member__avatar_asset", Value("/"), ), ), When( member__avatar_asset__isnull=True, then="member__avatar", ), default=Value(None), output_field=CharField(), ) ) .order_by("-created_at") ) users = users.distinct().values( "member__avatar_url", "member__display_name", "member__id", ) response_data["user_mention"] = list(users[:count]) elif query_type == "project": fields = ["name", "identifier"] q = Q() if query: for field in fields: q |= Q(**{f"{field}__icontains": query}) projects = ( Project.objects.filter( q, Q(project_projectmember__member=self.request.user) | Q(network=2), workspace__slug=slug, ) .order_by("-created_at") .distinct() .values("name", "id", "identifier", "logo_props", "workspace__slug")[:count] ) response_data["project"] = list(projects) elif query_type == "issue": fields = ["name", "sequence_id", "project__identifier"] q = Q() if query: for field in fields: if field == "sequence_id": 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}) issues = ( Issue.issue_objects.filter( q, project__project_projectmember__member=self.request.user, project__project_projectmember__is_active=True, workspace__slug=slug, project_id=project_id, ) .order_by("-created_at") .distinct() .values( "name", "id", "sequence_id", "project__identifier", "project_id", "priority", "state_id", "type_id", )[:count] ) response_data["issue"] = list(issues) elif query_type == "cycle": fields = ["name"] q = Q() if query: for field in fields: q |= Q(**{f"{field}__icontains": query}) cycles = ( Cycle.objects.filter( q, project__project_projectmember__member=self.request.user, project__project_projectmember__is_active=True, workspace__slug=slug, project_id=project_id, ) .annotate( status=Case( When( Q(start_date__lte=timezone.now()) & Q(end_date__gte=timezone.now()), then=Value("CURRENT"), ), When( start_date__gt=timezone.now(), then=Value("UPCOMING"), ), When(end_date__lt=timezone.now(), then=Value("COMPLETED")), When( Q(start_date__isnull=True) & Q(end_date__isnull=True), then=Value("DRAFT"), ), default=Value("DRAFT"), output_field=CharField(), ) ) .order_by("-created_at") .distinct() .values( "name", "id", "project_id", "project__identifier", "status", "workspace__slug", )[:count] ) response_data["cycle"] = list(cycles) elif query_type == "module": fields = ["name"] q = Q() if query: for field in fields: q |= Q(**{f"{field}__icontains": query}) modules = ( Module.objects.filter( q, project__project_projectmember__member=self.request.user, project__project_projectmember__is_active=True, workspace__slug=slug, project_id=project_id, ) .order_by("-created_at") .distinct() .values( "name", "id", "project_id", "project__identifier", "status", "workspace__slug", )[:count] ) response_data["module"] = list(modules) elif query_type == "page": fields = ["name"] q = Q() if query: for field in fields: q |= Q(**{f"{field}__icontains": query}) pages = ( Page.objects.filter( q, projects__project_projectmember__member=self.request.user, projects__project_projectmember__is_active=True, projects__id=project_id, workspace__slug=slug, access=0, ) .order_by("-created_at") .distinct() .values( "name", "id", "logo_props", "projects__id", "workspace__slug", )[:count] ) response_data["page"] = list(pages) return Response(response_data, status=status.HTTP_200_OK) else: for query_type in query_types: if query_type == "user_mention": fields = [ "member__first_name", "member__last_name", "member__display_name", ] q = Q() if query: for field in fields: q |= Q(**{f"{field}__icontains": query}) users = ( WorkspaceMember.objects.filter( q, is_active=True, workspace__slug=slug, member__is_bot=False, ) .annotate( member__avatar_url=Case( When( member__avatar_asset__isnull=False, then=Concat( Value("/api/assets/v2/static/"), "member__avatar_asset", Value("/"), ), ), When( member__avatar_asset__isnull=True, then="member__avatar", ), default=Value(None), output_field=models.CharField(), ) ) .order_by("-created_at") .values("member__avatar_url", "member__display_name", "member__id")[:count] ) response_data["user_mention"] = list(users) elif query_type == "project": fields = ["name", "identifier"] q = Q() if query: for field in fields: q |= Q(**{f"{field}__icontains": query}) projects = ( Project.objects.filter( q, Q(project_projectmember__member=self.request.user) | Q(network=2), workspace__slug=slug, ) .order_by("-created_at") .distinct() .values("name", "id", "identifier", "logo_props", "workspace__slug")[:count] ) response_data["project"] = list(projects) elif query_type == "issue": fields = ["name", "sequence_id", "project__identifier"] q = Q() if query: for field in fields: if field == "sequence_id": 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}) issues = ( Issue.issue_objects.filter( q, project__project_projectmember__member=self.request.user, project__project_projectmember__is_active=True, workspace__slug=slug, ) .order_by("-created_at") .distinct() .values( "name", "id", "sequence_id", "project__identifier", "project_id", "priority", "state_id", "type_id", )[:count] ) response_data["issue"] = list(issues) elif query_type == "cycle": fields = ["name"] q = Q() if query: for field in fields: q |= Q(**{f"{field}__icontains": query}) cycles = ( Cycle.objects.filter( q, project__project_projectmember__member=self.request.user, project__project_projectmember__is_active=True, workspace__slug=slug, ) .annotate( status=Case( When( Q(start_date__lte=timezone.now()) & Q(end_date__gte=timezone.now()), then=Value("CURRENT"), ), When( start_date__gt=timezone.now(), then=Value("UPCOMING"), ), When(end_date__lt=timezone.now(), then=Value("COMPLETED")), When( Q(start_date__isnull=True) & Q(end_date__isnull=True), then=Value("DRAFT"), ), default=Value("DRAFT"), output_field=CharField(), ) ) .order_by("-created_at") .distinct() .values( "name", "id", "project_id", "project__identifier", "status", "workspace__slug", )[:count] ) response_data["cycle"] = list(cycles) elif query_type == "module": fields = ["name"] q = Q() if query: for field in fields: q |= Q(**{f"{field}__icontains": query}) modules = ( Module.objects.filter( q, project__project_projectmember__member=self.request.user, project__project_projectmember__is_active=True, workspace__slug=slug, ) .order_by("-created_at") .distinct() .values( "name", "id", "project_id", "project__identifier", "status", "workspace__slug", )[:count] ) response_data["module"] = list(modules) elif query_type == "page": fields = ["name"] q = Q() if query: for field in fields: q |= Q(**{f"{field}__icontains": query}) pages = ( Page.objects.filter( q, projects__project_projectmember__member=self.request.user, projects__project_projectmember__is_active=True, workspace__slug=slug, access=0, is_global=True, ) .order_by("-created_at") .distinct() .values( "name", "id", "logo_props", "projects__id", "workspace__slug", )[:count] ) response_data["page"] = list(pages) return Response(response_data, status=status.HTTP_200_OK)