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:
0
backend/app/routers/__init__.py
Normal file
0
backend/app/routers/__init__.py
Normal file
90
backend/app/routers/auth.py
Normal file
90
backend/app/routers/auth.py
Normal 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"}
|
||||
86
backend/app/routers/children.py
Normal file
86
backend/app/routers/children.py
Normal 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()
|
||||
65
backend/app/routers/dashboard.py
Normal file
65
backend/app/routers/dashboard.py
Normal 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,
|
||||
)
|
||||
95
backend/app/routers/logs.py
Normal file
95
backend/app/routers/logs.py
Normal 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()
|
||||
165
backend/app/routers/schedules.py
Normal file
165
backend/app/routers/schedules.py
Normal 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()
|
||||
159
backend/app/routers/sessions.py
Normal file
159
backend/app/routers/sessions.py
Normal 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
|
||||
86
backend/app/routers/subjects.py
Normal file
86
backend/app/routers/subjects.py
Normal 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()
|
||||
28
backend/app/routers/users.py
Normal file
28
backend/app/routers/users.py
Normal 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
|
||||
Reference in New Issue
Block a user