Delivers the append-only audit trail system for all compliance-critical actions per the TRA-246 acceptance criteria. - tracking/models.py: AuditEvent model with ORM-level immutability guard (save raises on update, delete raises on direct call) - tracking/audit.py: single record() call point; never raises in production - tracking/admin.py: read-only Django admin for AuditEvent inspection - tracking/migrations/0001_initial.py: DB schema with composite indexes - tracking/serializers.py: PII metadata gating (oidc_sub stripped for non-admin callers) - tracking/views.py: read-only AuditEventViewSet (IsPrivileged + 60/min throttle) - tracking/urls.py: registers audit/events/ router - tracking/management/commands/prune_audit_log.py: retention enforcement command with --dry-run and --class filter; writes access.admin_action event on real prune runs - config/settings/base.py: AUDIT_RETENTION_DAYS per event class + audit throttle rate - api/exceptions.py: wires access.permission_denied audit event on every PermissionDenied exception (M1 integration point) - tests/test_audit.py: 26-event taxonomy coverage, immutability, retention, API permission, PII gating, and service helper unit tests Co-Authored-By: Paperclip <noreply@paperclip.ing>
148 lines
5.6 KiB
Python
148 lines
5.6 KiB
Python
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})"
|