create eleve

This commit is contained in:
2026-04-25 20:35:43 +02:00
parent bd7c67fc93
commit c420d539da
5 changed files with 215 additions and 46 deletions

View File

@@ -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`

View File

@@ -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")

View File

@@ -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

View File

@@ -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 (
<div className="layout">
<aside className="sidebar card">
<h2>Élève</h2>
<select value={selectedStudentId} onChange={(event) => setSelectedStudentId(event.target.value)}>
<option value="">Choisir un élève</option>
{students.map((student) => (
<option key={student.id} value={student.id}>
{student.first_name} · {student.grade}
</option>
))}
</select>
<h2>{isStudentUser ? 'Ma seance' : 'Eleves'}</h2>
{!isStudentUser && (
<>
<select value={selectedStudentId} onChange={(event) => setSelectedStudentId(event.target.value)}>
<option value="">Choisir un eleve</option>
{students.map((student) => (
<option key={student.id} value={student.id}>
{student.first_name} · {student.grade}
</option>
))}
</select>
<form onSubmit={createStudent} className="stack">
<input
placeholder="Prénom"
value={form.first_name}
onChange={(event) => setForm({ ...form, first_name: event.target.value })}
/>
<input
type="number"
min="8"
max="12"
value={form.age}
onChange={(event) => setForm({ ...form, age: event.target.value })}
/>
<select value={form.grade} onChange={(event) => setForm({ ...form, grade: event.target.value })}>
<option>CE2</option>
<option>CM1</option>
<option>CM2</option>
<option>6e</option>
</select>
<button type="submit">Créer un élève</button>
</form>
<form onSubmit={createStudent} className="stack">
<input
placeholder="Prenom"
value={form.first_name}
onChange={(event) => setForm({ ...form, first_name: event.target.value })}
/>
<input
type="number"
min="8"
max="12"
value={form.age}
onChange={(event) => setForm({ ...form, age: event.target.value })}
/>
<select value={form.grade} onChange={(event) => setForm({ ...form, grade: event.target.value })}>
<option>CE2</option>
<option>CM1</option>
<option>CM2</option>
<option>6e</option>
</select>
<input
placeholder="Identifiant eleve"
value={form.username}
onChange={(event) => setForm({ ...form, username: event.target.value })}
/>
<input
placeholder="Mot de passe eleve"
type="password"
value={form.password}
onChange={(event) => setForm({ ...form, password: event.target.value })}
/>
<button type="submit">Creer le compte eleve</button>
</form>
</>
)}
{isStudentUser && selectedStudent && (
<div className="session-summary">
<strong>{selectedStudent.first_name}</strong>
<span>{selectedStudent.grade}</span>
<span>Score moyen: {averageScore}%</span>
</div>
)}
<button onClick={startSession} disabled={!selectedStudentId}>Démarrer la séance</button>
<button onClick={fetchAssessment} disabled={!selectedStudentId}>Lancer un mini-test</button>
@@ -797,7 +856,7 @@ function TutorApp({ currentUser, onLogout }) {
</p>
</div>
<div className="session-user">
<span>{currentUser.username} · {currentUser.role}</span>
<span>{currentUser.username} · {currentUser.role}</span>
<button type="button" className="secondary-button" onClick={onLogout}>
Deconnexion
</button>

View File

@@ -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;