Initial project scaffold

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

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

View File

@@ -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"
)