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