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

16
backend/Dockerfile Normal file
View File

@@ -0,0 +1,16 @@
FROM python:3.12-slim
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends \
default-libmysqlclient-dev gcc pkg-config \
&& rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]

42
backend/alembic.ini Normal file
View File

@@ -0,0 +1,42 @@
[alembic]
script_location = alembic
prepend_sys_path = .
version_path_separator = os
sqlalchemy.url = driver://user:pass@localhost/dbname
[post_write_hooks]
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
qualname =
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S

62
backend/alembic/env.py Normal file
View File

@@ -0,0 +1,62 @@
import asyncio
from logging.config import fileConfig
from sqlalchemy import pool
from sqlalchemy.engine import Connection
from sqlalchemy.ext.asyncio import async_engine_from_config
from alembic import context
# Import all models so Alembic can detect them
from app.models import Base # noqa: F401
from app.config import get_settings
config = context.config
settings = get_settings()
# Override the sqlalchemy.url from environment
config.set_main_option("sqlalchemy.url", settings.database_url)
if config.config_file_name is not None:
fileConfig(config.config_file_name)
target_metadata = Base.metadata
def run_migrations_offline() -> None:
url = config.get_main_option("sqlalchemy.url")
context.configure(
url=url,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
)
with context.begin_transaction():
context.run_migrations()
def do_run_migrations(connection: Connection) -> None:
context.configure(connection=connection, target_metadata=target_metadata)
with context.begin_transaction():
context.run_migrations()
async def run_async_migrations() -> None:
connectable = async_engine_from_config(
config.get_section(config.config_ini_section, {}),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
async with connectable.connect() as connection:
await connection.run_sync(do_run_migrations)
await connectable.dispose()
def run_migrations_online() -> None:
asyncio.run(run_async_migrations())
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()

View File

@@ -0,0 +1,26 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
# revision identifiers, used by Alembic.
revision: str = ${repr(up_revision)}
down_revision: Union[str, None] = ${repr(down_revision)}
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
def upgrade() -> None:
${upgrades if upgrades else "pass"}
def downgrade() -> None:
${downgrades if downgrades else "pass"}

View File

0
backend/app/__init__.py Normal file
View File

View File

40
backend/app/auth/jwt.py Normal file
View File

@@ -0,0 +1,40 @@
from datetime import datetime, timedelta, timezone
from typing import Any
from jose import JWTError, jwt
from passlib.context import CryptContext
from app.config import get_settings
settings = get_settings()
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
def hash_password(plain: str) -> str:
return pwd_context.hash(plain)
def verify_password(plain: str, hashed: str) -> bool:
return pwd_context.verify(plain, hashed)
def create_access_token(data: dict[str, Any]) -> str:
payload = data.copy()
expire = datetime.now(timezone.utc) + timedelta(minutes=settings.access_token_expire_minutes)
payload.update({"exp": expire, "type": "access"})
return jwt.encode(payload, settings.secret_key, algorithm=settings.algorithm)
def create_refresh_token(data: dict[str, Any]) -> str:
payload = data.copy()
expire = datetime.now(timezone.utc) + timedelta(days=settings.refresh_token_expire_days)
payload.update({"exp": expire, "type": "refresh"})
return jwt.encode(payload, settings.secret_key, algorithm=settings.algorithm)
def decode_token(token: str) -> dict[str, Any]:
try:
return jwt.decode(token, settings.secret_key, algorithms=[settings.algorithm])
except JWTError:
raise ValueError("Invalid or expired token")

23
backend/app/config.py Normal file
View File

@@ -0,0 +1,23 @@
from pydantic_settings import BaseSettings
from functools import lru_cache
class Settings(BaseSettings):
database_url: str
secret_key: str
algorithm: str = "HS256"
access_token_expire_minutes: int = 30
refresh_token_expire_days: int = 30
cors_origins: str = "http://localhost:8054"
@property
def cors_origins_list(self) -> list[str]:
return [o.strip() for o in self.cors_origins.split(",")]
class Config:
env_file = ".env"
@lru_cache
def get_settings() -> Settings:
return Settings()

18
backend/app/database.py Normal file
View File

@@ -0,0 +1,18 @@
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker
from app.config import get_settings
settings = get_settings()
engine = create_async_engine(
settings.database_url,
pool_pre_ping=True,
pool_size=10,
max_overflow=20,
echo=False,
)
AsyncSessionLocal = async_sessionmaker(
engine,
class_=AsyncSession,
expire_on_commit=False,
)

View File

@@ -0,0 +1,44 @@
from typing import AsyncGenerator, Optional
from fastapi import Depends, HTTPException, Request, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from sqlalchemy.ext.asyncio import AsyncSession
from app.auth.jwt import decode_token
from app.database import AsyncSessionLocal
from app.models.user import User
from sqlalchemy import select
bearer_scheme = HTTPBearer(auto_error=False)
async def get_db() -> AsyncGenerator[AsyncSession, None]:
async with AsyncSessionLocal() as session:
yield session
async def get_current_user(
credentials: Optional[HTTPAuthorizationCredentials] = Depends(bearer_scheme),
db: AsyncSession = Depends(get_db),
) -> User:
if not credentials:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Not authenticated")
try:
payload = decode_token(credentials.credentials)
except ValueError:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token")
if payload.get("type") != "access":
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Wrong token type")
user_id: int = payload.get("sub")
if user_id is None:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token payload")
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=status.HTTP_401_UNAUTHORIZED, detail="User not found")
return user

64
backend/app/main.py Normal file
View File

@@ -0,0 +1,64 @@
from contextlib import asynccontextmanager
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
from fastapi.middleware.cors import CORSMiddleware
from app.config import get_settings
from app.database import engine
from app.models import Base
from app.routers import auth, users, children, subjects, schedules, sessions, logs, dashboard
from app.websocket.manager import manager
settings = get_settings()
@asynccontextmanager
async def lifespan(app: FastAPI):
# Create tables on startup (Alembic handles migrations in prod, this is a safety net)
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
yield
app = FastAPI(
title="Homeschool API",
version="1.0.0",
docs_url="/api/docs",
redoc_url="/api/redoc",
openapi_url="/api/openapi.json",
lifespan=lifespan,
)
app.add_middleware(
CORSMiddleware,
allow_origins=settings.cors_origins_list,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# Routers
app.include_router(auth.router)
app.include_router(users.router)
app.include_router(children.router)
app.include_router(subjects.router)
app.include_router(schedules.router)
app.include_router(sessions.router)
app.include_router(logs.router)
app.include_router(dashboard.router)
@app.get("/api/health")
async def health():
return {"status": "ok"}
@app.websocket("/ws/{child_id}")
async def websocket_endpoint(websocket: WebSocket, child_id: int):
await manager.connect(websocket, child_id)
try:
while True:
# Keep connection alive; TV clients are receive-only
await websocket.receive_text()
except WebSocketDisconnect:
manager.disconnect(websocket, child_id)

View File

@@ -0,0 +1,22 @@
# Import all models here so Alembic can discover them via Base.metadata
from app.models.base import Base, TimestampMixin
from app.models.user import User
from app.models.child import Child
from app.models.subject import Subject
from app.models.schedule import ScheduleTemplate, ScheduleBlock
from app.models.session import DailySession, TimerEvent, TimerEventType
from app.models.activity import ActivityLog
__all__ = [
"Base",
"TimestampMixin",
"User",
"Child",
"Subject",
"ScheduleTemplate",
"ScheduleBlock",
"DailySession",
"TimerEvent",
"TimerEventType",
"ActivityLog",
]

View File

@@ -0,0 +1,24 @@
from datetime import date
from sqlalchemy import Date, ForeignKey, Integer, Text
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.models.base import Base, TimestampMixin
class ActivityLog(TimestampMixin, Base):
__tablename__ = "activity_logs"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
child_id: Mapped[int] = mapped_column(ForeignKey("children.id", ondelete="CASCADE"), nullable=False)
subject_id: Mapped[int | None] = mapped_column(
ForeignKey("subjects.id", ondelete="SET NULL"), nullable=True
)
session_id: Mapped[int | None] = mapped_column(
ForeignKey("daily_sessions.id", ondelete="SET NULL"), nullable=True
)
log_date: Mapped[date] = mapped_column(Date, nullable=False)
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
duration_minutes: Mapped[int | None] = mapped_column(Integer, nullable=True)
child: Mapped["Child"] = relationship("Child", back_populates="activity_logs") # noqa: F821
subject: Mapped["Subject | None"] = relationship("Subject", back_populates="activity_logs") # noqa: F821
session: Mapped["DailySession | None"] = relationship("DailySession", back_populates="activity_logs") # noqa: F821

View File

@@ -0,0 +1,14 @@
from datetime import datetime
from sqlalchemy import func
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
class Base(DeclarativeBase):
pass
class TimestampMixin:
created_at: Mapped[datetime] = mapped_column(default=func.now(), server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(
default=func.now(), server_default=func.now(), onupdate=func.now()
)

View File

@@ -0,0 +1,23 @@
from datetime import date
from sqlalchemy import String, Boolean, ForeignKey, Date
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.models.base import Base, TimestampMixin
class Child(TimestampMixin, Base):
__tablename__ = "children"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
name: Mapped[str] = mapped_column(String(100), nullable=False)
birth_date: Mapped[date | None] = mapped_column(Date, nullable=True)
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
color: Mapped[str] = mapped_column(String(7), default="#4F46E5") # hex color for UI
user: Mapped["User"] = relationship("User", back_populates="children") # noqa: F821
daily_sessions: Mapped[list["DailySession"]] = relationship( # noqa: F821
"DailySession", back_populates="child"
)
activity_logs: Mapped[list["ActivityLog"]] = relationship( # noqa: F821
"ActivityLog", back_populates="child"
)

View File

@@ -0,0 +1,45 @@
from datetime import time
from sqlalchemy import String, Boolean, ForeignKey, Time, Text, Integer
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.models.base import Base, TimestampMixin
class ScheduleTemplate(TimestampMixin, Base):
__tablename__ = "schedule_templates"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
child_id: Mapped[int | None] = mapped_column(
ForeignKey("children.id", ondelete="SET NULL"), nullable=True
)
name: Mapped[str] = mapped_column(String(100), nullable=False)
is_default: Mapped[bool] = mapped_column(Boolean, default=False)
user: Mapped["User"] = relationship("User", back_populates="schedule_templates") # noqa: F821
child: Mapped["Child | None"] = relationship("Child") # noqa: F821
blocks: Mapped[list["ScheduleBlock"]] = relationship(
"ScheduleBlock", back_populates="template", cascade="all, delete-orphan", order_by="ScheduleBlock.order_index"
)
daily_sessions: Mapped[list["DailySession"]] = relationship( # noqa: F821
"DailySession", back_populates="template"
)
class ScheduleBlock(Base):
__tablename__ = "schedule_blocks"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
template_id: Mapped[int] = mapped_column(
ForeignKey("schedule_templates.id", ondelete="CASCADE"), nullable=False
)
subject_id: Mapped[int | None] = mapped_column(
ForeignKey("subjects.id", ondelete="SET NULL"), nullable=True
)
time_start: Mapped[time] = mapped_column(Time, nullable=False)
time_end: Mapped[time] = mapped_column(Time, nullable=False)
label: Mapped[str | None] = mapped_column(String(100), nullable=True) # override subject name
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
order_index: Mapped[int] = mapped_column(Integer, default=0)
template: Mapped["ScheduleTemplate"] = relationship("ScheduleTemplate", back_populates="blocks")
subject: Mapped["Subject | None"] = relationship("Subject", back_populates="schedule_blocks") # noqa: F821

View File

@@ -0,0 +1,55 @@
from datetime import date, datetime
from sqlalchemy import Date, DateTime, ForeignKey, String, func
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.models.base import Base, TimestampMixin
import enum
class TimerEventType(str, enum.Enum):
start = "start"
pause = "pause"
resume = "resume"
complete = "complete"
skip = "skip"
class DailySession(TimestampMixin, Base):
__tablename__ = "daily_sessions"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
child_id: Mapped[int] = mapped_column(ForeignKey("children.id", ondelete="CASCADE"), nullable=False)
template_id: Mapped[int | None] = mapped_column(
ForeignKey("schedule_templates.id", ondelete="SET NULL"), nullable=True
)
session_date: Mapped[date] = mapped_column(Date, nullable=False)
is_active: Mapped[bool] = mapped_column(default=True)
current_block_id: Mapped[int | None] = mapped_column(
ForeignKey("schedule_blocks.id", ondelete="SET NULL"), nullable=True
)
child: Mapped["Child"] = relationship("Child", back_populates="daily_sessions") # noqa: F821
template: Mapped["ScheduleTemplate | None"] = relationship( # noqa: F821
"ScheduleTemplate", back_populates="daily_sessions"
)
current_block: Mapped["ScheduleBlock | None"] = relationship("ScheduleBlock", foreign_keys=[current_block_id]) # noqa: F821
timer_events: Mapped[list["TimerEvent"]] = relationship(
"TimerEvent", back_populates="session", cascade="all, delete-orphan"
)
activity_logs: Mapped[list["ActivityLog"]] = relationship( # noqa: F821
"ActivityLog", back_populates="session"
)
class TimerEvent(Base):
__tablename__ = "timer_events"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
session_id: Mapped[int] = mapped_column(ForeignKey("daily_sessions.id", ondelete="CASCADE"), nullable=False)
block_id: Mapped[int | None] = mapped_column(
ForeignKey("schedule_blocks.id", ondelete="SET NULL"), nullable=True
)
event_type: Mapped[str] = mapped_column(String(20), nullable=False)
occurred_at: Mapped[datetime] = mapped_column(DateTime, default=func.now(), server_default=func.now())
session: Mapped["DailySession"] = relationship("DailySession", back_populates="timer_events")
block: Mapped["ScheduleBlock | None"] = relationship("ScheduleBlock") # noqa: F821

View File

@@ -0,0 +1,22 @@
from sqlalchemy import String, Boolean, ForeignKey
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.models.base import Base, TimestampMixin
class Subject(TimestampMixin, Base):
__tablename__ = "subjects"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
name: Mapped[str] = mapped_column(String(100), nullable=False)
color: Mapped[str] = mapped_column(String(7), default="#10B981") # hex color
icon: Mapped[str] = mapped_column(String(10), default="📚") # emoji
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
user: Mapped["User"] = relationship("User", back_populates="subjects") # noqa: F821
schedule_blocks: Mapped[list["ScheduleBlock"]] = relationship( # noqa: F821
"ScheduleBlock", back_populates="subject"
)
activity_logs: Mapped[list["ActivityLog"]] = relationship( # noqa: F821
"ActivityLog", back_populates="subject"
)

View File

@@ -0,0 +1,20 @@
from sqlalchemy import String, Boolean
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.models.base import Base, TimestampMixin
class User(TimestampMixin, Base):
__tablename__ = "users"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
email: Mapped[str] = mapped_column(String(255), unique=True, nullable=False, index=True)
hashed_password: Mapped[str] = mapped_column(String(255), nullable=False)
full_name: Mapped[str] = mapped_column(String(255), nullable=False)
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
is_admin: Mapped[bool] = mapped_column(Boolean, default=False)
children: Mapped[list["Child"]] = relationship("Child", back_populates="user") # noqa: F821
subjects: Mapped[list["Subject"]] = relationship("Subject", back_populates="user") # noqa: F821
schedule_templates: Mapped[list["ScheduleTemplate"]] = relationship( # noqa: F821
"ScheduleTemplate", back_populates="user"
)

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

View File

View File

@@ -0,0 +1,29 @@
from datetime import date
from pydantic import BaseModel
class ActivityLogCreate(BaseModel):
child_id: int
subject_id: int | None = None
session_id: int | None = None
log_date: date
notes: str | None = None
duration_minutes: int | None = None
class ActivityLogUpdate(BaseModel):
notes: str | None = None
duration_minutes: int | None = None
subject_id: int | None = None
class ActivityLogOut(BaseModel):
id: int
child_id: int
subject_id: int | None
session_id: int | None
log_date: date
notes: str | None
duration_minutes: int | None
model_config = {"from_attributes": True}

View File

@@ -0,0 +1,21 @@
from pydantic import BaseModel, EmailStr
class RegisterRequest(BaseModel):
email: EmailStr
password: str
full_name: str
class LoginRequest(BaseModel):
email: EmailStr
password: str
class TokenResponse(BaseModel):
access_token: str
token_type: str = "bearer"
class RefreshRequest(BaseModel):
refresh_token: str

View File

@@ -0,0 +1,25 @@
from datetime import date
from pydantic import BaseModel
class ChildCreate(BaseModel):
name: str
birth_date: date | None = None
color: str = "#4F46E5"
class ChildUpdate(BaseModel):
name: str | None = None
birth_date: date | None = None
color: str | None = None
is_active: bool | None = None
class ChildOut(BaseModel):
id: int
name: str
birth_date: date | None
is_active: bool
color: str
model_config = {"from_attributes": True}

View File

@@ -0,0 +1,46 @@
from datetime import time
from pydantic import BaseModel
class ScheduleBlockCreate(BaseModel):
subject_id: int | None = None
time_start: time
time_end: time
label: str | None = None
notes: str | None = None
order_index: int = 0
class ScheduleBlockOut(BaseModel):
id: int
subject_id: int | None
time_start: time
time_end: time
label: str | None
notes: str | None
order_index: int
model_config = {"from_attributes": True}
class ScheduleTemplateCreate(BaseModel):
name: str
child_id: int | None = None
is_default: bool = False
blocks: list[ScheduleBlockCreate] = []
class ScheduleTemplateUpdate(BaseModel):
name: str | None = None
child_id: int | None = None
is_default: bool | None = None
class ScheduleTemplateOut(BaseModel):
id: int
name: str
child_id: int | None
is_default: bool
blocks: list[ScheduleBlockOut] = []
model_config = {"from_attributes": True}

View File

@@ -0,0 +1,44 @@
from datetime import date, datetime
from pydantic import BaseModel
from app.schemas.schedule import ScheduleBlockOut
from app.schemas.child import ChildOut
class SessionStart(BaseModel):
child_id: int
template_id: int | None = None
session_date: date | None = None # defaults to today
class TimerAction(BaseModel):
event_type: str # start | pause | resume | complete | skip
block_id: int | None = None
class TimerEventOut(BaseModel):
id: int
block_id: int | None
event_type: str
occurred_at: datetime
model_config = {"from_attributes": True}
class DailySessionOut(BaseModel):
id: int
child_id: int
template_id: int | None
session_date: date
is_active: bool
current_block_id: int | None
current_block: ScheduleBlockOut | None = None
model_config = {"from_attributes": True}
class DashboardSnapshot(BaseModel):
"""Public TV dashboard payload — no auth required."""
session: DailySessionOut | None
child: ChildOut
blocks: list[ScheduleBlockOut] = []
completed_block_ids: list[int] = []

View File

@@ -0,0 +1,24 @@
from pydantic import BaseModel
class SubjectCreate(BaseModel):
name: str
color: str = "#10B981"
icon: str = "📚"
class SubjectUpdate(BaseModel):
name: str | None = None
color: str | None = None
icon: str | None = None
is_active: bool | None = None
class SubjectOut(BaseModel):
id: int
name: str
color: str
icon: str
is_active: bool
model_config = {"from_attributes": True}

View File

@@ -0,0 +1,16 @@
from pydantic import BaseModel, EmailStr
class UserOut(BaseModel):
id: int
email: EmailStr
full_name: str
is_active: bool
is_admin: bool
model_config = {"from_attributes": True}
class UserUpdate(BaseModel):
full_name: str | None = None
email: EmailStr | None = None

View File

View File

@@ -0,0 +1,42 @@
import json
import logging
from collections import defaultdict
from fastapi import WebSocket
logger = logging.getLogger(__name__)
class ConnectionManager:
def __init__(self):
# child_id → list of active WebSocket connections
self.active: dict[int, list[WebSocket]] = defaultdict(list)
async def connect(self, websocket: WebSocket, child_id: int) -> None:
await websocket.accept()
self.active[child_id].append(websocket)
logger.info("WS connected for child %d%d total", child_id, len(self.active[child_id]))
def disconnect(self, websocket: WebSocket, child_id: int) -> None:
self.active[child_id].discard(websocket) if hasattr(self.active[child_id], "discard") else None
try:
self.active[child_id].remove(websocket)
except ValueError:
pass
logger.info("WS disconnected for child %d%d remaining", child_id, len(self.active[child_id]))
async def broadcast(self, child_id: int, message: dict) -> None:
"""Send a JSON message to all TVs watching a given child."""
dead = []
for ws in list(self.active.get(child_id, [])):
try:
await ws.send_text(json.dumps(message))
except Exception:
dead.append(ws)
for ws in dead:
self.disconnect(ws, child_id)
# Singleton — imported by routers and the WS endpoint
manager = ConnectionManager()

9
backend/requirements.txt Normal file
View File

@@ -0,0 +1,9 @@
fastapi==0.115.0
uvicorn[standard]==0.30.6
sqlalchemy[asyncio]==2.0.35
aiomysql==0.2.0
python-jose[cryptography]==3.3.0
passlib[bcrypt]==1.7.4
pydantic-settings==2.5.2
alembic==1.13.3
python-multipart==0.0.12