from datetime import timedelta from django.conf import settings from django.db import models from django.utils import timezone from django.utils.translation import gettext_lazy as _ class EngagementStatus(models.TextChoices): ACTIVE = "active", _("Active") AT_RISK = "at_risk", _("At risk") INACTIVE = "inactive", _("Inactive") COMPLETED = "completed", _("Completed") class Enrollment(models.Model): student = models.ForeignKey( settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="enrollments", limit_choices_to={"role": "student"}, ) course = models.ForeignKey( "courses.Course", on_delete=models.CASCADE, related_name="enrollments", ) enrolled_at = models.DateTimeField(_("enrolled at"), auto_now_add=True) completed_at = models.DateTimeField(_("completed at"), null=True, blank=True) class Meta: verbose_name = _("enrollment") verbose_name_plural = _("enrollments") ordering = ["-enrolled_at"] constraints = [ models.UniqueConstraint(fields=["student", "course"], name="unique_enrollment_per_student_course"), ] indexes = [ models.Index(fields=["student", "-enrolled_at"]), models.Index(fields=["course", "-enrolled_at"]), ] def __str__(self): return f"{self.student_id} → {self.course_id}" @property def is_completed(self) -> bool: return self.completed_at is not None def total_lessons_count(self) -> int: from apps.courses.models import Lesson, LessonStatus return Lesson.objects.filter( section__course_id=self.course_id, status=LessonStatus.PUBLISHED ).count() def completed_lessons_count(self) -> int: return self.lesson_progress.filter(completed_at__isnull=False).count() def progress_percent(self) -> int: total = self.total_lessons_count() if total == 0: return 0 return int(round(self.completed_lessons_count() * 100 / total)) def maybe_mark_completed(self): total = self.total_lessons_count() if total > 0 and self.completed_lessons_count() >= total and self.completed_at is None: self.completed_at = timezone.now() self.save(update_fields=["completed_at"]) def maybe_unmark_completed(self): if self.completed_at is not None and self.completed_lessons_count() < self.total_lessons_count(): self.completed_at = None self.save(update_fields=["completed_at"]) def engagement_status(self) -> str: """Compute engagement bucket based on completion + student's last activity. Buckets: - completed → enrollment.completed_at is set - active → student active within last 7 days and enrollment isn't completed - at_risk → student inactive 7-14 days OR low progress (<20%) and inactive >7 days - inactive → student last active >14 days ago Falls back to `active` for brand-new enrollments where last_active_at is null. """ if self.completed_at is not None: return EngagementStatus.COMPLETED.value last = getattr(self.student, "last_active_at", None) if last is None: return EngagementStatus.ACTIVE.value delta = timezone.now() - last if delta < timedelta(days=7): return EngagementStatus.ACTIVE.value if delta < timedelta(days=14): return EngagementStatus.AT_RISK.value return EngagementStatus.INACTIVE.value # --------------------------------------------------------------------------- # Activity events — power the instructor's home activity feed and student # detail timelines. Recorded explicitly via `services.events.record_event`. class ActivityKind(models.TextChoices): ENROLLED = "enrolled", _("Enrolled") PAID = "paid", _("Order paid") LESSON_COMPLETED = "lesson_completed", _("Lesson completed") COURSE_COMPLETED = "course_completed", _("Course completed") ANNOUNCEMENT_POSTED = "announcement_posted", _("Announcement posted") class ActivityEvent(models.Model): course = models.ForeignKey( "courses.Course", on_delete=models.CASCADE, related_name="activity_events" ) actor = models.ForeignKey( settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, blank=True, related_name="activity_events", ) kind = models.CharField(_("kind"), max_length=32, choices=ActivityKind.choices) payload = models.JSONField(_("payload"), default=dict, blank=True) created_at = models.DateTimeField(_("created at"), auto_now_add=True) class Meta: verbose_name = _("activity event") verbose_name_plural = _("activity events") ordering = ["-created_at"] indexes = [ models.Index(fields=["course", "-created_at"]), models.Index(fields=["actor", "-created_at"]), models.Index(fields=["kind", "-created_at"]), ] def __str__(self): return f"{self.kind}@{self.course_id}#{self.pk}" class LessonProgress(models.Model): enrollment = models.ForeignKey( Enrollment, on_delete=models.CASCADE, related_name="lesson_progress" ) lesson = models.ForeignKey( "courses.Lesson", on_delete=models.CASCADE, related_name="progress_records" ) completed_at = models.DateTimeField(_("completed at"), null=True, blank=True) class Meta: verbose_name = _("lesson progress") verbose_name_plural = _("lesson progress") constraints = [ models.UniqueConstraint( fields=["enrollment", "lesson"], name="unique_progress_per_enrollment_lesson", ), ] indexes = [models.Index(fields=["enrollment", "lesson"])] def __str__(self): return f"progress<{self.enrollment_id}:{self.lesson_id}>"