"""Search endpoints.""" from __future__ import annotations import logging from drf_spectacular.utils import OpenApiParameter, extend_schema from rest_framework import status from rest_framework.permissions import IsAuthenticated from rest_framework.request import Request from rest_framework.response import Response from rest_framework.views import APIView from apps.core.permissions import IsAdminRole from apps.core.throttling import SearchRateThrottle from apps.search.backends import get_search_backend from apps.search.serializers import ( SearchRequestSerializer, SearchResponseSerializer, SuggestResponseSerializer, ) audit = logging.getLogger("audit.search") @extend_schema( tags=["search"], parameters=[SearchRequestSerializer], responses={200: SearchResponseSerializer}, summary="Keyword search with filters and facets", description=( "Ranked keyword search across name, job title, skills, employer, education, " "location and summary, combined with structured filters. Filter values are " "OR-ed inside one filter and AND-ed across filters." ), ) class SearchView(APIView): """ ``GET /api/v1/search/`` Thin by design: validate the query string, hand a normalised request to the configured backend, shape the response, emit an audit event. All ranking lives in the backend, all validation lives in the serializer. """ permission_classes = [IsAuthenticated] throttle_classes = [SearchRateThrottle] serializer_class = SearchRequestSerializer def get(self, request: Request) -> Response: serializer = SearchRequestSerializer(data=request.query_params) serializer.is_valid(raise_exception=True) search_request = serializer.to_search_request() backend = get_search_backend() result = backend.search(search_request) audit.info( "search_executed", extra={ "event": "search_executed", "user_id": request.user.id, "backend": result.backend, "result_count": result.total, "took_ms": result.took_ms, **search_request.as_log_context(), }, ) return Response(SearchResponseSerializer.from_result(result, search_request)) @extend_schema( tags=["search"], parameters=[ OpenApiParameter("q", str, description="Partial name or skill.", required=True), OpenApiParameter("limit", int, description="Maximum suggestions (1-20)."), ], responses={200: SuggestResponseSerializer}, summary="Type-ahead suggestions", ) class SuggestView(APIView): """Edge n-gram completions used by the search box.""" permission_classes = [IsAuthenticated] throttle_classes = [SearchRateThrottle] def get(self, request: Request) -> Response: prefix = request.query_params.get("q", "").strip()[:64] try: limit = min(max(int(request.query_params.get("limit", 8)), 1), 20) except ValueError: limit = 8 suggestions = get_search_backend().suggest(prefix, limit=limit) if prefix else [] return Response({"suggestions": suggestions}) @extend_schema( tags=["search"], request=None, responses={202: dict}, summary="Rebuild the search index (admin only)", ) class ReindexView(APIView): """ Triggers a zero-downtime rebuild of the Elasticsearch index. Restricted to the admin role: it is an expensive, cluster-wide operation. """ permission_classes = [IsAuthenticated, IsAdminRole] def post(self, request: Request) -> Response: from apps.search.indexing import rebuild_index result = rebuild_index() audit.info( "index_rebuilt", extra={"event": "index_rebuilt", "user_id": request.user.id, **result}, ) return Response(result, status=status.HTTP_202_ACCEPTED)