Initial project scaffold

Full-stack homeschool web app with FastAPI backend, Vue 3 frontend,
MySQL database, and Docker Compose orchestration. Includes JWT auth,
WebSocket real-time TV dashboard, schedule builder, activity logging,
and multi-child support.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-27 22:56:31 -08:00
parent 93e0494864
commit 417b3adfe8
68 changed files with 3919 additions and 0 deletions

View File

View File

@@ -0,0 +1,90 @@
from fastapi import APIRouter, Depends, HTTPException, Response, Request, status
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from app.auth.jwt import (
create_access_token,
create_refresh_token,
decode_token,
hash_password,
verify_password,
)
from app.dependencies import get_db
from app.models.user import User
from app.schemas.auth import LoginRequest, RegisterRequest, TokenResponse
from app.schemas.user import UserOut
router = APIRouter(prefix="/api/auth", tags=["auth"])
REFRESH_COOKIE = "refresh_token"
COOKIE_OPTS = {
"httponly": True,
"samesite": "lax",
"secure": False, # set True in production with HTTPS
}
@router.post("/register", response_model=TokenResponse, status_code=status.HTTP_201_CREATED)
async def register(body: RegisterRequest, response: Response, db: AsyncSession = Depends(get_db)):
existing = await db.execute(select(User).where(User.email == body.email))
if existing.scalar_one_or_none():
raise HTTPException(status_code=400, detail="Email already registered")
user = User(
email=body.email,
hashed_password=hash_password(body.password),
full_name=body.full_name,
)
db.add(user)
await db.commit()
await db.refresh(user)
access = create_access_token({"sub": str(user.id)})
refresh = create_refresh_token({"sub": str(user.id)})
response.set_cookie(REFRESH_COOKIE, refresh, **COOKIE_OPTS)
return TokenResponse(access_token=access)
@router.post("/login", response_model=TokenResponse)
async def login(body: LoginRequest, response: Response, db: AsyncSession = Depends(get_db)):
result = await db.execute(select(User).where(User.email == body.email, User.is_active == True))
user = result.scalar_one_or_none()
if not user or not verify_password(body.password, user.hashed_password):
raise HTTPException(status_code=401, detail="Invalid credentials")
access = create_access_token({"sub": str(user.id)})
refresh = create_refresh_token({"sub": str(user.id)})
response.set_cookie(REFRESH_COOKIE, refresh, **COOKIE_OPTS)
return TokenResponse(access_token=access)
@router.post("/refresh", response_model=TokenResponse)
async def refresh_token(request: Request, response: Response, db: AsyncSession = Depends(get_db)):
token = request.cookies.get(REFRESH_COOKIE)
if not token:
raise HTTPException(status_code=401, detail="No refresh token")
try:
payload = decode_token(token)
except ValueError:
raise HTTPException(status_code=401, detail="Invalid refresh token")
if payload.get("type") != "refresh":
raise HTTPException(status_code=401, detail="Wrong token type")
user_id = payload.get("sub")
result = await db.execute(select(User).where(User.id == int(user_id), User.is_active == True))
user = result.scalar_one_or_none()
if not user:
raise HTTPException(status_code=401, detail="User not found")
access = create_access_token({"sub": str(user.id)})
new_refresh = create_refresh_token({"sub": str(user.id)})
response.set_cookie(REFRESH_COOKIE, new_refresh, **COOKIE_OPTS)
return TokenResponse(access_token=access)
@router.post("/logout")
async def logout(response: Response):
response.delete_cookie(REFRESH_COOKIE)
return {"detail": "Logged out"}

View File

@@ -0,0 +1,86 @@
from fastapi import APIRouter, Depends, HTTPException, status
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.user import User
from app.schemas.child import ChildCreate, ChildOut, ChildUpdate
router = APIRouter(prefix="/api/children", tags=["children"])
@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)
)
return result.scalars().all()
@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),
):
child = Child(**body.model_dump(), user_id=current_user.id)
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
@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()

View File

@@ -0,0 +1,65 @@
"""
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.schedule import ScheduleBlock
from app.models.session import DailySession, TimerEvent
from app.schemas.session import DashboardSnapshot
router = APIRouter(prefix="/api/dashboard", tags=["dashboard"])
@router.get("/{child_id}", response_model=DashboardSnapshot)
async def get_dashboard(child_id: int, db: AsyncSession = Depends(get_db)):
child_result = await db.execute(select(Child).where(Child.id == child_id, Child.is_active == True))
child = child_result.scalar_one_or_none()
if not child:
raise HTTPException(status_code=404, detail="Child not found")
# 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 = []
if session and session.template_id:
blocks_result = await db.execute(
select(ScheduleBlock)
.where(ScheduleBlock.template_id == session.template_id)
.order_by(ScheduleBlock.order_index)
)
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]
return DashboardSnapshot(
session=session,
child=child,
blocks=blocks,
completed_block_ids=completed_ids,
)

View File

@@ -0,0 +1,95 @@
from datetime import date
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from app.dependencies import get_db, get_current_user
from app.models.activity import ActivityLog
from app.models.child import Child
from app.models.user import User
from app.schemas.activity import ActivityLogCreate, ActivityLogOut, ActivityLogUpdate
router = APIRouter(prefix="/api/logs", tags=["logs"])
@router.get("", response_model=list[ActivityLogOut])
async def list_logs(
child_id: int | None = None,
log_date: date | None = None,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
query = (
select(ActivityLog)
.join(Child)
.where(Child.user_id == current_user.id)
.order_by(ActivityLog.log_date.desc(), ActivityLog.created_at.desc())
)
if child_id:
query = query.where(ActivityLog.child_id == child_id)
if log_date:
query = query.where(ActivityLog.log_date == log_date)
result = await db.execute(query)
return result.scalars().all()
@router.post("", response_model=ActivityLogOut, status_code=status.HTTP_201_CREATED)
async def create_log(
body: ActivityLogCreate,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
child_result = await db.execute(
select(Child).where(Child.id == body.child_id, Child.user_id == current_user.id)
)
if not child_result.scalar_one_or_none():
raise HTTPException(status_code=404, detail="Child not found")
log = ActivityLog(**body.model_dump())
db.add(log)
await db.commit()
await db.refresh(log)
return log
@router.patch("/{log_id}", response_model=ActivityLogOut)
async def update_log(
log_id: int,
body: ActivityLogUpdate,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(
select(ActivityLog)
.join(Child)
.where(ActivityLog.id == log_id, Child.user_id == current_user.id)
)
log = result.scalar_one_or_none()
if not log:
raise HTTPException(status_code=404, detail="Log not found")
for field, value in body.model_dump(exclude_none=True).items():
setattr(log, field, value)
await db.commit()
await db.refresh(log)
return log
@router.delete("/{log_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_log(
log_id: int,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(
select(ActivityLog)
.join(Child)
.where(ActivityLog.id == log_id, Child.user_id == current_user.id)
)
log = result.scalar_one_or_none()
if not log:
raise HTTPException(status_code=404, detail="Log not found")
await db.delete(log)
await db.commit()

View File

@@ -0,0 +1,165 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from sqlalchemy.orm import selectinload
from app.dependencies import get_db, get_current_user
from app.models.schedule import ScheduleTemplate, ScheduleBlock
from app.models.user import User
from app.schemas.schedule import (
ScheduleTemplateCreate,
ScheduleTemplateOut,
ScheduleTemplateUpdate,
ScheduleBlockCreate,
ScheduleBlockOut,
)
router = APIRouter(prefix="/api/schedules", tags=["schedules"])
@router.get("", response_model=list[ScheduleTemplateOut])
async def list_templates(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(
select(ScheduleTemplate)
.where(ScheduleTemplate.user_id == current_user.id)
.options(selectinload(ScheduleTemplate.blocks))
.order_by(ScheduleTemplate.name)
)
return result.scalars().all()
@router.post("", response_model=ScheduleTemplateOut, status_code=status.HTTP_201_CREATED)
async def create_template(
body: ScheduleTemplateCreate,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
template = ScheduleTemplate(
user_id=current_user.id,
name=body.name,
child_id=body.child_id,
is_default=body.is_default,
)
db.add(template)
await db.flush() # get template.id before adding blocks
for block_data in body.blocks:
block = ScheduleBlock(template_id=template.id, **block_data.model_dump())
db.add(block)
await db.commit()
await db.refresh(template)
# Re-fetch with blocks loaded
result = await db.execute(
select(ScheduleTemplate)
.where(ScheduleTemplate.id == template.id)
.options(selectinload(ScheduleTemplate.blocks))
)
return result.scalar_one()
@router.get("/{template_id}", response_model=ScheduleTemplateOut)
async def get_template(
template_id: int,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(
select(ScheduleTemplate)
.where(ScheduleTemplate.id == template_id, ScheduleTemplate.user_id == current_user.id)
.options(selectinload(ScheduleTemplate.blocks))
)
template = result.scalar_one_or_none()
if not template:
raise HTTPException(status_code=404, detail="Template not found")
return template
@router.patch("/{template_id}", response_model=ScheduleTemplateOut)
async def update_template(
template_id: int,
body: ScheduleTemplateUpdate,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(
select(ScheduleTemplate)
.where(ScheduleTemplate.id == template_id, ScheduleTemplate.user_id == current_user.id)
.options(selectinload(ScheduleTemplate.blocks))
)
template = result.scalar_one_or_none()
if not template:
raise HTTPException(status_code=404, detail="Template not found")
for field, value in body.model_dump(exclude_none=True).items():
setattr(template, field, value)
await db.commit()
await db.refresh(template)
return template
@router.delete("/{template_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_template(
template_id: int,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(
select(ScheduleTemplate)
.where(ScheduleTemplate.id == template_id, ScheduleTemplate.user_id == current_user.id)
)
template = result.scalar_one_or_none()
if not template:
raise HTTPException(status_code=404, detail="Template not found")
await db.delete(template)
await db.commit()
# --- Schedule Block sub-routes ---
@router.post("/{template_id}/blocks", response_model=ScheduleBlockOut, status_code=status.HTTP_201_CREATED)
async def add_block(
template_id: int,
body: ScheduleBlockCreate,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(
select(ScheduleTemplate)
.where(ScheduleTemplate.id == template_id, ScheduleTemplate.user_id == current_user.id)
)
if not result.scalar_one_or_none():
raise HTTPException(status_code=404, detail="Template not found")
block = ScheduleBlock(template_id=template_id, **body.model_dump())
db.add(block)
await db.commit()
await db.refresh(block)
return block
@router.delete("/{template_id}/blocks/{block_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_block(
template_id: int,
block_id: int,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(
select(ScheduleBlock)
.join(ScheduleTemplate)
.where(
ScheduleBlock.id == block_id,
ScheduleBlock.template_id == template_id,
ScheduleTemplate.user_id == current_user.id,
)
)
block = result.scalar_one_or_none()
if not block:
raise HTTPException(status_code=404, detail="Block not found")
await db.delete(block)
await db.commit()

View File

@@ -0,0 +1,159 @@
from datetime import date
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from sqlalchemy.orm import selectinload
from app.dependencies import get_db, get_current_user
from app.models.child import Child
from app.models.schedule import ScheduleBlock, ScheduleTemplate
from app.models.session import DailySession, TimerEvent
from app.models.user import User
from app.schemas.session import DailySessionOut, SessionStart, TimerAction
from app.websocket.manager import manager
router = APIRouter(prefix="/api/sessions", tags=["sessions"])
async def _broadcast_session(db: AsyncSession, session: DailySession) -> None:
"""Build a snapshot dict and broadcast it to all connected TVs for this child."""
# Load template blocks if available
blocks = []
if session.template_id:
result = await db.execute(
select(ScheduleBlock)
.where(ScheduleBlock.template_id == session.template_id)
.order_by(ScheduleBlock.order_index)
)
blocks = [{"id": b.id, "subject_id": b.subject_id, "time_start": str(b.time_start),
"time_end": str(b.time_end), "label": b.label, "order_index": b.order_index}
for b in result.scalars().all()]
# Gather completed block IDs from timer events
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]
payload = {
"event": "session_update",
"session": {
"id": session.id,
"child_id": session.child_id,
"session_date": str(session.session_date),
"is_active": session.is_active,
"current_block_id": session.current_block_id,
},
"blocks": blocks,
"completed_block_ids": completed_ids,
}
await manager.broadcast(session.child_id, payload)
@router.post("", response_model=DailySessionOut, status_code=status.HTTP_201_CREATED)
async def start_session(
body: SessionStart,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
# Verify child belongs to user
child_result = await db.execute(
select(Child).where(Child.id == body.child_id, Child.user_id == current_user.id)
)
if not child_result.scalar_one_or_none():
raise HTTPException(status_code=404, detail="Child not found")
session_date = body.session_date or date.today()
# Deactivate any existing active session for this child today
existing = await db.execute(
select(DailySession).where(
DailySession.child_id == body.child_id,
DailySession.session_date == session_date,
DailySession.is_active == True,
)
)
for old in existing.scalars().all():
old.is_active = False
session = DailySession(
child_id=body.child_id,
template_id=body.template_id,
session_date=session_date,
is_active=True,
)
db.add(session)
await db.commit()
await db.refresh(session)
await _broadcast_session(db, session)
return session
@router.get("/{session_id}", response_model=DailySessionOut)
async def get_session(
session_id: int,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(
select(DailySession)
.join(Child)
.where(DailySession.id == session_id, Child.user_id == current_user.id)
.options(selectinload(DailySession.current_block))
)
session = result.scalar_one_or_none()
if not session:
raise HTTPException(status_code=404, detail="Session not found")
return session
@router.post("/{session_id}/timer", response_model=DailySessionOut)
async def timer_action(
session_id: int,
body: TimerAction,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(
select(DailySession)
.join(Child)
.where(DailySession.id == session_id, Child.user_id == current_user.id)
.options(selectinload(DailySession.current_block))
)
session = result.scalar_one_or_none()
if not session:
raise HTTPException(status_code=404, detail="Session not found")
# Update current block if provided
if body.block_id is not None:
session.current_block_id = body.block_id
# Record the timer event
event = TimerEvent(
session_id=session.id,
block_id=body.block_id or session.current_block_id,
event_type=body.event_type,
)
db.add(event)
# Mark session complete if event is session-level complete
if body.event_type == "complete" and body.block_id is None:
session.is_active = False
await db.commit()
await db.refresh(session)
# Broadcast the timer event to all TV clients
ws_payload = {
"event": body.event_type,
"session_id": session.id,
"block_id": event.block_id,
"current_block_id": session.current_block_id,
}
await manager.broadcast(session.child_id, ws_payload)
return session

View File

@@ -0,0 +1,86 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from app.dependencies import get_db, get_current_user
from app.models.subject import Subject
from app.models.user import User
from app.schemas.subject import SubjectCreate, SubjectOut, SubjectUpdate
router = APIRouter(prefix="/api/subjects", tags=["subjects"])
@router.get("", response_model=list[SubjectOut])
async def list_subjects(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(
select(Subject).where(Subject.user_id == current_user.id).order_by(Subject.name)
)
return result.scalars().all()
@router.post("", response_model=SubjectOut, status_code=status.HTTP_201_CREATED)
async def create_subject(
body: SubjectCreate,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
subject = Subject(**body.model_dump(), user_id=current_user.id)
db.add(subject)
await db.commit()
await db.refresh(subject)
return subject
@router.get("/{subject_id}", response_model=SubjectOut)
async def get_subject(
subject_id: int,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(
select(Subject).where(Subject.id == subject_id, Subject.user_id == current_user.id)
)
subject = result.scalar_one_or_none()
if not subject:
raise HTTPException(status_code=404, detail="Subject not found")
return subject
@router.patch("/{subject_id}", response_model=SubjectOut)
async def update_subject(
subject_id: int,
body: SubjectUpdate,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(
select(Subject).where(Subject.id == subject_id, Subject.user_id == current_user.id)
)
subject = result.scalar_one_or_none()
if not subject:
raise HTTPException(status_code=404, detail="Subject not found")
for field, value in body.model_dump(exclude_none=True).items():
setattr(subject, field, value)
await db.commit()
await db.refresh(subject)
return subject
@router.delete("/{subject_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_subject(
subject_id: int,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(
select(Subject).where(Subject.id == subject_id, Subject.user_id == current_user.id)
)
subject = result.scalar_one_or_none()
if not subject:
raise HTTPException(status_code=404, detail="Subject not found")
await db.delete(subject)
await db.commit()

View File

@@ -0,0 +1,28 @@
from fastapi import APIRouter, Depends
from sqlalchemy.ext.asyncio import AsyncSession
from app.dependencies import get_db, get_current_user
from app.models.user import User
from app.schemas.user import UserOut, UserUpdate
router = APIRouter(prefix="/api/users", tags=["users"])
@router.get("/me", response_model=UserOut)
async def get_me(current_user: User = Depends(get_current_user)):
return current_user
@router.patch("/me", response_model=UserOut)
async def update_me(
body: UserUpdate,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
if body.full_name is not None:
current_user.full_name = body.full_name
if body.email is not None:
current_user.email = body.email
await db.commit()
await db.refresh(current_user)
return current_user