import uuid from django.contrib.auth import get_user_model from django.db import models, IntegrityError from courses.models import Course, Page User = get_user_model() # Gap threshold (seconds) below which two consecutive events are merged. RECONNECT_TOLERANCE_SECONDS = 30 # Anti-idle cap: a merged dwell window longer than this is capped before counting. MAX_VALID_EVENT_SECONDS = 120 class AuditEventClass(models.TextChoices): AUTH = "auth", "Authentication" PROGRESS = "progress", "Progression" ASSESSMENT = "assessment", "Assessment" TRAINER = "trainer", "Trainer Decision" CERTIFICATE = "certificate", "Certificate" ACCESS = "access", "Access Control" _CLASS_PREFIX_MAP = {c.value: c.value for c in AuditEventClass} class AuditEvent(models.Model): """Append-only compliance audit record. Never update or delete rows directly.""" id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) occurred_at = models.DateTimeField(db_index=True) recorded_at = models.DateTimeField(auto_now_add=True) event_class = models.CharField(max_length=32, choices=AuditEventClass.choices, db_index=True) event_type = models.CharField(max_length=64, db_index=True) actor_id = models.UUIDField(null=True, blank=True, db_index=True) actor_type = models.CharField(max_length=32, default="user") actor_ip = models.GenericIPAddressField(null=True, blank=True) object_type = models.CharField(max_length=64, null=True, blank=True) object_id = models.CharField(max_length=128, null=True, blank=True, db_index=True) metadata = models.JSONField(default=dict) retention_class = models.CharField(max_length=32, db_index=True) class Meta: db_table = "tracking_audit_event" indexes = [ models.Index(fields=["event_class", "occurred_at"]), models.Index(fields=["actor_id", "occurred_at"]), models.Index(fields=["object_type", "object_id"]), ] def save(self, *args, **kwargs): if self.pk and AuditEvent.objects.filter(pk=self.pk).exists(): raise IntegrityError("AuditEvent records are immutable") super().save(*args, **kwargs) def delete(self, *args, **kwargs): raise IntegrityError("AuditEvent records cannot be deleted via ORM") def __str__(self): return f"{self.event_type} @ {self.occurred_at}" # ── Dwell-time tracking ─────────────────────────────────────────────────────── class Enrollment(models.Model): id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) user = models.ForeignKey(User, on_delete=models.CASCADE, related_name="enrollments") course = models.ForeignKey(Course, on_delete=models.CASCADE, related_name="enrollments") org_id = models.UUIDField(db_index=True) enrolled_at = models.DateTimeField(auto_now_add=True) completed_at = models.DateTimeField(null=True, blank=True) class Meta: constraints = [ models.UniqueConstraint( fields=["user", "course"], name="unique_user_course_enrollment" ) ] ordering = ["-enrolled_at"] def __str__(self): return f"{self.user_id} → {self.course_id}" class PageProgress(models.Model): """Accumulated dwell-time and completion state for a single (enrollment, page) pair.""" id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) enrollment = models.ForeignKey( Enrollment, on_delete=models.CASCADE, related_name="page_progresses" ) page = models.ForeignKey(Page, on_delete=models.CASCADE, related_name="progresses") accumulated_seconds = models.PositiveIntegerField(default=0) is_complete = models.BooleanField(default=False) last_seen_at = models.DateTimeField(null=True, blank=True) created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) class Meta: constraints = [ models.UniqueConstraint( fields=["enrollment", "page"], name="unique_enrollment_page_progress" ) ] ordering = ["-updated_at"] @property def can_advance(self) -> bool: """True when accumulated dwell satisfies the page's required_seconds threshold.""" return self.accumulated_seconds >= self.page.required_seconds def __str__(self): return f"progress({self.enrollment_id}/{self.page_id} {self.accumulated_seconds}s)" class DwellEvent(models.Model): """ Raw contiguous window during which the learner was active on a page. The service layer coalesces events within RECONNECT_TOLERANCE_SECONDS and applies MAX_VALID_EVENT_SECONDS anti-idle capping before crediting PageProgress.accumulated_seconds. """ id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) page_progress = models.ForeignKey( PageProgress, on_delete=models.CASCADE, related_name="dwell_events" ) started_at = models.DateTimeField() ended_at = models.DateTimeField(null=True, blank=True) raw_seconds = models.PositiveIntegerField(default=0) # Set False when the event is determined to be an idle/bot artifact. is_valid = models.BooleanField(default=True) created_at = models.DateTimeField(auto_now_add=True) class Meta: ordering = ["started_at"] def __str__(self): return f"dwell({self.page_progress_id} {self.started_at}–{self.ended_at})"