diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..05eb479 --- /dev/null +++ b/.env.example @@ -0,0 +1,15 @@ +# Copy this file to .env and fill in values +# Generate SECRET_KEY with: openssl rand -hex 32 + +MYSQL_ROOT_PASSWORD=change_me_root +MYSQL_DATABASE=homeschool +MYSQL_USER=homeschool +MYSQL_PASSWORD=change_me_db + +SECRET_KEY=change_me_generate_with_openssl_rand_hex_32 +ALGORITHM=HS256 +ACCESS_TOKEN_EXPIRE_MINUTES=30 +REFRESH_TOKEN_EXPIRE_DAYS=30 + +# Comma-separated allowed CORS origins (no trailing slash) +CORS_ORIGINS=http://localhost:8054 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..91ccbcd --- /dev/null +++ b/.gitignore @@ -0,0 +1,27 @@ +# Environment โ never commit real secrets +.env + +# Python +__pycache__/ +*.py[cod] +*.egg-info/ +.venv/ +venv/ +.pytest_cache/ + +# Node / frontend +node_modules/ +frontend/dist/ +frontend/.vite/ + +# Alembic generated (keep versions dir, ignore cache) +backend/alembic/__pycache__/ + +# OS +.DS_Store +Thumbs.db + +# IDE +.vscode/ +.idea/ +*.swp diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..bd7d777 --- /dev/null +++ b/backend/Dockerfile @@ -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"] diff --git a/backend/alembic.ini b/backend/alembic.ini new file mode 100644 index 0000000..cd64a16 --- /dev/null +++ b/backend/alembic.ini @@ -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 diff --git a/backend/alembic/env.py b/backend/alembic/env.py new file mode 100644 index 0000000..6a6a0e2 --- /dev/null +++ b/backend/alembic/env.py @@ -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() diff --git a/backend/alembic/script.py.mako b/backend/alembic/script.py.mako new file mode 100644 index 0000000..fbc4b07 --- /dev/null +++ b/backend/alembic/script.py.mako @@ -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"} diff --git a/backend/alembic/versions/.gitkeep b/backend/alembic/versions/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/__init__.py b/backend/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/auth/__init__.py b/backend/app/auth/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/auth/jwt.py b/backend/app/auth/jwt.py new file mode 100644 index 0000000..bd3a938 --- /dev/null +++ b/backend/app/auth/jwt.py @@ -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") diff --git a/backend/app/config.py b/backend/app/config.py new file mode 100644 index 0000000..88711ec --- /dev/null +++ b/backend/app/config.py @@ -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() diff --git a/backend/app/database.py b/backend/app/database.py new file mode 100644 index 0000000..9e03ea2 --- /dev/null +++ b/backend/app/database.py @@ -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, +) diff --git a/backend/app/dependencies.py b/backend/app/dependencies.py new file mode 100644 index 0000000..3f66cf2 --- /dev/null +++ b/backend/app/dependencies.py @@ -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 diff --git a/backend/app/main.py b/backend/app/main.py new file mode 100644 index 0000000..6f7cbcb --- /dev/null +++ b/backend/app/main.py @@ -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) diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py new file mode 100644 index 0000000..91b7e6c --- /dev/null +++ b/backend/app/models/__init__.py @@ -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", +] diff --git a/backend/app/models/activity.py b/backend/app/models/activity.py new file mode 100644 index 0000000..d65ef9e --- /dev/null +++ b/backend/app/models/activity.py @@ -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 diff --git a/backend/app/models/base.py b/backend/app/models/base.py new file mode 100644 index 0000000..0176889 --- /dev/null +++ b/backend/app/models/base.py @@ -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() + ) diff --git a/backend/app/models/child.py b/backend/app/models/child.py new file mode 100644 index 0000000..c78c875 --- /dev/null +++ b/backend/app/models/child.py @@ -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" + ) diff --git a/backend/app/models/schedule.py b/backend/app/models/schedule.py new file mode 100644 index 0000000..d5cad38 --- /dev/null +++ b/backend/app/models/schedule.py @@ -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 diff --git a/backend/app/models/session.py b/backend/app/models/session.py new file mode 100644 index 0000000..7d931fa --- /dev/null +++ b/backend/app/models/session.py @@ -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 diff --git a/backend/app/models/subject.py b/backend/app/models/subject.py new file mode 100644 index 0000000..3db2441 --- /dev/null +++ b/backend/app/models/subject.py @@ -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" + ) diff --git a/backend/app/models/user.py b/backend/app/models/user.py new file mode 100644 index 0000000..8ccd294 --- /dev/null +++ b/backend/app/models/user.py @@ -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" + ) diff --git a/backend/app/routers/__init__.py b/backend/app/routers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py new file mode 100644 index 0000000..510bbf1 --- /dev/null +++ b/backend/app/routers/auth.py @@ -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"} diff --git a/backend/app/routers/children.py b/backend/app/routers/children.py new file mode 100644 index 0000000..9033659 --- /dev/null +++ b/backend/app/routers/children.py @@ -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() diff --git a/backend/app/routers/dashboard.py b/backend/app/routers/dashboard.py new file mode 100644 index 0000000..5d7b348 --- /dev/null +++ b/backend/app/routers/dashboard.py @@ -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, + ) diff --git a/backend/app/routers/logs.py b/backend/app/routers/logs.py new file mode 100644 index 0000000..e1729db --- /dev/null +++ b/backend/app/routers/logs.py @@ -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() diff --git a/backend/app/routers/schedules.py b/backend/app/routers/schedules.py new file mode 100644 index 0000000..95d38e8 --- /dev/null +++ b/backend/app/routers/schedules.py @@ -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() diff --git a/backend/app/routers/sessions.py b/backend/app/routers/sessions.py new file mode 100644 index 0000000..c463916 --- /dev/null +++ b/backend/app/routers/sessions.py @@ -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 diff --git a/backend/app/routers/subjects.py b/backend/app/routers/subjects.py new file mode 100644 index 0000000..69411f6 --- /dev/null +++ b/backend/app/routers/subjects.py @@ -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() diff --git a/backend/app/routers/users.py b/backend/app/routers/users.py new file mode 100644 index 0000000..587432e --- /dev/null +++ b/backend/app/routers/users.py @@ -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 diff --git a/backend/app/schemas/__init__.py b/backend/app/schemas/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/schemas/activity.py b/backend/app/schemas/activity.py new file mode 100644 index 0000000..fb410db --- /dev/null +++ b/backend/app/schemas/activity.py @@ -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} diff --git a/backend/app/schemas/auth.py b/backend/app/schemas/auth.py new file mode 100644 index 0000000..97675e0 --- /dev/null +++ b/backend/app/schemas/auth.py @@ -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 diff --git a/backend/app/schemas/child.py b/backend/app/schemas/child.py new file mode 100644 index 0000000..3221356 --- /dev/null +++ b/backend/app/schemas/child.py @@ -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} diff --git a/backend/app/schemas/schedule.py b/backend/app/schemas/schedule.py new file mode 100644 index 0000000..cd162b3 --- /dev/null +++ b/backend/app/schemas/schedule.py @@ -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} diff --git a/backend/app/schemas/session.py b/backend/app/schemas/session.py new file mode 100644 index 0000000..e0c6318 --- /dev/null +++ b/backend/app/schemas/session.py @@ -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] = [] diff --git a/backend/app/schemas/subject.py b/backend/app/schemas/subject.py new file mode 100644 index 0000000..4c03faf --- /dev/null +++ b/backend/app/schemas/subject.py @@ -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} diff --git a/backend/app/schemas/user.py b/backend/app/schemas/user.py new file mode 100644 index 0000000..071309b --- /dev/null +++ b/backend/app/schemas/user.py @@ -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 diff --git a/backend/app/websocket/__init__.py b/backend/app/websocket/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/websocket/manager.py b/backend/app/websocket/manager.py new file mode 100644 index 0000000..37b052a --- /dev/null +++ b/backend/app/websocket/manager.py @@ -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() diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..0025d52 --- /dev/null +++ b/backend/requirements.txt @@ -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 diff --git a/docker-compose.override.yml b/docker-compose.override.yml new file mode 100644 index 0000000..39bea69 --- /dev/null +++ b/docker-compose.override.yml @@ -0,0 +1,17 @@ +# Development overrides โ hot reload for backend, Vite dev server for frontend +services: + backend: + volumes: + - ./backend/app:/app/app + command: uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload + + frontend: + build: + context: ./frontend + target: builder + ports: + - "8054:5173" + volumes: + - ./frontend/src:/app/src + - ./frontend/index.html:/app/index.html + command: npm run dev -- --host 0.0.0.0 --port 5173 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..93b1236 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,54 @@ +services: + db: + image: mysql:8.0 + container_name: homeschool_db + restart: unless-stopped + environment: + MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD} + MYSQL_DATABASE: ${MYSQL_DATABASE} + MYSQL_USER: ${MYSQL_USER} + MYSQL_PASSWORD: ${MYSQL_PASSWORD} + volumes: + - mysql_data:/var/lib/mysql + networks: + - homeschool_net + healthcheck: + test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-p${MYSQL_ROOT_PASSWORD}"] + interval: 10s + timeout: 5s + retries: 5 + + backend: + build: ./backend + container_name: homeschool_backend + restart: unless-stopped + environment: + DATABASE_URL: mysql+aiomysql://${MYSQL_USER}:${MYSQL_PASSWORD}@db/${MYSQL_DATABASE} + SECRET_KEY: ${SECRET_KEY} + ALGORITHM: ${ALGORITHM:-HS256} + ACCESS_TOKEN_EXPIRE_MINUTES: ${ACCESS_TOKEN_EXPIRE_MINUTES:-30} + REFRESH_TOKEN_EXPIRE_DAYS: ${REFRESH_TOKEN_EXPIRE_DAYS:-30} + CORS_ORIGINS: ${CORS_ORIGINS:-http://localhost:8054} + depends_on: + db: + condition: service_healthy + networks: + - homeschool_net + + frontend: + build: ./frontend + container_name: homeschool_frontend + restart: unless-stopped + ports: + - "8054:80" + depends_on: + - backend + networks: + - homeschool_net + +networks: + homeschool_net: + driver: bridge + +volumes: + mysql_data: diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..6b6fff3 --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,18 @@ +# Stage 1: Build Vue.js app +FROM node:20-alpine AS builder + +WORKDIR /app + +COPY package.json package-lock.json* ./ +RUN npm install + +COPY . . +RUN npm run build + +# Stage 2: Serve with nginx +FROM nginx:alpine + +COPY --from=builder /app/dist /usr/share/nginx/html +COPY nginx.conf /etc/nginx/conf.d/default.conf + +EXPOSE 80 diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..9ae8a15 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,12 @@ + + +
+ + +Add a child in
No active session.
+ +Sign in to manage your homeschool
+ +