Files
homeschool/backend/app/routers/dashboard.py
T
derekcandClaude Opus 5.5 5a2510059d Fix timer action 500s after FastAPI upgrade
FastAPI 0.141 raises when response serialization hits a lazy-load
(current_block.subject.options) in async context, where 0.115 silently
skipped it. Eager-load Subject.options in session get/timer and the TV
dashboard queries. Pin pydantic 2.13.5.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-24 23:51:10 -07:00

136 lines
5.2 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.session_block_agenda import SessionBlockAgenda
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).selectinload(ScheduleBlock.subject).selectinload(Subject.options))
.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()]
block_agendas: dict[str, str] = {}
if session:
agendas_result = await db.execute(
select(SessionBlockAgenda).where(SessionBlockAgenda.session_id == session.id)
)
block_agendas = {
str(item.block_id): item.text for item in agendas_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,
block_agendas=block_agendas,
)