Files
training-software/tracking/services.py
Paperclip CTO cfa9ad6f53 feat(TRA-236,TRA-238): M2 course domain model and dwell-time tracking
TRA-236 — Course domain model:
- courses/models.py: Course → Module → Lesson → Page hierarchy with UUID
  PKs, ordering fields, required_seconds navigation gate, version tracking,
  and UniqueConstraint per (parent, order) pair
- courses/migrations/0001_initial.py: initial migration (applies cleanly on
  a fresh DB)
- tests/test_course_domain.py: migration smoke, relation integrity, cascade
  delete, ordering, and uniqueness-constraint tests

TRA-238 — Dwell-time tracking:
- tracking/models.py: Enrollment, PageProgress (can_advance property), and
  DwellEvent models appended alongside existing AuditEvent
- tracking/services.py: record_dwell_event, compute_eligible_seconds (pure),
  check_navigation_gate, _recompute_accumulated — reconnect merging within
  RECONNECT_TOLERANCE_SECONDS and anti-idle cap at MAX_VALID_EVENT_SECONDS
- tracking/migrations/0001_initial.py: updated to include all four models
  (AuditEvent, Enrollment, PageProgress, DwellEvent) with FK dependencies
  on courses.Course and courses.Page
- tests/test_dwell_tracking.py: event replay, reconnect tolerance, anti-idle
  cap, gate pass/block, and can_advance DB integration tests

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-07 09:11:23 +02:00

112 lines
3.6 KiB
Python

"""
Dwell-time accumulation service (TRA-238).
Public surface:
record_dwell_event(page_progress, started_at, ended_at) → DwellEvent
compute_eligible_seconds(events) → int (pure, usable in tests)
check_navigation_gate(page_progress) → bool
Algorithm:
1. Events are ordered by started_at.
2. Adjacent events with a gap ≤ RECONNECT_TOLERANCE_SECONDS are merged into
one window — brief disconnects do not interrupt progress.
3. Each merged window is capped at MAX_VALID_EVENT_SECONDS (anti-idle).
4. The sum of capped windows is the eligible credit.
"""
from __future__ import annotations
from datetime import datetime
from typing import Iterable
from django.db import transaction
from django.utils import timezone
from .models import DwellEvent, PageProgress, MAX_VALID_EVENT_SECONDS, RECONNECT_TOLERANCE_SECONDS
def record_dwell_event(
page_progress: PageProgress,
started_at: datetime,
ended_at: datetime,
) -> DwellEvent:
"""
Persist a raw dwell window and recompute PageProgress.accumulated_seconds.
Negative or zero-length windows are stored with is_valid=False and do not
contribute to accumulated credit.
"""
raw_seconds = max(0, int((ended_at - started_at).total_seconds()))
is_valid = raw_seconds > 0
event = DwellEvent.objects.create(
page_progress=page_progress,
started_at=started_at,
ended_at=ended_at,
raw_seconds=raw_seconds,
is_valid=is_valid,
)
_recompute_accumulated(page_progress)
return event
def compute_eligible_seconds(events: Iterable) -> int:
"""
Pure function: given an iterable of DwellEvent-like objects (must have
started_at, ended_at, is_valid), return the total eligible seconds after
applying reconnect merging and anti-idle capping.
Suitable for unit testing without a database.
"""
valid = [e for e in events if e.is_valid and e.ended_at is not None]
valid.sort(key=lambda e: e.started_at)
if not valid:
return 0
merged: list[tuple[datetime, datetime]] = []
cur_start = valid[0].started_at
cur_end = valid[0].ended_at
for ev in valid[1:]:
gap = (ev.started_at - cur_end).total_seconds()
if gap <= RECONNECT_TOLERANCE_SECONDS:
cur_end = max(cur_end, ev.ended_at)
else:
merged.append((cur_start, cur_end))
cur_start = ev.started_at
cur_end = ev.ended_at
merged.append((cur_start, cur_end))
total = 0
for start, end in merged:
window = int((end - start).total_seconds())
total += min(window, MAX_VALID_EVENT_SECONDS)
return total
def check_navigation_gate(page_progress: PageProgress) -> bool:
"""Return True if the learner has met the required dwell threshold for this page."""
return page_progress.can_advance
# ── Internal ──────────────────────────────────────────────────────────────────
def _recompute_accumulated(page_progress: PageProgress) -> int:
events = DwellEvent.objects.filter(page_progress=page_progress)
total = compute_eligible_seconds(list(events))
with transaction.atomic():
page_progress.accumulated_seconds = total
if page_progress.page.required_seconds > 0 and total >= page_progress.page.required_seconds:
page_progress.is_complete = True
page_progress.last_seen_at = timezone.now()
page_progress.save(
update_fields=["accumulated_seconds", "is_complete", "last_seen_at", "updated_at"]
)
return total