Files
homeschool/backend/app/routers/dashboard.py
derekc 68a5e9cb4f Add random 4-digit TV token per child for obfuscated TV URLs
Each child is assigned a unique permanent tv_token on creation. The TV
dashboard URL (/tv/:tvToken) and WebSocket (/ws/:tvToken) now use this
token instead of the internal DB ID. Existing children are backfilled
on startup. README updated to reflect the change.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-10 22:53:26 -07:00

125 lines
4.7 KiB
Python

"""
Public dashboard endpoint — no authentication required.
Used by the TV view to get the initial session snapshot before WebSocket connects.
"""
from datetime import date
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from sqlalchemy.orm import selectinload
from app.dependencies import get_db
from app.models.child import Child
from app.models.morning_routine import MorningRoutineItem
from app.models.break_activity import BreakActivityItem
from app.models.schedule import ScheduleBlock
from app.models.subject import Subject # noqa: F401 — needed for selectinload chain
from app.models.session import DailySession, TimerEvent
from app.schemas.session import DashboardSnapshot
from app.utils.timer import compute_block_elapsed, compute_break_elapsed
router = APIRouter(prefix="/api/dashboard", tags=["dashboard"])
@router.get("/{tv_token}", response_model=DashboardSnapshot)
async def get_dashboard(tv_token: int, db: AsyncSession = Depends(get_db)):
child_result = await db.execute(select(Child).where(Child.tv_token == tv_token, Child.is_active == True))
child = child_result.scalar_one_or_none()
if not child:
raise HTTPException(status_code=404, detail="Child not found")
child_id = child.id
# Get today's active session
session_result = await db.execute(
select(DailySession)
.where(
DailySession.child_id == child_id,
DailySession.session_date == date.today(),
DailySession.is_active == True,
)
.options(selectinload(DailySession.current_block))
.limit(1)
)
session = session_result.scalar_one_or_none()
blocks = []
completed_ids = []
block_elapsed_seconds = 0
if session and session.template_id:
blocks_result = await db.execute(
select(ScheduleBlock)
.where(ScheduleBlock.template_id == session.template_id)
.options(selectinload(ScheduleBlock.subject).selectinload(Subject.options))
.order_by(ScheduleBlock.time_start)
)
blocks = blocks_result.scalars().all()
events_result = await db.execute(
select(TimerEvent).where(
TimerEvent.session_id == session.id,
TimerEvent.event_type == "complete",
)
)
completed_ids = [e.block_id for e in events_result.scalars().all() if e.block_id]
# Compute elapsed seconds and paused state for the current block from timer_events
is_paused = False
is_break_active = False
break_elapsed_seconds = 0
is_break_paused = False
if session and session.current_block_id:
block_elapsed_seconds, is_paused = await compute_block_elapsed(
db, session.id, session.current_block_id
)
# Determine if break mode is active: check whether the most recent
# timer event for this block (main or break) is a break event.
last_event_result = await db.execute(
select(TimerEvent)
.where(
TimerEvent.session_id == session.id,
TimerEvent.block_id == session.current_block_id,
TimerEvent.event_type.in_([
"start", "resume",
"break_start", "break_resume", "break_pause", "break_reset",
]),
)
.order_by(TimerEvent.occurred_at.desc())
.limit(1)
)
last_event = last_event_result.scalar_one_or_none()
if last_event and last_event.event_type.startswith("break_"):
is_break_active = True
break_elapsed_seconds, is_break_paused = await compute_break_elapsed(
db, session.id, session.current_block_id
)
routine_result = await db.execute(
select(MorningRoutineItem)
.where(MorningRoutineItem.user_id == child.user_id)
.order_by(MorningRoutineItem.order_index, MorningRoutineItem.id)
)
morning_routine = [item.text for item in routine_result.scalars().all()]
break_result = await db.execute(
select(BreakActivityItem)
.where(BreakActivityItem.user_id == child.user_id)
.order_by(BreakActivityItem.order_index, BreakActivityItem.id)
)
break_activities = [item.text for item in break_result.scalars().all()]
return DashboardSnapshot(
session=session,
child=child,
blocks=blocks,
completed_block_ids=completed_ids,
block_elapsed_seconds=block_elapsed_seconds,
is_paused=is_paused,
morning_routine=morning_routine,
break_activities=break_activities,
is_break_active=is_break_active,
break_elapsed_seconds=break_elapsed_seconds,
is_break_paused=is_break_paused,
)