Files
open-school/backend/app/main.py
2026-04-25 20:48:31 +02:00

340 lines
11 KiB
Python

from contextlib import asynccontextmanager
import os
from fastapi import Depends, FastAPI, File, HTTPException, Response, UploadFile
from fastapi.middleware.cors import CORSMiddleware
from sqlalchemy.orm import Session
from .database import Base, engine, get_db
from . import models, schemas
from .auth import (
AUTH_COOKIE_NAME,
create_access_token,
get_current_user,
hash_password,
require_roles,
seed_admin_user,
verify_password,
)
from .curriculum import QUESTIONS
from .services import (
build_llm_reply,
ensure_student_mastery,
evaluate_answer,
list_tts_profiles,
pick_next_skill,
seed_skills,
synthesize_speech,
transcribe_audio,
)
@asynccontextmanager
async def lifespan(app: FastAPI):
Base.metadata.create_all(bind=engine)
db = next(get_db())
try:
seed_skills(db)
seed_admin_user(db)
finally:
db.close()
yield
app = FastAPI(title="Professeur Virtuel API", version="0.1.0", lifespan=lifespan)
app.add_middleware(
CORSMiddleware,
allow_origins=[
"https://prof.open-squared.tech",
"http://localhost:3000",
"http://localhost:3001",
],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
def ensure_student_access(current_user: models.User, student_id: int) -> None:
if current_user.role in {"teacher", "maintenance"}:
return
if current_user.role == "student" and current_user.student_id == student_id:
return
raise HTTPException(status_code=403, detail="Droits insuffisants pour cet eleve")
@app.get("/health")
def health():
return {"status": "ok"}
@app.post("/auth/login", response_model=schemas.LoginResponse)
def login(payload: schemas.LoginRequest, response: Response, db: Session = Depends(get_db)):
user = db.query(models.User).filter_by(username=payload.username).first()
if not user or not verify_password(payload.password, user.password_hash):
raise HTTPException(status_code=401, detail="Identifiants invalides")
token = create_access_token(user)
response.set_cookie(
key=AUTH_COOKIE_NAME,
value=token,
httponly=True,
secure=os.getenv("AUTH_COOKIE_SECURE", "false").lower() == "true",
samesite=os.getenv("AUTH_COOKIE_SAMESITE", "lax"),
max_age=int(os.getenv("ACCESS_TOKEN_EXPIRE_MINUTES", "720")) * 60,
path="/",
)
return schemas.LoginResponse(access_token=token, user=user)
@app.get("/auth/me", response_model=schemas.UserRead)
def auth_me(current_user: models.User = Depends(get_current_user)):
return current_user
@app.post("/auth/logout")
def logout(response: Response):
response.delete_cookie(key=AUTH_COOKIE_NAME, path="/")
return {"status": "ok"}
@app.get("/students", response_model=list[schemas.StudentRead])
def list_students(
db: Session = Depends(get_db),
current_user: models.User = Depends(require_roles("teacher", "maintenance")),
):
return db.query(models.Student).order_by(models.Student.id.asc()).all()
@app.post("/students", response_model=schemas.StudentRead)
def create_student(
payload: schemas.StudentCreate,
db: Session = Depends(get_db),
current_user: models.User = Depends(require_roles("teacher", "maintenance")),
):
student = models.Student(**payload.model_dump())
db.add(student)
db.commit()
db.refresh(student)
ensure_student_mastery(db, student)
return student
@app.post("/admin/student-accounts", response_model=schemas.StudentAccountResponse)
def create_student_account(
payload: schemas.StudentAccountCreate,
db: Session = Depends(get_db),
current_user: models.User = Depends(require_roles("teacher", "maintenance")),
):
username = payload.username.strip()
first_name = payload.first_name.strip()
if not username or not first_name:
raise HTTPException(status_code=400, detail="Prenom et identifiant obligatoires")
existing_user = db.query(models.User).filter_by(username=username).first()
if existing_user:
raise HTTPException(status_code=409, detail="Identifiant deja utilise")
student = models.Student(
first_name=first_name,
age=payload.age,
grade=payload.grade,
)
db.add(student)
db.commit()
db.refresh(student)
ensure_student_mastery(db, student)
user = models.User(
username=username,
password_hash=hash_password(payload.password),
role="student",
student_id=student.id,
)
db.add(user)
db.commit()
db.refresh(user)
return schemas.StudentAccountResponse(student=student, user=user)
@app.get("/students/{student_id}/messages", response_model=list[schemas.MessageRead])
def list_student_messages(
student_id: int,
limit: int = 80,
db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user),
):
ensure_student_access(current_user, student_id)
student = db.query(models.Student).filter_by(id=student_id).first()
if not student:
raise HTTPException(status_code=404, detail="Eleve introuvable")
safe_limit = min(max(limit, 1), 200)
rows = (
db.query(models.Message)
.filter_by(student_id=student_id)
.order_by(models.Message.created_at.desc(), models.Message.id.desc())
.limit(safe_limit)
.all()
)
return list(reversed(rows))
@app.post("/session/start", response_model=schemas.ChatResponse)
def start_session(
student_id: int,
db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user),
):
ensure_student_access(current_user, student_id)
student = db.query(models.Student).filter_by(id=student_id).first()
if not student:
raise HTTPException(status_code=404, detail="Élève introuvable")
ensure_student_mastery(db, student)
message = (
f"Bonjour {student.first_name} ! Je suis Professeur TOP, ton professeur virtuel. "
"Aujourd'hui, on va apprendre pas à pas et faire un petit test pour voir ce que tu maîtrises déjà."
)
db.add(models.Message(student_id=student.id, role="assistant", content=message))
db.commit()
return schemas.ChatResponse(reply=message)
@app.post("/chat", response_model=schemas.ChatResponse)
def chat(
payload: schemas.ChatRequest,
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")
db.add(models.Message(student_id=student.id, role="user", content=payload.message))
db.commit()
reply = build_llm_reply(db, payload.student_id, payload.message)
db.add(models.Message(student_id=student.id, role="assistant", content=reply))
db.commit()
return schemas.ChatResponse(reply=reply)
@app.post("/transcribe")
async def transcribe(
file: UploadFile = File(...),
current_user: models.User = Depends(get_current_user),
):
if not file.filename:
raise HTTPException(status_code=400, detail="Fichier audio manquant")
audio_bytes = await file.read()
if not audio_bytes:
raise HTTPException(status_code=400, detail="Fichier audio vide")
try:
text = transcribe_audio(file.filename, audio_bytes, file.content_type)
except Exception as exc:
raise HTTPException(status_code=502, detail=f"Erreur de transcription: {exc}") from exc
return {"text": text}
@app.get("/tts/profiles", response_model=schemas.TTSProfilesResponse)
def get_tts_profiles():
return schemas.TTSProfilesResponse(profiles=list_tts_profiles())
@app.post("/tts")
def text_to_speech(
payload: schemas.TTSRequest,
current_user: models.User = Depends(get_current_user),
):
try:
audio_bytes = synthesize_speech(payload.text, payload.profile_id)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
except Exception as exc:
raise HTTPException(status_code=502, detail=f"Erreur de synthèse vocale: {exc}") from exc
return Response(content=audio_bytes, media_type="audio/mpeg")
@app.get("/progress/{student_id}", response_model=schemas.ProgressResponse)
def get_progress(
student_id: int,
db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user),
):
ensure_student_access(current_user, student_id)
student = db.query(models.Student).filter_by(id=student_id).first()
if not student:
raise HTTPException(status_code=404, detail="Élève introuvable")
rows = (
db.query(models.StudentSkillMastery, models.Skill)
.join(models.Skill, models.Skill.id == models.StudentSkillMastery.skill_id)
.filter(models.StudentSkillMastery.student_id == student_id)
.order_by(models.Skill.subject.asc(), models.Skill.label.asc())
.all()
)
progress = [
schemas.SkillProgress(
code=skill.code,
subject=skill.subject,
label=skill.label,
mastery_score=mastery.mastery_score,
confidence=mastery.confidence,
evidence_count=mastery.evidence_count,
)
for mastery, skill in rows
]
return schemas.ProgressResponse(student=student, progress=progress)
@app.get("/assessment/next/{student_id}", response_model=schemas.AssessmentQuestionResponse)
def next_assessment(
student_id: int,
db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user),
):
ensure_student_access(current_user, student_id)
student = db.query(models.Student).filter_by(id=student_id).first()
if not student:
raise HTTPException(status_code=404, detail="Élève introuvable")
skill = pick_next_skill(db, student_id)
question = QUESTIONS[skill.code]["question"]
return schemas.AssessmentQuestionResponse(skill_code=skill.code, skill_label=skill.label, question=question)
@app.post("/assessment/answer", response_model=schemas.AssessmentAnswerResponse)
def answer_assessment(
payload: schemas.AssessmentAnswerRequest,
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")
if payload.skill_code not in QUESTIONS:
raise HTTPException(status_code=400, detail="Compétence inconnue")
correct, feedback, mastery_score = evaluate_answer(
db, payload.student_id, payload.skill_code, payload.answer
)
db.add(
models.Message(
student_id=student.id,
role="user",
content=f"Reponse au mini-test ({payload.skill_code}): {payload.answer}",
)
)
db.add(models.Message(student_id=student.id, role="assistant", content=feedback))
db.commit()
return schemas.AssessmentAnswerResponse(
correct=correct,
feedback=feedback,
mastery_score=mastery_score,
)