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
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
**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
- **Docker** : 24.0+ (ou Docker Desktop 4.20+)

View File

@@ -43,11 +43,12 @@ npm run dev
```bash
# Configuration
cp .env.docker .env
cd docker
cp .env.example .env
# Éditez .env et changez SECRET_KEY
# Démarrage (Docker ou Podman)
docker-compose up -d
docker compose up -d
# ou
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
📖 **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,
DomainStats,
CompetenceStats,
AssessmentScore,
DomainStudentStats,
CompetenceStudentStats,
)
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 (
CSVImportResponse,
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(
class_id: int,
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)"),
):
"""
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.
"""
@@ -190,22 +194,29 @@ async def get_class_students(
students = []
for enrollment in enrollments:
student = enrollment.student
is_active = enrollment.departure_date is None
students.append(
StudentWithClass(
StudentWithEnrollmentInfo(
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=class_id if enrollment.departure_date is None else None,
current_class_name=cls.name if enrollment.departure_date is None else None
current_class_id=class_id if is_active 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
students.sort(key=lambda s: (s.last_name.lower(), s.first_name.lower()))
return StudentList(
return StudentEnrollmentList(
students=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.
Inclut:
- Moyennes par élève
- Moyennes par élève avec détail par évaluation
- Statistiques globales (moyenne, médiane, écart-type)
- 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
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 = students_result.scalars().all()
# Récupérer les évaluations du trimestre
assessments_query = select(Assessment).where(
# Récupérer les évaluations du trimestre avec leurs relations
assessments_query = (
select(Assessment)
.options(
selectinload(Assessment.exercises).selectinload(Exercise.grading_elements)
)
.where(
Assessment.class_group_id == class_id,
Assessment.trimester == trimester
)
.order_by(Assessment.date)
)
assessments_result = await session.execute(assessments_query)
assessments = assessments_result.scalars().all()
# Calculer les moyennes de chaque élève
calculator = GradingCalculator()
student_averages = []
all_averages = []
# Récupérer les domaines et compétences
domains_query = select(Domain).order_by(Domain.name)
domains_result = await session.execute(domains_query)
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:
weighted_sum = 0.0
total_coefficient = 0.0
assessment_count = 0
for assessment in assessments:
# Récupérer les notes de l'élève pour cette évaluation
grades_query = (
select(Grade, GradingElement)
.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_data = grades_result.all()
grades_by_student_assessment[(student.id, assessment.id)] = grades_result.all()
if grades_data:
total_score = 0.0
total_max_points = 0.0
for grade, element in grades_data:
if grade.value:
score = calculator.calculate_score(
grade.value, element.grading_type, element.max_points
# 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,
)
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:
# Ramener sur 20
score_on_20 = total_score / total_max_points * 20
weighted_sum += score_on_20 * assessment.coefficient
total_coefficient += assessment.coefficient
assessment_count += 1
# 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 domaines/compétences depuis les éléments de notation
# Perspective enseignant : ce qui a été évalué, pas les résultats des élèves
domains_stats, competences_stats = stats_service.calculate_domain_competence_from_elements(
assessments=assessments,
domains=domains,
competences=competences,
)
# 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
if all_averages:
mean = round(sum(all_averages) / len(all_averages), 2)
sorted_averages = sorted(all_averages)
@@ -345,6 +349,7 @@ async def get_class_stats(
count=count
))
# Ajouter le dernier bin pour 20
if histogram:
count_20 = sum(1 for avg in all_averages if avg == 20)
if count_20 > 0:
histogram[-1].count += count_20
@@ -379,36 +384,6 @@ async def get_class_stats(
elif grades_count > 0:
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(
class_id=class_id,
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)
async def delete_student(
student_id: int,

View File

@@ -29,6 +29,7 @@ from .student_report_service import (
StudentReportData,
generate_report_html,
)
from .class_statistics_service import ClassStatisticsService
__all__ = [
# Grading Calculator
@@ -39,6 +40,7 @@ __all__ = [
"ScoreStrategy",
# Statistics
"StatisticsService",
"ClassStatisticsService",
# Score Calculator
"StudentScoreCalculator",
"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 typing import Optional, List
from typing import Optional, List, Dict
from pydantic import Field
@@ -117,6 +117,9 @@ class StudentAverage(BaseSchema):
full_name: str
average: Optional[float] = None
assessment_count: int = 0
assessment_scores: Dict[int, "AssessmentScore"] = {}
domain_stats: Dict[int, "DomainStudentStats"] = {}
competence_stats: Dict[int, "CompetenceStudentStats"] = {}
class HistogramBin(BaseSchema):
@@ -128,14 +131,43 @@ class HistogramBin(BaseSchema):
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):
"""Statistiques par domaine."""
id: int
name: str
color: str
mean: Optional[float] = None
elements_count: int = 0
evaluation_count: int = 0
total_points_obtained: float = 0.0
total_points_possible: float = 0.0
class CompetenceStats(BaseSchema):
@@ -144,8 +176,9 @@ class CompetenceStats(BaseSchema):
id: int
name: str
color: str
mean: Optional[float] = None
elements_count: int = 0
evaluation_count: int = 0
total_points_obtained: float = 0.0
total_points_possible: float = 0.0
class ClassDashboardStats(BaseSchema):

View File

@@ -36,6 +36,19 @@ class StudentWithClass(StudentRead):
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):
"""Schema détaillé avec historique d'inscriptions."""
@@ -49,6 +62,13 @@ class StudentList(BaseSchema):
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
class EnrollmentBase(BaseSchema):
"""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:
# Backend FastAPI
backend:
build:
context: ./backend
dockerfile: Dockerfile
image: ${REGISTRY_URL:-git.opytex.org}/${REGISTRY_NAMESPACE:-notytex}/notytex-backend:${IMAGE_TAG:-latest}
container_name: notytex-backend
restart: unless-stopped
ports:
- "8080:8000"
- "${BACKEND_PORT:-8080}:8000"
environment:
- DATABASE_URL=sqlite+aiosqlite:////data/school_management.db
- SECRET_KEY=${SECRET_KEY:-change-me-in-production-min-32-chars}
- CORS_ORIGINS=["http://localhost:8081","http://localhost:8080","http://localhost:3000"]
- LOG_LEVEL=INFO
- SECRET_KEY=${SECRET_KEY:?SECRET_KEY est obligatoire - voir .env.example}
- CORS_ORIGINS=${CORS_ORIGINS:-["http://localhost:8081","http://localhost:8080"]}
- LOG_LEVEL=${LOG_LEVEL:-INFO}
volumes:
- ./data:/data
- ./backend:/app
networks:
- notytex-network
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8000/api/v2/health"]
interval: 30s
@@ -29,27 +26,16 @@ services:
# Frontend Vue.js + Nginx
frontend:
build:
context: ./frontend
dockerfile: Dockerfile
image: ${REGISTRY_URL:-git.opytex.org}/${REGISTRY_NAMESPACE:-notytex}/notytex-frontend:${IMAGE_TAG:-latest}
container_name: notytex-frontend
restart: unless-stopped
ports:
- "8081:80"
- "${FRONTEND_PORT:-8081}:80"
depends_on:
- backend
networks:
- notytex-network
backend:
condition: service_healthy
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost/"]
interval: 30s
timeout: 10s
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
// atDate: optional date string (YYYY-MM-DD) to filter students enrolled at that date
async getStudents(id, atDate = null) {
const params = atDate ? { at_date: atDate } : {}
// includeDeparted: include students who have left the class
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 })
// API returns { students: [...], total: N }
return response.data.students || []

View File

@@ -27,6 +27,14 @@ export const studentsService = {
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
async delete(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">
<!-- Domaines -->
<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 v-for="domain in stats.domains_stats" :key="domain.id" class="space-y-1">
<div class="flex justify-between text-sm">
<span class="font-medium text-gray-700 truncate" :title="domain.name">{{ domain.name }}</span>
<span class="font-bold" :style="{ color: domain.color || '#6B7280' }">
{{ domain.mean?.toFixed(1) || '-' }}/20
{{ domain.total_points_possible?.toFixed(1) || '0' }} points
</span>
</div>
<div class="w-full bg-gray-200 rounded-full h-2">
<div
class="h-2 rounded-full transition-all duration-1000 ease-out"
:style="{
width: `${domain.mean ? (domain.mean / 20) * 100 : 0}%`,
width: `${getRelativeWidth(domain, stats.domains_stats)}%`,
backgroundColor: domain.color || '#6B7280'
}"
></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 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>
</div>
<!-- Compétences -->
<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 v-for="competence in stats.competences_stats" :key="competence.id" class="space-y-1">
<div class="flex justify-between text-sm">
<span class="font-medium text-gray-700 truncate" :title="competence.name">{{ competence.name }}</span>
<span class="font-bold" :style="{ color: competence.color || '#6B7280' }">
{{ competence.mean?.toFixed(1) || '-' }}/3
{{ competence.total_points_possible?.toFixed(1) || '0' }} points
</span>
</div>
<div class="w-full bg-gray-200 rounded-full h-2">
<div
class="h-2 rounded-full transition-all duration-1000 ease-out"
:style="{
width: `${competence.mean ? (competence.mean / 3) * 100 : 0}%`,
width: `${getRelativeWidth(competence, stats.competences_stats)}%`,
backgroundColor: competence.color || '#6B7280'
}"
></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 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>
</div>
</div>
@@ -139,32 +145,53 @@
<!-- Students averages -->
<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">
<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 class="overflow-x-auto">
<table class="w-full">
<thead class="bg-gray-50">
<tr>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Élève</th>
<th class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Moyenne</th>
<th class="px-6 py-3 text-center text-xs font-medium text-gray-500 uppercase tracking-wider">Performance</th>
<th
@click="sortBy('name')"
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>
</thead>
<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">
{{ student.last_name }} {{ student.first_name }}
</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) || '-' }}
</td>
<td class="px-6 py-4 whitespace-nowrap text-center">
<span
class="px-2 py-1 text-xs font-medium rounded-full"
:class="getPerformanceClass(student.average)"
<td
v-for="assessment in assessments"
:key="assessment.assessment_id"
class="px-4 py-4 whitespace-nowrap text-sm text-center text-gray-700"
>
{{ getPerformanceLabel(student.average) }}
</span>
{{ getAssessmentScore(student, assessment.assessment_id) }}
</td>
</tr>
</tbody>
@@ -172,15 +199,15 @@
</div>
</div>
<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>
<p class="text-sm text-gray-500 italic text-center py-4">Aucune moyenne disponible pour ce trimestre</p>
<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 note disponible pour ce trimestre</p>
</div>
</template>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { ref, computed, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import { useClassesStore } from '@/stores/classes'
import LoadingSpinner from '@/components/common/LoadingSpinner.vue'
@@ -192,6 +219,8 @@ const loading = ref(true)
const classData = ref(null)
const stats = ref(null)
const trimester = ref(1)
const sortColumn = ref('name')
const sortDirection = ref('asc')
async function fetchData() {
loading.value = true
@@ -209,21 +238,71 @@ async function selectTrimester(t) {
stats.value = await classesStore.fetchClassStats(route.params.id, t)
}
// Fonctions pour les labels de performance
function getPerformanceClass(average) {
if (average === null || average === undefined) return 'bg-gray-100 text-gray-600'
if (average >= 16) return 'bg-green-100 text-green-800'
if (average >= 12) return 'bg-blue-100 text-blue-800'
if (average >= 8) return 'bg-orange-100 text-orange-800'
return 'bg-red-100 text-red-800'
// Récupérer la liste des évaluations triée par date
const assessments = computed(() => {
if (!stats.value?.student_averages?.length) return []
// Extraire les évaluations depuis le premier élève
const firstStudent = stats.value.student_averages[0]
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) {
if (average === null || average === undefined) return '-'
if (average >= 16) return 'Excellent'
if (average >= 12) return 'Bon'
if (average >= 8) return 'Moyen'
return 'Insuffisant'
const comparison = valA > valB ? 1 : valA < valB ? -1 : 0
return sortDirection.value === 'asc' ? comparison : -comparison
})
return students
})
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)

View File

@@ -3,16 +3,29 @@
<LoadingSpinner v-if="loading" text="Chargement..." fullPage />
<template v-else>
<!-- Header -->
<div class="flex justify-between items-center mb-6">
<div>
<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 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">
+ Ajouter un élève
</button>
</div>
</div>
<!-- Students table -->
<div class="card overflow-hidden">
<table class="table">
<thead>
@@ -20,54 +33,577 @@
<th>Nom</th>
<th>Prénom</th>
<th>Email</th>
<th>Statut</th>
<th>Inscrit le</th>
<th>Parti le</th>
<th class="text-right">Actions</th>
</tr>
</thead>
<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>
<!-- Prénom -->
<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>
<!-- 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>
</tbody>
</table>
<!-- Empty state -->
<div v-if="students.length === 0" class="p-8 text-center text-gray-500">
Aucun élève trouvé
</div>
</div>
</template>
<!-- Add modal placeholder -->
<Modal v-model="showAddModal" title="Ajouter un élève">
<p class="text-gray-500">Formulaire d'ajout d'élève (à implémenter)</p>
<!-- Add/Enroll Student Modal -->
<Modal v-model="showAddModal" title="Ajouter un élève" size="large">
<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>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { ref, computed, onMounted, nextTick } from 'vue'
import { useRoute } from 'vue-router'
import { useClassesStore } from '@/stores/classes'
import { useNotificationsStore } from '@/stores/notifications'
import classesService from '@/services/classes'
import studentsService from '@/services/students'
import LoadingSpinner from '@/components/common/LoadingSpinner.vue'
import Modal from '@/components/common/Modal.vue'
const route = useRoute()
const classesStore = useClassesStore()
const notifications = useNotificationsStore()
const loading = ref(true)
const classData = ref(null)
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 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) {
if (!dateStr) return '-'
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 () => {
try {
const id = route.params.id
classData.value = await classesStore.fetchClass(id)
students.value = await classesService.getStudents(id)
await loadStudents()
await loadAvailableStudents()
} finally {
loading.value = false
}