From c420d539da89566d0ad0db6b4ec827380262ccd0 Mon Sep 17 00:00:00 2001 From: laurentbarontini Date: Sat, 25 Apr 2026 20:35:43 +0200 Subject: [PATCH] create eleve --- MEMORY.md | 5 ++ backend/app/main.py | 103 ++++++++++++++++++++++++++++--- backend/app/schemas.py | 11 ++++ frontend/src/App.jsx | 133 +++++++++++++++++++++++++++++----------- frontend/src/styles.css | 9 +++ 5 files changed, 215 insertions(+), 46 deletions(-) diff --git a/MEMORY.md b/MEMORY.md index d5b2d5d..4584c81 100644 --- a/MEMORY.md +++ b/MEMORY.md @@ -19,6 +19,11 @@ Ce projet est un POC de professeur virtuel pour enfants, avec: - Le login pose un cookie HTTP-only `professeur_top_session` et renvoie aussi un token bearer. - Le frontend affiche maintenant une page de connexion avant l'application et verifie la session via `/auth/me`. - Apres connexion admin/prof, l'interface actuelle reste accessible avec un bouton de deconnexion. +- Les routes eleves principales verifient maintenant les roles: + - `teacher` et `maintenance` peuvent acceder aux eleves + - `student` ne peut acceder qu'a son propre `student_id` +- Le backend expose `POST /admin/student-accounts` pour creer une fiche eleve et son compte login en une seule operation. +- Le frontend masque le choix/creation d'eleve pour un compte `student` et ouvre directement sa propre seance. - Le vrai `docker-compose.yml` de production n'est pas dans ce repo. Il est situe un niveau au-dessus sur le serveur. - La conf nginx reelle route: - `/api/` vers `tutor-backend:8000` diff --git a/backend/app/main.py b/backend/app/main.py index 488abb5..e707182 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -9,6 +9,8 @@ from .auth import ( AUTH_COOKIE_NAME, create_access_token, get_current_user, + hash_password, + require_roles, seed_admin_user, verify_password, ) @@ -51,6 +53,14 @@ app.add_middleware( 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"} @@ -87,12 +97,19 @@ def logout(response: Response): @app.get("/students", response_model=list[schemas.StudentRead]) -def list_students(db: Session = Depends(get_db)): +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)): +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() @@ -101,8 +118,50 @@ def create_student(payload: schemas.StudentCreate, db: Session = Depends(get_db) 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.post("/session/start", response_model=schemas.ChatResponse) -def start_session(student_id: int, db: Session = Depends(get_db)): +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") @@ -118,7 +177,12 @@ def start_session(student_id: int, db: Session = Depends(get_db)): @app.post("/chat", response_model=schemas.ChatResponse) -def chat(payload: schemas.ChatRequest, db: Session = Depends(get_db)): +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") @@ -134,7 +198,10 @@ def chat(payload: schemas.ChatRequest, db: Session = Depends(get_db)): @app.post("/transcribe") -async def transcribe(file: UploadFile = File(...)): +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") @@ -156,7 +223,10 @@ def get_tts_profiles(): @app.post("/tts") -def text_to_speech(payload: schemas.TTSRequest): +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: @@ -168,7 +238,12 @@ def text_to_speech(payload: schemas.TTSRequest): @app.get("/progress/{student_id}", response_model=schemas.ProgressResponse) -def get_progress(student_id: int, db: Session = Depends(get_db)): +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") @@ -195,7 +270,12 @@ def get_progress(student_id: int, db: Session = Depends(get_db)): @app.get("/assessment/next/{student_id}", response_model=schemas.AssessmentQuestionResponse) -def next_assessment(student_id: int, db: Session = Depends(get_db)): +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") @@ -205,7 +285,12 @@ def next_assessment(student_id: int, db: Session = Depends(get_db)): @app.post("/assessment/answer", response_model=schemas.AssessmentAnswerResponse) -def answer_assessment(payload: schemas.AssessmentAnswerRequest, db: Session = Depends(get_db)): +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") diff --git a/backend/app/schemas.py b/backend/app/schemas.py index 44ea4e7..a6a6c24 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -23,6 +23,7 @@ class UserRead(BaseModel): username: str role: str student_id: int | None = None + student: StudentRead | None = None class Config: from_attributes = True @@ -39,6 +40,16 @@ class LoginResponse(BaseModel): user: UserRead +class StudentAccountCreate(StudentCreate): + username: str = Field(..., min_length=1) + password: str = Field(..., min_length=1) + + +class StudentAccountResponse(BaseModel): + student: StudentRead + user: UserRead + + class ChatRequest(BaseModel): student_id: int message: str diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index bcc6211..2270bb6 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -171,9 +171,16 @@ function LoginPage({ onLogin }) { } function TutorApp({ currentUser, onLogout }) { + const isStudentUser = currentUser.role === 'student' const [students, setStudents] = useState([]) const [selectedStudentId, setSelectedStudentId] = useState('') - const [form, setForm] = useState({ first_name: '', age: 8, grade: 'CM1' }) + const [form, setForm] = useState({ + first_name: '', + age: 8, + grade: 'CM1', + username: '', + password: '', + }) const [messages, setMessages] = useState([]) const [input, setInput] = useState('') const [progress, setProgress] = useState([]) @@ -218,9 +225,15 @@ function TutorApp({ currentUser, onLogout }) { [voiceProfiles, selectedVoiceProfileId] ) + const averageScore = useMemo(() => { + if (!progress.length) return 0 + const total = progress.reduce((sum, item) => sum + item.mastery_score, 0) + return Math.round(total / progress.length) + }, [progress]) + useEffect(() => { loadStudents() - }, []) + }, [currentUser]) function pushAudioDebug(message) { if (!DEBUG_AUDIO) return @@ -265,6 +278,18 @@ function TutorApp({ currentUser, onLogout }) { }, []) async function loadStudents() { + if (isStudentUser) { + if (currentUser.student) { + setStudents([currentUser.student]) + setSelectedStudentId(String(currentUser.student.id)) + } else { + setStudents([]) + setSelectedStudentId('') + setErrorMessage('Ce compte eleve n est pas encore rattache a une fiche eleve.') + } + return + } + try { setErrorMessage('') const data = await apiFetch('/students') @@ -301,22 +326,33 @@ function TutorApp({ currentUser, onLogout }) { async function createStudent(e) { e.preventDefault() + if (isStudentUser) return if (!form.first_name.trim()) { setErrorMessage('Le prénom est obligatoire.') return } + if (!form.username.trim() || !form.password) { + setErrorMessage('Identifiant et mot de passe eleve obligatoires.') + return + } try { setErrorMessage('') - const data = await apiFetch('/students', { + const data = await apiFetch('/admin/student-accounts', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ ...form, first_name: form.first_name.trim(), age: Number(form.age) }), + body: JSON.stringify({ + ...form, + first_name: form.first_name.trim(), + username: form.username.trim(), + age: Number(form.age), + }), }) await loadStudents() - setSelectedStudentId(String(data.id)) + setSelectedStudentId(String(data.student.id)) + setForm({ first_name: '', age: 8, grade: 'CM1', username: '', password: '' }) } catch (error) { - setErrorMessage(error.message || 'Impossible de créer l’élève.') + setErrorMessage(error.message || 'Impossible de creer le compte eleve.') } } @@ -744,37 +780,60 @@ function TutorApp({ currentUser, onLogout }) { return (
- {currentUser.username} · {currentUser.role} + {currentUser.username} · {currentUser.role} diff --git a/frontend/src/styles.css b/frontend/src/styles.css index 5dec03e..1099038 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -41,6 +41,15 @@ button:disabled { opacity: 0.5; cursor: not-allowed; } .stack { display: flex; flex-direction: column; gap: 0.75rem; } .small-gap { gap: 0.5rem; } .hero { display: flex; align-items: center; gap: 1rem; flex-wrap: wrap; } +.session-summary { + display: flex; + flex-direction: column; + gap: 0.35rem; + border: 1px solid #e5e7eb; + border-radius: 16px; + padding: 0.75rem; + background: #f8fafc; +} .session-user { margin-left: auto; display: flex;