Files
homeschool/backend/app/routers/children.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

151 lines
4.7 KiB
Python

import random
from datetime import datetime, timezone
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel, Field
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from app.dependencies import get_db, get_current_user
from app.models.child import Child
from app.models.strike import StrikeEvent
from app.models.user import User
from app.schemas.child import ChildCreate, ChildOut, ChildUpdate
from app.websocket.manager import manager
router = APIRouter(prefix="/api/children", tags=["children"])
def _today_in_tz(tz_name: str):
"""Return today's date in the given IANA timezone, falling back to UTC."""
try:
tz = ZoneInfo(tz_name)
except (ZoneInfoNotFoundError, Exception):
tz = timezone.utc
return datetime.now(tz).date()
@router.get("", response_model=list[ChildOut])
async def list_children(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(
select(Child).where(Child.user_id == current_user.id).order_by(Child.name)
)
children = result.scalars().all()
today = _today_in_tz(current_user.timezone)
needs_commit = False
for child in children:
if child.strikes != 0 and child.strikes_last_reset != today:
child.strikes = 0
child.strikes_last_reset = today
needs_commit = True
await manager.broadcast(child.id, {"event": "strikes_update", "strikes": 0})
if needs_commit:
await db.commit()
return children
async def _generate_tv_token(db: AsyncSession) -> int:
while True:
token = random.randint(1000, 9999)
result = await db.execute(select(Child).where(Child.tv_token == token))
if not result.scalar_one_or_none():
return token
@router.post("", response_model=ChildOut, status_code=status.HTTP_201_CREATED)
async def create_child(
body: ChildCreate,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
tv_token = await _generate_tv_token(db)
child = Child(**body.model_dump(), user_id=current_user.id, tv_token=tv_token)
db.add(child)
await db.commit()
await db.refresh(child)
return child
@router.get("/{child_id}", response_model=ChildOut)
async def get_child(
child_id: int,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(
select(Child).where(Child.id == child_id, Child.user_id == current_user.id)
)
child = result.scalar_one_or_none()
if not child:
raise HTTPException(status_code=404, detail="Child not found")
return child
@router.patch("/{child_id}", response_model=ChildOut)
async def update_child(
child_id: int,
body: ChildUpdate,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(
select(Child).where(Child.id == child_id, Child.user_id == current_user.id)
)
child = result.scalar_one_or_none()
if not child:
raise HTTPException(status_code=404, detail="Child not found")
for field, value in body.model_dump(exclude_none=True).items():
setattr(child, field, value)
await db.commit()
await db.refresh(child)
return child
class StrikesBody(BaseModel):
strikes: int = Field(..., ge=0, le=3)
@router.patch("/{child_id}/strikes", response_model=ChildOut)
async def update_strikes(
child_id: int,
body: StrikesBody,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(
select(Child).where(Child.id == child_id, Child.user_id == current_user.id)
)
child = result.scalar_one_or_none()
if not child:
raise HTTPException(status_code=404, detail="Child not found")
prev = child.strikes
child.strikes = body.strikes
db.add(StrikeEvent(child_id=child.id, prev_strikes=prev, new_strikes=body.strikes))
await db.commit()
await db.refresh(child)
await manager.broadcast(child_id, {"event": "strikes_update", "strikes": child.strikes})
return child
@router.delete("/{child_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_child(
child_id: int,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(
select(Child).where(Child.id == child_id, Child.user_id == current_user.id)
)
child = result.scalar_one_or_none()
if not child:
raise HTTPException(status_code=404, detail="Child not found")
await db.delete(child)
await db.commit()