Ilo/apps/core/search.py
2026-05-02 20:01:30 +03:30

112 lines
4.5 KiB
Python

"""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)