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

162 lines
5.9 KiB
Python

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}>"