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

308 lines
12 KiB
Python

from django.db import transaction
from django.shortcuts import get_object_or_404
from drf_spectacular.utils import OpenApiResponse, extend_schema
from rest_framework import status
from rest_framework.exceptions import PermissionDenied
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from rest_framework.views import APIView
from apps.courses.models import Course, CourseStatus, Lesson, LessonStatus
from apps.enrollments.models import ActivityKind, Enrollment, LessonProgress
from apps.enrollments.services.events import record_event
from .permissions import IsStudent
from .serializers import (
EnrolledCourseSummarySerializer,
EnrollResponseSerializer,
InstructorEnrollmentSerializer,
LessonProgressResponseSerializer,
StudentCourseDetailSerializer,
)
# --- helpers ---------------------------------------------------------------------
def _get_or_404_published(slug: str) -> Course:
return get_object_or_404(Course, slug=slug, status=CourseStatus.PUBLISHED)
def _next_and_last(enrollment: Enrollment, completed_lesson_ids: set):
"""Return (last_lesson, next_lesson) tuples (lesson_id, lesson_title) given a course's
published lessons in (section.order, lesson.order) sequence."""
lessons = list(
Lesson.objects.filter(
section__course_id=enrollment.course_id, status=LessonStatus.PUBLISHED
)
.select_related("section")
.order_by("section__order", "section__id", "order", "id")
)
last = next_l = None
for lesson in lessons:
if lesson.id in completed_lesson_ids:
last = lesson
elif next_l is None:
next_l = lesson
return last, next_l
# --- student-facing -------------------------------------------------------------
class EnrollView(APIView):
permission_classes = [IsAuthenticated, IsStudent]
@extend_schema(
tags=["enrollments"],
summary="Enroll in a published course (free only)",
description=(
"Free-course path. Creates an Enrollment idempotently. "
"If the course has a non-zero `price_toman`, returns 402 — caller must use "
"`POST /api/courses/<slug>/checkout/` instead."
),
request=None,
responses={
200: EnrollResponseSerializer,
201: EnrollResponseSerializer,
402: OpenApiResponse(description="Course is paid; use checkout."),
403: OpenApiResponse(description="Not a student."),
404: OpenApiResponse(description="Course not found or not published."),
},
)
def post(self, request, slug):
course = _get_or_404_published(slug)
if not course.is_free:
return Response(
{
"detail": "This course requires payment.",
"checkout_url": f"/api/courses/{course.slug}/checkout/",
"price_toman": course.price_toman,
},
status=status.HTTP_402_PAYMENT_REQUIRED,
)
enrollment, created = Enrollment.objects.get_or_create(
student=request.user, course=course
)
if created:
record_event(
course=course,
actor=request.user,
kind=ActivityKind.ENROLLED.value,
payload={"price_toman": 0, "via": "free"},
)
body = {
"enrolled": True,
"course_id": course.id,
"course_slug": course.slug,
"enrolled_at": enrollment.enrolled_at,
}
return Response(
EnrollResponseSerializer(body).data,
status=status.HTTP_201_CREATED if created else status.HTTP_200_OK,
)
class MyCoursesView(APIView):
permission_classes = [IsAuthenticated, IsStudent]
@extend_schema(
tags=["enrollments"],
summary="List my enrolled courses",
description="Returns each enrolled course with progress %, completed/total lessons, last/next lesson — the shape the student dashboard expects.",
responses={200: EnrolledCourseSummarySerializer(many=True)},
)
def get(self, request):
qs = (
Enrollment.objects
.filter(student=request.user)
.select_related("course", "course__instructor")
.order_by("-enrolled_at")
)
results = []
for e in qs:
completed_ids = set(
e.lesson_progress.filter(completed_at__isnull=False).values_list("lesson_id", flat=True)
)
last, nxt = _next_and_last(e, completed_ids)
results.append({
"id": e.course.id,
"slug": e.course.slug,
"title": e.course.title,
"tagline": e.course.tagline,
"color": e.course.color,
"cover_image_url": e.course.cover_image.url if e.course.cover_image else None,
"instructor_id": e.course.instructor_id,
"instructor_name": e.course.instructor.full_name,
"enrolled_at": e.enrolled_at,
"completed_at": e.completed_at,
"progress_percent": e.progress_percent(),
"completed_lessons": e.completed_lessons_count(),
"total_lessons": e.total_lessons_count(),
"last_lesson_id": last.id if last else None,
"last_lesson_title": last.title if last else None,
"next_lesson_id": nxt.id if nxt else None,
"next_lesson_title": nxt.title if nxt else None,
})
ser = EnrolledCourseSummarySerializer(results, many=True, context={"request": request})
return Response(ser.data)
class MyCourseDetailView(APIView):
permission_classes = [IsAuthenticated, IsStudent]
@extend_schema(
tags=["enrollments"],
summary="My course detail (full content unlocked)",
description="Returns the full structure of an enrolled course with all published lesson content (no preview gating). 403 if not enrolled, 404 if course is not published.",
responses={
200: StudentCourseDetailSerializer,
403: OpenApiResponse(description="Not enrolled."),
},
)
def get(self, request, slug):
course = _get_or_404_published(slug)
try:
enrollment = Enrollment.objects.get(student=request.user, course=course)
except Enrollment.DoesNotExist:
raise PermissionDenied("You are not enrolled in this course.")
completed_lesson_ids = set(
enrollment.lesson_progress.filter(completed_at__isnull=False).values_list(
"lesson_id", flat=True
)
)
payload = {
"id": course.id,
"slug": course.slug,
"title": course.title,
"tagline": course.tagline,
"description": course.description,
"level": course.level,
"language": course.language,
"color": course.color,
"instructor_id": course.instructor_id,
"instructor_name": course.instructor.full_name,
"category": course.category,
"highlights": list(course.highlights.all()),
"faqs": list(course.faqs.all()),
"course": course,
"enrollment": enrollment,
"completed_lesson_ids": completed_lesson_ids,
}
ser = StudentCourseDetailSerializer(payload, context={"request": request})
return Response(ser.data)
class _LessonProgressBase(APIView):
permission_classes = [IsAuthenticated, IsStudent]
def _get_enrollment_and_lesson(self, request, lesson_id):
lesson = get_object_or_404(Lesson, pk=lesson_id, status=LessonStatus.PUBLISHED)
try:
enrollment = Enrollment.objects.get(
student=request.user, course=lesson.section.course
)
except Enrollment.DoesNotExist:
raise PermissionDenied("You are not enrolled in this course.")
return enrollment, lesson
class LessonCompleteView(_LessonProgressBase):
@extend_schema(
tags=["enrollments"],
summary="Mark a lesson complete",
description="Marks the lesson complete for the current student. Idempotent. Auto-marks the enrollment as completed when all published lessons are done.",
request=None,
responses={200: LessonProgressResponseSerializer},
)
def post(self, request, lesson_id):
from django.utils import timezone
enrollment, lesson = self._get_enrollment_and_lesson(request, lesson_id)
course_just_completed = False
with transaction.atomic():
progress, _ = LessonProgress.objects.get_or_create(
enrollment=enrollment, lesson=lesson
)
newly_completed = progress.completed_at is None
if newly_completed:
progress.completed_at = timezone.now()
progress.save(update_fields=["completed_at"])
was_completed_before = enrollment.completed_at is not None
enrollment.maybe_mark_completed()
course_just_completed = (not was_completed_before) and enrollment.completed_at is not None
if newly_completed:
record_event(
course=enrollment.course,
actor=request.user,
kind=ActivityKind.LESSON_COMPLETED.value,
payload={"lesson_id": lesson.id, "lesson_title": lesson.title},
)
if course_just_completed:
record_event(
course=enrollment.course,
actor=request.user,
kind=ActivityKind.COURSE_COMPLETED.value,
payload={},
)
return Response(LessonProgressResponseSerializer(progress).data)
class LessonUncompleteView(_LessonProgressBase):
@extend_schema(
tags=["enrollments"],
summary="Un-mark a lesson complete",
description="Reverses a previous mark-complete. Also clears `enrollment.completed_at` if the course was previously fully completed.",
request=None,
responses={
200: LessonProgressResponseSerializer,
404: OpenApiResponse(description="No progress record exists for this lesson."),
},
)
def post(self, request, lesson_id):
enrollment, lesson = self._get_enrollment_and_lesson(request, lesson_id)
try:
progress = LessonProgress.objects.get(enrollment=enrollment, lesson=lesson)
except LessonProgress.DoesNotExist:
return Response({"detail": "No progress yet."}, status=status.HTTP_404_NOT_FOUND)
if progress.completed_at is not None:
progress.completed_at = None
progress.save(update_fields=["completed_at"])
enrollment.maybe_unmark_completed()
return Response(LessonProgressResponseSerializer(progress).data)
# --- instructor surface ---------------------------------------------------------
class CourseEnrollmentsView(APIView):
"""Instructor view of the students enrolled in one of their courses."""
permission_classes = [IsAuthenticated]
@extend_schema(
tags=["enrollments"],
summary="List students enrolled in my course",
description=(
"Instructor-only. Owner of the course only. "
"Filter `?engagement_status=active|at_risk|inactive|completed` to scope the CRM table."
),
responses={
200: InstructorEnrollmentSerializer(many=True),
403: OpenApiResponse(description="Not the course owner."),
},
)
def get(self, request, course_id):
course = get_object_or_404(Course, pk=course_id)
if course.instructor_id != request.user.id:
raise PermissionDenied("You don't own this course.")
qs = (
Enrollment.objects
.filter(course=course)
.select_related("student")
.order_by("-enrolled_at")
)
engagement_filter = request.query_params.get("engagement_status")
rows = InstructorEnrollmentSerializer(qs, many=True).data
if engagement_filter:
rows = [r for r in rows if r["engagement_status"] == engagement_filter]
return Response(rows)