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>
112 lines
3.6 KiB
Python
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
|