create eleve
This commit is contained in:
@@ -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`
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user