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