Files
training-software/tracking/models.py
Paperclip CTO 686acf259a feat(TRA-246): implement audit logging and compliance controls (M5)
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>
2026-05-07 09:11:23 +02:00

148 lines
5.6 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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})"