""" 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