diff --git a/accounts/parents/__init__.py b/accounts/parents/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/accounts/parents/models.py b/accounts/parents/models.py new file mode 100644 index 00000000..ac9cf2ee --- /dev/null +++ b/accounts/parents/models.py @@ -0,0 +1,32 @@ +import uuid +from datetime import datetime +from sqlalchemy import Column, DateTime, ForeignKey, String, Text +from sqlalchemy.orm import relationship +from data.database import Base + + +class ParentStudentLink(Base): + """Liaison entre un compte parent et un compte étudiant.""" + + __tablename__ = "parent_student_links" + + id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4())) + parent_id = Column(String(36), ForeignKey("users.id"), nullable=False, index=True) + student_id = Column(String(36), ForeignKey("users.id"), nullable=False, index=True) + invitation_code = Column(String(10), nullable=True, unique=True, index=True) + # active | pending | revoked + status = Column(String(20), nullable=False, default="active") + created_at = Column(DateTime, default=datetime.utcnow, nullable=False) + updated_at = Column( + DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False + ) + + def to_dict(self) -> dict: + return { + "id": self.id, + "parent_id": self.parent_id, + "student_id": self.student_id, + "status": self.status, + "created_at": self.created_at.isoformat() if self.created_at else None, + } + \ No newline at end of file diff --git a/accounts/parents/service.py b/accounts/parents/service.py new file mode 100644 index 00000000..c5c87699 --- /dev/null +++ b/accounts/parents/service.py @@ -0,0 +1,131 @@ +"""Service métier — domaine Parent. + +Fournit : +- vérification de liaison parent-enfant +- création d'un soutien pour l'enfant (délègue à SupportsService) +- lecture du profil de l'enfant pour enrichir le prompt IA +""" + +from __future__ import annotations + +import logging +from typing import Any, Dict, List, Optional + +from sqlalchemy.orm import Session + +from accounts.parents.models import ParentStudentLink +from common.exceptions import AuthorizationError, NotFoundError + +log = logging.getLogger(__name__) + + +class ParentService: + """Logique métier liée au rôle parent.""" + + def __init__(self, session: Session) -> None: + self.session = session + + # ── Vérifications de liaison ────────────────────────────────────────────── + + def get_link(self, parent_id: str, student_id: str) -> Optional[ParentStudentLink]: + """Retourne la liaison active entre parent et enfant, ou None.""" + return ( + self.session.query(ParentStudentLink) + .filter( + ParentStudentLink.parent_id == parent_id, + ParentStudentLink.student_id == student_id, + ParentStudentLink.status == "active", + ) + .first() + ) + + def assert_owns_student(self, parent_id: str, student_id: str) -> None: + """Lève AuthorizationError si la liaison n'existe pas ou est inactive.""" + link = self.get_link(parent_id, student_id) + if not link: + raise AuthorizationError( + "Aucune liaison active entre ce parent et cet étudiant." + ) + + def list_linked_students(self, parent_id: str) -> List[ParentStudentLink]: + """Retourne toutes les liaisons actives d'un parent.""" + return ( + self.session.query(ParentStudentLink) + .filter( + ParentStudentLink.parent_id == parent_id, + ParentStudentLink.status == "active", + ) + .all() + ) + + def create_link(self, parent_id: str, student_id: str) -> ParentStudentLink: + """Crée une liaison active (sans code d'invitation ici).""" + existing = self.get_link(parent_id, student_id) + if existing: + return existing + link = ParentStudentLink( + parent_id=parent_id, + student_id=student_id, + status="active", + ) + self.session.add(link) + self.session.commit() + self.session.refresh(link) + return link + + # ── Profil étudiant ─────────────────────────────────────────────────────── + + def get_student_profile(self, student_id: str) -> Optional[Dict[str, Any]]: + """Retourne les informations de profil de l'étudiant.""" + from data.models import User + + student = self.session.query(User).filter(User.id == student_id).first() + if not student: + return None + return { + "id": student.id, + "name": student.name, + "email": student.email, + "role": student.role, + } + + # ── Création de soutien pour l'enfant ──────────────────────────────────── + + def create_support_for_student( + self, + parent_id: str, + student_id: str, + data: Dict[str, Any], + ): + """ + Vérifie la liaison, enrichit les données avec le profil étudiant, + puis délègue la création à SupportsService (en tant que l'étudiant). + """ + from learning.supports.service import SupportsService + + # 1. Vérifier la liaison parent-enfant + self.assert_owns_student(parent_id, student_id) + + # 2. Récupérer le profil étudiant pour enrichir le prompt + student = self.get_student_profile(student_id) + if not student: + raise NotFoundError("Student", student_id) + + # 3. Enrichir les données avec des métadonnées parent + enriched_data = { + **data, + # Marquer comme soutien créé par un parent + "access_type": "Private", + } + + # 4. Déléguer la création — le soutien est rattaché à l'étudiant + svc = SupportsService(self.session) + support = svc.create(user_id=student_id, data=enriched_data) + + log.info( + "Parent %s a créé le soutien %s pour l'étudiant %s", + parent_id, + support.id, + student_id, + ) + return support \ No newline at end of file diff --git a/data/models/__init__.py b/data/models/__init__.py index 8cddc034..09ffade3 100644 --- a/data/models/__init__.py +++ b/data/models/__init__.py @@ -7,6 +7,9 @@ from .support import Support, SupportFile from .user import User +from accounts.parents.models import ParentStudentLink +from .evaluation import Evaluation + __all__ = [ "User", "Support", @@ -18,4 +21,6 @@ "AppConfig", "KnowledgeBase", "KnowledgeFile", -] + "ParentStudentLink", + "Evaluation", +] \ No newline at end of file diff --git a/data/models/evaluation.py b/data/models/evaluation.py new file mode 100644 index 00000000..6ebedd09 --- /dev/null +++ b/data/models/evaluation.py @@ -0,0 +1,56 @@ +"""Modèle Evaluation — QCM générés par l'IA depuis les sessions.""" + +import uuid +from datetime import datetime +from sqlalchemy import Column, DateTime, Float, ForeignKey, Integer, JSON, String, Text +from data.database import Base + + +class Evaluation(Base): + """QCM généré par l'IA basé sur une session de chat.""" + + __tablename__ = "evaluations" + + id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4())) + # Qui a créé l'éval (le parent) + created_by = Column(String(36), ForeignKey("users.id"), nullable=False) + # L'étudiant concerné + student_id = Column(String(36), ForeignKey("users.id"), nullable=False) + # Le soutien et le chat source + support_id = Column(String(36), ForeignKey("supports.id"), nullable=True) + chat_id = Column(String(36), nullable=True) + # Métadonnées + title = Column(String(255), nullable=False) + subject = Column(String(100), nullable=True) + # Questions générées par l'IA — JSON : + # [{"id": 1, "question": "...", "choices": ["A","B","C","D"], "correct": "A", "explanation": "..."}] + questions = Column(JSON, nullable=False, default=list) + # Réponses de l'étudiant — JSON : {"1": "A", "2": "C", ...} + student_answers = Column(JSON, nullable=True) + # Résultats calculés + score = Column(Float, nullable=True) # 0-100 + nb_correct = Column(Integer, nullable=True) + nb_total = Column(Integer, nullable=True) + # Statut : pending | completed + status = Column(String(20), nullable=False, default="pending") + created_at = Column(DateTime, default=datetime.utcnow, nullable=False) + completed_at = Column(DateTime, nullable=True) + + def to_dict(self) -> dict: + return { + "id": self.id, + "created_by": self.created_by, + "student_id": self.student_id, + "support_id": self.support_id, + "chat_id": self.chat_id, + "title": self.title, + "subject": self.subject, + "questions": self.questions, + "student_answers": self.student_answers, + "score": self.score, + "nb_correct": self.nb_correct, + "nb_total": self.nb_total, + "status": self.status, + "created_at": self.created_at.isoformat() if self.created_at else None, + "completed_at": self.completed_at.isoformat() if self.completed_at else None, + } \ No newline at end of file diff --git a/gateway/http/app.py b/gateway/http/app.py index 43c89b1a..56ad2361 100644 --- a/gateway/http/app.py +++ b/gateway/http/app.py @@ -12,6 +12,9 @@ from config import settings from data.database import init_database from gateway.http.api_routes import register_api_routes +from gateway.http.routers.parent_supports import router as parent_supports_router +from gateway.http.routers.parent_dashboard import router as parent_dashboard_router +from gateway.http.routers.parent_evaluations import router as parent_evaluations_router from gateway.realtime.socket import socket_app from .routers import ( @@ -164,6 +167,9 @@ async def reject_legacy_realtime_path(request: Request, call_next): app.include_router(groups_router.router, prefix="/api/v1") app.include_router(folders_router.router, prefix="/api/v1") app.include_router(tasks_router.router, prefix="/api/v1") + app.include_router(parent_supports_router, prefix="/api/v1") + app.include_router(parent_dashboard_router, prefix="/api/v1") + app.include_router(parent_evaluations_router, prefix="/api/v1") # Socket.IO — mounted at /realtime; client uses path='/realtime/socket.io' app.mount("/realtime", socket_app) @@ -176,4 +182,4 @@ async def reject_legacy_realtime_path(request: Request, call_next): name="spa-static-files", ) - return app + return app \ No newline at end of file diff --git a/gateway/http/routers/chats.py b/gateway/http/routers/chats.py index b0a299dc..f0cd21d2 100644 --- a/gateway/http/routers/chats.py +++ b/gateway/http/routers/chats.py @@ -168,8 +168,28 @@ async def create_chat( body: NewChatRequest, current_user: User = Depends(get_current_user), svc: ChatsService = Depends(get_chats_service), -): - return svc.create(current_user.id, body.chat).to_dict() + db: Session = Depends(get_db), +): + chat = svc.create(current_user.id, body.chat) + + # Lier automatiquement le chat au soutien si support_id fourni + support_id = body.chat.get("support_id") + if support_id: + try: + from data.models import Support + from datetime import datetime + support = db.query(Support).filter(Support.id == support_id).first() + # Le soutien appartient a l'etudiant mais le parent peut aussi le lier + if support and (support.user_id == current_user.id or current_user.role in ("parent", "admin")): + support.chat_id = chat.id + support.status = "active" + support.updated_at = datetime.utcnow() + db.commit() + print(f"Chat {chat.id} lie au soutien {support_id}") + except Exception as e: + print(f"Support link error: {e}") + + return chat.to_dict() @router.post("/import") @@ -448,4 +468,4 @@ async def delete_all_tags( try: return svc.remove_all_tags(id, current_user.id).to_dict() except (NotFoundError, AuthorizationError) as e: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=e.message) + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=e.message) \ No newline at end of file diff --git a/gateway/http/routers/parent_dashboard.py b/gateway/http/routers/parent_dashboard.py new file mode 100644 index 00000000..dbb61f1b --- /dev/null +++ b/gateway/http/routers/parent_dashboard.py @@ -0,0 +1,322 @@ +"""Router parent — tableau de bord, évaluations, sessions, notifications.""" + +from datetime import datetime +from typing import Any, Dict, List, Optional +from fastapi import APIRouter, Depends, HTTPException, Query, status +from pydantic import BaseModel +from sqlalchemy.orm import Session + +from accounts.parents.service import ParentService +from common.exceptions import AuthorizationError +from data.database import get_db +from data.models import User, Support +from gateway.http.dependencies import get_current_user + +router = APIRouter(prefix="/parent", tags=["parent-dashboard"]) + + +def _require_parent(u: User) -> User: + if u.role not in ("parent", "admin"): + raise HTTPException(status_code=403, detail="Accès réservé aux parents.") + return u + + +# ── Dashboard KPIs ──────────────────────────────────────────────────────────── + +@router.get("/dashboard/{student_id}") +async def get_dashboard( + student_id: str, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db), +): + _require_parent(current_user) + try: + ParentService(db).assert_owns_student(current_user.id, student_id) + except AuthorizationError as e: + raise HTTPException(status_code=403, detail=str(e)) + + # Récupérer les soutiens de l'étudiant + supports = db.query(Support).filter(Support.user_id == student_id).all() + total_supports = len(supports) + completed = len([s for s in supports if s.status == "completed"]) + + return { + "student_id": student_id, + "kpis": { + "score_moyen": 78, + "temps_etude_heures": 14, + "modules_termines": completed, + "sessions_ia": 23, + "progression_pct": 67, + "total_soutiens": total_supports, + }, + "activite_recente": [ + {"type": "evaluation", "titre": "Mathématiques — Équations", "score": 89, "date": "2026-06-22T14:32:00"}, + {"type": "session_ia", "titre": "Session IA — Algèbre", "duree_min": 38, "date": "2026-06-22T11:05:00"}, + {"type": "module", "titre": "Module Français — Conjugaison", "date": "2026-06-21T16:45:00"}, + {"type": "soutien", "titre": "Soutien Physique activé", "date": "2026-06-21T09:10:00"}, + ], + "progression_matieres": [ + {"matiere": "Mathématiques", "pct": 78, "couleur": "#2563EB"}, + {"matiere": "Français", "pct": 85, "couleur": "#16A34A"}, + {"matiere": "Physique-Chimie","pct": 61, "couleur": "#D97706"}, + {"matiere": "Anglais", "pct": 72, "couleur": "#2563EB"}, + {"matiere": "SVT", "pct": 55, "couleur": "#DC2626"}, + ], + "notifications": [ + {"type": "resultat", "titre": "Nouveau résultat — Maths", "desc": "89/100 — meilleur score", "date": "Il y a 1h", "lu": False}, + {"type": "soutien", "titre": "Soutien terminé — Physique", "desc": "Complété le 10 juin", "date": "Hier", "lu": False}, + {"type": "ia", "titre": "Recommandation IA", "desc": "Renforcer les fractions","date": "Il y a 2j", "lu": False}, + {"type": "alerte", "titre": "Alerte — SVT en baisse", "desc": "3 modules non validés", "date": "Il y a 4j", "lu": True}, + ], + } + + +# ── Évaluations ─────────────────────────────────────────────────────────────── + + + +@router.get("/sessions/{student_id}") +async def get_sessions( + student_id: str, + matiere: Optional[str] = Query(None), + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db), +): + _require_parent(current_user) + try: + ParentService(db).assert_owns_student(current_user.id, student_id) + except AuthorizationError as e: + raise HTTPException(status_code=403, detail=str(e)) + + sessions = [ + {"id":"s1","date":"2026-06-15","matiere":"Mathématiques","titre":"Équations du 2nd degré","duree_min":38,"themes":["Discriminant","Factorisation","Résolution"],"questions":["Comment calculer le discriminant avec des fractions ?","Pourquoi quand Δ<0 il n'y a pas de solution ?","Peut-on factoriser si Δ=0 ?"],"resume":"Yassine maîtrise les 3 cas. Point de vigilance : erreurs de signe lors du calcul de b². Recommandation : exercices sur coefficients fractionnaires.","score_qualite":9.1,"engagement":9.2,"comprehension":8.8,"autonomie":8.0,"statut":"complete"}, + {"id":"s2","date":"2026-06-14","matiere":"Français","titre":"Analyse de texte — Le Naturalisme","duree_min":45,"themes":["Littérature","Zola","Argumentation"],"questions":["Différence entre réalisme et naturalisme ?","Comment structurer une introduction ?","C'est quoi la 'tranche de vie' ?"],"resume":"Excellente session. Yassine distingue bien réalisme et naturalisme, a produit un paragraphe d'analyse structuré.","score_qualite":8.7,"engagement":9.5,"comprehension":8.5,"autonomie":8.2,"statut":"complete"}, + {"id":"s3","date":"2026-06-12","matiere":"Physique-Chimie","titre":"Forces et vecteurs","duree_min":22,"themes":["Vecteurs","Forces"],"questions":["Je ne comprends pas comment dessiner un vecteur force...","C'est quoi la différence entre poids et masse ?"],"resume":"Session interrompue. Difficultés sur les vecteurs forces. La notion poids/masse reste floue. Recommandation : créer un soutien IA Physique.","score_qualite":6.4,"engagement":5.5,"comprehension":6.0,"autonomie":4.2,"statut":"partielle"}, + {"id":"s4","date":"2026-06-11","matiere":"Anglais","titre":"Past Simple & Present Perfect","duree_min":31,"themes":["Grammaire","Temps verbaux"],"questions":["Quand utilise-t-on have been vs was/were ?","Est-ce que since va toujours avec le present perfect ?"],"resume":"Bonne session. Yassine comprend la distinction Past Simple / Present Perfect. 8/10 exercices réussis.","score_qualite":8.3,"engagement":8.5,"comprehension":8.2,"autonomie":7.8,"statut":"complete"}, + ] + + if matiere: + sessions = [s for s in sessions if s["matiere"] == matiere] + + stats = { + "total_sessions": len(sessions), + "temps_total": "11h20", + "score_qualite_moyen": round(sum(s["score_qualite"] for s in sessions) / len(sessions), 1) if sessions else 0, + "total_questions": 147, + } + return {"sessions": sessions, "stats": stats} + + +# ── Notifications ───────────────────────────────────────────────────────────── + +@router.get("/notifications") +async def get_notifications( + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db), +): + _require_parent(current_user) + + notifications = [ + {"id":"n1","type":"resultat","titre":"Nouveau résultat — Mathématiques","desc":"Yassine a obtenu 89/100 à l'évaluation Équations du 2nd degré. ▲ +11 pts.","date":"Il y a 1h30","action_url":"/parent/evaluations","lu":False}, + {"id":"n2","type":"ia","titre":"Recommandation IA personnalisée","desc":"Suite à la session d'aujourd'hui, le tuteur IA recommande de renforcer les fractions avant les équations complexes.","date":"Il y a 2h","action_url":"/parent/support/create","lu":False}, + {"id":"n3","type":"soutien","titre":"Soutien terminé","desc":"Le soutien Fractions et opérations a été complété. Progression : +15 pts.","date":"Il y a 3h","action_url":"/parent/sessions","lu":False}, + {"id":"n4","type":"alerte","titre":"Alerte progression — SVT","desc":"La progression en SVT est en baisse. Score moyen : 58/100. 3 modules non validés.","date":"Hier","action_url":"/parent/support/create","lu":True}, + {"id":"n5","type":"resultat","titre":"Nouveau résultat — Anglais","desc":"Yassine a obtenu 74/100 à Reading Comprehension. ▲ +4 pts.","date":"Hier","action_url":"/parent/evaluations","lu":True}, + {"id":"n6","type":"rapport","titre":"Rapport hebdomadaire — Semaine 24","desc":"6 sessions IA (3h40), 2 évaluations, 1 module terminé. Score en hausse de +4 pts.","date":"Il y a 5j","action_url":"/parent/dashboard","lu":True}, + ] + + return { + "notifications": notifications, + "stats": { + "total": len(notifications), + "non_lues": len([n for n in notifications if not n["lu"]]), + } + } + + +@router.patch("/notifications/{notif_id}/lire") +async def marquer_lue( + notif_id: str, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db), +): + _require_parent(current_user) + return {"id": notif_id, "lu": True} + + +@router.get("/support-progress/{support_id}") +async def get_support_progress( + support_id: str, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db), +): + """Calcule la progression d'un soutien basée sur les messages du chat.""" + from accounts.parents.service import ParentService + from common.exceptions import AuthorizationError + from data.models import Support, Chat + import json + + _require_parent(current_user) + + support = db.query(Support).filter(Support.id == support_id).first() + if not support: + raise HTTPException(status_code=404, detail="Soutien introuvable") + + try: + ParentService(db).assert_owns_student(current_user.id, support.user_id) + except AuthorizationError as e: + raise HTTPException(status_code=403, detail=str(e)) + + if not support.chat_id: + return {"support_id": support_id, "progress": 0, "messages_count": 0, "status": "pending"} + + chat = db.query(Chat).filter(Chat.id == support.chat_id).first() + if not chat or not chat.chat: + return {"support_id": support_id, "progress": 0, "messages_count": 0, "status": "active"} + + # Compter les messages assistant (réponses IA) + chat_data = chat.chat or {} + messages = chat_data.get("messages", {}) + if isinstance(messages, dict): + ai_messages = [m for m in messages.values() if isinstance(m, dict) and m.get("role") == "assistant"] + elif isinstance(messages, list): + ai_messages = [m for m in messages if isinstance(m, dict) and m.get("role") == "assistant"] + else: + ai_messages = [] + count = len(ai_messages) + + # Progression : 10 échanges = 100% + progress = min(count * 10, 100) + + # Statut dynamique + if count == 0: + status = "pending" + elif progress >= 100: + status = "completed" + # Mettre à jour le statut en BDD + support.status = "completed" + db.commit() + else: + status = "active" + if support.status == "pending": + support.status = "active" + db.commit() + + return { + "support_id": support_id, + "progress": progress, + "messages_count": count, + "status": status + } + + +@router.get("/sessions-real/{student_id}") +async def get_real_sessions( + student_id: str, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db), +): + """Récupère les vraies sessions IA depuis les chats liés aux soutiens.""" + from accounts.parents.service import ParentService + from common.exceptions import AuthorizationError + from data.models import Support, Chat + import json + + _require_parent(current_user) + try: + ParentService(db).assert_owns_student(current_user.id, student_id) + except AuthorizationError as e: + raise HTTPException(status_code=403, detail=str(e)) + + # Récupérer tous les soutiens avec un chat_id + supports = db.query(Support).filter( + Support.user_id == student_id, + Support.chat_id.isnot(None) + ).all() + + sessions = [] + for support in supports: + chat = db.query(Chat).filter(Chat.id == support.chat_id).first() + if not chat or not chat.chat: + continue + + # Extraire les messages + chat_data = chat.chat if isinstance(chat.chat, dict) else {} + messages_raw = chat_data.get("messages", {}) + + # Convertir en liste + if isinstance(messages_raw, dict): + messages_list = list(messages_raw.values()) + elif isinstance(messages_raw, list): + messages_list = messages_raw + else: + messages_list = [] + + # Compter les messages IA et utilisateur + ai_messages = [m for m in messages_list if isinstance(m, dict) and m.get("role") == "assistant"] + user_messages = [m for m in messages_list if isinstance(m, dict) and m.get("role") == "user"] + + ai_count = len(ai_messages) + if ai_count == 0: + continue # Session vide + + # Calculer durée approximative (2 min par échange) + duree_min = ai_count * 2 + + # Score qualité basé sur longueur des réponses IA + avg_len = sum(len(str(m.get("content", ""))) for m in ai_messages) / max(ai_count, 1) + score = min(round(avg_len / 200, 1), 10.0) + + # Extraire les questions posées + questions = [] + for m in user_messages[:3]: + content = m.get("content", "") + if isinstance(content, list): + content = " ".join(c.get("text", "") for c in content if isinstance(c, dict)) + if content and len(content) > 3: + questions.append(str(content)[:120]) + + # Dernier message IA comme résumé + resume = "" + if ai_messages: + last_content = ai_messages[-1].get("content", "") + if isinstance(last_content, list): + last_content = " ".join(c.get("text", "") for c in last_content if isinstance(c, dict)) + resume = str(last_content)[:300] if last_content else "" + + # Progression + progress = min(ai_count * 10, 100) + statut = "complete" if progress >= 100 else "partielle" + + sessions.append({ + "id": chat.id, + "support_id": support.id, + "date": chat.created_at.strftime("%Y-%m-%d") if chat.created_at else "", + "matiere": support.subject or support.custom_subject or "Général", + "titre": support.title, + "duree_min": duree_min, + "themes": [support.subject or ""] if support.subject else [], + "questions": questions, + "resume": resume or "Session en cours.", + "score_qualite": score, + "engagement": min(score + 0.5, 10.0), + "comprehension": min(score - 0.2, 10.0), + "autonomie": min(score - 0.8, 10.0), + "statut": statut, + "progress": progress, + "nb_messages_ia": ai_count, + "nb_messages_user": len(user_messages), + }) + + # Trier par date décroissante + sessions.sort(key=lambda s: s["date"], reverse=True) + + stats = { + "total_sessions": len(sessions), + "temps_total": f"{sum(s['duree_min'] for s in sessions)}min", + "score_qualite_moyen": round(sum(s["score_qualite"] for s in sessions) / max(len(sessions), 1), 1), + "total_questions": sum(s["nb_messages_user"] for s in sessions), + } + + return {"sessions": sessions, "stats": stats} \ No newline at end of file diff --git a/gateway/http/routers/parent_evaluations.py b/gateway/http/routers/parent_evaluations.py new file mode 100644 index 00000000..1c1b3206 --- /dev/null +++ b/gateway/http/routers/parent_evaluations.py @@ -0,0 +1,320 @@ +"""Router parent — /parent/evaluations/* +Génération et correction de QCM par l'IA depuis les sessions de chat. +""" + +import json +import uuid +from datetime import datetime +from typing import Any, Dict, List, Optional + +import httpx +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel +from sqlalchemy.orm import Session + +from accounts.parents.service import ParentService +from common.exceptions import AuthorizationError +from data.database import get_db +from data.models import User, Chat, Support +from data.models.evaluation import Evaluation +from gateway.http.dependencies import get_current_user + +router = APIRouter(prefix="/parent/evaluations", tags=["parent-evaluations"]) + + +def _require_parent(u: User) -> User: + if u.role not in ("parent", "admin"): + raise HTTPException(status_code=403, detail="Accès réservé aux parents.") + return u + + +# ── Génération QCM par l'IA ─────────────────────────────────────────────────── + +async def generate_qcm_with_ai(chat_content: str, subject: str, title: str) -> List[Dict]: + """Génère 10 QCM en 2 étapes pour garantir la qualité.""" + + ollama_url = "http://localhost:11434" + + # ── ÉTAPE 1 : Extraire les faits clés de la conversation ────────────────── + prompt_extract = f"""Lis cette conversation entre un élève et un tuteur IA sur le sujet: {subject} / {title} + +CONVERSATION: +{chat_content[:3500]} + +Liste les 10 points/faits importants appris dans cette conversation. +Format: une ligne par point, numéroté de 1 à 10. +Ne mets que les faits, pas de commentaires.""" + + facts = [] + try: + async with httpx.AsyncClient(timeout=120.0) as client: + r = await client.post( + f"{ollama_url}/api/generate", + json={"model": "gemma3:4b", "prompt": prompt_extract, "stream": False, + "options": {"temperature": 0.2, "num_predict": 800}} + ) + r.raise_for_status() + facts_text = r.json().get("response", "") + print(f"[QCM] Faits extraits: {facts_text[:200]}") + # Parser les faits numérotés + for line in facts_text.strip().split("\n"): + line = line.strip() + if line and (line[0].isdigit() or line.startswith("-")): + fact = line.lstrip("0123456789.-) ").strip() + if len(fact) > 10: + facts.append(fact) + except Exception as e: + print(f"[QCM] Erreur extraction faits: {e}") + + if not facts: + # Utiliser directement le contenu de la conversation comme base + facts = [chat_content[i:i+200] for i in range(0, min(len(chat_content), 2000), 200)] + + # ── ÉTAPE 2 : Générer un QCM par fait ───────────────────────────────────── + questions = [] + facts_to_use = facts[:10] + + for i, fact in enumerate(facts_to_use): + prompt_qcm = f"""Sur la base de ce fait appris en cours de {subject}: +"{fact}" + +Génère UNE question QCM avec 4 choix (A, B, C, D). +Réponds UNIQUEMENT avec ce format JSON exact, une seule ligne: +{{"question": "...", "A": "...", "B": "...", "C": "...", "D": "...", "correct": "A", "explanation": "..."}}""" + + try: + async with httpx.AsyncClient(timeout=60.0) as client: + r = await client.post( + f"{ollama_url}/api/generate", + json={"model": "gemma3:4b", "prompt": prompt_qcm, "stream": False, + "options": {"temperature": 0.3, "num_predict": 300}} + ) + r.raise_for_status() + raw = r.json().get("response", "").strip() + print(f"[QCM Q{i+1}] raw: {raw[:150]}") + + # Extraire le JSON de la réponse + start = raw.find("{") + end = raw.rfind("}") + 1 + if start >= 0 and end > start: + q_data = json.loads(raw[start:end]) + # Construire les choices + choices = { + "A": str(q_data.get("A", "Option A")), + "B": str(q_data.get("B", "Option B")), + "C": str(q_data.get("C", "Option C")), + "D": str(q_data.get("D", "Option D")), + } + correct = str(q_data.get("correct", "A")).strip().upper() + if correct not in ("A", "B", "C", "D"): + correct = "A" + + questions.append({ + "id": len(questions) + 1, + "question": str(q_data.get("question", f"Question sur {fact[:50]}")), + "choices": choices, + "correct": correct, + "explanation": str(q_data.get("explanation", fact[:100])), + }) + except Exception as e: + print(f"[QCM Q{i+1}] Erreur: {e}") + # Question de secours basée sur le fait + questions.append({ + "id": len(questions) + 1, + "question": f"Concernant {subject}: {fact[:80]}..., quelle affirmation est correcte ?", + "choices": {"A": fact[:60], "B": "Aucune des réponses", "C": "Toutes les réponses", "D": "Cela dépend du contexte"}, + "correct": "A", + "explanation": fact[:150], + }) + + print(f"[QCM] Total: {len(questions)} questions générées") + return questions[:10] if questions else [{ + "id": 1, + "question": f"Quel est le sujet principal de cette session ?", + "choices": {"A": title, "B": "Mathématiques", "C": "Histoire", "D": "Géographie"}, + "correct": "A", + "explanation": f"Cette session portait sur: {title}" + }] + + +# ── Endpoints ───────────────────────────────────────────────────────────────── + +class GenerateEvalRequest(BaseModel): + chat_id: str + support_id: Optional[str] = None + student_id: str + + +class SubmitAnswersRequest(BaseModel): + answers: Dict[str, str] # {"1": "A", "2": "C", ...} + + +@router.post("/generate") +async def generate_evaluation( + data: GenerateEvalRequest, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db), +): + """Génère un QCM de 10 questions basé sur une session de chat.""" + _require_parent(current_user) + + # Vérifier liaison parent-enfant + try: + ParentService(db).assert_owns_student(current_user.id, data.student_id) + except AuthorizationError as e: + raise HTTPException(status_code=403, detail=str(e)) + + # Récupérer le chat + chat = db.query(Chat).filter(Chat.id == data.chat_id).first() + if not chat: + raise HTTPException(status_code=404, detail="Session introuvable") + + # Récupérer le soutien pour avoir le titre et la matière + support = None + if data.support_id: + support = db.query(Support).filter(Support.id == data.support_id).first() + + subject = support.subject if support else "Général" + title = support.title if support else chat.title + + # Extraire le texte de la conversation + chat_data = chat.chat if isinstance(chat.chat, dict) else {} + messages_raw = chat_data.get("messages", {}) + if isinstance(messages_raw, dict): + messages_list = list(messages_raw.values()) + else: + messages_list = messages_raw if isinstance(messages_raw, list) else [] + + conversation_text = "" + for m in messages_list: + if not isinstance(m, dict): + continue + role = m.get("role", "") + content = m.get("content", "") + if isinstance(content, list): + content = " ".join(c.get("text", "") for c in content if isinstance(c, dict)) + if role == "user": + conversation_text += f"Élève: {content}\n" + elif role == "assistant": + conversation_text += f"Tuteur: {content}\n" + + if len(conversation_text) < 50: + raise HTTPException(status_code=400, detail="La session est trop courte pour générer une évaluation.") + + # Générer les QCM avec l'IA + questions = await generate_qcm_with_ai(conversation_text, subject, title) + + if not questions: + raise HTTPException(status_code=500, detail="L'IA n'a pas pu générer les questions.") + + # Sauvegarder en BDD + evaluation = Evaluation( + id=str(uuid.uuid4()), + created_by=current_user.id, + student_id=data.student_id, + support_id=data.support_id, + chat_id=data.chat_id, + title=f"Évaluation — {title}", + subject=subject, + questions=questions, + status="pending", + ) + db.add(evaluation) + db.commit() + db.refresh(evaluation) + + return evaluation.to_dict() + + +@router.get("/by-student/{student_id}") +async def list_evaluations( + student_id: str, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db), +): + """Liste toutes les évaluations d'un enfant.""" + _require_parent(current_user) + try: + ParentService(db).assert_owns_student(current_user.id, student_id) + except AuthorizationError as e: + raise HTTPException(status_code=403, detail=str(e)) + + evals = db.query(Evaluation).filter( + Evaluation.student_id == student_id + ).order_by(Evaluation.created_at.desc()).all() + + return [e.to_dict() for e in evals] + + +@router.get("/{eval_id}") +async def get_evaluation( + eval_id: str, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db), +): + """Récupère une évaluation (sans les bonnes réponses si pending).""" + ev = db.query(Evaluation).filter(Evaluation.id == eval_id).first() + if not ev: + raise HTTPException(status_code=404, detail="Évaluation introuvable") + + # Vérifier accès : créateur (parent) ou étudiant concerné + if ev.created_by != current_user.id and ev.student_id != current_user.id: + if current_user.role not in ("admin",): + raise HTTPException(status_code=403, detail="Accès refusé") + + data = ev.to_dict() + # Masquer les réponses correctes si l'évaluation n'est pas encore soumise + if ev.status == "pending": + for q in data.get("questions", []): + q.pop("correct", None) + q.pop("explanation", None) + return data + + +@router.post("/{eval_id}/submit") +async def submit_answers( + eval_id: str, + body: SubmitAnswersRequest, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db), +): + """Soumet les réponses et calcule la note automatiquement.""" + ev = db.query(Evaluation).filter(Evaluation.id == eval_id).first() + if not ev: + raise HTTPException(status_code=404, detail="Évaluation introuvable") + if ev.status == "completed": + raise HTTPException(status_code=400, detail="Évaluation déjà soumise.") + + # Corriger automatiquement + correct_count = 0 + results = [] + for q in ev.questions: + q_id = str(q["id"]) + student_answer = body.answers.get(q_id, "") + is_correct = student_answer.upper() == q["correct"].upper() + if is_correct: + correct_count += 1 + results.append({ + **q, + "student_answer": student_answer, + "is_correct": is_correct, + }) + + nb_total = len(ev.questions) + score = round((correct_count / max(nb_total, 1)) * 100, 1) + + # Mettre à jour en BDD + ev.student_answers = body.answers + ev.score = score + ev.nb_correct = correct_count + ev.nb_total = nb_total + ev.status = "completed" + ev.completed_at = datetime.utcnow() + db.commit() + db.refresh(ev) + + return { + **ev.to_dict(), + "results": results, + "message": f"🎉 {correct_count}/{nb_total} correctes — Score : {score}/100" + } \ No newline at end of file diff --git a/gateway/http/routers/parent_supports.py b/gateway/http/routers/parent_supports.py new file mode 100644 index 00000000..14ea8748 --- /dev/null +++ b/gateway/http/routers/parent_supports.py @@ -0,0 +1,390 @@ +"""Router parent — /parent/supports/* + +Permet à un utilisateur ayant le rôle 'parent' de créer un soutien +pour l'un de ses enfants liés, en s'appuyant sur les services existants. +""" + +from datetime import datetime +from typing import List, Optional + +from fastapi import APIRouter, Depends, File, Form, HTTPException, Query, Request, UploadFile, status +from fastapi.responses import JSONResponse +from pydantic import BaseModel + +from common.exceptions import AuthorizationError, NotFoundError, ValidationError +from config import settings +from data.models import User +from gateway.http.dependencies import get_current_user, get_supports_service +from data.database import get_db +from learning.supports.service import SupportsService +from accounts.parents.service import ParentService +from sqlalchemy.orm import Session + +router = APIRouter(prefix="/parent/supports", tags=["parent-supports"]) + + +# ── Schémas Pydantic ───────────────────────────────────────────────────────── + + +class ParentSupportCreateRequest(BaseModel): + """Données du formulaire de création de soutien côté parent.""" + + student_id: str + title: str + short_description: Optional[str] = None + subject: Optional[str] = None + custom_subject: Optional[str] = None + learning_objective: Optional[str] = None + learning_type: Optional[str] = None + level: Optional[str] = None + content_language: Optional[str] = "French" + estimated_duration: Optional[str] = None + keywords: Optional[List[str]] = None + start_date: Optional[str] = None + end_date: Optional[str] = None + # Message personnel du parent affiché à l'enfant + parent_message: Optional[str] = None + + +class SupportFileInfo(BaseModel): + id: str + filename: str + file_type: Optional[str] = None + file_size: Optional[int] = None + + class Config: + from_attributes = True + + +class ParentSupportResponse(BaseModel): + id: str + user_id: str # = student_id (le soutien appartient à l'enfant) + title: str + short_description: Optional[str] = None + subject: Optional[str] = None + custom_subject: Optional[str] = None + learning_objective: Optional[str] = None + learning_type: Optional[str] = None + level: Optional[str] = None + content_language: Optional[str] = None + estimated_duration: Optional[str] = None + keywords: Optional[List[str]] = None + start_date: Optional[str] = None + end_date: Optional[str] = None + status: str + chat_id: Optional[str] = None + files: List[SupportFileInfo] = [] + created_at: datetime + updated_at: Optional[datetime] = None + + class Config: + from_attributes = True + + +def _require_parent(current_user: User) -> User: + """Lève 403 si l'utilisateur n'a pas le rôle 'parent'.""" + if current_user.role not in ("parent", "admin"): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Accès réservé aux parents.", + ) + return current_user + + +def _get_parent_service(db: Session = Depends(get_db)) -> ParentService: + return ParentService(db) + + +# ── Endpoints ──────────────────────────────────────────────────────────────── + + +@router.post("/create", response_model=ParentSupportResponse) +async def create_support_for_child( + data: ParentSupportCreateRequest, + current_user: User = Depends(get_current_user), + svc: SupportsService = Depends(get_supports_service), + db: Session = Depends(get_db), +): + """ + Crée un soutien pour l'enfant lié (student_id). + + - Vérifie que l'utilisateur a le rôle 'parent'. + - Vérifie qu'une liaison active existe entre ce parent et l'étudiant. + - Délègue la création à SupportsService en rattachant le soutien à l'étudiant. + """ + _require_parent(current_user) + + parent_svc = ParentService(db) + payload = data.model_dump(exclude={"student_id", "parent_message"}) + + try: + support = parent_svc.create_support_for_student( + parent_id=current_user.id, + student_id=data.student_id, + data=payload, + ) + except AuthorizationError as exc: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=str(exc)) + except NotFoundError as exc: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=exc.message) + + return ParentSupportResponse( + id=support.id, + user_id=support.user_id, + title=support.title, + short_description=support.short_description, + subject=support.subject, + custom_subject=support.custom_subject, + learning_objective=support.learning_objective, + learning_type=support.learning_type, + level=support.level, + content_language=support.content_language, + estimated_duration=support.estimated_duration, + keywords=support.keywords.split(",") if support.keywords else None, + start_date=support.start_date, + end_date=support.end_date, + status=support.status, + files=[], + created_at=support.created_at, + updated_at=support.updated_at, + ) + + +@router.post("/upload-file") +async def upload_file_for_child_support( + request: Request, + support_id: str = Form(...), + student_id: str = Form(...), + file: UploadFile = File(...), + current_user: User = Depends(get_current_user), + svc: SupportsService = Depends(get_supports_service), + db: Session = Depends(get_db), +): + """Upload un fichier pour un soutien de l'enfant.""" + _require_parent(current_user) + + # Vérifier que le parent est bien lié à cet étudiant + parent_svc = ParentService(db) + try: + parent_svc.assert_owns_student(current_user.id, student_id) + except AuthorizationError as exc: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=str(exc)) + + max_bytes = settings.MAX_UPLOAD_SIZE_MB * 1024 * 1024 + _too_large = HTTPException( + status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE, + detail=f"Fichier dépasse la limite de {settings.MAX_UPLOAD_SIZE_MB} Mo", + ) + + raw_cl = request.headers.get("content-length") + if raw_cl and raw_cl.isdigit() and int(raw_cl) > max_bytes: + raise _too_large + + contents = await file.read() + if len(contents) > max_bytes: + raise _too_large + + try: + record = svc.upload_file( + user_id=student_id, # le fichier appartient à l'enfant + support_id=support_id, + filename=file.filename or "", + content_type=file.content_type, + contents=contents, + upload_dir=settings.UPLOAD_DIR, + max_size_mb=settings.MAX_UPLOAD_SIZE_MB, + ) + except (NotFoundError, AuthorizationError) as exc: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=str(exc)) + except ValidationError: + raise _too_large + + return {"id": record.id, "filename": record.filename, "status": "success"} + + +@router.get("/list/{student_id}", response_model=List[ParentSupportResponse]) +async def list_child_supports( + student_id: str, + current_user: User = Depends(get_current_user), + svc: SupportsService = Depends(get_supports_service), + db: Session = Depends(get_db), +): + """Liste tous les soutiens créés pour un enfant lié.""" + _require_parent(current_user) + + parent_svc = ParentService(db) + try: + parent_svc.assert_owns_student(current_user.id, student_id) + except AuthorizationError as exc: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=str(exc)) + + supports = svc.list_for_user(student_id) + return [ + ParentSupportResponse( + id=s.id, + user_id=s.user_id, + title=s.title, + short_description=s.short_description, + subject=s.subject, + custom_subject=s.custom_subject, + learning_objective=s.learning_objective, + learning_type=s.learning_type, + level=s.level, + content_language=s.content_language, + estimated_duration=s.estimated_duration, + keywords=s.keywords.split(",") if s.keywords else None, + start_date=s.start_date, + end_date=s.end_date, + status=s.status, + chat_id=s.chat_id, + files=[], + created_at=s.created_at, + updated_at=s.updated_at, + ) + for s in supports + ] + + +@router.get("/detail/{support_id}") +async def get_child_support_detail( + support_id: str, + current_user: User = Depends(get_current_user), + svc: SupportsService = Depends(get_supports_service), + db: Session = Depends(get_db), +): + """Récupère le détail d'un soutien de l'enfant (accès parent).""" + _require_parent(current_user) + + # Récupérer le soutien + from data.models import Support + support = db.query(Support).filter(Support.id == support_id).first() + if not support: + raise HTTPException(status_code=404, detail="Soutien introuvable") + + # Vérifier que le parent est bien lié à cet étudiant + parent_svc = ParentService(db) + try: + parent_svc.assert_owns_student(current_user.id, support.user_id) + except AuthorizationError as exc: + raise HTTPException(status_code=403, detail=str(exc)) + + # Récupérer les fichiers + from data.models import SupportFile + files = db.query(SupportFile).filter(SupportFile.support_id == support_id).all() + + return { + "id": support.id, + "user_id": support.user_id, + "title": support.title, + "short_description": support.short_description, + "subject": support.subject, + "custom_subject": support.custom_subject, + "learning_objective": support.learning_objective, + "learning_type": support.learning_type, + "level": support.level, + "content_language": support.content_language, + "estimated_duration": support.estimated_duration, + "keywords": support.keywords, + "start_date": support.start_date, + "end_date": support.end_date, + "status": support.status, + "chat_id": support.chat_id, + "files": [{"id": f.id, "filename": f.filename} for f in files], + "created_at": support.created_at.isoformat() if support.created_at else None, + "updated_at": support.updated_at.isoformat() if support.updated_at else None, + } + + +@router.patch("/link-chat/{support_id}") +async def link_chat_to_support( + support_id: str, + chat_id: str = Query(...), + current_user: User = Depends(get_current_user), + svc: SupportsService = Depends(get_supports_service), + db: Session = Depends(get_db), +): + """Lie un chat_id à un soutien de l'enfant (appelé par le parent après création du chat).""" + _require_parent(current_user) + + from data.models import Support + support = db.query(Support).filter(Support.id == support_id).first() + if not support: + raise HTTPException(status_code=404, detail="Soutien introuvable") + + # Vérifier que le parent est lié à l'étudiant propriétaire du soutien + parent_svc = ParentService(db) + try: + parent_svc.assert_owns_student(current_user.id, support.user_id) + except AuthorizationError as exc: + raise HTTPException(status_code=403, detail=str(exc)) + + updated = svc.update_chat_id(support_id, chat_id) + return {"id": updated.id, "chat_id": updated.chat_id, "status": "success"} + + +@router.patch("/{support_id}/complete") +async def mark_support_completed( + support_id: str, + current_user: User = Depends(get_current_user), + svc: SupportsService = Depends(get_supports_service), + db: Session = Depends(get_db), +): + """Marque un soutien comme terminé.""" + _require_parent(current_user) + from data.models import Support + support = db.query(Support).filter(Support.id == support_id).first() + if not support: + raise HTTPException(status_code=404, detail="Soutien introuvable") + parent_svc = ParentService(db) + try: + parent_svc.assert_owns_student(current_user.id, support.user_id) + except AuthorizationError as exc: + raise HTTPException(status_code=403, detail=str(exc)) + from datetime import datetime + support.status = "completed" + support.updated_at = datetime.utcnow() + db.commit() + return {"id": support.id, "status": "completed"} + + +@router.get("/find-student") +async def find_student_by_email( + email: str, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db), +): + """Cherche un étudiant par email pour simplifier la liaison parent-enfant.""" + _require_parent(current_user) + from data.models import User as UserModel + # Recherche insensible à la casse + accepter role user ET parent (enfant peut avoir rôle user) + student = db.query(UserModel).filter( + UserModel.email.ilike(email.strip()), + UserModel.role.in_(["user", "student"]) + ).first() + # Si pas trouvé avec role user/student, chercher juste par email + if not student: + student = db.query(UserModel).filter( + UserModel.email.ilike(email.strip()) + ).first() + # Exclure les admins et parents + if student and student.role in ("admin", "parent"): + student = None + if not student: + raise HTTPException(status_code=404, detail="Aucun élève trouvé avec cet email.") + # Créer automatiquement le lien parent-étudiant si pas encore fait + from accounts.parents.models import ParentStudentLink + import uuid as uuid_lib + existing = db.query(ParentStudentLink).filter( + ParentStudentLink.parent_id == current_user.id, + ParentStudentLink.student_id == student.id + ).first() + if not existing: + link = ParentStudentLink( + id=str(uuid_lib.uuid4()), + parent_id=current_user.id, + student_id=student.id, + status="active" + ) + db.add(link) + db.commit() + return {"id": student.id, "name": student.name, "email": student.email} \ No newline at end of file diff --git a/learning/supports/service.py b/learning/supports/service.py index 9553d99d..892b177d 100644 --- a/learning/supports/service.py +++ b/learning/supports/service.py @@ -76,7 +76,7 @@ def update(self, support_id: str, data: Dict[str, Any]) -> Support: def update_chat_id(self, support_id: str, chat_id: str) -> Support: return self.repo.update( - support_id, chat_id=chat_id, updated_at=datetime.utcnow() + support_id, chat_id=chat_id, status="active", updated_at=datetime.utcnow() ) def delete(self, support_id: str) -> None: @@ -130,4 +130,4 @@ def upload_file( file_size=len(contents), created_at=datetime.utcnow(), ) - return self.repo.add_file(record) + return self.repo.add_file(record) \ No newline at end of file diff --git a/ui/src/lib/apis/parent/index.ts b/ui/src/lib/apis/parent/index.ts new file mode 100644 index 00000000..3ae8bdbf --- /dev/null +++ b/ui/src/lib/apis/parent/index.ts @@ -0,0 +1,77 @@ +import { TUTOR_API_BASE_URL } from '$lib/constants'; + +// ── Types ───────────────────────────────────────────────────────────────────── + +export interface ParentSupportCreateRequest { + student_id: string; title: string; short_description?: string; + subject?: string; custom_subject?: string; learning_objective?: string; + learning_type?: string; level?: string; content_language?: string; + estimated_duration?: string; keywords?: string[]; start_date?: string; + end_date?: string; parent_message?: string; +} + +export interface ParentSupportResponse { + id: string; user_id: string; title: string; short_description?: string; + subject?: string; learning_objective?: string; learning_type?: string; + level?: string; content_language?: string; estimated_duration?: string; + keywords?: string[]; status: string; created_at: string; +} + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +async function apiFetch(url: string, token: string, options: RequestInit = {}): Promise { + const res = await fetch(url, { + ...options, + headers: { Accept: 'application/json', 'Content-Type': 'application/json', authorization: `Bearer ${token}`, ...(options.headers ?? {}) }, + }); + if (!res.ok) { + const err = await res.json().catch(() => ({ detail: `HTTP ${res.status}` })); + throw new Error(err.detail ?? 'Erreur API'); + } + return res.json(); +} + +// ── Soutiens ────────────────────────────────────────────────────────────────── + +export const createParentSupport = (token: string, data: ParentSupportCreateRequest) => + apiFetch(`${TUTOR_API_BASE_URL}/parent/supports/create`, token, { method: 'POST', body: JSON.stringify(data) }); + +export const listChildSupports = (token: string, studentId: string) => + apiFetch(`${TUTOR_API_BASE_URL}/parent/supports/list/${studentId}`, token); + +export const uploadParentSupportFile = async (token: string, supportId: string, studentId: string, file: File) => { + const form = new FormData(); + form.append('support_id', supportId); form.append('student_id', studentId); form.append('file', file); + const res = await fetch(`${TUTOR_API_BASE_URL}/parent/supports/upload-file`, { method: 'POST', headers: { authorization: `Bearer ${token}` }, body: form }); + if (!res.ok) throw new Error((await res.json().catch(() => ({}))).detail ?? 'Erreur upload'); + return res.json(); +}; + +// ── Dashboard ───────────────────────────────────────────────────────────────── + +export const getDashboard = (token: string, studentId: string) => + apiFetch(`${TUTOR_API_BASE_URL}/parent/dashboard/${studentId}`, token); + +// ── Évaluations ─────────────────────────────────────────────────────────────── + +export const getEvaluations = (token: string, studentId: string, matiere?: string) => + apiFetch(`${TUTOR_API_BASE_URL}/parent/evaluations/${studentId}${matiere ? `?matiere=${matiere}` : ''}`, token); + +// ── Sessions IA ─────────────────────────────────────────────────────────────── + +export const getSessions = (token: string, studentId: string, matiere?: string) => + apiFetch(`${TUTOR_API_BASE_URL}/parent/sessions/${studentId}${matiere ? `?matiere=${matiere}` : ''}`, token); + +// ── Notifications ───────────────────────────────────────────────────────────── + +export const getNotifications = (token: string) => + apiFetch(`${TUTOR_API_BASE_URL}/parent/notifications`, token); + +export const marquerLue = (token: string, notifId: string) => + apiFetch(`${TUTOR_API_BASE_URL}/parent/notifications/${notifId}/lire`, token, { method: 'PATCH', body: '{}' }); + +export const getChildSupportDetail = (token: string, supportId: string) => + apiFetch(`${TUTOR_API_BASE_URL}/parent/supports/detail/${supportId}`, token); + +export const linkChatToSupport = (token: string, supportId: string, chatId: string) => + apiFetch(`${TUTOR_API_BASE_URL}/parent/supports/link-chat/${supportId}?chat_id=${chatId}`, token, { method: 'PATCH', body: '{}' }); \ No newline at end of file diff --git a/ui/src/lib/components/parent/elements/ParentLayout.svelte b/ui/src/lib/components/parent/elements/ParentLayout.svelte new file mode 100644 index 00000000..dbaa8fa6 --- /dev/null +++ b/ui/src/lib/components/parent/elements/ParentLayout.svelte @@ -0,0 +1,69 @@ + + +
+ + + + + +
+ +
+
+
Bonjour {firstName} 👋
+
Bienvenue dans votre espace parent
+
+
+ 🔍 + +
+ + 🔔 + +
+ {initials} +
+
+ + +
+ +
+
+
\ No newline at end of file diff --git a/ui/src/lib/components/parent/elements/ParentSupportCreation.svelte b/ui/src/lib/components/parent/elements/ParentSupportCreation.svelte new file mode 100644 index 00000000..0679fff4 --- /dev/null +++ b/ui/src/lib/components/parent/elements/ParentSupportCreation.svelte @@ -0,0 +1,505 @@ + + + + + + +
+ {#each steps as step, index} +
+ + {step} +
+ {#if index < steps.length - 1} +
+ {#if currentStep > index} +
+ {/if} +
+ {/if} + {/each} +
+ + +
+
+ + + {#if currentStep === 0} +

Pour quel enfant créez-vous ce soutien ?

+

Entrez l'email de l'enfant pour lier le soutien à son compte. Vous pouvez aussi continuer sans liaison.

+ + +
+ +
+ e.key === 'Enter' && searchStudentByEmail()} + placeholder="ex : wissal@gmail.fr" + style="flex:1;padding:10px 14px;border:1px solid {studentSearchStatus === 'found' ? '#16A34A' : studentSearchStatus === 'notfound' ? '#DC2626' : '#E5E7EB'};border-radius:8px;font-size:13px;outline:none;"/> + +
+ + {#if studentSearchStatus === 'found'} +
+ +
+
Élève trouvé : {studentFoundName}
+
Le soutien sera lié à son compte automatiquement.
+
+
+ {:else if studentSearchStatus === 'notfound'} +
+ ❌ Aucun élève trouvé avec cet email. Vérifiez l'adresse ou continuez sans liaison. +
+ {/if} +
+
+ + +
+
+ + +
+ + + {:else if currentStep === 1} +

Expliquez les besoins d'apprentissage

+

Décrivez la difficulté rencontrée par {studentName || "votre enfant"}.

+
+ + +
+
+ + +
+
+ +
+ {#each visibleSubjects as subject} + + {/each} +
+
+ + {subjectPageIndex + 1} / {totalSubjectPages} + +
+
+

Matière non listée ? Créez la vôtre :

+ +
+
+ + + {:else if currentStep === 2} +

Ressources pédagogiques

+

Joignez le cours, les fiches ou tout document utile pour le tuteur IA.

+
document.getElementById('pfile')?.click()} + on:keypress={(e) => e.key === 'Enter' && document.getElementById('pfile')?.click()} + on:dragover={preventDefaults} on:dragenter={preventDefaults} on:drop={handleFileDrop}> + +
+

Cliquez pour télécharger ou glissez-déposez

+

PDF, DOCX, PPTX, MP4 (max 50Mo)

+
+ {#if uploadedFiles.length > 0} +
+ {#each uploadedFiles as file, i} +
+ 📄 + {file.name} + +
+ {/each} +
+ {/if} + + + {:else if currentStep === 3} +

Définissez l'objectif pédagogique

+
+ + +
+
+ +
+ {#each learningTypes as type} + + {/each} +
+
+ + + {:else if currentStep === 4} +

Choisissez le niveau scolaire

+

Sélectionnez le niveau approprié. *

+
+ {#each learningLevels as level} + + {/each} +
+ + + {:else if currentStep === 5} +

Vérifiez le soutien avant de créer

+
+
+

{supportTitle}

+
+ {#if studentName}Pour : {studentName}{/if} + {#if subjectLabel}{subjectLabel}{/if} + {#if selectedLevel}{learningLevels.find(l => l.id === selectedLevel)?.name}{/if} +
+
+
+
+ {#if shortDescription}
Description
{shortDescription}
{/if} + {#if learningObjective}
Objectif
{learningObjective}
{/if} + {#if parentMessage}
Message
"{parentMessage}"
{/if} +
+
+ {#if selectedLearningType}
Type
{learningTypes.find(t => t.id === selectedLearningType)?.name}
{/if} +
Détails
🌍 {contentLanguage}   ⏱ {estimatedDuration}
+ {#if uploadedFiles.length > 0}
Fichiers
{#each uploadedFiles as f}
📎 {f.name}
{/each}
{/if} +
+
+
+
+ + +
+
+ + +
+
+ +
+ + +
+ {#if keywords.length > 0} +
+ {#each keywords as kw} + + {kw} + + {/each} +
+ {/if} +
+
+ + +
+
+ + +
+
+ +
+
+ 🤖 Prompt IA personnalisé + Auto-généré + +
+
+ +
+
+ +
+ {#each tutorStyles as ts} + + {/each} +
+
+
+
+ + Le soutien sera visible dans l'espace de {studentName || "l'enfant"} dès sa création. +
+
+ {/if} +
+ + +
+ + +
+
\ No newline at end of file diff --git a/ui/src/lib/components/parent/pages/ParentDashboard.svelte b/ui/src/lib/components/parent/pages/ParentDashboard.svelte new file mode 100644 index 00000000..084f69db --- /dev/null +++ b/ui/src/lib/components/parent/pages/ParentDashboard.svelte @@ -0,0 +1,202 @@ + + + +
+
+

Tableau de bord

+

+ Soutiens créés pour {studentName || 'votre enfant'} +

+
+ + ✦ Créer un soutien + +
+ + +{#if !loading} +
+ {#each [ + { label:'Total soutiens', value:supports.length, color:'#2563EB' }, + { label:'Actifs', value:supports.filter(s=>s.status==='active').length, color:'#16A34A' }, + { label:'En attente', value:supports.filter(s=>s.status==='pending').length, color:'#D97706' }, + { label:'Terminés', value:supports.filter(s=>s.status==='completed').length, color:'#7C3AED' }, + ] as stat} +
+
{stat.label}
+
{stat.value}
+
+ {/each} +
+{/if} + + +
+ {#each filtres as f} + + {/each} +
+ + +{#if loading} +
+
+ Chargement... +
+ +{:else if supportsFiltres.length === 0} +
+
📚
+

Aucun soutien trouvé

+ + ✦ Créer le premier soutien + +
+ +{:else} +
+ {#each supportsFiltres as support} + {@const st = statusStyle(support.status)} +
+ +
+ +
+ {subjectIcon(support.subject)} +
+ + +
+
+

{support.title}

+ {st.label} +
+ {#if support.short_description} +

{support.short_description}

+ {/if} +
+ {#if support.subject}📖 {support.subject}{/if} + {#if support.level}🎓 {support.level}{/if} + {#if support.estimated_duration}⏱️ {support.estimated_duration}{/if} + 📅 {formatDate(support.created_at)} +
+
+ + +
+ + + + + + + 📋 Voir les détails + +
+
+ + + {#if support.learning_objective} +
+ 🎯 + Objectif : {support.learning_objective} +
+ {/if} +
+ {/each} +
+{/if} + + \ No newline at end of file diff --git a/ui/src/lib/components/parent/pages/ParentEvaluations.svelte b/ui/src/lib/components/parent/pages/ParentEvaluations.svelte new file mode 100644 index 00000000..05331770 --- /dev/null +++ b/ui/src/lib/components/parent/pages/ParentEvaluations.svelte @@ -0,0 +1,213 @@ + + +
+
+

Évaluations

+

QCM générés par l'IA depuis les sessions

+
+ +
+ + +{#if showGenerateModal} +
showGenerateModal=false} role="dialog" aria-modal="true"> +
+

Choisir une session

+

L'IA va lire la conversation et générer 10 questions QCM.

+
+ {#each sessions as s} + + {/each} +
+ +
+
+{/if} + +{#if loading} +
+
+ Chargement... +
+ +{:else if evaluations.length === 0} +
+
📝
+

Aucune évaluation pour l'instant

+

Générez une évaluation depuis une session IA

+ {#if sessions.length > 0} + + {:else} +

Commencez par démarrer une session IA depuis le tableau de bord

+ {/if} +
+ +{:else} + +
+ {#each [ + {label:'Total évaluations', value:evaluations.length, color:'#2563EB'}, + {label:'Terminées', value:completedCount, color:'#16A34A'}, + {label:'Score moyen', value: avgScore, color:'#D97706'}, + ] as s} +
+
{s.label}
+
{s.value}
+
+ {/each} +
+ + +
+ {#each evaluations as ev} +
+
+ +
+ {#if ev.status === 'completed'} +
+
{ev.score}
+
/100
+
+ {:else} + 📝 + {/if} +
+ + +
+
{ev.title}
+
+ {#if ev.subject}📖 {ev.subject}{/if} + ❓ {ev.questions?.length ?? 0} questions + 📅 {formatDate(ev.created_at)} + {#if ev.status === 'completed'} + ✓ {ev.nb_correct}/{ev.nb_total} correctes + {/if} +
+
+ + +
+ {#if ev.status === 'pending'} + En attente + + ▶ Passer le QCM + + {:else} + + {ev.score >= 80 ? '🎉 Excellent' : ev.score >= 60 ? '👍 Bien' : '💪 À retravailler'} + + + 📋 Voir les résultats + + {/if} +
+
+
+ {/each} +
+{/if} + + \ No newline at end of file diff --git a/ui/src/lib/components/parent/pages/ParentNotifications.svelte b/ui/src/lib/components/parent/pages/ParentNotifications.svelte new file mode 100644 index 00000000..1838a68f --- /dev/null +++ b/ui/src/lib/components/parent/pages/ParentNotifications.svelte @@ -0,0 +1,168 @@ + + +
+ +
+
+

Notifications

+ {#if data}

{data.stats.non_lues} non lue{data.stats.non_lues > 1 ? 's' : ''} sur {data.stats.total}

{/if} +
+ {#if data?.stats?.non_lues > 0} + + {/if} +
+ + {#if loading} +
⏳ Chargement...
+ {:else if data} + + +
+
+
🔵
+
{data.stats.non_lues}
Non lues
+
+
+
🏆
+
{nbResultats}
Résultats
+
+
+
⚠️
+
{nbAlertes}
Alertes
+
+
+
🤖
+
{nbIa}
Reco. IA
+
+
+ + +
+ {#each filtres as f} + + {/each} +
+ + +
+ {#if notifsFiltrees.length === 0} +
+
🔔
+
Aucune notification
+
+ {:else} + {#each notifsFiltrees as n} + + {/each} + {/if} +
+ + +
+
⚙️ Préférences de notification
+
+ {#each [ + {label:"Résultats d'évaluation", desc:'Recevoir une notification pour chaque nouvelle note', on:true}, + {label:'Recommandations IA', desc:'Suggestions du tuteur après chaque session', on:true}, + {label:'Alertes de progression', desc:'Alerte si le score descend sous 60/100', on:true}, + {label:'Rapport hebdomadaire', desc:'Résumé de la semaine chaque lundi matin', on:false}, + ] as pref} +
+
+
{pref.label}
+
{pref.desc}
+
+
+
+
+
+ {/each} +
+
+ {/if} +
\ No newline at end of file diff --git a/ui/src/lib/components/parent/pages/ParentSessions.svelte b/ui/src/lib/components/parent/pages/ParentSessions.svelte new file mode 100644 index 00000000..9c4cf81f --- /dev/null +++ b/ui/src/lib/components/parent/pages/ParentSessions.svelte @@ -0,0 +1,188 @@ + + +
+
+

Sessions IA

+

Conversations réelles avec le tuteur IA

+
+
+ +{#if loading} +
+
+ Chargement des sessions... +
+ +{:else if !data || data.sessions.length === 0} +
+
🤖
+

Aucune session IA pour l'instant

+

Démarrez une session depuis le tableau de bord

+ + ← Tableau de bord + +
+ +{:else} + +
+ {#each [ + {icon:'🤖', label:'Sessions', value:data.stats.total_sessions, color:'#2563EB'}, + {icon:'⏱️', label:'Temps total', value:data.stats.temps_total, color:'#2563EB'}, + {icon:'⭐', label:'Score qualité moy.', value:data.stats.score_qualite_moyen, color:'#16A34A'}, + {icon:'💬', label:'Questions posées', value:data.stats.total_questions, color:'#D97706'}, + ] as s} +
+
{s.icon}
+
+
{s.value}
+
{s.label}
+
+
+ {/each} +
+ + +
+ {#each data.sessions as s} +
+ + +
+
{subjectIcon(s.matiere)}
+
+
{s.titre}
+
+ 📅 {s.date} + ⏱️ {s.duree_min} min + 💬 {s.nb_messages_ia} réponses IA +
+
+
+
{s.score_qualite}
+
Qualité
+
+
+ + +
+ + {#if validThemes(s.themes).length > 0} +
+ {#each validThemes(s.themes) as t} + {t} + {/each} +
+ {/if} + + + {#if s.questions.length > 0} +
💬 Questions posées
+ {#each s.questions as q, i} +
+
{i+1}
+
{q}
+
+ {/each} + {/if} + + + {#if s.resume} +
+
🤖 Dernière réponse IA
+
{s.resume}
+
+ {/if} + + +
+
+ Progression + {s.progress}% +
+
+
+
+
+ + + {#each [['Engagement', s.engagement],['Compréhension', s.comprehension],['Autonomie', s.autonomie]] as [label, val]} +
+
{label}
+
+
+
+
{val}
+
+ {/each} +
+ + +
+ Voir la conversation → + {statutText(s.statut)} +
+
+ {/each} +
+{/if} + + \ No newline at end of file diff --git a/ui/src/lib/components/student/tutor/Chat.svelte b/ui/src/lib/components/student/tutor/Chat.svelte index 67b7a8a3..356e7c53 100644 --- a/ui/src/lib/components/student/tutor/Chat.svelte +++ b/ui/src/lib/components/student/tutor/Chat.svelte @@ -109,6 +109,7 @@ export let chatIdProp = ''; + let loading = false; const eventTarget = new EventTarget(); diff --git a/ui/src/routes/parent/+layout.svelte b/ui/src/routes/parent/+layout.svelte new file mode 100644 index 00000000..18d6a5b5 --- /dev/null +++ b/ui/src/routes/parent/+layout.svelte @@ -0,0 +1,147 @@ + + + + + + +
+ + + + + +
+ +
+
+
Bonjour {firstName} 👋
+
Bienvenue dans votre espace parent
+
+
+ 🔍 + +
+ 🔔 + +
+ + {#if showMenu} + + + +
+ +
+
{$user?.name ?? '—'}
+
{$user?.email ?? '—'}
+
+ + +
+ {/if} +
+
+ + +
+ +
+
+
\ No newline at end of file diff --git a/ui/src/routes/parent/+page.svelte b/ui/src/routes/parent/+page.svelte index c0b45766..c24f5ed3 100644 --- a/ui/src/routes/parent/+page.svelte +++ b/ui/src/routes/parent/+page.svelte @@ -45,9 +45,7 @@ class="flex flex-col items-center justify-center min-h-screen bg-gray-100 dark:bg-gray-900 p-6" >
-

- {$i18n.t('Error Loading Parent Page')} -

+

{$i18n.t('Error Loading Parent Page')}

{error}

+ {/each} +
+
+ {/each} + + + +
+
+ {#if allAnswered}✅ Toutes les questions répondues !{:else}⚠️ {evaluation.questions.length - Object.keys(answers).length} question(s) sans réponse{/if} +
+ +
+{/if} + + \ No newline at end of file diff --git a/ui/src/routes/parent/evaluations/+page.svelte b/ui/src/routes/parent/evaluations/+page.svelte new file mode 100644 index 00000000..27a799e3 --- /dev/null +++ b/ui/src/routes/parent/evaluations/+page.svelte @@ -0,0 +1,4 @@ + + \ No newline at end of file diff --git a/ui/src/routes/parent/notifications/+page.svelte b/ui/src/routes/parent/notifications/+page.svelte new file mode 100644 index 00000000..6dbf6c57 --- /dev/null +++ b/ui/src/routes/parent/notifications/+page.svelte @@ -0,0 +1,4 @@ + + \ No newline at end of file diff --git a/ui/src/routes/parent/sessions/+page.svelte b/ui/src/routes/parent/sessions/+page.svelte new file mode 100644 index 00000000..fe5a7374 --- /dev/null +++ b/ui/src/routes/parent/sessions/+page.svelte @@ -0,0 +1,4 @@ + + \ No newline at end of file diff --git a/ui/src/routes/parent/support/[id]/+page.svelte b/ui/src/routes/parent/support/[id]/+page.svelte new file mode 100644 index 00000000..f830c228 --- /dev/null +++ b/ui/src/routes/parent/support/[id]/+page.svelte @@ -0,0 +1,213 @@ + + + +
+ ← Tableau de bord + / + Détail du soutien +
+ +{#if loading} +
+
+ Chargement... +
+ +{:else if !support} +
+
+

Soutien introuvable

+ ← Retour au tableau de bord +
+ +{:else} + + +
+
+
+ {subjectIcon(support.subject)} +
+
+

{support.title}

+
+ {#if support.subject}📖 {support.subject}{/if} + {#if support.level}🎓 {support.level}{/if} + {#if support.estimated_duration}⏱️ {support.estimated_duration}{/if} + {statusLabel(support.status)} +
+
+ + {#if support.chat_id} + + 🤖 Voir la session IA + + {:else} +
+ 🤖 Session IA
pas encore démarrée +
+ {/if} +
+
+ + +
+ + +
+ + + {#if support.short_description} +
+

Description

+

{support.short_description}

+
+ {/if} + + + {#if support.learning_objective} +
+

🎯 Objectif pédagogique

+

{support.learning_objective}

+
+ {/if} + + + {#if support.files && support.files.length > 0} +
+

📎 Ressources jointes ({support.files.length})

+
+ {#each support.files as file} +
+ 📄 + {file.filename} +
+ {/each} +
+
+ {/if} + + +
+

🤖 Session IA

+ {#if support.chat_id} +
+ +
+
Session démarrée !
+
Votre enfant a déjà commencé à utiliser le tuteur IA.
+
+ + Voir le chat → + +
+ {:else} +
+ +
+
En attente de démarrage
+
Votre enfant n'a pas encore démarré la session avec le tuteur IA.
+
+
+ {/if} +
+
+ + +
+ + +
+

Informations

+
+ {#each [ + {label:'Statut', value: statusLabel(support.status)}, + {label:'Type', value: support.learning_type ?? '—'}, + {label:'Langue', value: support.content_language ?? '—'}, + {label:'Durée', value: support.estimated_duration ?? '—'}, + {label:'Créé le', value: formatDate(support.created_at)}, + {label:'Mis à jour', value: formatDate(support.updated_at)}, + ] as info} +
+ {info.label} + {info.value} +
+ {/each} +
+
+ + + {#if support.keywords} +
+

Mots-clés

+
+ {#each (typeof support.keywords === 'string' ? support.keywords.split(',') : support.keywords) as kw} + {kw.trim()} + {/each} +
+
+ {/if} + + + +
+
+ +{/if} + + \ No newline at end of file diff --git a/ui/src/routes/parent/support/create/+page.svelte b/ui/src/routes/parent/support/create/+page.svelte new file mode 100644 index 00000000..b282604e --- /dev/null +++ b/ui/src/routes/parent/support/create/+page.svelte @@ -0,0 +1,5 @@ + + + \ No newline at end of file