Ilo/apps/courses/api/views.py
2026-05-02 20:01:30 +03:30

302 lines
11 KiB
Python

from django.db import transaction
from drf_spectacular.utils import OpenApiResponse, extend_schema
from rest_framework import status, viewsets
from rest_framework.decorators import action
from rest_framework.exceptions import PermissionDenied, ValidationError
from rest_framework.generics import ListAPIView, RetrieveAPIView
from rest_framework.permissions import AllowAny, IsAuthenticated
from rest_framework.response import Response
from rest_framework.views import APIView
from django.db.models import Count, Q
from apps.courses.models import (
Category,
Course,
CourseFAQ,
CourseHighlight,
CourseStatus,
Lesson,
Section,
)
from .filters import PublicCourseFilter
from .permissions import IsApprovedTeacher, IsCourseOwner, IsTeacher
from .queries import annotate_course_stats
from .serializers import (
CategorySerializer,
CourseFAQSerializer,
CourseFAQWriteSerializer,
CourseHighlightSerializer,
CourseHighlightWriteSerializer,
CourseInstructorDetailSerializer,
CourseInstructorWriteSerializer,
CourseListSerializer,
CoursePublicDetailSerializer,
IDListSerializer,
LessonReadSerializer,
LessonWriteSerializer,
SectionReadSerializer,
SectionWriteSerializer,
)
# =============================================================================
# Public surface
# =============================================================================
@extend_schema(
tags=["categories"],
summary="List categories",
description="Includes a `course_count` of currently-published courses per category.",
)
class CategoryListView(ListAPIView):
permission_classes = [AllowAny]
pagination_class = None
serializer_class = CategorySerializer
def get_queryset(self):
return Category.objects.annotate(
course_count=Count(
"courses",
filter=Q(courses__status=CourseStatus.PUBLISHED),
distinct=True,
)
).order_by("order", "slug")
@extend_schema(
tags=["courses-public"],
summary="Browse published courses",
description=(
"Public catalog. Filters: `category` (slug), `level`, `instructor` (id), `language`. "
"Search: `?search=...` over title/tagline. "
"Ordering: `created_at`, `published_at`, `popularity` (paid orders in the last "
"`COURSE_BESTSELLER_WINDOW_DAYS`). Use `?ordering=-popularity` for the bestsellers feed."
),
)
class PublicCourseListView(ListAPIView):
permission_classes = [AllowAny]
serializer_class = CourseListSerializer
filterset_class = PublicCourseFilter
search_fields = ["title", "title_en", "title_fa", "tagline", "tagline_en", "tagline_fa"]
ordering_fields = ["created_at", "published_at", "popularity"]
ordering = ["-published_at"]
def get_queryset(self):
qs = (
Course.objects.filter(status=CourseStatus.PUBLISHED)
.select_related("instructor", "category")
)
return annotate_course_stats(qs)
@extend_schema(
tags=["courses-public"],
summary="Public course detail",
description=(
"Returns the full structure (sections + published lessons + highlights + FAQ) plus "
"an embedded `instructor` card (bio, headline, verified badge, years_experience, avatar) "
"and the same stats/badges as the list endpoint (`lesson_count`, `enrolled_count`, "
"`total_minutes_seconds`, `is_new`, `is_bestseller`, `is_coming_soon`). "
"Locked lesson content is null unless `is_preview=true`."
),
)
class PublicCourseDetailView(RetrieveAPIView):
permission_classes = [AllowAny]
serializer_class = CoursePublicDetailSerializer
lookup_field = "slug"
def get_queryset(self):
qs = (
Course.objects.filter(status=CourseStatus.PUBLISHED)
.select_related("instructor", "instructor__teacher_profile", "category")
.prefetch_related("highlights", "faqs", "sections__lessons")
)
return annotate_course_stats(qs)
# =============================================================================
# Instructor surface
# =============================================================================
class InstructorCourseViewSet(viewsets.ModelViewSet):
"""CRUD over the instructor's own courses, plus publish/unpublish actions."""
permission_classes = [IsAuthenticated, IsTeacher, IsCourseOwner]
queryset = Course.objects.all().select_related("instructor", "category")
lookup_field = "pk"
filterset_fields = ["status", "category", "is_coming_soon"]
search_fields = ["title", "title_en", "title_fa"]
ordering_fields = ["created_at", "updated_at", "published_at", "popularity"]
def get_queryset(self):
qs = super().get_queryset().filter(instructor=self.request.user)
return annotate_course_stats(qs)
def get_serializer_class(self):
if self.action in {"create", "update", "partial_update"}:
return CourseInstructorWriteSerializer
if self.action == "list":
return CourseListSerializer
return CourseInstructorDetailSerializer
def perform_create(self, serializer):
serializer.save(instructor=self.request.user)
@extend_schema(
tags=["courses-instructor"],
summary="Publish course",
description="Sets status=published. Requires the instructor's TeacherProfile to be approved.",
request=None,
responses={
200: CourseInstructorDetailSerializer,
403: OpenApiResponse(description="Teacher profile not approved or not owner."),
},
)
@action(detail=True, methods=["post"], permission_classes=[IsAuthenticated, IsApprovedTeacher, IsCourseOwner])
def publish(self, request, pk=None):
course = self.get_object()
course.publish()
return Response(CourseInstructorDetailSerializer(course, context={"request": request}).data)
@extend_schema(
tags=["courses-instructor"],
summary="Unpublish course (back to draft)",
request=None,
responses={200: CourseInstructorDetailSerializer},
)
@action(detail=True, methods=["post"])
def unpublish(self, request, pk=None):
course = self.get_object()
course.unpublish()
return Response(CourseInstructorDetailSerializer(course, context={"request": request}).data)
@extend_schema(
tags=["courses-instructor"],
summary="Reorder sections within a course",
description="Body: `{\"ids\": [section_id, ...]}` — array order = new order.",
request=IDListSerializer,
responses={204: OpenApiResponse(description="Reordered.")},
)
@action(detail=True, methods=["post"], url_path="sections/reorder")
def reorder_sections(self, request, pk=None):
course = self.get_object()
ser = IDListSerializer(data=request.data)
ser.is_valid(raise_exception=True)
ids = ser.validated_data["ids"]
sections = list(course.sections.filter(id__in=ids))
if {s.id for s in sections} != set(ids):
raise ValidationError({"ids": "Some sections do not belong to this course."})
with transaction.atomic():
for order, sid in enumerate(ids):
Section.objects.filter(id=sid, course=course).update(order=order)
return Response(status=status.HTTP_204_NO_CONTENT)
class InstructorSectionViewSet(viewsets.ModelViewSet):
permission_classes = [IsAuthenticated, IsTeacher, IsCourseOwner]
serializer_class = SectionWriteSerializer
queryset = Section.objects.all().select_related("course")
filterset_fields = ["course"]
ordering_fields = ["order"]
ordering = ["order"]
def get_queryset(self):
return super().get_queryset().filter(course__instructor=self.request.user)
def get_serializer_class(self):
if self.action in {"list", "retrieve"}:
return SectionReadSerializer
return SectionWriteSerializer
def perform_create(self, serializer):
course = serializer.validated_data["course"]
if course.instructor_id != self.request.user.id:
raise PermissionDenied("You don't own this course.")
serializer.save()
@extend_schema(
tags=["courses-instructor"],
summary="Reorder lessons within a section",
description="Body: `{\"ids\": [lesson_id, ...]}` — array order = new order.",
request=IDListSerializer,
responses={204: OpenApiResponse(description="Reordered.")},
)
@action(detail=True, methods=["post"], url_path="lessons/reorder")
def reorder_lessons(self, request, pk=None):
section = self.get_object()
ser = IDListSerializer(data=request.data)
ser.is_valid(raise_exception=True)
ids = ser.validated_data["ids"]
lessons = list(section.lessons.filter(id__in=ids))
if {l.id for l in lessons} != set(ids):
raise ValidationError({"ids": "Some lessons do not belong to this section."})
with transaction.atomic():
for order, lid in enumerate(ids):
Lesson.objects.filter(id=lid, section=section).update(order=order)
return Response(status=status.HTTP_204_NO_CONTENT)
class InstructorLessonViewSet(viewsets.ModelViewSet):
permission_classes = [IsAuthenticated, IsTeacher, IsCourseOwner]
serializer_class = LessonWriteSerializer
queryset = Lesson.objects.all().select_related("section__course")
filterset_fields = ["section", "section__course", "kind", "status", "is_preview"]
ordering_fields = ["order"]
ordering = ["order"]
def get_queryset(self):
return super().get_queryset().filter(section__course__instructor=self.request.user)
def get_serializer_class(self):
if self.action in {"list", "retrieve"}:
return LessonReadSerializer
return LessonWriteSerializer
def perform_create(self, serializer):
section = serializer.validated_data["section"]
if section.course.instructor_id != self.request.user.id:
raise PermissionDenied("You don't own this course.")
serializer.save()
class _CourseScopedViewSet(viewsets.ModelViewSet):
"""Shared logic for highlights + FAQs: filter to the requester's courses, enforce ownership on write."""
permission_classes = [IsAuthenticated, IsTeacher, IsCourseOwner]
filterset_fields = ["course"]
def get_queryset(self):
return super().get_queryset().filter(course__instructor=self.request.user)
def perform_create(self, serializer):
course = serializer.validated_data["course"]
if course.instructor_id != self.request.user.id:
raise PermissionDenied("You don't own this course.")
serializer.save()
@extend_schema(tags=["courses-instructor"])
class InstructorHighlightViewSet(_CourseScopedViewSet):
"""Bullet-list highlights shown on the public course detail."""
queryset = CourseHighlight.objects.all().select_related("course")
def get_serializer_class(self):
if self.action in {"create", "update", "partial_update"}:
return CourseHighlightWriteSerializer
return CourseHighlightSerializer
@extend_schema(tags=["courses-instructor"])
class InstructorFAQViewSet(_CourseScopedViewSet):
"""FAQ entries shown on the public course detail."""
queryset = CourseFAQ.objects.all().select_related("course")
def get_serializer_class(self):
if self.action in {"create", "update", "partial_update"}:
return CourseFAQWriteSerializer
return CourseFAQSerializer