302 lines
11 KiB
Python
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
|