Cards flow
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -78,6 +78,44 @@ class StudentProgramAssignment(Base):
|
||||
title: Mapped[str] = mapped_column(String(255))
|
||||
subject: Mapped[str] = mapped_column(String(100))
|
||||
grade: Mapped[str] = mapped_column(String(50), index=True)
|
||||
start_asset_index: Mapped[int] = mapped_column(Integer, default=0)
|
||||
start_section_index: Mapped[int] = mapped_column(Integer, default=0)
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
|
||||
student = relationship("Student")
|
||||
|
||||
|
||||
class LessonSessionLog(Base):
|
||||
__tablename__ = "lesson_session_logs"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
student_id: Mapped[int] = mapped_column(ForeignKey("students.id"), index=True)
|
||||
lesson_id: Mapped[str] = mapped_column(String(500), index=True)
|
||||
lesson_title: Mapped[str] = mapped_column(String(255))
|
||||
started_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||
ended_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||
conversation: Mapped[str] = mapped_column(Text)
|
||||
card_results: Mapped[str] = mapped_column(Text)
|
||||
|
||||
student = relationship("Student")
|
||||
|
||||
|
||||
class LessonCardProgress(Base):
|
||||
__tablename__ = "lesson_card_progress"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("student_id", "lesson_id", "asset_path", "section_key", name="uq_student_lesson_card"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
student_id: Mapped[int] = mapped_column(ForeignKey("students.id"), index=True)
|
||||
lesson_id: Mapped[str] = mapped_column(String(500), index=True)
|
||||
asset_path: Mapped[str] = mapped_column(String(500), index=True)
|
||||
asset_title: Mapped[str] = mapped_column(String(255))
|
||||
section_key: Mapped[str] = mapped_column(String(120), index=True)
|
||||
section_title: Mapped[str] = mapped_column(String(255))
|
||||
comprehension_score: Mapped[float] = mapped_column(Float, default=0.0)
|
||||
attempts: Mapped[int] = mapped_column(Integer, default=0)
|
||||
validated_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
|
||||
student = relationship("Student")
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from pydantic import BaseModel, Field
|
||||
from datetime import datetime
|
||||
from typing import List
|
||||
from typing import Any, List
|
||||
|
||||
|
||||
class StudentCreate(BaseModel):
|
||||
@@ -137,6 +137,63 @@ class StudentProgramAssignRequest(BaseModel):
|
||||
class StudentProgramResponse(BaseModel):
|
||||
student: StudentRead
|
||||
lesson: ProgramLesson | None = None
|
||||
start_asset_index: int = 0
|
||||
start_section_index: int = 0
|
||||
|
||||
|
||||
class StudentProgramStartRequest(BaseModel):
|
||||
asset_index: int = Field(..., ge=0)
|
||||
section_index: int = Field(..., ge=0)
|
||||
|
||||
|
||||
class LessonCardState(BaseModel):
|
||||
lesson_id: str
|
||||
asset_path: str
|
||||
asset_title: str
|
||||
section_key: str
|
||||
section_title: str
|
||||
comprehension_score: float = Field(..., ge=0, le=100)
|
||||
attempts: int = Field(0, ge=0)
|
||||
validated: bool = False
|
||||
|
||||
|
||||
class LessonSessionEndRequest(BaseModel):
|
||||
student_id: int
|
||||
lesson_id: str
|
||||
lesson_title: str
|
||||
conversation: List[dict[str, Any]]
|
||||
card_states: List[LessonCardState] = []
|
||||
|
||||
|
||||
class LessonSessionLogRead(BaseModel):
|
||||
id: int
|
||||
student_id: int
|
||||
lesson_id: str
|
||||
lesson_title: str
|
||||
started_at: datetime
|
||||
ended_at: datetime
|
||||
conversation: str
|
||||
card_results: str
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class LessonCardProgressRead(BaseModel):
|
||||
id: int
|
||||
student_id: int
|
||||
lesson_id: str
|
||||
asset_path: str
|
||||
asset_title: str
|
||||
section_key: str
|
||||
section_title: str
|
||||
comprehension_score: float
|
||||
attempts: int
|
||||
validated_at: datetime | None = None
|
||||
updated_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class AssessmentQuestionResponse(BaseModel):
|
||||
|
||||
Reference in New Issue
Block a user