Cards flow

This commit is contained in:
2026-05-02 19:38:30 +02:00
parent 11e63f7758
commit 6ddb120c94
11 changed files with 681 additions and 25 deletions

View File

@@ -1,9 +1,11 @@
from contextlib import asynccontextmanager
import json
import os
from fastapi import Depends, FastAPI, File, HTTPException, Response, UploadFile
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse
from sqlalchemy.orm import Session
from sqlalchemy import inspect, text
from .database import Base, engine, get_db
from . import models, schemas
from .auth import (
@@ -40,6 +42,7 @@ from .services import (
@asynccontextmanager
async def lifespan(app: FastAPI):
Base.metadata.create_all(bind=engine)
ensure_lightweight_migrations()
db = next(get_db())
try:
seed_skills(db)
@@ -64,6 +67,21 @@ app.add_middleware(
)
def ensure_lightweight_migrations() -> None:
inspector = inspect(engine)
if "student_program_assignments" in inspector.get_table_names():
columns = {column["name"] for column in inspector.get_columns("student_program_assignments")}
with engine.begin() as connection:
if "start_asset_index" not in columns:
connection.execute(
text("ALTER TABLE student_program_assignments ADD COLUMN start_asset_index INTEGER DEFAULT 0")
)
if "start_section_index" not in columns:
connection.execute(
text("ALTER TABLE student_program_assignments ADD COLUMN start_section_index INTEGER DEFAULT 0")
)
def ensure_student_access(current_user: models.User, student_id: int) -> None:
if current_user.role in {"teacher", "maintenance"}:
return
@@ -74,7 +92,54 @@ def ensure_student_access(current_user: models.User, student_id: int) -> None:
def build_student_program_response(student: models.Student, assignment: models.StudentProgramAssignment | None):
lesson = get_lesson(assignment.lesson_id) if assignment else None
return schemas.StudentProgramResponse(student=student, lesson=lesson)
return schemas.StudentProgramResponse(
student=student,
lesson=lesson,
start_asset_index=assignment.start_asset_index if assignment else 0,
start_section_index=assignment.start_section_index if assignment else 0,
)
def clamp_lesson_position(lesson: dict, asset_index: int, section_index: int) -> tuple[int, int]:
assets = lesson.get("assets") or []
if not assets:
return 0, 0
safe_asset_index = min(max(asset_index, 0), len(assets) - 1)
sections = assets[safe_asset_index].get("sections") or []
safe_section_index = min(max(section_index, 0), max(len(sections) - 1, 0))
return safe_asset_index, safe_section_index
def upsert_card_progress(db: Session, student_id: int, state: schemas.LessonCardState) -> models.LessonCardProgress:
row = (
db.query(models.LessonCardProgress)
.filter_by(
student_id=student_id,
lesson_id=state.lesson_id,
asset_path=state.asset_path,
section_key=state.section_key,
)
.first()
)
if not row:
row = models.LessonCardProgress(
student_id=student_id,
lesson_id=state.lesson_id,
asset_path=state.asset_path,
asset_title=state.asset_title,
section_key=state.section_key,
section_title=state.section_title,
)
row.asset_title = state.asset_title
row.section_title = state.section_title
row.comprehension_score = max(row.comprehension_score or 0, state.comprehension_score)
row.attempts = max(row.attempts or 0, state.attempts)
if state.validated and row.validated_at is None:
from datetime import datetime
row.validated_at = datetime.utcnow()
db.add(row)
return row
@app.get("/health")
def health():
@@ -282,6 +347,108 @@ def assign_student_program(
return build_student_program_response(student, assignment)
@app.put("/admin/students/{student_id}/program/start", response_model=schemas.StudentProgramResponse)
def update_student_program_start(
student_id: int,
payload: schemas.StudentProgramStartRequest,
db: Session = Depends(get_db),
current_user: models.User = Depends(require_roles("teacher", "maintenance")),
):
student = db.query(models.Student).filter_by(id=student_id).first()
if not student:
raise HTTPException(status_code=404, detail="Élève introuvable")
assignment = db.query(models.StudentProgramAssignment).filter_by(student_id=student_id).first()
if not assignment:
raise HTTPException(status_code=404, detail="Aucune leçon active")
lesson = get_lesson(assignment.lesson_id)
if not lesson:
raise HTTPException(status_code=404, detail="Leçon introuvable")
asset_index, section_index = clamp_lesson_position(lesson, payload.asset_index, payload.section_index)
assignment.start_asset_index = asset_index
assignment.start_section_index = section_index
db.add(assignment)
db.commit()
db.refresh(assignment)
return build_student_program_response(student, assignment)
@app.get("/students/{student_id}/card-progress", response_model=list[schemas.LessonCardProgressRead])
def list_card_progress(
student_id: int,
lesson_id: str | None = None,
db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user),
):
ensure_student_access(current_user, student_id)
query = db.query(models.LessonCardProgress).filter_by(student_id=student_id)
if lesson_id:
query = query.filter_by(lesson_id=lesson_id)
return query.order_by(models.LessonCardProgress.updated_at.desc()).all()
@app.post("/session/end", response_model=schemas.LessonSessionLogRead)
def end_lesson_session(
payload: schemas.LessonSessionEndRequest,
db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user),
):
ensure_student_access(current_user, payload.student_id)
student = db.query(models.Student).filter_by(id=payload.student_id).first()
if not student:
raise HTTPException(status_code=404, detail="Élève introuvable")
for state in payload.card_states:
upsert_card_progress(db, student.id, state)
conversation_json = json.dumps(
payload.conversation,
ensure_ascii=False,
)
card_results_json = json.dumps(
[state.model_dump(mode="json") for state in payload.card_states],
ensure_ascii=False,
)
log = models.LessonSessionLog(
student_id=student.id,
lesson_id=payload.lesson_id,
lesson_title=payload.lesson_title,
conversation=conversation_json,
card_results=card_results_json,
)
db.add(log)
db.commit()
db.refresh(log)
return log
@app.get("/admin/students/{student_id}/session-logs", response_model=list[schemas.LessonSessionLogRead])
def list_session_logs(
student_id: int,
db: Session = Depends(get_db),
current_user: models.User = Depends(require_roles("teacher", "maintenance")),
):
return (
db.query(models.LessonSessionLog)
.filter_by(student_id=student_id)
.order_by(models.LessonSessionLog.ended_at.desc(), models.LessonSessionLog.id.desc())
.all()
)
@app.delete("/admin/session-logs/{log_id}")
def delete_session_log(
log_id: int,
db: Session = Depends(get_db),
current_user: models.User = Depends(require_roles("teacher", "maintenance")),
):
log = db.query(models.LessonSessionLog).filter_by(id=log_id).first()
if not log:
raise HTTPException(status_code=404, detail="Log introuvable")
db.delete(log)
db.commit()
return {"status": "ok"}
@app.post("/session/start", response_model=schemas.ChatResponse)
def start_session(
student_id: int,
@@ -301,8 +468,14 @@ def start_session(
assignment = db.query(models.StudentProgramAssignment).filter_by(student_id=student.id).first()
assigned_lesson = get_lesson(assignment.lesson_id) if assignment else None
if assigned_lesson:
first_asset = (assigned_lesson.get("assets") or [None])[0]
first_section = (first_asset.get("sections") or [None])[0] if first_asset else None
asset_index, section_index = clamp_lesson_position(
assigned_lesson,
assignment.start_asset_index if assignment else 0,
assignment.start_section_index if assignment else 0,
)
first_asset = (assigned_lesson.get("assets") or [None])[asset_index]
first_sections = first_asset.get("sections") or [] if first_asset else []
first_section = first_sections[section_index] if first_sections else None
section_label = first_section["title"] if first_section else first_asset["title"] if first_asset else assigned_lesson["title"]
lesson_context = (
f"Leçon courante: {assigned_lesson['title']}\n"