diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 91b7e6c..0d01900 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -2,7 +2,7 @@ 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.subject import Subject, SubjectOption from app.models.schedule import ScheduleTemplate, ScheduleBlock from app.models.session import DailySession, TimerEvent, TimerEventType from app.models.activity import ActivityLog @@ -13,6 +13,7 @@ __all__ = [ "User", "Child", "Subject", + "SubjectOption", "ScheduleTemplate", "ScheduleBlock", "DailySession", diff --git a/backend/app/models/subject.py b/backend/app/models/subject.py index 3db2441..d8fb914 100644 --- a/backend/app/models/subject.py +++ b/backend/app/models/subject.py @@ -1,4 +1,4 @@ -from sqlalchemy import String, Boolean, ForeignKey +from sqlalchemy import String, Boolean, ForeignKey, Text, Integer from sqlalchemy.orm import Mapped, mapped_column, relationship from app.models.base import Base, TimestampMixin @@ -20,3 +20,20 @@ class Subject(TimestampMixin, Base): activity_logs: Mapped[list["ActivityLog"]] = relationship( # noqa: F821 "ActivityLog", back_populates="subject" ) + options: Mapped[list["SubjectOption"]] = relationship( + "SubjectOption", back_populates="subject", cascade="all, delete-orphan", + order_by="SubjectOption.order_index" + ) + + +class SubjectOption(Base): + __tablename__ = "subject_options" + + id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) + subject_id: Mapped[int] = mapped_column( + ForeignKey("subjects.id", ondelete="CASCADE"), nullable=False + ) + text: Mapped[str] = mapped_column(Text, nullable=False) + order_index: Mapped[int] = mapped_column(Integer, default=0) + + subject: Mapped["Subject"] = relationship("Subject", back_populates="options") diff --git a/backend/app/routers/dashboard.py b/backend/app/routers/dashboard.py index a32ba33..9201ca8 100644 --- a/backend/app/routers/dashboard.py +++ b/backend/app/routers/dashboard.py @@ -12,6 +12,7 @@ from sqlalchemy.orm import selectinload from app.dependencies import get_db from app.models.child import Child from app.models.schedule import ScheduleBlock, ScheduleTemplate +from app.models.subject import Subject # noqa: F401 — needed for selectinload chain from app.models.session import DailySession, TimerEvent from app.schemas.session import DashboardSnapshot @@ -48,6 +49,7 @@ async def get_dashboard(child_id: int, db: AsyncSession = Depends(get_db)): blocks_result = await db.execute( select(ScheduleBlock) .where(ScheduleBlock.template_id == session.template_id) + .options(selectinload(ScheduleBlock.subject).selectinload(Subject.options)) .order_by(ScheduleBlock.order_index) ) blocks = blocks_result.scalars().all() diff --git a/backend/app/routers/schedules.py b/backend/app/routers/schedules.py index e1f0cfc..e3f609b 100644 --- a/backend/app/routers/schedules.py +++ b/backend/app/routers/schedules.py @@ -5,6 +5,7 @@ from sqlalchemy.orm import selectinload from app.dependencies import get_db, get_current_user from app.models.schedule import ScheduleTemplate, ScheduleBlock +from app.models.subject import Subject # noqa: F401 — for selectinload chain from app.models.user import User from app.schemas.schedule import ( ScheduleTemplateCreate, @@ -26,7 +27,7 @@ async def list_templates( result = await db.execute( select(ScheduleTemplate) .where(ScheduleTemplate.user_id == current_user.id) - .options(selectinload(ScheduleTemplate.blocks)) + .options(selectinload(ScheduleTemplate.blocks).selectinload(ScheduleBlock.subject).selectinload(Subject.options)) .order_by(ScheduleTemplate.name) ) return result.scalars().all() @@ -60,7 +61,7 @@ async def create_template( result = await db.execute( select(ScheduleTemplate) .where(ScheduleTemplate.id == template.id) - .options(selectinload(ScheduleTemplate.blocks)) + .options(selectinload(ScheduleTemplate.blocks).selectinload(ScheduleBlock.subject).selectinload(Subject.options)) ) return result.scalar_one() @@ -74,7 +75,7 @@ async def get_template( result = await db.execute( select(ScheduleTemplate) .where(ScheduleTemplate.id == template_id, ScheduleTemplate.user_id == current_user.id) - .options(selectinload(ScheduleTemplate.blocks)) + .options(selectinload(ScheduleTemplate.blocks).selectinload(ScheduleBlock.subject).selectinload(Subject.options)) ) template = result.scalar_one_or_none() if not template: @@ -92,7 +93,7 @@ async def update_template( result = await db.execute( select(ScheduleTemplate) .where(ScheduleTemplate.id == template_id, ScheduleTemplate.user_id == current_user.id) - .options(selectinload(ScheduleTemplate.blocks)) + .options(selectinload(ScheduleTemplate.blocks).selectinload(ScheduleBlock.subject).selectinload(Subject.options)) ) template = result.scalar_one_or_none() if not template: @@ -141,8 +142,12 @@ async def add_block( block = ScheduleBlock(template_id=template_id, **body.model_dump()) db.add(block) await db.commit() - await db.refresh(block) - return block + result = await db.execute( + select(ScheduleBlock) + .where(ScheduleBlock.id == block.id) + .options(selectinload(ScheduleBlock.subject).selectinload(Subject.options)) + ) + return result.scalar_one() @router.patch("/{template_id}/blocks/{block_id}", response_model=ScheduleBlockOut) @@ -169,8 +174,12 @@ async def update_block( for field, value in body.model_dump(exclude_unset=True).items(): setattr(block, field, value) await db.commit() - await db.refresh(block) - return block + result = await db.execute( + select(ScheduleBlock) + .where(ScheduleBlock.id == block.id) + .options(selectinload(ScheduleBlock.subject).selectinload(Subject.options)) + ) + return result.scalar_one() @router.delete("/{template_id}/blocks/{block_id}", status_code=status.HTTP_204_NO_CONTENT) diff --git a/backend/app/routers/sessions.py b/backend/app/routers/sessions.py index 03c7d68..90d212e 100644 --- a/backend/app/routers/sessions.py +++ b/backend/app/routers/sessions.py @@ -8,6 +8,7 @@ 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.subject import Subject # noqa: F401 — needed for selectinload chain from app.models.session import DailySession, TimerEvent from app.models.user import User from app.schemas.session import DailySessionOut, SessionStart, TimerAction @@ -26,12 +27,21 @@ async def _broadcast_session(db: AsyncSession, session: DailySession) -> None: blocks_result = await db.execute( select(ScheduleBlock) .where(ScheduleBlock.template_id == session.template_id) + .options(selectinload(ScheduleBlock.subject).selectinload(Subject.options)) .order_by(ScheduleBlock.order_index) ) blocks = [ { "id": b.id, "subject_id": b.subject_id, + "subject": { + "id": b.subject.id, + "name": b.subject.name, + "color": b.subject.color, + "icon": b.subject.icon, + "options": [{"id": o.id, "text": o.text, "order_index": o.order_index} + for o in b.subject.options], + } if b.subject else None, "time_start": str(b.time_start), "time_end": str(b.time_end), "duration_minutes": b.duration_minutes, diff --git a/backend/app/routers/subjects.py b/backend/app/routers/subjects.py index 69411f6..264f8b6 100644 --- a/backend/app/routers/subjects.py +++ b/backend/app/routers/subjects.py @@ -1,22 +1,33 @@ 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.subject import Subject +from app.models.subject import Subject, SubjectOption from app.models.user import User -from app.schemas.subject import SubjectCreate, SubjectOut, SubjectUpdate +from app.schemas.subject import ( + SubjectCreate, SubjectOut, SubjectUpdate, + SubjectOptionCreate, SubjectOptionUpdate, SubjectOptionOut, +) router = APIRouter(prefix="/api/subjects", tags=["subjects"]) +def _opts(): + return selectinload(Subject.options) + + @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) + select(Subject) + .where(Subject.user_id == current_user.id) + .options(_opts()) + .order_by(Subject.name) ) return result.scalars().all() @@ -30,8 +41,10 @@ async def create_subject( subject = Subject(**body.model_dump(), user_id=current_user.id) db.add(subject) await db.commit() - await db.refresh(subject) - return subject + result = await db.execute( + select(Subject).where(Subject.id == subject.id).options(_opts()) + ) + return result.scalar_one() @router.get("/{subject_id}", response_model=SubjectOut) @@ -41,7 +54,9 @@ async def get_subject( db: AsyncSession = Depends(get_db), ): result = await db.execute( - select(Subject).where(Subject.id == subject_id, Subject.user_id == current_user.id) + select(Subject) + .where(Subject.id == subject_id, Subject.user_id == current_user.id) + .options(_opts()) ) subject = result.scalar_one_or_none() if not subject: @@ -57,7 +72,9 @@ async def update_subject( db: AsyncSession = Depends(get_db), ): result = await db.execute( - select(Subject).where(Subject.id == subject_id, Subject.user_id == current_user.id) + select(Subject) + .where(Subject.id == subject_id, Subject.user_id == current_user.id) + .options(_opts()) ) subject = result.scalar_one_or_none() if not subject: @@ -66,8 +83,10 @@ async def update_subject( for field, value in body.model_dump(exclude_none=True).items(): setattr(subject, field, value) await db.commit() - await db.refresh(subject) - return subject + result = await db.execute( + select(Subject).where(Subject.id == subject.id).options(_opts()) + ) + return result.scalar_one() @router.delete("/{subject_id}", status_code=status.HTTP_204_NO_CONTENT) @@ -84,3 +103,76 @@ async def delete_subject( raise HTTPException(status_code=404, detail="Subject not found") await db.delete(subject) await db.commit() + + +# --- Subject Option sub-routes --- + +@router.post("/{subject_id}/options", response_model=SubjectOptionOut, status_code=status.HTTP_201_CREATED) +async def add_option( + subject_id: int, + body: SubjectOptionCreate, + 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) + ) + if not result.scalar_one_or_none(): + raise HTTPException(status_code=404, detail="Subject not found") + + option = SubjectOption(subject_id=subject_id, **body.model_dump()) + db.add(option) + await db.commit() + await db.refresh(option) + return option + + +@router.patch("/{subject_id}/options/{option_id}", response_model=SubjectOptionOut) +async def update_option( + subject_id: int, + option_id: int, + body: SubjectOptionUpdate, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + result = await db.execute( + select(SubjectOption) + .join(Subject) + .where( + SubjectOption.id == option_id, + SubjectOption.subject_id == subject_id, + Subject.user_id == current_user.id, + ) + ) + option = result.scalar_one_or_none() + if not option: + raise HTTPException(status_code=404, detail="Option not found") + + for field, value in body.model_dump(exclude_unset=True).items(): + setattr(option, field, value) + await db.commit() + await db.refresh(option) + return option + + +@router.delete("/{subject_id}/options/{option_id}", status_code=status.HTTP_204_NO_CONTENT) +async def delete_option( + subject_id: int, + option_id: int, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + result = await db.execute( + select(SubjectOption) + .join(Subject) + .where( + SubjectOption.id == option_id, + SubjectOption.subject_id == subject_id, + Subject.user_id == current_user.id, + ) + ) + option = result.scalar_one_or_none() + if not option: + raise HTTPException(status_code=404, detail="Option not found") + await db.delete(option) + await db.commit() diff --git a/backend/app/schemas/schedule.py b/backend/app/schemas/schedule.py index 3be64db..448bdfb 100644 --- a/backend/app/schemas/schedule.py +++ b/backend/app/schemas/schedule.py @@ -1,5 +1,6 @@ from datetime import time from pydantic import BaseModel +from app.schemas.subject import SubjectOut class ScheduleBlockCreate(BaseModel): @@ -25,6 +26,7 @@ class ScheduleBlockUpdate(BaseModel): class ScheduleBlockOut(BaseModel): id: int subject_id: int | None + subject: SubjectOut | None = None time_start: time time_end: time duration_minutes: int | None diff --git a/backend/app/schemas/subject.py b/backend/app/schemas/subject.py index 4c03faf..d7ef68b 100644 --- a/backend/app/schemas/subject.py +++ b/backend/app/schemas/subject.py @@ -1,6 +1,23 @@ from pydantic import BaseModel +class SubjectOptionCreate(BaseModel): + text: str + order_index: int = 0 + + +class SubjectOptionUpdate(BaseModel): + text: str | None = None + + +class SubjectOptionOut(BaseModel): + id: int + text: str + order_index: int + + model_config = {"from_attributes": True} + + class SubjectCreate(BaseModel): name: str color: str = "#10B981" @@ -20,5 +37,6 @@ class SubjectOut(BaseModel): color: str icon: str is_active: bool + options: list[SubjectOptionOut] = [] model_config = {"from_attributes": True} diff --git a/frontend/src/components/ScheduleBlock.vue b/frontend/src/components/ScheduleBlock.vue index da5006f..3f9b369 100644 --- a/frontend/src/components/ScheduleBlock.vue +++ b/frontend/src/components/ScheduleBlock.vue @@ -10,7 +10,8 @@