"""Unified `/api/search/` across courses + instructors (public). If the requester is an authenticated student, also returns matching lessons in courses they're enrolled in. Designed for the topbar's command palette / search box. """ from django.db.models import Q from drf_spectacular.utils import OpenApiParameter, extend_schema, inline_serializer from rest_framework import serializers from rest_framework.permissions import AllowAny from rest_framework.response import Response from rest_framework.views import APIView from apps.accounts.api.public import ( InstructorListItemSerializer, _annotate_instructor_stats, _instructor_qs, ) from apps.accounts.models import Role from apps.courses.api.queries import annotate_course_stats from apps.courses.api.serializers import CourseListSerializer from apps.courses.models import Course, CourseStatus, Lesson, LessonStatus from apps.enrollments.models import Enrollment class _LessonResultSerializer(serializers.ModelSerializer): course_id = serializers.IntegerField(source="section.course.id", read_only=True) course_slug = serializers.CharField(source="section.course.slug", read_only=True) course_title = serializers.CharField(source="section.course.title", read_only=True) class Meta: model = Lesson fields = ["id", "title", "kind", "course_id", "course_slug", "course_title"] class GlobalSearchView(APIView): """`GET /api/search/?q=...&types=courses,instructors,lessons&limit=5`.""" permission_classes = [AllowAny] @extend_schema( tags=["search"], summary="Global search across courses + instructors (+ lessons for enrolled students)", description=( "Returns mixed results grouped by entity. `?types=` is a comma-separated whitelist; " "default is `courses,instructors`. Lessons are only searched for authenticated students " "and only within courses they're enrolled in. `?limit=` caps each group (default 5, max 20)." ), parameters=[ OpenApiParameter(name="q", required=True, type=str, location=OpenApiParameter.QUERY), OpenApiParameter(name="types", required=False, type=str, location=OpenApiParameter.QUERY), OpenApiParameter(name="limit", required=False, type=int, location=OpenApiParameter.QUERY), ], ) def get(self, request): q = (request.query_params.get("q") or "").strip() if not q: return Response({"detail": "missing `q`"}, status=400) try: limit = max(1, min(int(request.query_params.get("limit") or 5), 20)) except ValueError: limit = 5 types_raw = request.query_params.get("types") or "courses,instructors" wanted = {t.strip() for t in types_raw.split(",") if t.strip()} out: dict = {"q": q} if "courses" in wanted: cqs = ( Course.objects.filter(status=CourseStatus.PUBLISHED) .filter( Q(title__icontains=q) | Q(title_en__icontains=q) | Q(title_fa__icontains=q) | Q(tagline__icontains=q) ) .select_related("instructor", "category") ) cqs = annotate_course_stats(cqs)[:limit] out["courses"] = CourseListSerializer(cqs, many=True, context={"request": request}).data if "instructors" in wanted: iqs = ( _annotate_instructor_stats(_instructor_qs()) .filter(full_name__icontains=q) )[:limit] out["instructors"] = InstructorListItemSerializer( iqs, many=True, context={"request": request} ).data if ( "lessons" in wanted and request.user.is_authenticated and getattr(request.user, "role", None) == Role.STUDENT ): enrolled_course_ids = list( Enrollment.objects.filter(student=request.user).values_list("course_id", flat=True) ) lqs = ( Lesson.objects.filter( section__course_id__in=enrolled_course_ids, status=LessonStatus.PUBLISHED, ) .filter( Q(title__icontains=q) | Q(title_en__icontains=q) | Q(title_fa__icontains=q) ) .select_related("section__course") )[:limit] out["lessons"] = _LessonResultSerializer(lqs, many=True).data return Response(out)