Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Empty file added accounts/parents/__init__.py
Empty file.
32 changes: 32 additions & 0 deletions accounts/parents/models.py
Original file line number Diff line number Diff line change
@@ -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,
}

131 changes: 131 additions & 0 deletions accounts/parents/service.py
Original file line number Diff line number Diff line change
@@ -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
7 changes: 6 additions & 1 deletion data/models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -18,4 +21,6 @@
"AppConfig",
"KnowledgeBase",
"KnowledgeFile",
]
"ParentStudentLink",
"Evaluation",
]
56 changes: 56 additions & 0 deletions data/models/evaluation.py
Original file line number Diff line number Diff line change
@@ -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,
}
8 changes: 7 additions & 1 deletion gateway/http/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -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)
Expand All @@ -176,4 +182,4 @@ async def reject_legacy_realtime_path(request: Request, call_next):
name="spa-static-files",
)

return app
return app
26 changes: 23 additions & 3 deletions gateway/http/routers/chats.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}")
Comment on lines +175 to +190

return chat.to_dict()


@router.post("/import")
Expand Down Expand Up @@ -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)
Loading