162 lines
5.9 KiB
Python
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}>"
|