308 lines
12 KiB
Python
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)
|