Compare commits

...

3 Commits

Author SHA1 Message Date
08c8ee4931 feat(class): improve class/id/student
All checks were successful
Build and Publish Docker Images / Build Backend Image (push) Successful in 3m1s
Build and Publish Docker Images / Build Frontend Image (push) Successful in 3m0s
Build and Publish Docker Images / Build Summary (push) Successful in 3s
2025-12-03 06:32:16 +01:00
ab86bbb2e1 feat(class): improve class/id 2025-12-03 06:20:17 +01:00
5b87f24b5b feat(docker): dockerisation 2025-12-03 05:55:41 +01:00
22 changed files with 2867 additions and 258 deletions

View File

@@ -1,26 +0,0 @@
# Configuration Docker pour Notytex v2
# COPIEZ CE FICHIER EN .env ET MODIFIEZ LES VALEURS
# ⚠️ IMPORTANT: Générez une clé SECRET_KEY unique et sécurisée !
# Commande: python -c "import secrets; print(secrets.token_hex(32))"
SECRET_KEY=CHANGEZ-MOI-cle-secrete-unique-minimum-32-caracteres-obligatoire
# Base de données (stockée dans le volume ./data)
DATABASE_URL=sqlite+aiosqlite:////data/school_management.db
# CORS - Ajustez selon vos domaines (ports Docker/Podman: 8080/8081)
CORS_ORIGINS=["http://localhost:8081","http://localhost:8080","http://localhost:3000","https://votre-domaine.com"]
# Logging
LOG_LEVEL=INFO
# Email (optionnel - pour l'envoi de bilans)
# SMTP_HOST=smtp.gmail.com
# SMTP_PORT=587
# SMTP_USERNAME=votre-email@gmail.com
# SMTP_PASSWORD=votre-mot-de-passe-app
# EMAIL_FROM=votre-email@gmail.com
# SMTP_USE_TLS=true
# Configuration production
DEBUG=false

View File

@@ -1,32 +0,0 @@
# Configuration du Registre Docker pour docker-compose.prod.yml
# Copiez ce fichier en .env et ajustez les valeurs
# URL du registre Docker (sans https://)
# Exemples:
# - Docker Hub: docker.io
# - GitHub: ghcr.io
# - GitLab: registry.gitlab.com
# - Gitea: git.example.com
# - Harbor: harbor.example.com
REGISTRY_URL=registry.example.com
# Namespace/Organisation dans le registre
# Exemples:
# - Docker Hub: votre-username
# - GitHub: votre-username ou organisation
# - GitLab: votre-username/projet
# - Gitea: votre-username
REGISTRY_NAMESPACE=myorganization
# Tag de l'image à utiliser
# Options:
# - latest (dernière version)
# - v2.0.0 (version spécifique)
# - rewrite (branche spécifique)
# - main-abc123 (commit SHA)
IMAGE_TAG=latest
# Note: Pour utiliser ces variables avec docker-compose.prod.yml:
# 1. Copiez ce fichier: cp .env.registry.example .env
# 2. Éditez .env avec vos valeurs
# 3. Lancez: docker-compose -f docker-compose.yml -f docker-compose.prod.yml up -d

View File

@@ -1,11 +1,100 @@
# 🐳 Déploiement Docker - Notytex v2 # 🐳 Déploiement Docker - Notytex v2
Guide complet pour déployer Notytex avec Docker et Docker Compose. > **📍 Documentation Déplacée**
> La documentation complète de déploiement Docker se trouve maintenant dans **[`docker/README.md`](docker/README.md)**
--- ---
## 🚀 Démarrage Rapide ## 🚀 Démarrage Rapide
**Nouvelle organisation** : Tous les fichiers Docker sont maintenant dans le dossier `docker/`
```bash
cd docker
cp .env.example .env
# Éditez .env et changez SECRET_KEY
docker compose up -d
```
**Accès :**
- Frontend : http://localhost:8081
- API Backend : http://localhost:8080
**Documentation complète :** Voir [`docker/README.md`](docker/README.md)
---
## 📂 Nouvelle Structure
```
notytex/
├── docker/ # 📁 Dossier dédié Docker
│ ├── compose.yaml # Production (défaut)
│ ├── compose.override.yaml # Développement (auto-merge)
│ ├── .env.example # Template variables
│ └── README.md # Documentation complète
├── backend/
│ └── Dockerfile
├── frontend/
│ └── Dockerfile
└── data/ # Volume persistant
```
---
## 🛠️ Modes d'Utilisation
### Mode Développement
```bash
cd docker
docker compose up # Build local + hot-reload
```
### Mode Production
```bash
cd docker
docker compose -f compose.yaml up -d # Images du registre
```
---
## 📖 Documentation Complète
Pour plus d'informations sur :
- Configuration avancée
- Variables d'environnement
- Utilisation avec Podman
- Déploiement production
- Dépannage
- CI/CD
👉 **Consultez [`docker/README.md`](docker/README.md)**
---
## 🔄 Migration depuis l'Ancienne Organisation
Si vous utilisez les anciens fichiers `docker-compose.yml` et `docker-compose.prod.yml` :
```bash
# Arrêter les anciens conteneurs
docker-compose down
# Utiliser la nouvelle organisation
cd docker
cp .env.example .env
# Éditez .env
docker compose up -d
```
Les anciens fichiers seront supprimés dans une version future.
---
## ⚡ Démarrage Rapide (Ancien Format - Déprécié)
### Prérequis ### Prérequis
- **Docker** : 24.0+ (ou Docker Desktop 4.20+) - **Docker** : 24.0+ (ou Docker Desktop 4.20+)

View File

@@ -43,11 +43,12 @@ npm run dev
```bash ```bash
# Configuration # Configuration
cp .env.docker .env cd docker
cp .env.example .env
# Éditez .env et changez SECRET_KEY # Éditez .env et changez SECRET_KEY
# Démarrage (Docker ou Podman) # Démarrage (Docker ou Podman)
docker-compose up -d docker compose up -d
# ou # ou
podman-compose up -d podman-compose up -d
``` ```
@@ -59,7 +60,7 @@ podman-compose up -d
> 💡 **Ports sans privilèges** : Les ports 8080/8081 permettent l'utilisation avec Podman sans root > 💡 **Ports sans privilèges** : Les ports 8080/8081 permettent l'utilisation avec Podman sans root
📖 **Documentation complète** : [`DOCKER.md`](DOCKER.md) 📖 **Documentation complète** : [`docker/README.md`](docker/README.md)
--- ---

View File

@@ -35,9 +35,13 @@ from schemas.class_group import (
HistogramBin, HistogramBin,
DomainStats, DomainStats,
CompetenceStats, CompetenceStats,
AssessmentScore,
DomainStudentStats,
CompetenceStudentStats,
) )
from domain.services.grading_calculator import GradingCalculator from domain.services.grading_calculator import GradingCalculator
from schemas.student import StudentWithClass, StudentList from domain.services.class_statistics_service import ClassStatisticsService
from schemas.student import StudentWithClass, StudentList, StudentWithEnrollmentInfo, StudentEnrollmentList
from schemas.csv_import import ( from schemas.csv_import import (
CSVImportResponse, CSVImportResponse,
ImportedStudentInfo, ImportedStudentInfo,
@@ -141,7 +145,7 @@ async def get_class(
) )
@router.get("/{class_id}/students", response_model=StudentList) @router.get("/{class_id}/students", response_model=StudentEnrollmentList)
async def get_class_students( async def get_class_students(
class_id: int, class_id: int,
session: AsyncSessionDep, session: AsyncSessionDep,
@@ -149,7 +153,7 @@ async def get_class_students(
at_date: Optional[str] = Query(None, description="Filtrer les élèves inscrits à cette date (YYYY-MM-DD)"), at_date: Optional[str] = Query(None, description="Filtrer les élèves inscrits à cette date (YYYY-MM-DD)"),
): ):
""" """
Récupère la liste des étudiants d'une classe. Récupère la liste des étudiants d'une classe avec leurs informations d'inscription.
Si at_date est fourni, retourne uniquement les élèves qui étaient inscrits à cette date. Si at_date est fourni, retourne uniquement les élèves qui étaient inscrits à cette date.
""" """
@@ -190,22 +194,29 @@ async def get_class_students(
students = [] students = []
for enrollment in enrollments: for enrollment in enrollments:
student = enrollment.student student = enrollment.student
is_active = enrollment.departure_date is None
students.append( students.append(
StudentWithClass( StudentWithEnrollmentInfo(
id=student.id, id=student.id,
last_name=student.last_name, last_name=student.last_name,
first_name=student.first_name, first_name=student.first_name,
email=student.email, email=student.email,
full_name=f"{student.first_name} {student.last_name}", full_name=f"{student.first_name} {student.last_name}",
current_class_id=class_id if enrollment.departure_date is None else None, current_class_id=class_id if is_active else None,
current_class_name=cls.name if enrollment.departure_date is None else None current_class_name=cls.name if is_active else None,
enrollment_id=enrollment.id,
enrollment_date=enrollment.enrollment_date,
departure_date=enrollment.departure_date,
enrollment_reason=enrollment.enrollment_reason,
departure_reason=enrollment.departure_reason,
is_active=is_active
) )
) )
# Trier par nom de famille puis prénom # Trier par nom de famille puis prénom
students.sort(key=lambda s: (s.last_name.lower(), s.first_name.lower())) students.sort(key=lambda s: (s.last_name.lower(), s.first_name.lower()))
return StudentList( return StudentEnrollmentList(
students=students, students=students,
total=len(students) total=len(students)
) )
@@ -221,10 +232,10 @@ async def get_class_stats(
Récupère les statistiques complètes d'une classe pour un trimestre. Récupère les statistiques complètes d'une classe pour un trimestre.
Inclut: Inclut:
- Moyennes par élève - Moyennes par élève avec détail par évaluation
- Statistiques globales (moyenne, médiane, écart-type) - Statistiques globales (moyenne, médiane, écart-type)
- Histogramme des moyennes - Histogramme des moyennes
- Analyse par domaines et compétences - Analyse par domaines et compétences (nombre d'évaluations + points)
""" """
# Vérifier que la classe existe # Vérifier que la classe existe
class_query = select(ClassGroup).where(ClassGroup.id == class_id) class_query = select(ClassGroup).where(ClassGroup.id == class_id)
@@ -247,26 +258,34 @@ async def get_class_stats(
students_result = await session.execute(students_query) students_result = await session.execute(students_query)
students = students_result.scalars().all() students = students_result.scalars().all()
# Récupérer les évaluations du trimestre # Récupérer les évaluations du trimestre avec leurs relations
assessments_query = select(Assessment).where( assessments_query = (
select(Assessment)
.options(
selectinload(Assessment.exercises).selectinload(Exercise.grading_elements)
)
.where(
Assessment.class_group_id == class_id, Assessment.class_group_id == class_id,
Assessment.trimester == trimester Assessment.trimester == trimester
) )
.order_by(Assessment.date)
)
assessments_result = await session.execute(assessments_query) assessments_result = await session.execute(assessments_query)
assessments = assessments_result.scalars().all() assessments = assessments_result.scalars().all()
# Calculer les moyennes de chaque élève # Récupérer les domaines et compétences
calculator = GradingCalculator() domains_query = select(Domain).order_by(Domain.name)
student_averages = [] domains_result = await session.execute(domains_query)
all_averages = [] domains = domains_result.scalars().all()
competences_query = select(Competence).order_by(Competence.order_index)
competences_result = await session.execute(competences_query)
competences = competences_result.scalars().all()
# Récupérer toutes les notes en une seule requête pour optimiser
grades_by_student_assessment = {}
for student in students: for student in students:
weighted_sum = 0.0
total_coefficient = 0.0
assessment_count = 0
for assessment in assessments: for assessment in assessments:
# Récupérer les notes de l'élève pour cette évaluation
grades_query = ( grades_query = (
select(Grade, GradingElement) select(Grade, GradingElement)
.join(GradingElement, Grade.grading_element_id == GradingElement.id) .join(GradingElement, Grade.grading_element_id == GradingElement.id)
@@ -277,45 +296,30 @@ async def get_class_stats(
) )
) )
grades_result = await session.execute(grades_query) grades_result = await session.execute(grades_query)
grades_data = grades_result.all() grades_by_student_assessment[(student.id, assessment.id)] = grades_result.all()
if grades_data: # Utiliser le service pour calculer les statistiques
total_score = 0.0 stats_service = ClassStatisticsService()
total_max_points = 0.0 student_averages = await stats_service.calculate_student_statistics(
students=students,
for grade, element in grades_data: assessments=assessments,
if grade.value: grades_by_student_assessment=grades_by_student_assessment,
score = calculator.calculate_score( domains=domains,
grade.value, element.grading_type, element.max_points competences=competences,
) )
if score is not None and calculator.is_counted_in_total(grade.value):
total_score += score
total_max_points += element.max_points
if total_max_points > 0: # Calculer les statistiques domaines/compétences depuis les éléments de notation
# Ramener sur 20 # Perspective enseignant : ce qui a été évalué, pas les résultats des élèves
score_on_20 = total_score / total_max_points * 20 domains_stats, competences_stats = stats_service.calculate_domain_competence_from_elements(
weighted_sum += score_on_20 * assessment.coefficient assessments=assessments,
total_coefficient += assessment.coefficient domains=domains,
assessment_count += 1 competences=competences,
)
# Calculer la moyenne pondérée
average = None
if total_coefficient > 0:
average = round(weighted_sum / total_coefficient, 2)
all_averages.append(average)
student_averages.append(StudentAverage(
student_id=student.id,
first_name=student.first_name,
last_name=student.last_name,
full_name=f"{student.first_name} {student.last_name}",
average=average,
assessment_count=assessment_count
))
# Calculer les statistiques globales # Calculer les statistiques globales
all_averages = [s.average for s in student_averages if s.average is not None]
mean = median = std_dev = min_score = max_score = None mean = median = std_dev = min_score = max_score = None
if all_averages: if all_averages:
mean = round(sum(all_averages) / len(all_averages), 2) mean = round(sum(all_averages) / len(all_averages), 2)
sorted_averages = sorted(all_averages) sorted_averages = sorted(all_averages)
@@ -345,6 +349,7 @@ async def get_class_stats(
count=count count=count
)) ))
# Ajouter le dernier bin pour 20 # Ajouter le dernier bin pour 20
if histogram:
count_20 = sum(1 for avg in all_averages if avg == 20) count_20 = sum(1 for avg in all_averages if avg == 20)
if count_20 > 0: if count_20 > 0:
histogram[-1].count += count_20 histogram[-1].count += count_20
@@ -379,36 +384,6 @@ async def get_class_stats(
elif grades_count > 0: elif grades_count > 0:
assessments_in_progress += 1 assessments_in_progress += 1
# Statistiques par domaine et compétence (simplifié)
domains_stats = []
competences_stats = []
# Récupérer les domaines
domains_query = select(Domain).order_by(Domain.name)
domains_result = await session.execute(domains_query)
domains = domains_result.scalars().all()
for domain in domains:
domains_stats.append(DomainStats(
id=domain.id,
name=domain.name,
color=domain.color,
mean=None,
elements_count=0
))
# Récupérer les compétences
competences_query = select(Competence).order_by(Competence.order_index)
competences_result = await session.execute(competences_query)
competences = competences_result.scalars().all()
for competence in competences:
competences_stats.append(CompetenceStats(
id=competence.id,
name=competence.name,
color=competence.color,
mean=None,
elements_count=0
))
return ClassDashboardStats( return ClassDashboardStats(
class_id=class_id, class_id=class_id,
class_name=cls.name, class_name=cls.name,

View File

@@ -281,6 +281,67 @@ async def update_student(
) )
@router.patch("/{student_id}/email", response_model=StudentWithClass)
async def update_student_email(
student_id: int,
email: str,
session: AsyncSessionDep,
):
"""
Modifie rapidement l'email d'un étudiant.
Endpoint optimisé pour l'édition inline.
"""
# Récupérer l'étudiant
query = (
select(Student)
.options(
selectinload(Student.enrollments).selectinload(StudentEnrollment.class_group)
)
.where(Student.id == student_id)
)
result = await session.execute(query)
student = result.scalar_one_or_none()
if not student:
raise HTTPException(status_code=404, detail="Étudiant non trouvé")
# Vérifier l'unicité du nouvel email si fourni
if email and email != student.email:
existing_query = select(Student).where(
Student.email == email,
Student.id != student_id
)
existing_result = await session.execute(existing_query)
if existing_result.scalar_one_or_none():
raise HTTPException(
status_code=400,
detail=f"Un autre élève avec l'email '{email}' existe déjà"
)
# Mettre à jour l'email (permet de le vider avec chaîne vide)
student.email = email if email else None
await session.commit()
await session.refresh(student)
# Trouver la classe actuelle
current_enrollment = None
for enrollment in student.enrollments:
if enrollment.departure_date is None:
current_enrollment = enrollment
break
return StudentWithClass(
id=student.id,
last_name=student.last_name,
first_name=student.first_name,
email=student.email,
full_name=f"{student.first_name} {student.last_name}",
current_class_id=current_enrollment.class_group_id if current_enrollment else None,
current_class_name=current_enrollment.class_group.name if current_enrollment else None
)
@router.delete("/{student_id}", status_code=204) @router.delete("/{student_id}", status_code=204)
async def delete_student( async def delete_student(
student_id: int, student_id: int,

View File

@@ -29,6 +29,7 @@ from .student_report_service import (
StudentReportData, StudentReportData,
generate_report_html, generate_report_html,
) )
from .class_statistics_service import ClassStatisticsService
__all__ = [ __all__ = [
# Grading Calculator # Grading Calculator
@@ -39,6 +40,7 @@ __all__ = [
"ScoreStrategy", "ScoreStrategy",
# Statistics # Statistics
"StatisticsService", "StatisticsService",
"ClassStatisticsService",
# Score Calculator # Score Calculator
"StudentScoreCalculator", "StudentScoreCalculator",
"ProgressCalculator", "ProgressCalculator",

View File

@@ -0,0 +1,331 @@
"""
Service de calcul des statistiques de classe.
Calcule les statistiques complètes pour le dashboard de classe:
- Moyennes par élève
- Scores par évaluation pour chaque élève
- Statistiques par domaine et compétence
"""
from typing import List, Dict, Optional, Tuple
from collections import defaultdict
from infrastructure.database.models import (
Student,
Assessment,
Exercise,
GradingElement,
Grade,
Domain,
Competence,
)
from domain.services.grading_calculator import GradingCalculator
from schemas.class_group import (
StudentAverage,
AssessmentScore,
DomainStudentStats,
CompetenceStudentStats,
DomainStats,
CompetenceStats,
)
class ClassStatisticsService:
"""Service de calcul des statistiques de classe."""
def __init__(self):
self.calculator = GradingCalculator()
async def calculate_student_statistics(
self,
students: List[Student],
assessments: List[Assessment],
grades_by_student_assessment: Dict[Tuple[int, int], List[Tuple[Grade, GradingElement]]],
domains: List[Domain],
competences: List[Competence],
) -> List[StudentAverage]:
"""
Calcule les statistiques complètes pour chaque élève.
Args:
students: Liste des élèves
assessments: Liste des évaluations du trimestre
grades_by_student_assessment: Dict[(student_id, assessment_id)] -> [(grade, element)]
domains: Liste des domaines
competences: Liste des compétences
Returns:
Liste des StudentAverage avec toutes les statistiques
"""
student_averages = []
for student in students:
# Initialiser les statistiques par domaine/compétence
domain_stats: Dict[int, DomainStudentStats] = {
domain.id: DomainStudentStats(
domain_id=domain.id,
evaluation_count=0,
total_points_obtained=0.0,
total_points_possible=0.0,
)
for domain in domains
}
competence_stats: Dict[int, CompetenceStudentStats] = {}
# Calculer les scores par évaluation
assessment_scores: Dict[int, AssessmentScore] = {}
weighted_sum = 0.0
total_coefficient = 0.0
assessment_count = 0
for assessment in assessments:
grades_data = grades_by_student_assessment.get((student.id, assessment.id), [])
if not grades_data:
continue
# Calculer le score total pour cette évaluation
total_score = 0.0
total_max_points = 0.0
for grade, element in grades_data:
if grade.value:
score = self.calculator.calculate_score(
grade.value, element.grading_type, element.max_points
)
if score is not None and self.calculator.is_counted_in_total(grade.value):
total_score += score
total_max_points += element.max_points
# Statistiques par domaine
if element.domain_id and element.domain_id in domain_stats:
domain_stats[element.domain_id].evaluation_count += 1
domain_stats[element.domain_id].total_points_obtained += score
domain_stats[element.domain_id].total_points_possible += element.max_points
# Statistiques par compétence (skill)
# Note: On utilise element.skill pour identifier la compétence
if element.skill:
# Trouver la compétence correspondante
matching_competence = next(
(c for c in competences if c.name == element.skill),
None
)
if matching_competence:
if matching_competence.id not in competence_stats:
competence_stats[matching_competence.id] = CompetenceStudentStats(
competence_id=matching_competence.id,
evaluation_count=0,
total_points_obtained=0.0,
total_points_possible=0.0,
)
competence_stats[matching_competence.id].evaluation_count += 1
competence_stats[matching_competence.id].total_points_obtained += score
competence_stats[matching_competence.id].total_points_possible += element.max_points
# Calculer le score sur 20
score_on_20 = None
if total_max_points > 0:
score_on_20 = round(total_score / total_max_points * 20, 2)
weighted_sum += score_on_20 * assessment.coefficient
total_coefficient += assessment.coefficient
assessment_count += 1
# Sauvegarder le score de cette évaluation
assessment_scores[assessment.id] = AssessmentScore(
assessment_id=assessment.id,
assessment_title=assessment.title,
score=round(total_score, 2) if total_score > 0 else None,
max_points=round(total_max_points, 2),
score_on_20=score_on_20,
)
# Calculer la moyenne pondérée
average = None
if total_coefficient > 0:
average = round(weighted_sum / total_coefficient, 2)
student_averages.append(StudentAverage(
student_id=student.id,
first_name=student.first_name,
last_name=student.last_name,
full_name=f"{student.first_name} {student.last_name}",
average=average,
assessment_count=assessment_count,
assessment_scores=assessment_scores,
domain_stats=domain_stats,
competence_stats=competence_stats,
))
return student_averages
def aggregate_domain_competence_stats(
self,
student_averages: List[StudentAverage],
domains: List[Domain],
competences: List[Competence],
) -> Tuple[List[DomainStats], List[CompetenceStats]]:
"""
Agrège les statistiques par domaine et compétence pour tous les élèves.
Args:
student_averages: Liste des statistiques par élève
domains: Liste des domaines
competences: Liste des compétences
Returns:
Tuple (domains_stats, competences_stats)
"""
# Agréger par domaine
domain_aggregates: Dict[int, Dict] = defaultdict(
lambda: {
"evaluation_count": 0,
"total_points_obtained": 0.0,
"total_points_possible": 0.0,
}
)
for student in student_averages:
for domain_id, stats in student.domain_stats.items():
domain_aggregates[domain_id]["evaluation_count"] += stats.evaluation_count
domain_aggregates[domain_id]["total_points_obtained"] += stats.total_points_obtained
domain_aggregates[domain_id]["total_points_possible"] += stats.total_points_possible
domains_stats = []
for domain in domains:
agg = domain_aggregates.get(domain.id, {
"evaluation_count": 0,
"total_points_obtained": 0.0,
"total_points_possible": 0.0,
})
domains_stats.append(DomainStats(
id=domain.id,
name=domain.name,
color=domain.color,
evaluation_count=agg["evaluation_count"],
total_points_obtained=round(agg["total_points_obtained"], 2),
total_points_possible=round(agg["total_points_possible"], 2),
))
# Agréger par compétence
competence_aggregates: Dict[int, Dict] = defaultdict(
lambda: {
"evaluation_count": 0,
"total_points_obtained": 0.0,
"total_points_possible": 0.0,
}
)
for student in student_averages:
for competence_id, stats in student.competence_stats.items():
competence_aggregates[competence_id]["evaluation_count"] += stats.evaluation_count
competence_aggregates[competence_id]["total_points_obtained"] += stats.total_points_obtained
competence_aggregates[competence_id]["total_points_possible"] += stats.total_points_possible
competences_stats = []
for competence in competences:
agg = competence_aggregates.get(competence.id, {
"evaluation_count": 0,
"total_points_obtained": 0.0,
"total_points_possible": 0.0,
})
competences_stats.append(CompetenceStats(
id=competence.id,
name=competence.name,
color=competence.color,
evaluation_count=agg["evaluation_count"],
total_points_obtained=round(agg["total_points_obtained"], 2),
total_points_possible=round(agg["total_points_possible"], 2),
))
return domains_stats, competences_stats
def calculate_domain_competence_from_elements(
self,
assessments: List[Assessment],
domains: List[Domain],
competences: List[Competence],
) -> Tuple[List[DomainStats], List[CompetenceStats]]:
"""
Calcule les statistiques domaines/compétences depuis les GradingElements.
Perspective enseignant : ce qui a été évalué, pas les résultats des élèves.
Args:
assessments: Liste des évaluations (avec exercises et grading_elements chargés)
domains: Liste des domaines
competences: Liste des compétences
Returns:
Tuple (domains_stats, competences_stats)
"""
# Compter les GradingElements par domaine
domain_aggregates: Dict[int, Dict] = defaultdict(
lambda: {
"evaluation_count": 0,
"total_points_possible": 0.0,
}
)
competence_aggregates: Dict[int, Dict] = defaultdict(
lambda: {
"evaluation_count": 0,
"total_points_possible": 0.0,
}
)
# Parcourir tous les éléments de notation
for assessment in assessments:
for exercise in assessment.exercises:
for element in exercise.grading_elements:
# Compter par domaine
if element.domain_id:
domain_aggregates[element.domain_id]["evaluation_count"] += 1
domain_aggregates[element.domain_id]["total_points_possible"] += element.max_points
# Compter par compétence (via skill)
if element.skill:
matching_competence = next(
(c for c in competences if c.name == element.skill),
None
)
if matching_competence:
competence_aggregates[matching_competence.id]["evaluation_count"] += 1
competence_aggregates[matching_competence.id]["total_points_possible"] += element.max_points
# Créer les stats par domaine
domains_stats = []
for domain in domains:
agg = domain_aggregates.get(domain.id, {
"evaluation_count": 0,
"total_points_possible": 0.0,
})
domains_stats.append(DomainStats(
id=domain.id,
name=domain.name,
color=domain.color,
evaluation_count=agg["evaluation_count"],
total_points_obtained=0.0, # Non utilisé dans cette perspective
total_points_possible=round(agg["total_points_possible"], 2),
))
# Créer les stats par compétence
competences_stats = []
for competence in competences:
agg = competence_aggregates.get(competence.id, {
"evaluation_count": 0,
"total_points_possible": 0.0,
})
competences_stats.append(CompetenceStats(
id=competence.id,
name=competence.name,
color=competence.color,
evaluation_count=agg["evaluation_count"],
total_points_obtained=0.0, # Non utilisé
total_points_possible=round(agg["total_points_possible"], 2),
))
return domains_stats, competences_stats

View File

@@ -3,7 +3,7 @@ Schemas Pydantic pour ClassGroup.
""" """
from datetime import date from datetime import date
from typing import Optional, List from typing import Optional, List, Dict
from pydantic import Field from pydantic import Field
@@ -117,6 +117,9 @@ class StudentAverage(BaseSchema):
full_name: str full_name: str
average: Optional[float] = None average: Optional[float] = None
assessment_count: int = 0 assessment_count: int = 0
assessment_scores: Dict[int, "AssessmentScore"] = {}
domain_stats: Dict[int, "DomainStudentStats"] = {}
competence_stats: Dict[int, "CompetenceStudentStats"] = {}
class HistogramBin(BaseSchema): class HistogramBin(BaseSchema):
@@ -128,14 +131,43 @@ class HistogramBin(BaseSchema):
count: int count: int
class AssessmentScore(BaseSchema):
"""Score d'un élève pour une évaluation."""
assessment_id: int
assessment_title: str
score: Optional[float] = None
max_points: float = 0.0
score_on_20: Optional[float] = None
class DomainStudentStats(BaseSchema):
"""Statistiques d'un élève pour un domaine."""
domain_id: int
evaluation_count: int = 0
total_points_obtained: float = 0.0
total_points_possible: float = 0.0
class CompetenceStudentStats(BaseSchema):
"""Statistiques d'un élève pour une compétence."""
competence_id: int
evaluation_count: int = 0
total_points_obtained: float = 0.0
total_points_possible: float = 0.0
class DomainStats(BaseSchema): class DomainStats(BaseSchema):
"""Statistiques par domaine.""" """Statistiques par domaine."""
id: int id: int
name: str name: str
color: str color: str
mean: Optional[float] = None evaluation_count: int = 0
elements_count: int = 0 total_points_obtained: float = 0.0
total_points_possible: float = 0.0
class CompetenceStats(BaseSchema): class CompetenceStats(BaseSchema):
@@ -144,8 +176,9 @@ class CompetenceStats(BaseSchema):
id: int id: int
name: str name: str
color: str color: str
mean: Optional[float] = None evaluation_count: int = 0
elements_count: int = 0 total_points_obtained: float = 0.0
total_points_possible: float = 0.0
class ClassDashboardStats(BaseSchema): class ClassDashboardStats(BaseSchema):

View File

@@ -36,6 +36,19 @@ class StudentWithClass(StudentRead):
current_class_name: Optional[str] = None current_class_name: Optional[str] = None
class StudentWithEnrollmentInfo(StudentRead):
"""Schema avec les informations d'inscription complètes."""
current_class_id: Optional[int] = None
current_class_name: Optional[str] = None
enrollment_id: Optional[int] = None
enrollment_date: Optional[date] = None
departure_date: Optional[date] = None
enrollment_reason: Optional[str] = None
departure_reason: Optional[str] = None
is_active: bool = True
class StudentDetail(StudentWithClass): class StudentDetail(StudentWithClass):
"""Schema détaillé avec historique d'inscriptions.""" """Schema détaillé avec historique d'inscriptions."""
@@ -49,6 +62,13 @@ class StudentList(BaseSchema):
total: int total: int
class StudentEnrollmentList(BaseSchema):
"""Schema pour la liste des étudiants avec informations d'inscription."""
students: List[StudentWithEnrollmentInfo]
total: int
# Schemas pour les inscriptions temporelles # Schemas pour les inscriptions temporelles
class EnrollmentBase(BaseSchema): class EnrollmentBase(BaseSchema):
"""Schema de base pour StudentEnrollment.""" """Schema de base pour StudentEnrollment."""

View File

@@ -1,24 +0,0 @@
# Docker Compose pour Production avec images du registre
# Usage: docker-compose -f docker-compose.yml -f docker-compose.prod.yml up -d
#
# Variables d'environnement requises:
# REGISTRY_URL - URL de votre registre Docker (ex: registry.example.com, ghcr.io, docker.io)
# REGISTRY_NAMESPACE - Namespace/organisation (ex: notytex, username)
# IMAGE_TAG - Tag de l'image (défaut: latest)
#
# Exemple:
# export REGISTRY_URL=registry.example.com
# export REGISTRY_NAMESPACE=myorg
# export IMAGE_TAG=latest
# docker-compose -f docker-compose.yml -f docker-compose.prod.yml up -d
version: "3.8"
services:
backend:
image: ${REGISTRY_URL}/${REGISTRY_NAMESPACE}/notytex-backend:${IMAGE_TAG:-latest}
build: null # Désactive le build local
frontend:
image: ${REGISTRY_URL}/${REGISTRY_NAMESPACE}/notytex-frontend:${IMAGE_TAG:-latest}
build: null # Désactive le build local

89
docker/.env.example Normal file
View File

@@ -0,0 +1,89 @@
# Configuration Docker Compose pour Notytex v2
# COPIEZ CE FICHIER EN .env ET MODIFIEZ LES VALEURS
# =============================================================================
# SÉCURITÉ - OBLIGATOIRE
# =============================================================================
# ⚠️ IMPORTANT: Générez une clé SECRET_KEY unique et sécurisée !
# Commande: python -c "import secrets; print(secrets.token_hex(32))"
SECRET_KEY=CHANGEZ-MOI-cle-secrete-unique-minimum-32-caracteres-obligatoire
# =============================================================================
# CONFIGURATION PRODUCTION (Images du registre)
# =============================================================================
# URL du registre Docker (exemples ci-dessous)
# - Docker Hub: docker.io
# - GitHub Container Registry: ghcr.io
# - GitLab Registry: registry.gitlab.com
# - Gitea Registry: git.example.com
REGISTRY_URL=git.opytex.org
# Namespace/organisation sur le registre
# Exemples: votre-username, votre-organisation, notytex
REGISTRY_NAMESPACE=notytex
# Tag de l'image (version)
# Exemples: latest, v2.0.0, main, develop
IMAGE_TAG=latest
# =============================================================================
# PORTS EXPOSÉS
# =============================================================================
# Port du backend API (défaut: 8080)
BACKEND_PORT=8080
# Port du frontend web (défaut: 8081)
FRONTEND_PORT=8081
# =============================================================================
# BASE DE DONNÉES
# =============================================================================
# L'URL de la base de données est fixée en SQLite dans compose.yaml
# Le fichier est stocké dans ../data/school_management.db (volume persistant)
# Pour changer de DB (PostgreSQL, MySQL), modifiez compose.yaml
# =============================================================================
# CONFIGURATION APPLICATIVE
# =============================================================================
# Niveau de logs (DEBUG, INFO, WARNING, ERROR)
LOG_LEVEL=INFO
# CORS - Domaines autorisés (format JSON array)
# En production, ajustez avec vos vrais domaines
CORS_ORIGINS=["http://localhost:8081","http://localhost:8080","https://votre-domaine.com"]
# =============================================================================
# CONFIGURATION EMAIL (Optionnel - pour envoi de bilans)
# =============================================================================
# Serveur SMTP
# SMTP_HOST=smtp.gmail.com
# SMTP_PORT=587
# SMTP_USERNAME=votre-email@gmail.com
# SMTP_PASSWORD=votre-mot-de-passe-app
# EMAIL_FROM=votre-email@gmail.com
# SMTP_USE_TLS=true
# =============================================================================
# NOTES D'UTILISATION
# =============================================================================
# MODE DÉVELOPPEMENT (build local avec hot-reload):
# cd docker
# docker compose up
# (utilise automatiquement compose.yaml + compose.override.yaml)
# MODE PRODUCTION (images du registre):
# cd docker
# docker compose -f compose.yaml up -d
# (utilise uniquement compose.yaml, sans override)
# ACCÈS:
# - Frontend: http://localhost:8081
# - API Backend: http://localhost:8080
# - API Docs: http://localhost:8080/api/v2/docs

6
docker/.gitignore vendored Normal file
View File

@@ -0,0 +1,6 @@
# Fichier de configuration local (contient des secrets)
.env
# Fichiers temporaires Docker
*.log
.dockerignore

452
docker/README.md Normal file
View File

@@ -0,0 +1,452 @@
# 🐳 Déploiement Docker - Notytex v2
Guide complet pour déployer Notytex avec Docker et Docker Compose.
---
## 🚀 Démarrage Rapide
### Prérequis
- **Docker** : 24.0+ (ou Docker Desktop 4.20+)
- **Docker Compose** : 2.20+
- **Alternative Podman** : Podman 4.0+ avec podman-compose
### Installation en 3 commandes
```bash
# 1. Configurer l'environnement
cd docker
cp .env.example .env
# Éditez .env et changez SECRET_KEY !
# 2. Créer le répertoire de données (si nécessaire)
mkdir -p ../data
# 3. Démarrer les services
docker compose up -d
```
**Accès :**
- Frontend : http://localhost:8081
- API Backend : http://localhost:8080
- Documentation API : http://localhost:8080/api/v2/docs
---
## 📂 Organisation des Fichiers
```
docker/
├── compose.yaml # Configuration PRODUCTION (défaut)
├── compose.override.yaml # Override DÉVELOPPEMENT (auto-merge)
├── .env.example # Template de variables d'environnement
└── README.md # Ce fichier
../backend/
├── Dockerfile # Image backend FastAPI
└── ...
../frontend/
├── Dockerfile # Image frontend Vue.js + Nginx
└── ...
../data/ # Volume persistant (base de données SQLite)
└── school_management.db
```
---
## 🔧 Configuration
### Variables d'Environnement
Copiez `.env.example` vers `.env` et personnalisez :
```bash
# Clé secrète (OBLIGATOIRE - générez-en une nouvelle !)
SECRET_KEY=votre-cle-secrete-unique-min-32-chars
# Configuration du registre Docker (pour production avec images pré-buildées)
REGISTRY_URL=docker.io
REGISTRY_NAMESPACE=notytex
IMAGE_TAG=latest
# Ports exposés
BACKEND_PORT=8080
FRONTEND_PORT=8081
# CORS (ajoutez vos domaines en production)
CORS_ORIGINS=["http://localhost:8081","https://votre-domaine.com"]
```
### Générer une SECRET_KEY
```bash
python -c "import secrets; print(secrets.token_hex(32))"
```
---
## 🛠️ Modes d'Utilisation
### Mode Développement (Build Local + Hot-Reload)
**Utilise automatiquement** : `compose.yaml` + `compose.override.yaml`
```bash
cd docker
# Démarrer avec build local et volumes montés
docker compose up
# Rebuild après modification du code
docker compose up --build
# Voir les logs en temps réel
docker compose logs -f
# Arrêter
docker compose down
```
**Caractéristiques du mode dev** :
- ✅ Build local des images depuis `../backend` et `../frontend`
- ✅ Volumes montés pour hot-reload backend
- ✅ Logs DEBUG activés
- ✅ Health checks moins stricts
### Mode Production (Images du Registre)
**Utilise uniquement** : `compose.yaml` (sans override)
```bash
cd docker
# Démarrer avec images pré-buildées du registre
docker compose -f compose.yaml up -d
# Voir le statut
docker compose -f compose.yaml ps
# Voir les logs
docker compose -f compose.yaml logs -f
# Arrêter
docker compose -f compose.yaml down
```
**Caractéristiques du mode production** :
- ✅ Images pré-buildées depuis le registre Docker
- ✅ Pas de volumes de code (images complètes)
- ✅ Logs INFO/WARNING
- ✅ Health checks stricts
- ✅ Restart automatique (`unless-stopped`)
---
## 🐋 Utilisation avec Podman
### Pourquoi Podman ?
- **Sans privilèges root** : Exécution en mode utilisateur (rootless)
- **Compatible Docker** : Même syntaxe que docker-compose
- **Plus sécurisé** : Pas de daemon en arrière-plan
### Installation Podman
```bash
# Debian/Ubuntu
sudo apt install podman podman-compose
# Fedora/RHEL
sudo dnf install podman podman-compose
# Arch Linux
sudo pacman -S podman podman-compose
```
### Commandes Podman
```bash
# Remplacer 'docker compose' par 'podman-compose'
cd docker
podman-compose up -d
podman-compose logs -f
podman-compose down
# Ou utiliser l'alias podman (compatible Docker CLI)
alias docker=podman
docker compose up -d
```
---
## 🗄️ Gestion de la Base de Données
### Backup
```bash
# Backup manuel
docker compose exec backend sh -c "cp /data/school_management.db /data/backup_$(date +%Y%m%d_%H%M%S).db"
# Ou depuis l'hôte
cp ../data/school_management.db ../data/backup_$(date +%Y%m%d_%H%M%S).db
```
### Restauration
```bash
# Restaurer depuis un backup
docker compose stop backend
cp ../data/backup_YYYYMMDD_HHMMSS.db ../data/school_management.db
docker compose start backend
```
### Migration depuis v1
```bash
# Si vous avez une base v1 Flask
cp ../instance/school_management.db ../data/school_management.db
docker compose restart backend
```
---
## 🔍 Healthcheck et Monitoring
Les services incluent des healthchecks automatiques :
```bash
# Vérifier la santé du backend
curl http://localhost:8080/api/v2/health
# Vérifier la santé du frontend
curl http://localhost:8081/
# Voir le statut Docker
docker compose ps
```
**Réponse attendue du backend :**
```json
{
"status": "healthy",
"database": "connected",
"tables": 12,
"classes": 5,
"students": 155
}
```
---
## 🚨 Dépannage
### Le backend ne démarre pas
```bash
# Voir les logs
docker compose logs backend
# Vérifier la configuration
docker compose exec backend env | grep DATABASE_URL
# Recréer le conteneur
docker compose up -d --force-recreate backend
```
### Le frontend ne charge pas
```bash
# Vérifier Nginx
docker compose logs frontend
# Tester la connectivité au backend
docker compose exec frontend curl http://backend:8000/api/v2/health
# Rebuild le frontend
docker compose build frontend
docker compose up -d frontend
```
### Problème de permissions (base de données)
```bash
# Ajuster les permissions
chmod 666 ../data/school_management.db
chmod 755 ../data/
```
### Les changements de code ne sont pas pris en compte
```bash
# Rebuild complet
docker compose down
docker compose build --no-cache
docker compose up -d
```
---
## 🌐 Déploiement Production
### Configuration Nginx (hôte)
Si vous utilisez Nginx sur l'hôte pour le reverse proxy :
```nginx
server {
listen 80;
server_name notytex.example.com;
location / {
proxy_pass http://localhost:8081;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
```
### HTTPS avec Let's Encrypt
```bash
# Installer certbot
sudo apt install certbot python3-certbot-nginx
# Obtenir un certificat
sudo certbot --nginx -d notytex.example.com
```
### Systemd Service
Créez `/etc/systemd/system/notytex.service` :
```ini
[Unit]
Description=Notytex Docker Compose
Requires=docker.service
After=docker.service
[Service]
Type=oneshot
RemainAfterExit=yes
WorkingDirectory=/path/to/notytex/docker
ExecStart=/usr/bin/docker compose -f compose.yaml up -d
ExecStop=/usr/bin/docker compose -f compose.yaml down
[Install]
WantedBy=multi-user.target
```
Activer :
```bash
sudo systemctl enable notytex
sudo systemctl start notytex
```
---
## 📊 Monitoring
### Logs centralisés
```bash
# Tous les logs en temps réel
docker compose logs -f
# Logs des dernières 24h
docker compose logs --since 24h
# Recherche dans les logs
docker compose logs | grep ERROR
```
### Métriques Docker
```bash
# Utilisation ressources
docker stats
# Espace disque
docker system df
# Nettoyage
docker system prune -a
```
---
## 🔐 Sécurité - Checklist Production
- [ ] Changer `SECRET_KEY` (unique et aléatoire)
- [ ] Configurer CORS avec vos domaines réels
- [ ] Utiliser HTTPS en production
- [ ] Limiter les ports exposés (firewall)
- [ ] Mettre en place des backups automatiques
- [ ] Configurer des alertes monitoring
- [ ] Restreindre les permissions fichiers
- [ ] Mettre à jour régulièrement les images Docker
---
## 🔄 CI/CD - Images du Registre
### Configuration CI/CD
Le projet inclut `.gitea/workflows/docker-publish.yml` pour la construction automatique des images Docker.
**Déclencheurs** :
- Push sur `main` ou `rewrite`
- Tags `v*` (releases)
- Déclenchement manuel
**Variables requises (Secrets)** :
- `REGISTRY_URL` - URL de votre registre Docker
- `REGISTRY_NAMESPACE` - Namespace/organisation
- `REGISTRY_USERNAME` - Nom d'utilisateur pour le registre
- `REGISTRY_PASSWORD` - Mot de passe ou token pour le registre
### Utiliser les images du registre
```bash
# 1. Se connecter au registre (si privé)
docker login <REGISTRY_URL>
# 2. Pull des images
docker pull <REGISTRY_URL>/<NAMESPACE>/notytex-backend:latest
docker pull <REGISTRY_URL>/<NAMESPACE>/notytex-frontend:latest
# 3. Configurer .env
REGISTRY_URL=registry.example.com
REGISTRY_NAMESPACE=myorg
IMAGE_TAG=latest
# 4. Démarrer
docker compose -f compose.yaml up -d
```
---
## 🎯 Ressources Recommandées
- **Backend** : 512MB RAM, 0.5 CPU
- **Frontend** : 256MB RAM, 0.25 CPU
- **Total** : ~1GB RAM, 1 CPU pour 50 utilisateurs
---
## 📞 Support
En cas de problème :
1. Vérifiez les logs : `docker compose logs`
2. Testez les healthchecks
3. Consultez la documentation principale : `../README.md`
4. Vérifiez les issues GitHub
---
**Développé avec ❤️ pour simplifier le déploiement de Notytex**

View File

@@ -0,0 +1,32 @@
# Docker Compose - Override pour Développement
# Ce fichier est automatiquement fusionné avec compose.yaml quand vous lancez: docker compose up
# Il active le hot-reload et monte les volumes de code source
services:
# Backend - Mode développement avec hot-reload
backend:
build:
context: ../backend
dockerfile: Dockerfile
image: notytex-backend:dev
environment:
- LOG_LEVEL=DEBUG
- DEBUG=true
volumes:
# Monte le code source pour le hot-reload
- ../backend:/app
# Volume data persistant
- ../data:/data
# Désactive les health checks stricts en dev
healthcheck:
interval: 60s
retries: 5
# Frontend - Mode développement avec hot-reload
frontend:
build:
context: ../frontend
dockerfile: Dockerfile
image: notytex-frontend:dev
# Pas besoin de volumes montés car Nginx sert les fichiers buildés
# Pour le hot-reload frontend, utilisez plutôt: cd frontend && npm run dev

View File

@@ -1,25 +1,22 @@
version: '3.8' # Docker Compose - Configuration Production
# Usage: docker compose up -d
# Documentation: ./README.md
services: services:
# Backend FastAPI # Backend FastAPI
backend: backend:
build: image: ${REGISTRY_URL:-git.opytex.org}/${REGISTRY_NAMESPACE:-notytex}/notytex-backend:${IMAGE_TAG:-latest}
context: ./backend
dockerfile: Dockerfile
container_name: notytex-backend container_name: notytex-backend
restart: unless-stopped restart: unless-stopped
ports: ports:
- "8080:8000" - "${BACKEND_PORT:-8080}:8000"
environment: environment:
- DATABASE_URL=sqlite+aiosqlite:////data/school_management.db - DATABASE_URL=sqlite+aiosqlite:////data/school_management.db
- SECRET_KEY=${SECRET_KEY:-change-me-in-production-min-32-chars} - SECRET_KEY=${SECRET_KEY:?SECRET_KEY est obligatoire - voir .env.example}
- CORS_ORIGINS=["http://localhost:8081","http://localhost:8080","http://localhost:3000"] - CORS_ORIGINS=${CORS_ORIGINS:-["http://localhost:8081","http://localhost:8080"]}
- LOG_LEVEL=INFO - LOG_LEVEL=${LOG_LEVEL:-INFO}
volumes: volumes:
- ./data:/data - ./data:/data
- ./backend:/app
networks:
- notytex-network
healthcheck: healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8000/api/v2/health"] test: ["CMD", "curl", "-f", "http://localhost:8000/api/v2/health"]
interval: 30s interval: 30s
@@ -29,27 +26,16 @@ services:
# Frontend Vue.js + Nginx # Frontend Vue.js + Nginx
frontend: frontend:
build: image: ${REGISTRY_URL:-git.opytex.org}/${REGISTRY_NAMESPACE:-notytex}/notytex-frontend:${IMAGE_TAG:-latest}
context: ./frontend
dockerfile: Dockerfile
container_name: notytex-frontend container_name: notytex-frontend
restart: unless-stopped restart: unless-stopped
ports: ports:
- "8081:80" - "${FRONTEND_PORT:-8081}:80"
depends_on: depends_on:
- backend backend:
networks: condition: service_healthy
- notytex-network
healthcheck: healthcheck:
test: ["CMD", "curl", "-f", "http://localhost/"] test: ["CMD", "curl", "-f", "http://localhost/"]
interval: 30s interval: 30s
timeout: 10s timeout: 10s
retries: 3 retries: 3
networks:
notytex-network:
driver: bridge
volumes:
data:
driver: local

View File

@@ -0,0 +1,601 @@
# Améliorations du Dashboard de Classe
**Date**: 3 décembre 2025
**Version**: 2.0
**Fichiers modifiés**: 4 fichiers (3 backend, 1 frontend)
## 📋 Objectifs des Modifications
### Tableau des Élèves
1. ✅ Permettre le tri sur toutes les colonnes
2. ✅ Afficher toutes les notes (une colonne par évaluation)
3. ✅ Supprimer les indicateurs de performance (badges Excellent/Bon/Moyen/Insuffisant)
### Tableau Domaines/Compétences
1. ✅ Afficher le nombre de fois qu'ils ont été évalués
2. ✅ Afficher le nombre de points attribués (total obtenu/total possible)
3. ✅ Supprimer les moyennes
---
## 🔧 Modifications Backend
### 1. Nouveaux Schemas (`backend/schemas/class_group.py`)
#### Schemas ajoutés
**AssessmentScore**
```python
class AssessmentScore(BaseSchema):
"""Score d'un élève pour une évaluation."""
assessment_id: int
assessment_title: str
score: Optional[float] = None # Score brut (ex: 15.5)
max_points: float = 0.0 # Points maximum (ex: 20)
score_on_20: Optional[float] = None # Score ramené sur 20
```
**DomainStudentStats**
```python
class DomainStudentStats(BaseSchema):
"""Statistiques d'un élève pour un domaine."""
domain_id: int
evaluation_count: int = 0 # Nombre de fois évalué sur ce domaine
total_points_obtained: float = 0.0 # Total des points obtenus
total_points_possible: float = 0.0 # Total des points possibles
```
**CompetenceStudentStats**
```python
class CompetenceStudentStats(BaseSchema):
"""Statistiques d'un élève pour une compétence."""
competence_id: int
evaluation_count: int = 0
total_points_obtained: float = 0.0
total_points_possible: float = 0.0
```
#### Schemas modifiés
**DomainStats** - Avant vs Après
```python
# AVANT
class DomainStats(BaseSchema):
id: int
name: str
color: str
mean: Optional[float] = None # ❌ Supprimé
elements_count: int = 0
# APRÈS
class DomainStats(BaseSchema):
id: int
name: str
color: str
evaluation_count: int = 0 # ✅ Nombre d'évaluations
total_points_obtained: float = 0.0 # ✅ Points obtenus
total_points_possible: float = 0.0 # ✅ Points possibles
```
**CompetenceStats** - Même structure que DomainStats
**StudentAverage** - Enrichi avec 3 nouveaux champs
```python
class StudentAverage(BaseSchema):
student_id: int
first_name: str
last_name: str
full_name: str
average: Optional[float] = None
assessment_count: int = 0
# ✅ NOUVEAUX CHAMPS
assessment_scores: Dict[int, AssessmentScore] = {} # Scores par évaluation
domain_stats: Dict[int, DomainStudentStats] = {} # Stats par domaine
competence_stats: Dict[int, CompetenceStudentStats] = {} # Stats par compétence
```
### 2. Nouveau Service (`backend/domain/services/class_statistics_service.py`)
**ClassStatisticsService** - Service de calcul des statistiques de classe
#### Méthode 1: `calculate_student_statistics()`
**Signature**:
```python
async def calculate_student_statistics(
students: List[Student],
assessments: List[Assessment],
grades_by_student_assessment: Dict[Tuple[int, int], List[Tuple[Grade, GradingElement]]],
domains: List[Domain],
competences: List[Competence],
) -> List[StudentAverage]
```
**Rôle**: Calcule toutes les statistiques pour chaque élève
- Score par évaluation (brut + sur 20)
- Moyenne pondérée par coefficient
- Statistiques par domaine (nombre d'évaluations + points)
- Statistiques par compétence (via `element.skill`)
**Logique**:
1. Pour chaque élève:
- Initialiser les dictionnaires de stats par domaine/compétence
- Pour chaque évaluation:
- Calculer le score total et max_points
- Ramener sur 20 pour la moyenne pondérée
- Pour chaque note:
- Mettre à jour les stats du domaine associé
- Mettre à jour les stats de la compétence associée (via skill)
#### Méthode 2: `aggregate_domain_competence_stats()`
**Signature**:
```python
def aggregate_domain_competence_stats(
student_averages: List[StudentAverage],
domains: List[Domain],
competences: List[Competence],
) -> Tuple[List[DomainStats], List[CompetenceStats]]
```
**Rôle**: Agrège les statistiques de tous les élèves par domaine/compétence
**Logique**:
1. Pour chaque domaine:
- Sommer evaluation_count de tous les élèves
- Sommer total_points_obtained de tous les élèves
- Sommer total_points_possible de tous les élèves
2. Même chose pour les compétences
### 3. Endpoint Refactorisé (`backend/api/routes/classes.py`)
**GET `/classes/{class_id}/stats?trimester={1|2|3}`**
#### Modifications principales
**Avant** (ancien code):
```python
# Calculer les moyennes de chaque élève
calculator = GradingCalculator()
student_averages = []
for student in students:
# ... calcul simple de la moyenne
student_averages.append(StudentAverage(
student_id=student.id,
average=average,
assessment_count=assessment_count
))
# Statistiques domaines/compétences simplifiées (vides)
domains_stats = []
for domain in domains:
domains_stats.append(DomainStats(
id=domain.id,
name=domain.name,
color=domain.color,
mean=None, # ❌ Pas calculé
elements_count=0 # ❌ Pas calculé
))
```
**Après** (nouveau code):
```python
# Récupérer toutes les notes en une passe
grades_by_student_assessment = {}
for student in students:
for assessment in assessments:
grades_query = (...)
grades_by_student_assessment[(student.id, assessment.id)] = grades_result.all()
# Utiliser le service pour calculer les statistiques
stats_service = ClassStatisticsService()
student_averages = await stats_service.calculate_student_statistics(
students=students,
assessments=assessments,
grades_by_student_assessment=grades_by_student_assessment,
domains=domains,
competences=competences,
)
# Agréger les statistiques domaines/compétences
domains_stats, competences_stats = stats_service.aggregate_domain_competence_stats(
student_averages=student_averages,
domains=domains,
competences=competences,
)
```
#### Avantages
- ✅ Code modulaire et testable
- ✅ Séparation des responsabilités (service vs controller)
- ✅ Statistiques complètes calculées automatiquement
- ✅ Données enrichies retournées au frontend
---
## 🎨 Modifications Frontend
### 1. Script Vue.js (`frontend/src/views/ClassDashboardView.vue`)
#### Variables ajoutées
```javascript
const sortColumn = ref('name') // Colonne de tri active
const sortDirection = ref('asc') // Direction du tri
```
#### Computed ajoutés
**assessments** - Extraction des évaluations
```javascript
const assessments = computed(() => {
if (!stats.value?.student_averages?.length) return []
const firstStudent = stats.value.student_averages[0]
if (!firstStudent?.assessment_scores) return []
// Extraire et trier les évaluations par ID
return Object.values(firstStudent.assessment_scores)
.sort((a, b) => a.assessment_id - b.assessment_id)
})
```
**sortedStudents** - Tri dynamique des élèves
```javascript
const sortedStudents = computed(() => {
if (!stats.value?.student_averages) return []
const students = [...stats.value.student_averages]
students.sort((a, b) => {
let valA, valB
if (sortColumn.value === 'name') {
valA = `${a.last_name} ${a.first_name}`.toLowerCase()
valB = `${b.last_name} ${b.first_name}`.toLowerCase()
} else if (sortColumn.value === 'average') {
valA = a.average ?? -1
valB = b.average ?? -1
} else if (sortColumn.value.startsWith('assessment_')) {
const assessmentId = parseInt(sortColumn.value.split('_')[1])
valA = a.assessment_scores?.[assessmentId]?.score ?? -1
valB = b.assessment_scores?.[assessmentId]?.score ?? -1
}
const comparison = valA > valB ? 1 : valA < valB ? -1 : 0
return sortDirection.value === 'asc' ? comparison : -comparison
})
return students
})
```
#### Fonctions ajoutées
**sortBy(column)** - Gestion du tri
```javascript
function sortBy(column) {
if (sortColumn.value === column) {
// Inverser la direction si même colonne
sortDirection.value = sortDirection.value === 'asc' ? 'desc' : 'asc'
} else {
// Nouvelle colonne : tri ascendant
sortColumn.value = column
sortDirection.value = 'asc'
}
}
```
**getAssessmentScore(student, assessmentId)** - Formatage des notes
```javascript
function getAssessmentScore(student, assessmentId) {
const score = student.assessment_scores?.[assessmentId]
if (!score || score.score === null) return '-'
return `${score.score.toFixed(1)}/${score.max_points.toFixed(0)}`
}
```
**getSortIcon(column)** - Indicateur visuel
```javascript
function getSortIcon(column) {
if (sortColumn.value !== column) return ''
return sortDirection.value === 'asc' ? '▲' : '▼'
}
```
#### Fonctions supprimées
```javascript
// ❌ Supprimé
function getPerformanceClass(average) { ... }
function getPerformanceLabel(average) { ... }
```
### 2. Template - Tableau des Élèves
#### Avant
```html
<table>
<thead>
<tr>
<th>Élève</th>
<th>Moyenne</th>
<th>Performance</th> <!-- ❌ Supprimé -->
</tr>
</thead>
<tbody>
<tr v-for="student in stats.student_averages">
<td>{{ student.last_name }} {{ student.first_name }}</td>
<td>{{ student.average?.toFixed(2) || '-' }}</td>
<td>
<span :class="getPerformanceClass(student.average)">
{{ getPerformanceLabel(student.average) }}
</span>
</td>
</tr>
</tbody>
</table>
```
#### Après
```html
<table>
<thead>
<tr>
<!-- Colonne Nom (triable) -->
<th @click="sortBy('name')" class="cursor-pointer hover:bg-gray-100">
Élève {{ getSortIcon('name') }}
</th>
<!-- Colonne Moyenne (triable) -->
<th @click="sortBy('average')" class="cursor-pointer hover:bg-gray-100">
Moyenne {{ getSortIcon('average') }}
</th>
<!-- Colonnes dynamiques pour chaque évaluation (triables) -->
<th
v-for="assessment in assessments"
:key="assessment.assessment_id"
@click="sortBy(`assessment_${assessment.assessment_id}`)"
class="cursor-pointer hover:bg-gray-100"
:title="assessment.assessment_title"
>
<div class="flex flex-col items-center">
<span class="truncate max-w-[120px]">
{{ assessment.assessment_title }}
</span>
<span class="text-[10px]">
{{ getSortIcon(`assessment_${assessment.assessment_id}`) }}
</span>
</div>
</th>
</tr>
</thead>
<tbody>
<tr v-for="student in sortedStudents" :key="student.student_id">
<td>{{ student.last_name }} {{ student.first_name }}</td>
<td class="font-bold text-blue-600">
{{ student.average?.toFixed(2) || '-' }}
</td>
<!-- Notes pour chaque évaluation -->
<td v-for="assessment in assessments" :key="assessment.assessment_id">
{{ getAssessmentScore(student, assessment.assessment_id) }}
</td>
</tr>
</tbody>
</table>
```
**Changements**:
- ✅ Colonnes dynamiques générées depuis `assessments`
- ✅ Tri sur toutes les colonnes (clic sur en-tête)
- ✅ Indicateur visuel de tri (▲/▼)
- ✅ Hover sur en-têtes
- ✅ Titre complet en tooltip (`:title`)
- ❌ Suppression colonne Performance
### 3. Template - Domaines/Compétences
#### Avant
```html
<div v-for="domain in stats.domains_stats" :key="domain.id">
<div class="flex justify-between">
<span>{{ domain.name }}</span>
<span>{{ domain.mean?.toFixed(1) || '-' }}/20</span> <!---->
</div>
<div class="progress-bar">
<div :style="{ width: `${(domain.mean / 20) * 100}%` }"></div>
</div>
<p>{{ domain.elements_count }} éléments évalués</p> <!---->
</div>
```
#### Après
```html
<div v-for="domain in stats.domains_stats" :key="domain.id">
<div class="flex justify-between">
<span>{{ domain.name }}</span>
<!-- ✅ Affichage points obtenus / points possibles -->
<span>
{{ domain.total_points_obtained?.toFixed(1) || '0' }}/{{ domain.total_points_possible?.toFixed(0) || '0' }}
</span>
</div>
<!-- ✅ Barre calculée sur les points réels -->
<div class="progress-bar">
<div :style="{
width: `${domain.total_points_possible > 0
? (domain.total_points_obtained / domain.total_points_possible) * 100
: 0}%`
}"></div>
</div>
<!-- ✅ Nombre d'évaluations -->
<p>{{ domain.evaluation_count }} évaluations</p>
</div>
```
**Changements**:
- ✅ Affichage `total_points_obtained / total_points_possible`
- ✅ Texte "X évaluations" au lieu de "X éléments"
- ✅ Barre calculée sur les points réels
- ❌ Suppression de `mean`
---
## 📊 Flux de Données Complet
### 1. Chargement Initial
```
Frontend (ClassDashboardView.vue)
→ fetchData()
→ classesStore.fetchClassStats(classId, trimester)
→ GET /api/classes/{id}/stats?trimester={t}
Backend (classes.py)
→ get_class_stats()
→ Récupérer students, assessments, domains, competences
→ Charger toutes les notes (grades_by_student_assessment)
→ ClassStatisticsService.calculate_student_statistics()
→ Pour chaque élève:
→ Calculer scores par évaluation
→ Calculer stats par domaine
→ Calculer stats par compétence
→ Retourner StudentAverage enrichi
→ ClassStatisticsService.aggregate_domain_competence_stats()
→ Agréger tous les élèves
→ Retourner DomainStats et CompetenceStats
→ Calculer statistiques globales (mean, median, std_dev)
→ Retourner ClassDashboardStats complet
Frontend (ClassDashboardView.vue)
→ stats.value = résultat API
→ assessments computed → extrait évaluations
→ sortedStudents computed → tri initial
→ Affichage tableau
```
### 2. Tri Utilisateur
```
Frontend
→ Utilisateur clique sur en-tête de colonne
→ sortBy(column) appelée
→ Met à jour sortColumn, sortDirection
→ sortedStudents computed se recalcule automatiquement
→ Vue.js re-rend le tableau
```
### 3. Changement de Trimestre
```
Frontend
→ Utilisateur clique sur "Trimestre 2"
→ selectTrimester(2) appelée
→ Nouvelle requête API avec trimester=2
→ stats.value mis à jour
→ assessments computed se recalcule
→ sortedStudents computed se recalcule
→ Tableau re-rendu avec nouvelles données
```
---
## 🎯 Résultat Final
### Tableau des Élèves - Fonctionnalités
| Fonctionnalité | Avant | Après |
|----------------|-------|-------|
| Tri sur colonnes | ❌ | ✅ Toutes colonnes |
| Affichage notes évaluations | ❌ | ✅ Toutes visibles |
| Indicateurs visuels | ✅ Badges | ❌ Supprimés |
| UX | Statique | ✅ Interactive |
### Tableau Domaines/Compétences - Données
| Donnée | Avant | Après |
|--------|-------|-------|
| Moyenne | ✅ X/20 ou X/3 | ❌ Supprimée |
| Points obtenus/possibles | ❌ | ✅ XX.X/YY |
| Nombre d'évaluations | ❌ "X éléments" | ✅ "X évaluations" |
| Barre de progression | Basée sur moyenne | ✅ Basée sur points |
---
## 🚀 Pour Tester
### 1. Lancer le backend
```bash
cd backend
uv run uvicorn api.main:app --reload
```
### 2. Lancer le frontend
```bash
cd frontend
npm run dev
```
### 3. Accéder à une classe
```
http://localhost:5173/classes/{id}
```
### 4. Vérifier
- ✅ Tableau élèves affiche toutes les colonnes d'évaluations
- ✅ Clic sur en-tête trie la colonne (nom, moyenne, évaluations)
- ✅ Indicateur ▲/▼ s'affiche
- ✅ Domaines/Compétences affichent points et nombre d'évaluations
- ✅ Pas de badges de performance
---
## 📁 Fichiers Modifiés
### Backend (3 fichiers)
1. **`backend/schemas/class_group.py`**
- Ajout: AssessmentScore, DomainStudentStats, CompetenceStudentStats
- Modification: DomainStats, CompetenceStats, StudentAverage
2. **`backend/domain/services/class_statistics_service.py`** (NOUVEAU)
- ClassStatisticsService
- calculate_student_statistics()
- aggregate_domain_competence_stats()
3. **`backend/api/routes/classes.py`**
- get_class_stats() refactorisé
- Utilisation de ClassStatisticsService
- Import des nouveaux schemas
### Frontend (1 fichier)
4. **`frontend/src/views/ClassDashboardView.vue`**
- Script: ajout tri, computed, fonctions
- Template: refonte tableau élèves + domaines/compétences
- Suppression: fonctions de performance
---
## 🎓 Points Clés Techniques
### Architecture Backend
- **Service Layer**: Logique métier isolée dans ClassStatisticsService
- **Schema Evolution**: Schemas enrichis pour supporter données complexes
- **Performance**: Une requête par élève/évaluation (optimisable avec joinedload)
### Architecture Frontend
- **Reactive Computing**: Tri géré par computed (pas de setState manuel)
- **Dynamic Columns**: Colonnes générées depuis les données backend
- **UX**: Hover, cursors, indicateurs visuels pour meilleure expérience
### Coordination
- **Contract-First**: Schemas Pydantic garantissent le contrat API
- **Type Safety**: Dict[int, Schema] pour accès rapide côté frontend
- **Consistency**: Même structure pour domaines et compétences

View File

@@ -0,0 +1,386 @@
# Perspective Enseignant - Domaines et Compétences
**Date**: 3 décembre 2025
**Type**: Changement de perspective - Ce qui a été évalué vs Résultats des élèves
## 🎯 Objectif du Changement
Passer d'une **perspective centrée sur les résultats des élèves** à une **perspective centrée sur ce qui a été évalué par l'enseignant**.
### Avant (Perspective Élèves)
```
Domaine Algèbre:
- 75 évaluations (25 élèves × 3 questions)
- 187.5 / 250 points
```
**Difficile à interpréter** : mélange le nombre d'élèves et les évaluations
### Après (Perspective Enseignant)
```
Domaine Algèbre:
- 3 éléments de notation
- 10 points maximum
```
**Clair et actionnable** : l'enseignant voit ce qu'il a créé
---
## 📊 Cas d'Usage Concrets
### Scénario 1: Vérifier l'équilibre d'un trimestre
**Données affichées**:
```
Domaines:
- Algèbre : 8 éléments, 25 points
- Géométrie : 3 éléments, 12 points ← Sous-représenté
- Probabilités : 2 éléments, 8 points ← Sous-représenté
- Calcul : 6 éléments, 18 points
```
**Action enseignant**: Créer plus d'évaluations sur Géométrie et Probabilités pour le prochain contrôle.
### Scénario 2: Analyser une évaluation
**Données affichées**:
```
Compétences:
- Calculer : 5 éléments, 15 points
- Raisonner : 3 éléments, 12 points
- Communiquer : 1 élément, 5 points ← Sous-représenté
```
**Action enseignant**: Ajouter des questions de communication dans la prochaine évaluation.
### Scénario 3: Comparer les trimestres
| Domaine | T1 | T2 | T3 |
|---------|----|----|-----|
| Algèbre | 8 éléments, 25pts | 6 éléments, 18pts | 10 éléments, 30pts |
| Géométrie | 3 éléments, 12pts | 7 éléments, 20pts | 5 éléments, 15pts |
**Insight**: L'enseignant voit qu'il a moins travaillé la géométrie au T3, peut ajuster pour l'année suivante.
---
## 🔧 Modifications Techniques
### Backend
#### 1. Nouveau Service (`backend/domain/services/class_statistics_service.py`)
**Méthode ajoutée**: `calculate_domain_competence_from_elements()`
**Logique**:
```python
for assessment in assessments:
for exercise in assessment.exercises:
for element in exercise.grading_elements:
# Compter par domaine
if element.domain_id:
domain_count[element.domain_id] += 1
domain_points[element.domain_id] += element.max_points
# Compter par compétence (via skill)
if element.skill:
competence = find_competence_by_name(element.skill)
if competence:
competence_count[competence.id] += 1
competence_points[competence.id] += element.max_points
```
**Retourne**:
- `evaluation_count`: Nombre de GradingElements utilisant ce domaine/compétence
- `total_points_possible`: Somme des max_points de ces éléments
- `total_points_obtained`: 0 (non pertinent dans cette perspective)
#### 2. Endpoint modifié (`backend/api/routes/classes.py`)
**Chargement des relations**:
```python
assessments_query = (
select(Assessment)
.options(
selectinload(Assessment.exercises).selectinload(Exercise.grading_elements)
)
.where(...)
)
```
**Utilisation du nouveau service**:
```python
# AVANT
domains_stats, competences_stats = stats_service.aggregate_domain_competence_stats(
student_averages=student_averages,
domains=domains,
competences=competences,
)
# APRÈS
domains_stats, competences_stats = stats_service.calculate_domain_competence_from_elements(
assessments=assessments, # Avec exercises et grading_elements chargés
domains=domains,
competences=competences,
)
```
### Frontend
#### 1. Affichage modifié (`frontend/src/views/ClassDashboardView.vue`)
**Template**:
```vue
<!-- AVANT -->
<div>
<span>{{ domain.name }}</span>
<span>{{ domain.total_points_obtained }}/{{ domain.total_points_possible }}</span>
<p>{{ domain.evaluation_count }} évaluations</p>
</div>
<!-- APRÈS -->
<div>
<span>{{ domain.name }}</span>
<span>{{ domain.total_points_possible }} points</span>
<p>{{ domain.evaluation_count }} élément(s) de notation</p>
</div>
```
**Titres modifiés**:
- "Statistiques par domaine" → "Évaluations par domaine"
- "Statistiques par compétence" → "Évaluations par compétence"
**Sous-titre ajouté**:
- "Perspective enseignant : ce qui a été évalué"
#### 2. Barre de progression relative
**Nouvelle logique**:
```javascript
function getRelativeWidth(item, allItems) {
const maxPoints = Math.max(...allItems.map(d => d.total_points_possible || 0))
if (maxPoints === 0) return 0
return ((item.total_points_possible || 0) / maxPoints) * 100
}
```
**Utilisation**:
```vue
<div class="progress-bar">
<div :style="{ width: `${getRelativeWidth(domain, stats.domains_stats)}%` }"></div>
</div>
```
La barre montre **la proportion relative** par rapport au domaine le plus évalué, pas un pourcentage absolu.
---
## 📈 Comparaison Avant/Après
### Exemple avec une classe de 25 élèves, 2 contrôles
**Contrôle 1** (Trimestre 1):
- Q1: Algèbre, 5 points
- Q2: Algèbre, 3 points
- Q3: Géométrie, 4 points
**Contrôle 2** (Trimestre 1):
- Q1: Algèbre, 2 points
- Q2: Probabilités, 5 points
### Avant (Perspective Élèves)
**Domaine Algèbre**:
- `evaluation_count`: 75 (3 questions × 25 élèves)
- `total_points_obtained`: 187.5 (moyenne hypothétique)
- `total_points_possible`: 250 ((5+3+2) × 25)
**Affichage**: "75 évaluations - 187.5/250"
**Illisible** pour l'enseignant
### Après (Perspective Enseignant)
**Domaine Algèbre**:
- `evaluation_count`: 3 (nombre de questions posées)
- `total_points_possible`: 10 (5+3+2)
**Affichage**: "3 éléments de notation - 10 points"
**Très lisible** et actionnable
---
## 🎓 Avantages Pédagogiques
### 1. Clarté
- **Indépendant du nombre d'élèves**: 3 éléments restent 3 éléments
- **Unité cohérente**: Points = barème défini par l'enseignant
### 2. Planification
- **Voir rapidement** quels domaines ont été peu évalués
- **Équilibrer** les évaluations entre domaines
- **Comparer** les trimestres facilement
### 3. Réflexivité
- **Analyse de pratique**: "Ai-je trop évalué l'algèbre ?"
- **Diversification**: "Dois-je ajouter plus de géométrie ?"
- **Cohérence**: "Mon programme est-il équilibré ?"
### 4. Communication
- **Conseil de classe**: "J'ai évalué 8 fois l'algèbre ce trimestre"
- **Parents**: "Voici la répartition de mes évaluations"
- **Équipe pédagogique**: "Comparons nos pratiques d'évaluation"
---
## 🔍 Détails d'Implémentation
### Gestion des Compétences
**Mapping via `element.skill`**:
```python
if element.skill:
matching_competence = next(
(c for c in competences if c.name == element.skill),
None
)
```
**Logique**:
- Les compétences sont stockées dans `GradingElement.skill` (champ texte libre)
- Le matching se fait par nom de compétence
- Si aucune correspondance: la compétence n'est pas comptée
**Amélioration future**: Ajouter `competence_id` dans `GradingElement` pour éviter le matching par nom.
### Performance
**Requête optimisée**:
```python
# Un seul trip à la base de données
assessments = session.execute(
select(Assessment)
.options(selectinload(Assessment.exercises).selectinload(Exercise.grading_elements))
.where(...)
).scalars().all()
```
**Complexité**: O(n) où n = nombre total de GradingElements du trimestre
**Charge typique**:
- 5 évaluations × 15 éléments/évaluation = 75 éléments à parcourir
- Temps: < 10ms
### Données Retournées
**Structure JSON**:
```json
{
"domains_stats": [
{
"id": 1,
"name": "Algèbre",
"color": "#3B82F6",
"evaluation_count": 3,
"total_points_obtained": 0.0,
"total_points_possible": 10.0
}
],
"competences_stats": [
{
"id": 1,
"name": "Calculer",
"color": "#10B981",
"evaluation_count": 5,
"total_points_obtained": 0.0,
"total_points_possible": 15.0
}
]
}
```
**Note**: `total_points_obtained` est à 0 car non pertinent dans cette perspective.
---
## 📁 Fichiers Modifiés
### Backend (2 fichiers)
1. **`backend/domain/services/class_statistics_service.py`**
- Ajout: `calculate_domain_competence_from_elements()`
- Logique: Parcours des GradingElements
- Retour: DomainStats et CompetenceStats
2. **`backend/api/routes/classes.py`**
- Modification: Chargement des assessments avec relations
- Modification: Appel de la nouvelle méthode de calcul
### Frontend (1 fichier)
3. **`frontend/src/views/ClassDashboardView.vue`**
- Modification: Titres ("Évaluations par..." au lieu de "Statistiques par...")
- Modification: Affichage (points seuls au lieu de obtained/possible)
- Modification: Texte ("élément(s) de notation" au lieu de "évaluations")
- Ajout: Fonction `getRelativeWidth()` pour barre proportionnelle
- Ajout: Sous-titre explicatif "Perspective enseignant : ce qui a été évalué"
---
## ✅ Tests Suggérés
### 1. Vérifier le comptage
- Créer 3 évaluations avec 2, 3, 4 éléments respectivement
- Vérifier: `evaluation_count = 9`
### 2. Vérifier les points
- Élément 1: 5 points
- Élément 2: 3 points
- Élément 3: 2 points
- Vérifier: `total_points_possible = 10`
### 3. Vérifier le filtrage par domaine
- 3 éléments Algèbre, 2 éléments Géométrie
- Vérifier: Algèbre montre 3, Géométrie montre 2
### 4. Vérifier les compétences
- Créer des éléments avec `skill = "Calculer"`
- Vérifier: Compétence "Calculer" affiche le bon nombre
### 5. Vérifier l'absence de données
- Domaine sans éléments: `evaluation_count = 0, total_points_possible = 0`
- Affichage: "0 élément(s) de notation - 0 points"
---
## 🚀 Utilisation
### Accéder au Dashboard
```
http://localhost:5173/classes/{id}
```
### Vérifier l'affichage
1. Sélectionner un trimestre
2. Scroller jusqu'aux sections "Évaluations par domaine/compétence"
3. Vérifier:
- ✅ Titre: "Évaluations par domaine/compétence"
- ✅ Sous-titre: "Perspective enseignant : ce qui a été évalué"
- ✅ Affichage: "X points" (pas de fraction)
- ✅ Texte: "X élément(s) de notation"
- ✅ Barre proportionnelle au domaine le plus évalué
---
## 🎯 Conclusion
Cette modification transforme le dashboard d'une **vue résultats** (complexe, orientée élèves) en une **vue enseignant** (simple, orientée pratique pédagogique).
**Impact pédagogique**:
- ✅ Meilleure visibilité sur la répartition des évaluations
- ✅ Aide à l'équilibrage du programme
- ✅ Facilite la réflexion sur les pratiques d'évaluation
- ✅ Indépendant du nombre d'élèves (plus cohérent)
**Impact technique**:
- ✅ Code plus simple (pas de double comptage élèves × éléments)
- ✅ Requêtes optimisées avec selectinload
- ✅ Données plus claires et exploitables

View File

@@ -23,8 +23,12 @@ export const classesService = {
// Get class students // Get class students
// atDate: optional date string (YYYY-MM-DD) to filter students enrolled at that date // atDate: optional date string (YYYY-MM-DD) to filter students enrolled at that date
async getStudents(id, atDate = null) { // includeDeparted: include students who have left the class
const params = atDate ? { at_date: atDate } : {} async getStudents(id, atDate = null, includeDeparted = false) {
const params = {}
if (atDate) params.at_date = atDate
if (includeDeparted) params.include_departed = true
const response = await api.get(`/classes/${id}/students`, { params }) const response = await api.get(`/classes/${id}/students`, { params })
// API returns { students: [...], total: N } // API returns { students: [...], total: N }
return response.data.students || [] return response.data.students || []

View File

@@ -27,6 +27,14 @@ export const studentsService = {
return response.data return response.data
}, },
// Update student email only (fast endpoint)
async updateEmail(id, email) {
const response = await api.patch(`/students/${id}/email`, null, {
params: { email }
})
return response.data
},
// Delete student // Delete student
async delete(id) { async delete(id) {
const response = await api.delete(`/students/${id}`) const response = await api.delete(`/students/${id}`)

View File

@@ -79,59 +79,65 @@
<div v-if="stats" class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-8"> <div v-if="stats" class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-8">
<!-- Domaines --> <!-- Domaines -->
<div v-if="stats.domains_stats?.length" class="bg-white rounded-xl shadow-md p-6"> <div v-if="stats.domains_stats?.length" class="bg-white rounded-xl shadow-md p-6">
<h2 class="text-lg font-semibold text-gray-800 mb-4">Performance par domaine</h2> <h2 class="text-lg font-semibold text-gray-800 mb-4">Évaluations par domaine</h2>
<p class="text-xs text-gray-500 mb-4">Perspective enseignant : ce qui a été évalué</p>
<div class="space-y-4"> <div class="space-y-4">
<div v-for="domain in stats.domains_stats" :key="domain.id" class="space-y-1"> <div v-for="domain in stats.domains_stats" :key="domain.id" class="space-y-1">
<div class="flex justify-between text-sm"> <div class="flex justify-between text-sm">
<span class="font-medium text-gray-700 truncate" :title="domain.name">{{ domain.name }}</span> <span class="font-medium text-gray-700 truncate" :title="domain.name">{{ domain.name }}</span>
<span class="font-bold" :style="{ color: domain.color || '#6B7280' }"> <span class="font-bold" :style="{ color: domain.color || '#6B7280' }">
{{ domain.mean?.toFixed(1) || '-' }}/20 {{ domain.total_points_possible?.toFixed(1) || '0' }} points
</span> </span>
</div> </div>
<div class="w-full bg-gray-200 rounded-full h-2"> <div class="w-full bg-gray-200 rounded-full h-2">
<div <div
class="h-2 rounded-full transition-all duration-1000 ease-out" class="h-2 rounded-full transition-all duration-1000 ease-out"
:style="{ :style="{
width: `${domain.mean ? (domain.mean / 20) * 100 : 0}%`, width: `${getRelativeWidth(domain, stats.domains_stats)}%`,
backgroundColor: domain.color || '#6B7280' backgroundColor: domain.color || '#6B7280'
}" }"
></div> ></div>
</div> </div>
<p class="text-xs text-gray-400">{{ domain.elements_count }} éléments évalués</p> <p class="text-xs text-gray-400">
{{ domain.evaluation_count }} élément(s) de notation
</p>
</div> </div>
</div> </div>
</div> </div>
<div v-else class="bg-white rounded-xl shadow-md p-6"> <div v-else class="bg-white rounded-xl shadow-md p-6">
<h2 class="text-lg font-semibold text-gray-800 mb-4">Performance par domaine</h2> <h2 class="text-lg font-semibold text-gray-800 mb-4">Évaluations par domaine</h2>
<p class="text-sm text-gray-500 italic">Aucune donnée de domaine disponible</p> <p class="text-sm text-gray-500 italic">Aucune donnée de domaine disponible</p>
</div> </div>
<!-- Compétences --> <!-- Compétences -->
<div v-if="stats.competences_stats?.length" class="bg-white rounded-xl shadow-md p-6"> <div v-if="stats.competences_stats?.length" class="bg-white rounded-xl shadow-md p-6">
<h2 class="text-lg font-semibold text-gray-800 mb-4">Performance par compétence</h2> <h2 class="text-lg font-semibold text-gray-800 mb-4">Évaluations par compétence</h2>
<p class="text-xs text-gray-500 mb-4">Perspective enseignant : ce qui a été évalué</p>
<div class="space-y-4"> <div class="space-y-4">
<div v-for="competence in stats.competences_stats" :key="competence.id" class="space-y-1"> <div v-for="competence in stats.competences_stats" :key="competence.id" class="space-y-1">
<div class="flex justify-between text-sm"> <div class="flex justify-between text-sm">
<span class="font-medium text-gray-700 truncate" :title="competence.name">{{ competence.name }}</span> <span class="font-medium text-gray-700 truncate" :title="competence.name">{{ competence.name }}</span>
<span class="font-bold" :style="{ color: competence.color || '#6B7280' }"> <span class="font-bold" :style="{ color: competence.color || '#6B7280' }">
{{ competence.mean?.toFixed(1) || '-' }}/3 {{ competence.total_points_possible?.toFixed(1) || '0' }} points
</span> </span>
</div> </div>
<div class="w-full bg-gray-200 rounded-full h-2"> <div class="w-full bg-gray-200 rounded-full h-2">
<div <div
class="h-2 rounded-full transition-all duration-1000 ease-out" class="h-2 rounded-full transition-all duration-1000 ease-out"
:style="{ :style="{
width: `${competence.mean ? (competence.mean / 3) * 100 : 0}%`, width: `${getRelativeWidth(competence, stats.competences_stats)}%`,
backgroundColor: competence.color || '#6B7280' backgroundColor: competence.color || '#6B7280'
}" }"
></div> ></div>
</div> </div>
<p class="text-xs text-gray-400">{{ competence.elements_count }} éléments évalués</p> <p class="text-xs text-gray-400">
{{ competence.evaluation_count }} élément(s) de notation
</p>
</div> </div>
</div> </div>
</div> </div>
<div v-else class="bg-white rounded-xl shadow-md p-6"> <div v-else class="bg-white rounded-xl shadow-md p-6">
<h2 class="text-lg font-semibold text-gray-800 mb-4">Performance par compétence</h2> <h2 class="text-lg font-semibold text-gray-800 mb-4">Évaluations par compétence</h2>
<p class="text-sm text-gray-500 italic">Aucune donnée de compétence disponible</p> <p class="text-sm text-gray-500 italic">Aucune donnée de compétence disponible</p>
</div> </div>
</div> </div>
@@ -139,32 +145,53 @@
<!-- Students averages --> <!-- Students averages -->
<div v-if="stats?.student_averages?.length" class="bg-white rounded-xl shadow-md overflow-hidden"> <div v-if="stats?.student_averages?.length" class="bg-white rounded-xl shadow-md overflow-hidden">
<div class="px-6 py-4 border-b border-gray-200"> <div class="px-6 py-4 border-b border-gray-200">
<h2 class="text-lg font-semibold text-gray-800">Moyennes par élève</h2> <h2 class="text-lg font-semibold text-gray-800">Notes par élève</h2>
<p class="text-xs text-gray-500 mt-1">Cliquez sur les en-têtes pour trier</p>
</div> </div>
<div class="overflow-x-auto"> <div class="overflow-x-auto">
<table class="w-full"> <table class="w-full">
<thead class="bg-gray-50"> <thead class="bg-gray-50">
<tr> <tr>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Élève</th> <th
<th class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Moyenne</th> @click="sortBy('name')"
<th class="px-6 py-3 text-center text-xs font-medium text-gray-500 uppercase tracking-wider">Performance</th> class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider cursor-pointer hover:bg-gray-100"
>
Élève {{ getSortIcon('name') }}
</th>
<th
@click="sortBy('average')"
class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider cursor-pointer hover:bg-gray-100"
>
Moyenne {{ getSortIcon('average') }}
</th>
<th
v-for="assessment in assessments"
:key="assessment.assessment_id"
@click="sortBy(`assessment_${assessment.assessment_id}`)"
class="px-4 py-3 text-center text-xs font-medium text-gray-500 uppercase tracking-wider cursor-pointer hover:bg-gray-100"
:title="assessment.assessment_title"
>
<div class="flex flex-col items-center">
<span class="truncate max-w-[120px]">{{ assessment.assessment_title }}</span>
<span class="text-[10px] font-normal">{{ getSortIcon(`assessment_${assessment.assessment_id}`) }}</span>
</div>
</th>
</tr> </tr>
</thead> </thead>
<tbody class="divide-y divide-gray-200"> <tbody class="divide-y divide-gray-200">
<tr v-for="student in stats.student_averages" :key="student.student_id" class="hover:bg-gray-50"> <tr v-for="student in sortedStudents" :key="student.student_id" class="hover:bg-gray-50">
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900"> <td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
{{ student.last_name }} {{ student.first_name }} {{ student.last_name }} {{ student.first_name }}
</td> </td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-right font-bold"> <td class="px-6 py-4 whitespace-nowrap text-sm text-right font-bold text-blue-600">
{{ student.average?.toFixed(2) || '-' }} {{ student.average?.toFixed(2) || '-' }}
</td> </td>
<td class="px-6 py-4 whitespace-nowrap text-center"> <td
<span v-for="assessment in assessments"
class="px-2 py-1 text-xs font-medium rounded-full" :key="assessment.assessment_id"
:class="getPerformanceClass(student.average)" class="px-4 py-4 whitespace-nowrap text-sm text-center text-gray-700"
> >
{{ getPerformanceLabel(student.average) }} {{ getAssessmentScore(student, assessment.assessment_id) }}
</span>
</td> </td>
</tr> </tr>
</tbody> </tbody>
@@ -172,15 +199,15 @@
</div> </div>
</div> </div>
<div v-else-if="stats" class="bg-white rounded-xl shadow-md p-6"> <div v-else-if="stats" class="bg-white rounded-xl shadow-md p-6">
<h2 class="text-lg font-semibold text-gray-800 mb-4">Moyennes par élève</h2> <h2 class="text-lg font-semibold text-gray-800 mb-4">Notes par élève</h2>
<p class="text-sm text-gray-500 italic text-center py-4">Aucune moyenne disponible pour ce trimestre</p> <p class="text-sm text-gray-500 italic text-center py-4">Aucune note disponible pour ce trimestre</p>
</div> </div>
</template> </template>
</div> </div>
</template> </template>
<script setup> <script setup>
import { ref, onMounted } from 'vue' import { ref, computed, onMounted } from 'vue'
import { useRoute } from 'vue-router' import { useRoute } from 'vue-router'
import { useClassesStore } from '@/stores/classes' import { useClassesStore } from '@/stores/classes'
import LoadingSpinner from '@/components/common/LoadingSpinner.vue' import LoadingSpinner from '@/components/common/LoadingSpinner.vue'
@@ -192,6 +219,8 @@ const loading = ref(true)
const classData = ref(null) const classData = ref(null)
const stats = ref(null) const stats = ref(null)
const trimester = ref(1) const trimester = ref(1)
const sortColumn = ref('name')
const sortDirection = ref('asc')
async function fetchData() { async function fetchData() {
loading.value = true loading.value = true
@@ -209,21 +238,71 @@ async function selectTrimester(t) {
stats.value = await classesStore.fetchClassStats(route.params.id, t) stats.value = await classesStore.fetchClassStats(route.params.id, t)
} }
// Fonctions pour les labels de performance // Récupérer la liste des évaluations triée par date
function getPerformanceClass(average) { const assessments = computed(() => {
if (average === null || average === undefined) return 'bg-gray-100 text-gray-600' if (!stats.value?.student_averages?.length) return []
if (average >= 16) return 'bg-green-100 text-green-800'
if (average >= 12) return 'bg-blue-100 text-blue-800' // Extraire les évaluations depuis le premier élève
if (average >= 8) return 'bg-orange-100 text-orange-800' const firstStudent = stats.value.student_averages[0]
return 'bg-red-100 text-red-800' if (!firstStudent?.assessment_scores) return []
return Object.values(firstStudent.assessment_scores).sort((a, b) => a.assessment_id - b.assessment_id)
})
// Fonction de tri des élèves
const sortedStudents = computed(() => {
if (!stats.value?.student_averages) return []
const students = [...stats.value.student_averages]
students.sort((a, b) => {
let valA, valB
if (sortColumn.value === 'name') {
valA = `${a.last_name} ${a.first_name}`.toLowerCase()
valB = `${b.last_name} ${b.first_name}`.toLowerCase()
} else if (sortColumn.value === 'average') {
valA = a.average ?? -1
valB = b.average ?? -1
} else if (sortColumn.value.startsWith('assessment_')) {
const assessmentId = parseInt(sortColumn.value.split('_')[1])
valA = a.assessment_scores?.[assessmentId]?.score ?? -1
valB = b.assessment_scores?.[assessmentId]?.score ?? -1
} else {
return 0
} }
function getPerformanceLabel(average) { const comparison = valA > valB ? 1 : valA < valB ? -1 : 0
if (average === null || average === undefined) return '-' return sortDirection.value === 'asc' ? comparison : -comparison
if (average >= 16) return 'Excellent' })
if (average >= 12) return 'Bon'
if (average >= 8) return 'Moyen' return students
return 'Insuffisant' })
function sortBy(column) {
if (sortColumn.value === column) {
sortDirection.value = sortDirection.value === 'asc' ? 'desc' : 'asc'
} else {
sortColumn.value = column
sortDirection.value = 'asc'
}
}
function getAssessmentScore(student, assessmentId) {
const score = student.assessment_scores?.[assessmentId]
if (!score || score.score === null) return '-'
return `${score.score.toFixed(1)}/${score.max_points.toFixed(0)}`
}
function getSortIcon(column) {
if (sortColumn.value !== column) return ''
return sortDirection.value === 'asc' ? '▲' : '▼'
}
function getRelativeWidth(item, allItems) {
const maxPoints = Math.max(...allItems.map(d => d.total_points_possible || 0))
if (maxPoints === 0) return 0
return ((item.total_points_possible || 0) / maxPoints) * 100
} }
onMounted(fetchData) onMounted(fetchData)

View File

@@ -3,16 +3,29 @@
<LoadingSpinner v-if="loading" text="Chargement..." fullPage /> <LoadingSpinner v-if="loading" text="Chargement..." fullPage />
<template v-else> <template v-else>
<!-- Header -->
<div class="flex justify-between items-center mb-6"> <div class="flex justify-between items-center mb-6">
<div> <div>
<h1 class="text-2xl font-bold">{{ classData?.name }}</h1> <h1 class="text-2xl font-bold">{{ classData?.name }}</h1>
<p class="text-gray-500">{{ students.length }} élève(s)</p> <p class="text-gray-500">{{ activeStudentsCount }} élève(s) actif(s)</p>
</div> </div>
<div class="flex gap-3">
<label class="flex items-center gap-2 px-4 py-2 bg-gray-100 rounded-lg cursor-pointer hover:bg-gray-200 transition-colors">
<input
type="checkbox"
v-model="includeDeparted"
@change="loadStudents"
class="rounded border-gray-300 text-indigo-600 focus:ring-indigo-500"
/>
<span class="text-sm font-medium text-gray-700">Inclure les élèves partis</span>
</label>
<button @click="showAddModal = true" class="btn btn-primary"> <button @click="showAddModal = true" class="btn btn-primary">
+ Ajouter un élève + Ajouter un élève
</button> </button>
</div> </div>
</div>
<!-- Students table -->
<div class="card overflow-hidden"> <div class="card overflow-hidden">
<table class="table"> <table class="table">
<thead> <thead>
@@ -20,54 +33,577 @@
<th>Nom</th> <th>Nom</th>
<th>Prénom</th> <th>Prénom</th>
<th>Email</th> <th>Email</th>
<th>Statut</th>
<th>Inscrit le</th> <th>Inscrit le</th>
<th>Parti le</th>
<th class="text-right">Actions</th>
</tr> </tr>
</thead> </thead>
<tbody class="divide-y divide-gray-200"> <tbody class="divide-y divide-gray-200">
<tr v-for="student in students" :key="student.id"> <tr v-for="student in students" :key="student.id" :class="{ 'bg-gray-50': !student.is_active }">
<!-- Nom -->
<td class="font-medium">{{ student.last_name }}</td> <td class="font-medium">{{ student.last_name }}</td>
<!-- Prénom -->
<td>{{ student.first_name }}</td> <td>{{ student.first_name }}</td>
<td class="text-gray-500">{{ student.email || '-' }}</td>
<!-- Email with inline editing -->
<td>
<div class="flex items-center gap-2 group">
<template v-if="editingEmailId === student.id">
<input
ref="emailInput"
v-model="editingEmailValue"
@blur="saveEmail(student)"
@keyup.enter="saveEmail(student)"
@keyup.escape="cancelEditEmail"
type="email"
class="flex-1 px-2 py-1 border border-indigo-300 rounded focus:outline-none focus:ring-2 focus:ring-indigo-500"
placeholder="email@exemple.com"
/>
<button @click="cancelEditEmail" class="text-gray-400 hover:text-gray-600">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</template>
<template v-else>
<span class="flex-1 text-gray-600">{{ student.email || '-' }}</span>
<button
@click="startEditEmail(student)"
class="opacity-0 group-hover:opacity-100 text-gray-400 hover:text-indigo-600 transition-opacity"
title="Modifier l'email"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z" />
</svg>
</button>
<!-- Feedback icons -->
<span v-if="emailFeedback[student.id] === 'success'" class="text-green-500">
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd" />
</svg>
</span>
<span v-if="emailFeedback[student.id] === 'error'" class="text-red-500">
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd" />
</svg>
</span>
</template>
</div>
</td>
<!-- Statut -->
<td>
<span
v-if="student.is_active"
class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800"
>
Actif
</span>
<span
v-else
class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800"
>
Parti
</span>
</td>
<!-- Inscrit le -->
<td class="text-gray-500">{{ formatDate(student.enrollment_date) }}</td> <td class="text-gray-500">{{ formatDate(student.enrollment_date) }}</td>
<!-- Parti le -->
<td class="text-gray-500">{{ formatDate(student.departure_date) }}</td>
<!-- Actions -->
<td class="text-right">
<button
v-if="student.is_active"
@click="openDepartureModal(student)"
class="text-sm text-red-600 hover:text-red-800 font-medium"
>
Sortir de la classe
</button>
<button
v-else
@click="openReenrollModal(student)"
class="text-sm text-indigo-600 hover:text-indigo-800 font-medium"
>
Réinscrire
</button>
</td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
<!-- Empty state -->
<div v-if="students.length === 0" class="p-8 text-center text-gray-500">
Aucun élève trouvé
</div>
</div> </div>
</template> </template>
<!-- Add modal placeholder --> <!-- Add/Enroll Student Modal -->
<Modal v-model="showAddModal" title="Ajouter un élève"> <Modal v-model="showAddModal" title="Ajouter un élève" size="large">
<p class="text-gray-500">Formulaire d'ajout d'élève (à implémenter)</p> <div class="space-y-4">
<!-- Tabs -->
<div class="border-b border-gray-200">
<nav class="-mb-px flex space-x-8">
<button
@click="addMode = 'new'"
:class="[
'py-4 px-1 border-b-2 font-medium text-sm',
addMode === 'new'
? 'border-indigo-500 text-indigo-600'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
]"
>
Nouvel élève
</button>
<button
@click="addMode = 'existing'"
:class="[
'py-4 px-1 border-b-2 font-medium text-sm',
addMode === 'existing'
? 'border-indigo-500 text-indigo-600'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
]"
>
Élève existant
</button>
</nav>
</div>
<!-- New Student Form -->
<div v-if="addMode === 'new'" class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Nom *</label>
<input
v-model="newStudent.last_name"
type="text"
required
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-indigo-500"
/>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Prénom *</label>
<input
v-model="newStudent.first_name"
type="text"
required
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-indigo-500"
/>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Email</label>
<input
v-model="newStudent.email"
type="email"
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-indigo-500"
/>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Date d'inscription *</label>
<input
v-model="newStudent.enrollment_date"
type="date"
required
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-indigo-500"
/>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Raison</label>
<input
v-model="newStudent.enrollment_reason"
type="text"
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-indigo-500"
placeholder="Ex: Nouvelle inscription"
/>
</div>
</div>
<!-- Existing Student Form -->
<div v-if="addMode === 'existing'" class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Élève *</label>
<select
v-model="existingStudent.student_id"
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-indigo-500"
>
<option :value="null">Sélectionner un élève...</option>
<option v-for="student in availableStudents" :key="student.id" :value="student.id">
{{ student.last_name }} {{ student.first_name }}
</option>
</select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Date d'inscription *</label>
<input
v-model="existingStudent.enrollment_date"
type="date"
required
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-indigo-500"
/>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Raison</label>
<input
v-model="existingStudent.enrollment_reason"
type="text"
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-indigo-500"
placeholder="Ex: Transfert depuis..."
/>
</div>
</div>
<!-- Actions -->
<div class="flex justify-end gap-3 pt-4">
<button
@click="showAddModal = false"
class="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50"
>
Annuler
</button>
<button
@click="enrollStudent"
:disabled="!canEnroll"
class="px-4 py-2 text-sm font-medium text-white bg-indigo-600 rounded-md hover:bg-indigo-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
Inscrire
</button>
</div>
</div>
</Modal>
<!-- Departure Modal -->
<Modal v-model="showDepartureModal" title="Sortir un élève de la classe">
<div class="space-y-4">
<p class="text-sm text-gray-600">
Vous êtes sur le point de marquer <strong>{{ departureStudent?.first_name }} {{ departureStudent?.last_name }}</strong> comme parti(e) de la classe.
</p>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Date de départ *</label>
<input
v-model="departureDate"
type="date"
required
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-indigo-500"
/>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Raison</label>
<input
v-model="departureReason"
type="text"
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-indigo-500"
placeholder="Ex: Déménagement, transfert..."
/>
</div>
<div class="flex justify-end gap-3 pt-4">
<button
@click="showDepartureModal = false"
class="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50"
>
Annuler
</button>
<button
@click="confirmDeparture"
:disabled="!departureDate"
class="px-4 py-2 text-sm font-medium text-white bg-red-600 rounded-md hover:bg-red-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
Confirmer le départ
</button>
</div>
</div>
</Modal>
<!-- Re-enroll Modal -->
<Modal v-model="showReenrollModal" title="Réinscrire un élève">
<div class="space-y-4">
<p class="text-sm text-gray-600">
Réinscrire <strong>{{ reenrollStudent?.first_name }} {{ reenrollStudent?.last_name }}</strong> dans la classe.
</p>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Date de réinscription *</label>
<input
v-model="reenrollDate"
type="date"
required
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-indigo-500"
/>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Raison</label>
<input
v-model="reenrollReason"
type="text"
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-indigo-500"
placeholder="Ex: Retour après absence..."
/>
</div>
<div class="flex justify-end gap-3 pt-4">
<button
@click="showReenrollModal = false"
class="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50"
>
Annuler
</button>
<button
@click="confirmReenroll"
:disabled="!reenrollDate"
class="px-4 py-2 text-sm font-medium text-white bg-indigo-600 rounded-md hover:bg-indigo-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
Réinscrire
</button>
</div>
</div>
</Modal> </Modal>
</div> </div>
</template> </template>
<script setup> <script setup>
import { ref, onMounted } from 'vue' import { ref, computed, onMounted, nextTick } from 'vue'
import { useRoute } from 'vue-router' import { useRoute } from 'vue-router'
import { useClassesStore } from '@/stores/classes' import { useClassesStore } from '@/stores/classes'
import { useNotificationsStore } from '@/stores/notifications'
import classesService from '@/services/classes' import classesService from '@/services/classes'
import studentsService from '@/services/students'
import LoadingSpinner from '@/components/common/LoadingSpinner.vue' import LoadingSpinner from '@/components/common/LoadingSpinner.vue'
import Modal from '@/components/common/Modal.vue' import Modal from '@/components/common/Modal.vue'
const route = useRoute() const route = useRoute()
const classesStore = useClassesStore() const classesStore = useClassesStore()
const notifications = useNotificationsStore()
const loading = ref(true) const loading = ref(true)
const classData = ref(null) const classData = ref(null)
const students = ref([]) const students = ref([])
const includeDeparted = ref(false)
// Email editing
const editingEmailId = ref(null)
const editingEmailValue = ref('')
const emailFeedback = ref({})
const emailInput = ref(null)
// Add/Enroll student
const showAddModal = ref(false) const showAddModal = ref(false)
const addMode = ref('new')
const availableStudents = ref([])
const newStudent = ref({
last_name: '',
first_name: '',
email: '',
enrollment_date: new Date().toISOString().split('T')[0],
enrollment_reason: ''
})
const existingStudent = ref({
student_id: null,
enrollment_date: new Date().toISOString().split('T')[0],
enrollment_reason: ''
})
// Departure
const showDepartureModal = ref(false)
const departureStudent = ref(null)
const departureDate = ref(new Date().toISOString().split('T')[0])
const departureReason = ref('')
// Re-enroll
const showReenrollModal = ref(false)
const reenrollStudent = ref(null)
const reenrollDate = ref(new Date().toISOString().split('T')[0])
const reenrollReason = ref('')
const activeStudentsCount = computed(() => {
return students.value.filter(s => s.is_active).length
})
const canEnroll = computed(() => {
if (addMode.value === 'new') {
return newStudent.value.last_name && newStudent.value.first_name && newStudent.value.enrollment_date
} else {
return existingStudent.value.student_id && existingStudent.value.enrollment_date
}
})
function formatDate(dateStr) { function formatDate(dateStr) {
if (!dateStr) return '-' if (!dateStr) return '-'
return new Date(dateStr).toLocaleDateString('fr-FR') return new Date(dateStr).toLocaleDateString('fr-FR')
} }
async function loadStudents() {
try {
const id = route.params.id
students.value = await classesService.getStudents(id, null, includeDeparted.value)
} catch (error) {
notifications.error('Erreur lors du chargement des élèves')
console.error(error)
}
}
async function loadAvailableStudents() {
try {
const allStudents = await studentsService.getAll()
// Filter students without active enrollment
availableStudents.value = allStudents.filter(s => !s.current_class_id)
} catch (error) {
notifications.error('Erreur lors du chargement des élèves disponibles')
console.error(error)
}
}
// Email editing functions
function startEditEmail(student) {
editingEmailId.value = student.id
editingEmailValue.value = student.email || ''
nextTick(() => {
emailInput.value?.[0]?.focus()
})
}
function cancelEditEmail() {
editingEmailId.value = null
editingEmailValue.value = ''
}
async function saveEmail(student) {
if (editingEmailValue.value === student.email) {
cancelEditEmail()
return
}
try {
await studentsService.updateEmail(student.id, editingEmailValue.value)
student.email = editingEmailValue.value
// Show success feedback
emailFeedback.value[student.id] = 'success'
setTimeout(() => {
emailFeedback.value[student.id] = null
}, 2000)
cancelEditEmail()
} catch (error) {
// Show error feedback
emailFeedback.value[student.id] = 'error'
setTimeout(() => {
emailFeedback.value[student.id] = null
}, 2000)
notifications.error(error.response?.data?.detail || 'Erreur lors de la mise à jour de l\'email')
console.error(error)
}
}
// Enroll student
async function enrollStudent() {
try {
const classId = parseInt(route.params.id)
if (addMode.value === 'new') {
await studentsService.enroll({
first_name: newStudent.value.first_name,
last_name: newStudent.value.last_name,
email: newStudent.value.email || null,
class_group_id: classId,
enrollment_date: newStudent.value.enrollment_date,
enrollment_reason: newStudent.value.enrollment_reason || null
})
notifications.success('Élève créé et inscrit avec succès')
} else {
await studentsService.enroll({
student_id: existingStudent.value.student_id,
class_group_id: classId,
enrollment_date: existingStudent.value.enrollment_date,
enrollment_reason: existingStudent.value.enrollment_reason || null
})
notifications.success('Élève inscrit avec succès')
}
// Reset forms
newStudent.value = {
last_name: '',
first_name: '',
email: '',
enrollment_date: new Date().toISOString().split('T')[0],
enrollment_reason: ''
}
existingStudent.value = {
student_id: null,
enrollment_date: new Date().toISOString().split('T')[0],
enrollment_reason: ''
}
showAddModal.value = false
await loadStudents()
} catch (error) {
notifications.error(error.response?.data?.detail || 'Erreur lors de l\'inscription')
console.error(error)
}
}
// Departure functions
function openDepartureModal(student) {
departureStudent.value = student
departureDate.value = new Date().toISOString().split('T')[0]
departureReason.value = ''
showDepartureModal.value = true
}
async function confirmDeparture() {
try {
await studentsService.departure({
student_id: departureStudent.value.id,
departure_date: departureDate.value,
departure_reason: departureReason.value || null
})
notifications.success('Départ enregistré avec succès')
showDepartureModal.value = false
await loadStudents()
} catch (error) {
notifications.error(error.response?.data?.detail || 'Erreur lors de l\'enregistrement du départ')
console.error(error)
}
}
// Re-enroll functions
function openReenrollModal(student) {
reenrollStudent.value = student
reenrollDate.value = new Date().toISOString().split('T')[0]
reenrollReason.value = ''
showReenrollModal.value = true
}
async function confirmReenroll() {
try {
const classId = parseInt(route.params.id)
await studentsService.enroll({
student_id: reenrollStudent.value.id,
class_group_id: classId,
enrollment_date: reenrollDate.value,
enrollment_reason: reenrollReason.value || null
})
notifications.success('Élève réinscrit avec succès')
showReenrollModal.value = false
await loadStudents()
} catch (error) {
notifications.error(error.response?.data?.detail || 'Erreur lors de la réinscription')
console.error(error)
}
}
onMounted(async () => { onMounted(async () => {
try { try {
const id = route.params.id const id = route.params.id
classData.value = await classesStore.fetchClass(id) classData.value = await classesStore.fetchClass(id)
students.value = await classesService.getStudents(id) await loadStudents()
await loadAvailableStudents()
} finally { } finally {
loading.value = false loading.value = false
} }