"""Learner-side models: enrollment, per-lesson progress, spaced repetition review cards (SM-2) and bookmarks. Enrollments pin a :class:`~apps.content.models.CourseVersion` so that learners keep a stable course structure even while newer versions are reviewed and published. Migration of an enrollment to a newer version is an explicit API action (handled in a later milestone), never an implicit change. """ from __future__ import annotations import uuid from datetime import timedelta from decimal import Decimal from django.conf import settings from django.db import models, transaction from django.db.models import Q from django.utils import timezone from apps.core.models import TimeStampedModel class EnrollmentStatus(models.TextChoices): ACTIVE = "active", "Active" COMPLETED = "completed", "Completed" DROPPED = "dropped", "Dropped" class ProgressStatus(models.TextChoices): NOT_STARTED = "not_started", "Not started" IN_PROGRESS = "in_progress", "In progress" COMPLETED = "completed", "Completed" class Enrollment(TimeStampedModel): """A learner's membership in a course, pinned to a course version.""" id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) user = models.ForeignKey( settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="enrollments" ) course = models.ForeignKey( "content.Course", on_delete=models.CASCADE, related_name="enrollments" ) course_version = models.ForeignKey( "content.CourseVersion", on_delete=models.PROTECT, related_name="enrollments" ) status = models.CharField( max_length=16, choices=EnrollmentStatus.choices, default=EnrollmentStatus.ACTIVE ) progress_percent = models.DecimalField(max_digits=5, decimal_places=2, default=0) completed_at = models.DateTimeField(null=True, blank=True) last_activity_at = models.DateTimeField(null=True, blank=True) class Meta: ordering = ["-created_at"] indexes = [ models.Index(fields=["user", "status"], name="enrollment_user_status_idx"), models.Index(fields=["course", "status"], name="enrollment_course_status_idx"), ] constraints = [ models.UniqueConstraint( fields=["user", "course"], name="enrollment_unique_course" ), models.CheckConstraint( condition=Q(progress_percent__gte=0) & Q(progress_percent__lte=100), name="enrollment_progress_range", ), ] def __str__(self) -> str: return f"{self.user_id} in {self.course_id} ({self.status})" @transaction.atomic def recalculate_progress(self) -> None: """Recompute ``progress_percent`` from completed lessons. Marks the enrollment completed when 100% is reached. Called by the API layer whenever a Progress row transitions to ``completed``. """ from apps.content.models import Lesson total = Lesson.objects.filter( module__course_version=self.course_version ).count() if total == 0: percent = Decimal("0.00") else: done = self.lesson_progress.filter( status=ProgressStatus.COMPLETED ).count() percent = (Decimal(done) * 100 / Decimal(total)).quantize(Decimal("0.01")) now = timezone.now() self.progress_percent = percent self.last_activity_at = now update_fields = ["progress_percent", "last_activity_at", "updated_at"] if percent >= 100 and self.status == EnrollmentStatus.ACTIVE: self.status = EnrollmentStatus.COMPLETED self.completed_at = now update_fields += ["status", "completed_at"] self.save(update_fields=update_fields) class Progress(TimeStampedModel): """Per-lesson progress within an enrollment.""" id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) enrollment = models.ForeignKey( Enrollment, on_delete=models.CASCADE, related_name="lesson_progress" ) lesson = models.ForeignKey( "content.Lesson", on_delete=models.CASCADE, related_name="progress_records" ) status = models.CharField( max_length=16, choices=ProgressStatus.choices, default=ProgressStatus.NOT_STARTED ) started_at = models.DateTimeField(null=True, blank=True) completed_at = models.DateTimeField(null=True, blank=True) score = models.DecimalField(max_digits=5, decimal_places=2, null=True, blank=True) class Meta: ordering = ["-updated_at"] verbose_name_plural = "progress records" indexes = [ models.Index(fields=["enrollment", "status"], name="progress_enroll_status_idx"), ] constraints = [ models.UniqueConstraint( fields=["enrollment", "lesson"], name="progress_unique_lesson" ), ] def __str__(self) -> str: return f"{self.enrollment_id} / {self.lesson_id}: {self.status}" def mark_started(self) -> None: if self.status == ProgressStatus.NOT_STARTED: self.status = ProgressStatus.IN_PROGRESS self.started_at = timezone.now() self.save(update_fields=["status", "started_at", "updated_at"]) @transaction.atomic def mark_completed(self, *, score: Decimal | None = None) -> None: now = timezone.now() self.status = ProgressStatus.COMPLETED self.completed_at = now if self.started_at is None: self.started_at = now if score is not None: self.score = score self.save( update_fields=["status", "started_at", "completed_at", "score", "updated_at"] ) self.enrollment.recalculate_progress() class SpacedRepetitionCard(TimeStampedModel): """SM-2 review card scheduling a problem for periodic re-practice. One card per (user, problem); created when the learner first solves a problem. ``record_review`` implements the classic SuperMemo-2 algorithm with the standard 1.3 ease-factor floor. """ id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) user = models.ForeignKey( settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="review_cards" ) problem = models.ForeignKey( "content.Problem", on_delete=models.CASCADE, related_name="review_cards" ) ease_factor = models.DecimalField( max_digits=4, decimal_places=2, default=Decimal("2.50") ) interval_days = models.PositiveIntegerField(default=0) repetitions = models.PositiveIntegerField(default=0) due_at = models.DateTimeField(db_index=True, default=timezone.now) last_reviewed_at = models.DateTimeField(null=True, blank=True) last_quality = models.PositiveSmallIntegerField(null=True, blank=True) is_suspended = models.BooleanField(default=False) class Meta: ordering = ["due_at"] indexes = [ models.Index(fields=["user", "due_at"], name="src_user_due_idx"), ] constraints = [ models.UniqueConstraint( fields=["user", "problem"], name="src_unique_user_problem" ), models.CheckConstraint( condition=Q(ease_factor__gte=1.3), name="src_ease_factor_min" ), models.CheckConstraint( condition=Q(last_quality__isnull=True) | Q(last_quality__lte=5), name="src_quality_range", ), ] def __str__(self) -> str: return f"Card {self.user_id}/{self.problem_id} due {self.due_at:%Y-%m-%d}" @classmethod def due_for(cls, user, *, now=None): """Queryset of cards due for review, soonest first.""" now = now or timezone.now() return cls.objects.filter( user=user, is_suspended=False, due_at__lte=now ).order_by("due_at") def record_review(self, quality: int, *, now=None) -> None: """Apply an SM-2 review with ``quality`` in 0..5 and reschedule.""" if not isinstance(quality, int) or not 0 <= quality <= 5: raise ValueError("quality must be an integer between 0 and 5") now = now or timezone.now() if quality < 3: # Failed recall: restart the repetition sequence, review tomorrow. self.repetitions = 0 self.interval_days = 1 else: if self.repetitions == 0: self.interval_days = 1 elif self.repetitions == 1: self.interval_days = 6 else: self.interval_days = max( 1, round(self.interval_days * float(self.ease_factor)) ) self.repetitions += 1 new_ef = float(self.ease_factor) + ( 0.1 - (5 - quality) * (0.08 + (5 - quality) * 0.02) ) self.ease_factor = Decimal(str(round(max(1.3, new_ef), 2))) self.last_quality = quality self.last_reviewed_at = now self.due_at = now + timedelta(days=self.interval_days) self.save( update_fields=[ "ease_factor", "interval_days", "repetitions", "due_at", "last_reviewed_at", "last_quality", "updated_at", ] ) class Bookmark(TimeStampedModel): """A saved problem, course or lesson. Exactly one target FK is set, enforced by a database check constraint; per-target uniqueness is enforced with conditional unique constraints. """ id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) user = models.ForeignKey( settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="bookmarks" ) problem = models.ForeignKey( "content.Problem", null=True, blank=True, on_delete=models.CASCADE, related_name="bookmarks", ) course = models.ForeignKey( "content.Course", null=True, blank=True, on_delete=models.CASCADE, related_name="bookmarks", ) lesson = models.ForeignKey( "content.Lesson", null=True, blank=True, on_delete=models.CASCADE, related_name="bookmarks", ) note = models.CharField(max_length=500, blank=True, default="") class Meta: ordering = ["-created_at"] constraints = [ models.CheckConstraint( condition=( Q(problem__isnull=False, course__isnull=True, lesson__isnull=True) | Q(problem__isnull=True, course__isnull=False, lesson__isnull=True) | Q(problem__isnull=True, course__isnull=True, lesson__isnull=False) ), name="bookmark_single_target", ), models.UniqueConstraint( fields=["user", "problem"], condition=Q(problem__isnull=False), name="bookmark_unique_problem", ), models.UniqueConstraint( fields=["user", "course"], condition=Q(course__isnull=False), name="bookmark_unique_course", ), models.UniqueConstraint( fields=["user", "lesson"], condition=Q(lesson__isnull=False), name="bookmark_unique_lesson", ), ] def __str__(self) -> str: return f"Bookmark by {self.user_id} on {self.target_id}" @property def target(self): return self.problem or self.course or self.lesson @property def target_id(self): return self.problem_id or self.course_id or self.lesson_id @property def target_type(self) -> str: if self.problem_id: return "problem" if self.course_id: return "course" return "lesson"