Compare commits
3 Commits
4e6818a5bc
...
08c8ee4931
| Author | SHA1 | Date | |
|---|---|---|---|
| 08c8ee4931 | |||
| ab86bbb2e1 | |||
| 5b87f24b5b |
26
.env.docker
26
.env.docker
@@ -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
|
|
||||||
@@ -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
|
|
||||||
91
DOCKER.md
91
DOCKER.md
@@ -1,11 +1,100 @@
|
|||||||
# 🐳 Déploiement Docker - Notytex v2
|
# 🐳 Déploiement Docker - Notytex v2
|
||||||
|
|
||||||
Guide complet pour déployer Notytex avec Docker et Docker Compose.
|
> **📍 Documentation Déplacée**
|
||||||
|
> La documentation complète de déploiement Docker se trouve maintenant dans **[`docker/README.md`](docker/README.md)**
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🚀 Démarrage Rapide
|
## 🚀 Démarrage Rapide
|
||||||
|
|
||||||
|
**Nouvelle organisation** : Tous les fichiers Docker sont maintenant dans le dossier `docker/`
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd docker
|
||||||
|
cp .env.example .env
|
||||||
|
# Éditez .env et changez SECRET_KEY
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
**Accès :**
|
||||||
|
- Frontend : http://localhost:8081
|
||||||
|
- API Backend : http://localhost:8080
|
||||||
|
|
||||||
|
**Documentation complète :** Voir [`docker/README.md`](docker/README.md)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📂 Nouvelle Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
notytex/
|
||||||
|
├── docker/ # 📁 Dossier dédié Docker
|
||||||
|
│ ├── compose.yaml # Production (défaut)
|
||||||
|
│ ├── compose.override.yaml # Développement (auto-merge)
|
||||||
|
│ ├── .env.example # Template variables
|
||||||
|
│ └── README.md # Documentation complète
|
||||||
|
├── backend/
|
||||||
|
│ └── Dockerfile
|
||||||
|
├── frontend/
|
||||||
|
│ └── Dockerfile
|
||||||
|
└── data/ # Volume persistant
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🛠️ Modes d'Utilisation
|
||||||
|
|
||||||
|
### Mode Développement
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd docker
|
||||||
|
docker compose up # Build local + hot-reload
|
||||||
|
```
|
||||||
|
|
||||||
|
### Mode Production
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd docker
|
||||||
|
docker compose -f compose.yaml up -d # Images du registre
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📖 Documentation Complète
|
||||||
|
|
||||||
|
Pour plus d'informations sur :
|
||||||
|
- Configuration avancée
|
||||||
|
- Variables d'environnement
|
||||||
|
- Utilisation avec Podman
|
||||||
|
- Déploiement production
|
||||||
|
- Dépannage
|
||||||
|
- CI/CD
|
||||||
|
|
||||||
|
👉 **Consultez [`docker/README.md`](docker/README.md)**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔄 Migration depuis l'Ancienne Organisation
|
||||||
|
|
||||||
|
Si vous utilisez les anciens fichiers `docker-compose.yml` et `docker-compose.prod.yml` :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Arrêter les anciens conteneurs
|
||||||
|
docker-compose down
|
||||||
|
|
||||||
|
# Utiliser la nouvelle organisation
|
||||||
|
cd docker
|
||||||
|
cp .env.example .env
|
||||||
|
# Éditez .env
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
Les anciens fichiers seront supprimés dans une version future.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ⚡ Démarrage Rapide (Ancien Format - Déprécié)
|
||||||
|
|
||||||
### Prérequis
|
### Prérequis
|
||||||
|
|
||||||
- **Docker** : 24.0+ (ou Docker Desktop 4.20+)
|
- **Docker** : 24.0+ (ou Docker Desktop 4.20+)
|
||||||
|
|||||||
@@ -43,11 +43,12 @@ npm run dev
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Configuration
|
# Configuration
|
||||||
cp .env.docker .env
|
cd docker
|
||||||
|
cp .env.example .env
|
||||||
# Éditez .env et changez SECRET_KEY
|
# Éditez .env et changez SECRET_KEY
|
||||||
|
|
||||||
# Démarrage (Docker ou Podman)
|
# Démarrage (Docker ou Podman)
|
||||||
docker-compose up -d
|
docker compose up -d
|
||||||
# ou
|
# ou
|
||||||
podman-compose up -d
|
podman-compose up -d
|
||||||
```
|
```
|
||||||
@@ -59,7 +60,7 @@ podman-compose up -d
|
|||||||
|
|
||||||
> 💡 **Ports sans privilèges** : Les ports 8080/8081 permettent l'utilisation avec Podman sans root
|
> 💡 **Ports sans privilèges** : Les ports 8080/8081 permettent l'utilisation avec Podman sans root
|
||||||
|
|
||||||
📖 **Documentation complète** : [`DOCKER.md`](DOCKER.md)
|
📖 **Documentation complète** : [`docker/README.md`](docker/README.md)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -35,9 +35,13 @@ from schemas.class_group import (
|
|||||||
HistogramBin,
|
HistogramBin,
|
||||||
DomainStats,
|
DomainStats,
|
||||||
CompetenceStats,
|
CompetenceStats,
|
||||||
|
AssessmentScore,
|
||||||
|
DomainStudentStats,
|
||||||
|
CompetenceStudentStats,
|
||||||
)
|
)
|
||||||
from domain.services.grading_calculator import GradingCalculator
|
from domain.services.grading_calculator import GradingCalculator
|
||||||
from schemas.student import StudentWithClass, StudentList
|
from domain.services.class_statistics_service import ClassStatisticsService
|
||||||
|
from schemas.student import StudentWithClass, StudentList, StudentWithEnrollmentInfo, StudentEnrollmentList
|
||||||
from schemas.csv_import import (
|
from schemas.csv_import import (
|
||||||
CSVImportResponse,
|
CSVImportResponse,
|
||||||
ImportedStudentInfo,
|
ImportedStudentInfo,
|
||||||
@@ -141,7 +145,7 @@ async def get_class(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{class_id}/students", response_model=StudentList)
|
@router.get("/{class_id}/students", response_model=StudentEnrollmentList)
|
||||||
async def get_class_students(
|
async def get_class_students(
|
||||||
class_id: int,
|
class_id: int,
|
||||||
session: AsyncSessionDep,
|
session: AsyncSessionDep,
|
||||||
@@ -149,7 +153,7 @@ async def get_class_students(
|
|||||||
at_date: Optional[str] = Query(None, description="Filtrer les élèves inscrits à cette date (YYYY-MM-DD)"),
|
at_date: Optional[str] = Query(None, description="Filtrer les élèves inscrits à cette date (YYYY-MM-DD)"),
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
Récupère la liste des étudiants d'une classe.
|
Récupère la liste des étudiants d'une classe avec leurs informations d'inscription.
|
||||||
|
|
||||||
Si at_date est fourni, retourne uniquement les élèves qui étaient inscrits à cette date.
|
Si at_date est fourni, retourne uniquement les élèves qui étaient inscrits à cette date.
|
||||||
"""
|
"""
|
||||||
@@ -190,22 +194,29 @@ async def get_class_students(
|
|||||||
students = []
|
students = []
|
||||||
for enrollment in enrollments:
|
for enrollment in enrollments:
|
||||||
student = enrollment.student
|
student = enrollment.student
|
||||||
|
is_active = enrollment.departure_date is None
|
||||||
students.append(
|
students.append(
|
||||||
StudentWithClass(
|
StudentWithEnrollmentInfo(
|
||||||
id=student.id,
|
id=student.id,
|
||||||
last_name=student.last_name,
|
last_name=student.last_name,
|
||||||
first_name=student.first_name,
|
first_name=student.first_name,
|
||||||
email=student.email,
|
email=student.email,
|
||||||
full_name=f"{student.first_name} {student.last_name}",
|
full_name=f"{student.first_name} {student.last_name}",
|
||||||
current_class_id=class_id if enrollment.departure_date is None else None,
|
current_class_id=class_id if is_active else None,
|
||||||
current_class_name=cls.name if enrollment.departure_date is None else None
|
current_class_name=cls.name if is_active else None,
|
||||||
|
enrollment_id=enrollment.id,
|
||||||
|
enrollment_date=enrollment.enrollment_date,
|
||||||
|
departure_date=enrollment.departure_date,
|
||||||
|
enrollment_reason=enrollment.enrollment_reason,
|
||||||
|
departure_reason=enrollment.departure_reason,
|
||||||
|
is_active=is_active
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
# Trier par nom de famille puis prénom
|
# Trier par nom de famille puis prénom
|
||||||
students.sort(key=lambda s: (s.last_name.lower(), s.first_name.lower()))
|
students.sort(key=lambda s: (s.last_name.lower(), s.first_name.lower()))
|
||||||
|
|
||||||
return StudentList(
|
return StudentEnrollmentList(
|
||||||
students=students,
|
students=students,
|
||||||
total=len(students)
|
total=len(students)
|
||||||
)
|
)
|
||||||
@@ -221,10 +232,10 @@ async def get_class_stats(
|
|||||||
Récupère les statistiques complètes d'une classe pour un trimestre.
|
Récupère les statistiques complètes d'une classe pour un trimestre.
|
||||||
|
|
||||||
Inclut:
|
Inclut:
|
||||||
- Moyennes par élève
|
- Moyennes par élève avec détail par évaluation
|
||||||
- Statistiques globales (moyenne, médiane, écart-type)
|
- Statistiques globales (moyenne, médiane, écart-type)
|
||||||
- Histogramme des moyennes
|
- Histogramme des moyennes
|
||||||
- Analyse par domaines et compétences
|
- Analyse par domaines et compétences (nombre d'évaluations + points)
|
||||||
"""
|
"""
|
||||||
# Vérifier que la classe existe
|
# Vérifier que la classe existe
|
||||||
class_query = select(ClassGroup).where(ClassGroup.id == class_id)
|
class_query = select(ClassGroup).where(ClassGroup.id == class_id)
|
||||||
@@ -247,26 +258,34 @@ async def get_class_stats(
|
|||||||
students_result = await session.execute(students_query)
|
students_result = await session.execute(students_query)
|
||||||
students = students_result.scalars().all()
|
students = students_result.scalars().all()
|
||||||
|
|
||||||
# Récupérer les évaluations du trimestre
|
# Récupérer les évaluations du trimestre avec leurs relations
|
||||||
assessments_query = select(Assessment).where(
|
assessments_query = (
|
||||||
|
select(Assessment)
|
||||||
|
.options(
|
||||||
|
selectinload(Assessment.exercises).selectinload(Exercise.grading_elements)
|
||||||
|
)
|
||||||
|
.where(
|
||||||
Assessment.class_group_id == class_id,
|
Assessment.class_group_id == class_id,
|
||||||
Assessment.trimester == trimester
|
Assessment.trimester == trimester
|
||||||
)
|
)
|
||||||
|
.order_by(Assessment.date)
|
||||||
|
)
|
||||||
assessments_result = await session.execute(assessments_query)
|
assessments_result = await session.execute(assessments_query)
|
||||||
assessments = assessments_result.scalars().all()
|
assessments = assessments_result.scalars().all()
|
||||||
|
|
||||||
# Calculer les moyennes de chaque élève
|
# Récupérer les domaines et compétences
|
||||||
calculator = GradingCalculator()
|
domains_query = select(Domain).order_by(Domain.name)
|
||||||
student_averages = []
|
domains_result = await session.execute(domains_query)
|
||||||
all_averages = []
|
domains = domains_result.scalars().all()
|
||||||
|
|
||||||
|
competences_query = select(Competence).order_by(Competence.order_index)
|
||||||
|
competences_result = await session.execute(competences_query)
|
||||||
|
competences = competences_result.scalars().all()
|
||||||
|
|
||||||
|
# Récupérer toutes les notes en une seule requête pour optimiser
|
||||||
|
grades_by_student_assessment = {}
|
||||||
for student in students:
|
for student in students:
|
||||||
weighted_sum = 0.0
|
|
||||||
total_coefficient = 0.0
|
|
||||||
assessment_count = 0
|
|
||||||
|
|
||||||
for assessment in assessments:
|
for assessment in assessments:
|
||||||
# Récupérer les notes de l'élève pour cette évaluation
|
|
||||||
grades_query = (
|
grades_query = (
|
||||||
select(Grade, GradingElement)
|
select(Grade, GradingElement)
|
||||||
.join(GradingElement, Grade.grading_element_id == GradingElement.id)
|
.join(GradingElement, Grade.grading_element_id == GradingElement.id)
|
||||||
@@ -277,45 +296,30 @@ async def get_class_stats(
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
grades_result = await session.execute(grades_query)
|
grades_result = await session.execute(grades_query)
|
||||||
grades_data = grades_result.all()
|
grades_by_student_assessment[(student.id, assessment.id)] = grades_result.all()
|
||||||
|
|
||||||
if grades_data:
|
# Utiliser le service pour calculer les statistiques
|
||||||
total_score = 0.0
|
stats_service = ClassStatisticsService()
|
||||||
total_max_points = 0.0
|
student_averages = await stats_service.calculate_student_statistics(
|
||||||
|
students=students,
|
||||||
for grade, element in grades_data:
|
assessments=assessments,
|
||||||
if grade.value:
|
grades_by_student_assessment=grades_by_student_assessment,
|
||||||
score = calculator.calculate_score(
|
domains=domains,
|
||||||
grade.value, element.grading_type, element.max_points
|
competences=competences,
|
||||||
)
|
)
|
||||||
if score is not None and calculator.is_counted_in_total(grade.value):
|
|
||||||
total_score += score
|
|
||||||
total_max_points += element.max_points
|
|
||||||
|
|
||||||
if total_max_points > 0:
|
# Calculer les statistiques domaines/compétences depuis les éléments de notation
|
||||||
# Ramener sur 20
|
# Perspective enseignant : ce qui a été évalué, pas les résultats des élèves
|
||||||
score_on_20 = total_score / total_max_points * 20
|
domains_stats, competences_stats = stats_service.calculate_domain_competence_from_elements(
|
||||||
weighted_sum += score_on_20 * assessment.coefficient
|
assessments=assessments,
|
||||||
total_coefficient += assessment.coefficient
|
domains=domains,
|
||||||
assessment_count += 1
|
competences=competences,
|
||||||
|
)
|
||||||
# Calculer la moyenne pondérée
|
|
||||||
average = None
|
|
||||||
if total_coefficient > 0:
|
|
||||||
average = round(weighted_sum / total_coefficient, 2)
|
|
||||||
all_averages.append(average)
|
|
||||||
|
|
||||||
student_averages.append(StudentAverage(
|
|
||||||
student_id=student.id,
|
|
||||||
first_name=student.first_name,
|
|
||||||
last_name=student.last_name,
|
|
||||||
full_name=f"{student.first_name} {student.last_name}",
|
|
||||||
average=average,
|
|
||||||
assessment_count=assessment_count
|
|
||||||
))
|
|
||||||
|
|
||||||
# Calculer les statistiques globales
|
# Calculer les statistiques globales
|
||||||
|
all_averages = [s.average for s in student_averages if s.average is not None]
|
||||||
mean = median = std_dev = min_score = max_score = None
|
mean = median = std_dev = min_score = max_score = None
|
||||||
|
|
||||||
if all_averages:
|
if all_averages:
|
||||||
mean = round(sum(all_averages) / len(all_averages), 2)
|
mean = round(sum(all_averages) / len(all_averages), 2)
|
||||||
sorted_averages = sorted(all_averages)
|
sorted_averages = sorted(all_averages)
|
||||||
@@ -345,6 +349,7 @@ async def get_class_stats(
|
|||||||
count=count
|
count=count
|
||||||
))
|
))
|
||||||
# Ajouter le dernier bin pour 20
|
# Ajouter le dernier bin pour 20
|
||||||
|
if histogram:
|
||||||
count_20 = sum(1 for avg in all_averages if avg == 20)
|
count_20 = sum(1 for avg in all_averages if avg == 20)
|
||||||
if count_20 > 0:
|
if count_20 > 0:
|
||||||
histogram[-1].count += count_20
|
histogram[-1].count += count_20
|
||||||
@@ -379,36 +384,6 @@ async def get_class_stats(
|
|||||||
elif grades_count > 0:
|
elif grades_count > 0:
|
||||||
assessments_in_progress += 1
|
assessments_in_progress += 1
|
||||||
|
|
||||||
# Statistiques par domaine et compétence (simplifié)
|
|
||||||
domains_stats = []
|
|
||||||
competences_stats = []
|
|
||||||
|
|
||||||
# Récupérer les domaines
|
|
||||||
domains_query = select(Domain).order_by(Domain.name)
|
|
||||||
domains_result = await session.execute(domains_query)
|
|
||||||
domains = domains_result.scalars().all()
|
|
||||||
for domain in domains:
|
|
||||||
domains_stats.append(DomainStats(
|
|
||||||
id=domain.id,
|
|
||||||
name=domain.name,
|
|
||||||
color=domain.color,
|
|
||||||
mean=None,
|
|
||||||
elements_count=0
|
|
||||||
))
|
|
||||||
|
|
||||||
# Récupérer les compétences
|
|
||||||
competences_query = select(Competence).order_by(Competence.order_index)
|
|
||||||
competences_result = await session.execute(competences_query)
|
|
||||||
competences = competences_result.scalars().all()
|
|
||||||
for competence in competences:
|
|
||||||
competences_stats.append(CompetenceStats(
|
|
||||||
id=competence.id,
|
|
||||||
name=competence.name,
|
|
||||||
color=competence.color,
|
|
||||||
mean=None,
|
|
||||||
elements_count=0
|
|
||||||
))
|
|
||||||
|
|
||||||
return ClassDashboardStats(
|
return ClassDashboardStats(
|
||||||
class_id=class_id,
|
class_id=class_id,
|
||||||
class_name=cls.name,
|
class_name=cls.name,
|
||||||
|
|||||||
@@ -281,6 +281,67 @@ async def update_student(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/{student_id}/email", response_model=StudentWithClass)
|
||||||
|
async def update_student_email(
|
||||||
|
student_id: int,
|
||||||
|
email: str,
|
||||||
|
session: AsyncSessionDep,
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Modifie rapidement l'email d'un étudiant.
|
||||||
|
Endpoint optimisé pour l'édition inline.
|
||||||
|
"""
|
||||||
|
# Récupérer l'étudiant
|
||||||
|
query = (
|
||||||
|
select(Student)
|
||||||
|
.options(
|
||||||
|
selectinload(Student.enrollments).selectinload(StudentEnrollment.class_group)
|
||||||
|
)
|
||||||
|
.where(Student.id == student_id)
|
||||||
|
)
|
||||||
|
result = await session.execute(query)
|
||||||
|
student = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not student:
|
||||||
|
raise HTTPException(status_code=404, detail="Étudiant non trouvé")
|
||||||
|
|
||||||
|
# Vérifier l'unicité du nouvel email si fourni
|
||||||
|
if email and email != student.email:
|
||||||
|
existing_query = select(Student).where(
|
||||||
|
Student.email == email,
|
||||||
|
Student.id != student_id
|
||||||
|
)
|
||||||
|
existing_result = await session.execute(existing_query)
|
||||||
|
if existing_result.scalar_one_or_none():
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail=f"Un autre élève avec l'email '{email}' existe déjà"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Mettre à jour l'email (permet de le vider avec chaîne vide)
|
||||||
|
student.email = email if email else None
|
||||||
|
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(student)
|
||||||
|
|
||||||
|
# Trouver la classe actuelle
|
||||||
|
current_enrollment = None
|
||||||
|
for enrollment in student.enrollments:
|
||||||
|
if enrollment.departure_date is None:
|
||||||
|
current_enrollment = enrollment
|
||||||
|
break
|
||||||
|
|
||||||
|
return StudentWithClass(
|
||||||
|
id=student.id,
|
||||||
|
last_name=student.last_name,
|
||||||
|
first_name=student.first_name,
|
||||||
|
email=student.email,
|
||||||
|
full_name=f"{student.first_name} {student.last_name}",
|
||||||
|
current_class_id=current_enrollment.class_group_id if current_enrollment else None,
|
||||||
|
current_class_name=current_enrollment.class_group.name if current_enrollment else None
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{student_id}", status_code=204)
|
@router.delete("/{student_id}", status_code=204)
|
||||||
async def delete_student(
|
async def delete_student(
|
||||||
student_id: int,
|
student_id: int,
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ from .student_report_service import (
|
|||||||
StudentReportData,
|
StudentReportData,
|
||||||
generate_report_html,
|
generate_report_html,
|
||||||
)
|
)
|
||||||
|
from .class_statistics_service import ClassStatisticsService
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
# Grading Calculator
|
# Grading Calculator
|
||||||
@@ -39,6 +40,7 @@ __all__ = [
|
|||||||
"ScoreStrategy",
|
"ScoreStrategy",
|
||||||
# Statistics
|
# Statistics
|
||||||
"StatisticsService",
|
"StatisticsService",
|
||||||
|
"ClassStatisticsService",
|
||||||
# Score Calculator
|
# Score Calculator
|
||||||
"StudentScoreCalculator",
|
"StudentScoreCalculator",
|
||||||
"ProgressCalculator",
|
"ProgressCalculator",
|
||||||
|
|||||||
331
backend/domain/services/class_statistics_service.py
Normal file
331
backend/domain/services/class_statistics_service.py
Normal 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
|
||||||
@@ -3,7 +3,7 @@ Schemas Pydantic pour ClassGroup.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
from datetime import date
|
from datetime import date
|
||||||
from typing import Optional, List
|
from typing import Optional, List, Dict
|
||||||
|
|
||||||
from pydantic import Field
|
from pydantic import Field
|
||||||
|
|
||||||
@@ -117,6 +117,9 @@ class StudentAverage(BaseSchema):
|
|||||||
full_name: str
|
full_name: str
|
||||||
average: Optional[float] = None
|
average: Optional[float] = None
|
||||||
assessment_count: int = 0
|
assessment_count: int = 0
|
||||||
|
assessment_scores: Dict[int, "AssessmentScore"] = {}
|
||||||
|
domain_stats: Dict[int, "DomainStudentStats"] = {}
|
||||||
|
competence_stats: Dict[int, "CompetenceStudentStats"] = {}
|
||||||
|
|
||||||
|
|
||||||
class HistogramBin(BaseSchema):
|
class HistogramBin(BaseSchema):
|
||||||
@@ -128,14 +131,43 @@ class HistogramBin(BaseSchema):
|
|||||||
count: int
|
count: int
|
||||||
|
|
||||||
|
|
||||||
|
class AssessmentScore(BaseSchema):
|
||||||
|
"""Score d'un élève pour une évaluation."""
|
||||||
|
|
||||||
|
assessment_id: int
|
||||||
|
assessment_title: str
|
||||||
|
score: Optional[float] = None
|
||||||
|
max_points: float = 0.0
|
||||||
|
score_on_20: Optional[float] = None
|
||||||
|
|
||||||
|
|
||||||
|
class DomainStudentStats(BaseSchema):
|
||||||
|
"""Statistiques d'un élève pour un domaine."""
|
||||||
|
|
||||||
|
domain_id: int
|
||||||
|
evaluation_count: int = 0
|
||||||
|
total_points_obtained: float = 0.0
|
||||||
|
total_points_possible: float = 0.0
|
||||||
|
|
||||||
|
|
||||||
|
class CompetenceStudentStats(BaseSchema):
|
||||||
|
"""Statistiques d'un élève pour une compétence."""
|
||||||
|
|
||||||
|
competence_id: int
|
||||||
|
evaluation_count: int = 0
|
||||||
|
total_points_obtained: float = 0.0
|
||||||
|
total_points_possible: float = 0.0
|
||||||
|
|
||||||
|
|
||||||
class DomainStats(BaseSchema):
|
class DomainStats(BaseSchema):
|
||||||
"""Statistiques par domaine."""
|
"""Statistiques par domaine."""
|
||||||
|
|
||||||
id: int
|
id: int
|
||||||
name: str
|
name: str
|
||||||
color: str
|
color: str
|
||||||
mean: Optional[float] = None
|
evaluation_count: int = 0
|
||||||
elements_count: int = 0
|
total_points_obtained: float = 0.0
|
||||||
|
total_points_possible: float = 0.0
|
||||||
|
|
||||||
|
|
||||||
class CompetenceStats(BaseSchema):
|
class CompetenceStats(BaseSchema):
|
||||||
@@ -144,8 +176,9 @@ class CompetenceStats(BaseSchema):
|
|||||||
id: int
|
id: int
|
||||||
name: str
|
name: str
|
||||||
color: str
|
color: str
|
||||||
mean: Optional[float] = None
|
evaluation_count: int = 0
|
||||||
elements_count: int = 0
|
total_points_obtained: float = 0.0
|
||||||
|
total_points_possible: float = 0.0
|
||||||
|
|
||||||
|
|
||||||
class ClassDashboardStats(BaseSchema):
|
class ClassDashboardStats(BaseSchema):
|
||||||
|
|||||||
@@ -36,6 +36,19 @@ class StudentWithClass(StudentRead):
|
|||||||
current_class_name: Optional[str] = None
|
current_class_name: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class StudentWithEnrollmentInfo(StudentRead):
|
||||||
|
"""Schema avec les informations d'inscription complètes."""
|
||||||
|
|
||||||
|
current_class_id: Optional[int] = None
|
||||||
|
current_class_name: Optional[str] = None
|
||||||
|
enrollment_id: Optional[int] = None
|
||||||
|
enrollment_date: Optional[date] = None
|
||||||
|
departure_date: Optional[date] = None
|
||||||
|
enrollment_reason: Optional[str] = None
|
||||||
|
departure_reason: Optional[str] = None
|
||||||
|
is_active: bool = True
|
||||||
|
|
||||||
|
|
||||||
class StudentDetail(StudentWithClass):
|
class StudentDetail(StudentWithClass):
|
||||||
"""Schema détaillé avec historique d'inscriptions."""
|
"""Schema détaillé avec historique d'inscriptions."""
|
||||||
|
|
||||||
@@ -49,6 +62,13 @@ class StudentList(BaseSchema):
|
|||||||
total: int
|
total: int
|
||||||
|
|
||||||
|
|
||||||
|
class StudentEnrollmentList(BaseSchema):
|
||||||
|
"""Schema pour la liste des étudiants avec informations d'inscription."""
|
||||||
|
|
||||||
|
students: List[StudentWithEnrollmentInfo]
|
||||||
|
total: int
|
||||||
|
|
||||||
|
|
||||||
# Schemas pour les inscriptions temporelles
|
# Schemas pour les inscriptions temporelles
|
||||||
class EnrollmentBase(BaseSchema):
|
class EnrollmentBase(BaseSchema):
|
||||||
"""Schema de base pour StudentEnrollment."""
|
"""Schema de base pour StudentEnrollment."""
|
||||||
|
|||||||
@@ -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
89
docker/.env.example
Normal 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
6
docker/.gitignore
vendored
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
# Fichier de configuration local (contient des secrets)
|
||||||
|
.env
|
||||||
|
|
||||||
|
# Fichiers temporaires Docker
|
||||||
|
*.log
|
||||||
|
.dockerignore
|
||||||
452
docker/README.md
Normal file
452
docker/README.md
Normal 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**
|
||||||
32
docker/docker-compose.dev.yaml
Normal file
32
docker/docker-compose.dev.yaml
Normal 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
|
||||||
@@ -1,25 +1,22 @@
|
|||||||
version: '3.8'
|
# Docker Compose - Configuration Production
|
||||||
|
# Usage: docker compose up -d
|
||||||
|
# Documentation: ./README.md
|
||||||
|
|
||||||
services:
|
services:
|
||||||
# Backend FastAPI
|
# Backend FastAPI
|
||||||
backend:
|
backend:
|
||||||
build:
|
image: ${REGISTRY_URL:-git.opytex.org}/${REGISTRY_NAMESPACE:-notytex}/notytex-backend:${IMAGE_TAG:-latest}
|
||||||
context: ./backend
|
|
||||||
dockerfile: Dockerfile
|
|
||||||
container_name: notytex-backend
|
container_name: notytex-backend
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
- "8080:8000"
|
- "${BACKEND_PORT:-8080}:8000"
|
||||||
environment:
|
environment:
|
||||||
- DATABASE_URL=sqlite+aiosqlite:////data/school_management.db
|
- DATABASE_URL=sqlite+aiosqlite:////data/school_management.db
|
||||||
- SECRET_KEY=${SECRET_KEY:-change-me-in-production-min-32-chars}
|
- SECRET_KEY=${SECRET_KEY:?SECRET_KEY est obligatoire - voir .env.example}
|
||||||
- CORS_ORIGINS=["http://localhost:8081","http://localhost:8080","http://localhost:3000"]
|
- CORS_ORIGINS=${CORS_ORIGINS:-["http://localhost:8081","http://localhost:8080"]}
|
||||||
- LOG_LEVEL=INFO
|
- LOG_LEVEL=${LOG_LEVEL:-INFO}
|
||||||
volumes:
|
volumes:
|
||||||
- ./data:/data
|
- ./data:/data
|
||||||
- ./backend:/app
|
|
||||||
networks:
|
|
||||||
- notytex-network
|
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "curl", "-f", "http://localhost:8000/api/v2/health"]
|
test: ["CMD", "curl", "-f", "http://localhost:8000/api/v2/health"]
|
||||||
interval: 30s
|
interval: 30s
|
||||||
@@ -29,27 +26,16 @@ services:
|
|||||||
|
|
||||||
# Frontend Vue.js + Nginx
|
# Frontend Vue.js + Nginx
|
||||||
frontend:
|
frontend:
|
||||||
build:
|
image: ${REGISTRY_URL:-git.opytex.org}/${REGISTRY_NAMESPACE:-notytex}/notytex-frontend:${IMAGE_TAG:-latest}
|
||||||
context: ./frontend
|
|
||||||
dockerfile: Dockerfile
|
|
||||||
container_name: notytex-frontend
|
container_name: notytex-frontend
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
- "8081:80"
|
- "${FRONTEND_PORT:-8081}:80"
|
||||||
depends_on:
|
depends_on:
|
||||||
- backend
|
backend:
|
||||||
networks:
|
condition: service_healthy
|
||||||
- notytex-network
|
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "curl", "-f", "http://localhost/"]
|
test: ["CMD", "curl", "-f", "http://localhost/"]
|
||||||
interval: 30s
|
interval: 30s
|
||||||
timeout: 10s
|
timeout: 10s
|
||||||
retries: 3
|
retries: 3
|
||||||
|
|
||||||
networks:
|
|
||||||
notytex-network:
|
|
||||||
driver: bridge
|
|
||||||
|
|
||||||
volumes:
|
|
||||||
data:
|
|
||||||
driver: local
|
|
||||||
601
docs/CLASS_DASHBOARD_IMPROVEMENTS.md
Normal file
601
docs/CLASS_DASHBOARD_IMPROVEMENTS.md
Normal 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
|
||||||
386
docs/DOMAIN_COMPETENCE_PERSPECTIVE.md
Normal file
386
docs/DOMAIN_COMPETENCE_PERSPECTIVE.md
Normal 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
|
||||||
@@ -23,8 +23,12 @@ export const classesService = {
|
|||||||
|
|
||||||
// Get class students
|
// Get class students
|
||||||
// atDate: optional date string (YYYY-MM-DD) to filter students enrolled at that date
|
// atDate: optional date string (YYYY-MM-DD) to filter students enrolled at that date
|
||||||
async getStudents(id, atDate = null) {
|
// includeDeparted: include students who have left the class
|
||||||
const params = atDate ? { at_date: atDate } : {}
|
async getStudents(id, atDate = null, includeDeparted = false) {
|
||||||
|
const params = {}
|
||||||
|
if (atDate) params.at_date = atDate
|
||||||
|
if (includeDeparted) params.include_departed = true
|
||||||
|
|
||||||
const response = await api.get(`/classes/${id}/students`, { params })
|
const response = await api.get(`/classes/${id}/students`, { params })
|
||||||
// API returns { students: [...], total: N }
|
// API returns { students: [...], total: N }
|
||||||
return response.data.students || []
|
return response.data.students || []
|
||||||
|
|||||||
@@ -27,6 +27,14 @@ export const studentsService = {
|
|||||||
return response.data
|
return response.data
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Update student email only (fast endpoint)
|
||||||
|
async updateEmail(id, email) {
|
||||||
|
const response = await api.patch(`/students/${id}/email`, null, {
|
||||||
|
params: { email }
|
||||||
|
})
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
// Delete student
|
// Delete student
|
||||||
async delete(id) {
|
async delete(id) {
|
||||||
const response = await api.delete(`/students/${id}`)
|
const response = await api.delete(`/students/${id}`)
|
||||||
|
|||||||
@@ -79,59 +79,65 @@
|
|||||||
<div v-if="stats" class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-8">
|
<div v-if="stats" class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-8">
|
||||||
<!-- Domaines -->
|
<!-- Domaines -->
|
||||||
<div v-if="stats.domains_stats?.length" class="bg-white rounded-xl shadow-md p-6">
|
<div v-if="stats.domains_stats?.length" class="bg-white rounded-xl shadow-md p-6">
|
||||||
<h2 class="text-lg font-semibold text-gray-800 mb-4">Performance par domaine</h2>
|
<h2 class="text-lg font-semibold text-gray-800 mb-4">Évaluations par domaine</h2>
|
||||||
|
<p class="text-xs text-gray-500 mb-4">Perspective enseignant : ce qui a été évalué</p>
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<div v-for="domain in stats.domains_stats" :key="domain.id" class="space-y-1">
|
<div v-for="domain in stats.domains_stats" :key="domain.id" class="space-y-1">
|
||||||
<div class="flex justify-between text-sm">
|
<div class="flex justify-between text-sm">
|
||||||
<span class="font-medium text-gray-700 truncate" :title="domain.name">{{ domain.name }}</span>
|
<span class="font-medium text-gray-700 truncate" :title="domain.name">{{ domain.name }}</span>
|
||||||
<span class="font-bold" :style="{ color: domain.color || '#6B7280' }">
|
<span class="font-bold" :style="{ color: domain.color || '#6B7280' }">
|
||||||
{{ domain.mean?.toFixed(1) || '-' }}/20
|
{{ domain.total_points_possible?.toFixed(1) || '0' }} points
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="w-full bg-gray-200 rounded-full h-2">
|
<div class="w-full bg-gray-200 rounded-full h-2">
|
||||||
<div
|
<div
|
||||||
class="h-2 rounded-full transition-all duration-1000 ease-out"
|
class="h-2 rounded-full transition-all duration-1000 ease-out"
|
||||||
:style="{
|
:style="{
|
||||||
width: `${domain.mean ? (domain.mean / 20) * 100 : 0}%`,
|
width: `${getRelativeWidth(domain, stats.domains_stats)}%`,
|
||||||
backgroundColor: domain.color || '#6B7280'
|
backgroundColor: domain.color || '#6B7280'
|
||||||
}"
|
}"
|
||||||
></div>
|
></div>
|
||||||
</div>
|
</div>
|
||||||
<p class="text-xs text-gray-400">{{ domain.elements_count }} éléments évalués</p>
|
<p class="text-xs text-gray-400">
|
||||||
|
{{ domain.evaluation_count }} élément(s) de notation
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="bg-white rounded-xl shadow-md p-6">
|
<div v-else class="bg-white rounded-xl shadow-md p-6">
|
||||||
<h2 class="text-lg font-semibold text-gray-800 mb-4">Performance par domaine</h2>
|
<h2 class="text-lg font-semibold text-gray-800 mb-4">Évaluations par domaine</h2>
|
||||||
<p class="text-sm text-gray-500 italic">Aucune donnée de domaine disponible</p>
|
<p class="text-sm text-gray-500 italic">Aucune donnée de domaine disponible</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Compétences -->
|
<!-- Compétences -->
|
||||||
<div v-if="stats.competences_stats?.length" class="bg-white rounded-xl shadow-md p-6">
|
<div v-if="stats.competences_stats?.length" class="bg-white rounded-xl shadow-md p-6">
|
||||||
<h2 class="text-lg font-semibold text-gray-800 mb-4">Performance par compétence</h2>
|
<h2 class="text-lg font-semibold text-gray-800 mb-4">Évaluations par compétence</h2>
|
||||||
|
<p class="text-xs text-gray-500 mb-4">Perspective enseignant : ce qui a été évalué</p>
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<div v-for="competence in stats.competences_stats" :key="competence.id" class="space-y-1">
|
<div v-for="competence in stats.competences_stats" :key="competence.id" class="space-y-1">
|
||||||
<div class="flex justify-between text-sm">
|
<div class="flex justify-between text-sm">
|
||||||
<span class="font-medium text-gray-700 truncate" :title="competence.name">{{ competence.name }}</span>
|
<span class="font-medium text-gray-700 truncate" :title="competence.name">{{ competence.name }}</span>
|
||||||
<span class="font-bold" :style="{ color: competence.color || '#6B7280' }">
|
<span class="font-bold" :style="{ color: competence.color || '#6B7280' }">
|
||||||
{{ competence.mean?.toFixed(1) || '-' }}/3
|
{{ competence.total_points_possible?.toFixed(1) || '0' }} points
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="w-full bg-gray-200 rounded-full h-2">
|
<div class="w-full bg-gray-200 rounded-full h-2">
|
||||||
<div
|
<div
|
||||||
class="h-2 rounded-full transition-all duration-1000 ease-out"
|
class="h-2 rounded-full transition-all duration-1000 ease-out"
|
||||||
:style="{
|
:style="{
|
||||||
width: `${competence.mean ? (competence.mean / 3) * 100 : 0}%`,
|
width: `${getRelativeWidth(competence, stats.competences_stats)}%`,
|
||||||
backgroundColor: competence.color || '#6B7280'
|
backgroundColor: competence.color || '#6B7280'
|
||||||
}"
|
}"
|
||||||
></div>
|
></div>
|
||||||
</div>
|
</div>
|
||||||
<p class="text-xs text-gray-400">{{ competence.elements_count }} éléments évalués</p>
|
<p class="text-xs text-gray-400">
|
||||||
|
{{ competence.evaluation_count }} élément(s) de notation
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="bg-white rounded-xl shadow-md p-6">
|
<div v-else class="bg-white rounded-xl shadow-md p-6">
|
||||||
<h2 class="text-lg font-semibold text-gray-800 mb-4">Performance par compétence</h2>
|
<h2 class="text-lg font-semibold text-gray-800 mb-4">Évaluations par compétence</h2>
|
||||||
<p class="text-sm text-gray-500 italic">Aucune donnée de compétence disponible</p>
|
<p class="text-sm text-gray-500 italic">Aucune donnée de compétence disponible</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -139,32 +145,53 @@
|
|||||||
<!-- Students averages -->
|
<!-- Students averages -->
|
||||||
<div v-if="stats?.student_averages?.length" class="bg-white rounded-xl shadow-md overflow-hidden">
|
<div v-if="stats?.student_averages?.length" class="bg-white rounded-xl shadow-md overflow-hidden">
|
||||||
<div class="px-6 py-4 border-b border-gray-200">
|
<div class="px-6 py-4 border-b border-gray-200">
|
||||||
<h2 class="text-lg font-semibold text-gray-800">Moyennes par élève</h2>
|
<h2 class="text-lg font-semibold text-gray-800">Notes par élève</h2>
|
||||||
|
<p class="text-xs text-gray-500 mt-1">Cliquez sur les en-têtes pour trier</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="overflow-x-auto">
|
<div class="overflow-x-auto">
|
||||||
<table class="w-full">
|
<table class="w-full">
|
||||||
<thead class="bg-gray-50">
|
<thead class="bg-gray-50">
|
||||||
<tr>
|
<tr>
|
||||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Élève</th>
|
<th
|
||||||
<th class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Moyenne</th>
|
@click="sortBy('name')"
|
||||||
<th class="px-6 py-3 text-center text-xs font-medium text-gray-500 uppercase tracking-wider">Performance</th>
|
class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider cursor-pointer hover:bg-gray-100"
|
||||||
|
>
|
||||||
|
Élève {{ getSortIcon('name') }}
|
||||||
|
</th>
|
||||||
|
<th
|
||||||
|
@click="sortBy('average')"
|
||||||
|
class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider cursor-pointer hover:bg-gray-100"
|
||||||
|
>
|
||||||
|
Moyenne {{ getSortIcon('average') }}
|
||||||
|
</th>
|
||||||
|
<th
|
||||||
|
v-for="assessment in assessments"
|
||||||
|
:key="assessment.assessment_id"
|
||||||
|
@click="sortBy(`assessment_${assessment.assessment_id}`)"
|
||||||
|
class="px-4 py-3 text-center text-xs font-medium text-gray-500 uppercase tracking-wider cursor-pointer hover:bg-gray-100"
|
||||||
|
:title="assessment.assessment_title"
|
||||||
|
>
|
||||||
|
<div class="flex flex-col items-center">
|
||||||
|
<span class="truncate max-w-[120px]">{{ assessment.assessment_title }}</span>
|
||||||
|
<span class="text-[10px] font-normal">{{ getSortIcon(`assessment_${assessment.assessment_id}`) }}</span>
|
||||||
|
</div>
|
||||||
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody class="divide-y divide-gray-200">
|
<tbody class="divide-y divide-gray-200">
|
||||||
<tr v-for="student in stats.student_averages" :key="student.student_id" class="hover:bg-gray-50">
|
<tr v-for="student in sortedStudents" :key="student.student_id" class="hover:bg-gray-50">
|
||||||
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
|
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
|
||||||
{{ student.last_name }} {{ student.first_name }}
|
{{ student.last_name }} {{ student.first_name }}
|
||||||
</td>
|
</td>
|
||||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-right font-bold">
|
<td class="px-6 py-4 whitespace-nowrap text-sm text-right font-bold text-blue-600">
|
||||||
{{ student.average?.toFixed(2) || '-' }}
|
{{ student.average?.toFixed(2) || '-' }}
|
||||||
</td>
|
</td>
|
||||||
<td class="px-6 py-4 whitespace-nowrap text-center">
|
<td
|
||||||
<span
|
v-for="assessment in assessments"
|
||||||
class="px-2 py-1 text-xs font-medium rounded-full"
|
:key="assessment.assessment_id"
|
||||||
:class="getPerformanceClass(student.average)"
|
class="px-4 py-4 whitespace-nowrap text-sm text-center text-gray-700"
|
||||||
>
|
>
|
||||||
{{ getPerformanceLabel(student.average) }}
|
{{ getAssessmentScore(student, assessment.assessment_id) }}
|
||||||
</span>
|
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
@@ -172,15 +199,15 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-else-if="stats" class="bg-white rounded-xl shadow-md p-6">
|
<div v-else-if="stats" class="bg-white rounded-xl shadow-md p-6">
|
||||||
<h2 class="text-lg font-semibold text-gray-800 mb-4">Moyennes par élève</h2>
|
<h2 class="text-lg font-semibold text-gray-800 mb-4">Notes par élève</h2>
|
||||||
<p class="text-sm text-gray-500 italic text-center py-4">Aucune moyenne disponible pour ce trimestre</p>
|
<p class="text-sm text-gray-500 italic text-center py-4">Aucune note disponible pour ce trimestre</p>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, onMounted } from 'vue'
|
import { ref, computed, onMounted } from 'vue'
|
||||||
import { useRoute } from 'vue-router'
|
import { useRoute } from 'vue-router'
|
||||||
import { useClassesStore } from '@/stores/classes'
|
import { useClassesStore } from '@/stores/classes'
|
||||||
import LoadingSpinner from '@/components/common/LoadingSpinner.vue'
|
import LoadingSpinner from '@/components/common/LoadingSpinner.vue'
|
||||||
@@ -192,6 +219,8 @@ const loading = ref(true)
|
|||||||
const classData = ref(null)
|
const classData = ref(null)
|
||||||
const stats = ref(null)
|
const stats = ref(null)
|
||||||
const trimester = ref(1)
|
const trimester = ref(1)
|
||||||
|
const sortColumn = ref('name')
|
||||||
|
const sortDirection = ref('asc')
|
||||||
|
|
||||||
async function fetchData() {
|
async function fetchData() {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
@@ -209,21 +238,71 @@ async function selectTrimester(t) {
|
|||||||
stats.value = await classesStore.fetchClassStats(route.params.id, t)
|
stats.value = await classesStore.fetchClassStats(route.params.id, t)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fonctions pour les labels de performance
|
// Récupérer la liste des évaluations triée par date
|
||||||
function getPerformanceClass(average) {
|
const assessments = computed(() => {
|
||||||
if (average === null || average === undefined) return 'bg-gray-100 text-gray-600'
|
if (!stats.value?.student_averages?.length) return []
|
||||||
if (average >= 16) return 'bg-green-100 text-green-800'
|
|
||||||
if (average >= 12) return 'bg-blue-100 text-blue-800'
|
// Extraire les évaluations depuis le premier élève
|
||||||
if (average >= 8) return 'bg-orange-100 text-orange-800'
|
const firstStudent = stats.value.student_averages[0]
|
||||||
return 'bg-red-100 text-red-800'
|
if (!firstStudent?.assessment_scores) return []
|
||||||
|
|
||||||
|
return Object.values(firstStudent.assessment_scores).sort((a, b) => a.assessment_id - b.assessment_id)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Fonction de tri des élèves
|
||||||
|
const sortedStudents = computed(() => {
|
||||||
|
if (!stats.value?.student_averages) return []
|
||||||
|
|
||||||
|
const students = [...stats.value.student_averages]
|
||||||
|
|
||||||
|
students.sort((a, b) => {
|
||||||
|
let valA, valB
|
||||||
|
|
||||||
|
if (sortColumn.value === 'name') {
|
||||||
|
valA = `${a.last_name} ${a.first_name}`.toLowerCase()
|
||||||
|
valB = `${b.last_name} ${b.first_name}`.toLowerCase()
|
||||||
|
} else if (sortColumn.value === 'average') {
|
||||||
|
valA = a.average ?? -1
|
||||||
|
valB = b.average ?? -1
|
||||||
|
} else if (sortColumn.value.startsWith('assessment_')) {
|
||||||
|
const assessmentId = parseInt(sortColumn.value.split('_')[1])
|
||||||
|
valA = a.assessment_scores?.[assessmentId]?.score ?? -1
|
||||||
|
valB = b.assessment_scores?.[assessmentId]?.score ?? -1
|
||||||
|
} else {
|
||||||
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
function getPerformanceLabel(average) {
|
const comparison = valA > valB ? 1 : valA < valB ? -1 : 0
|
||||||
if (average === null || average === undefined) return '-'
|
return sortDirection.value === 'asc' ? comparison : -comparison
|
||||||
if (average >= 16) return 'Excellent'
|
})
|
||||||
if (average >= 12) return 'Bon'
|
|
||||||
if (average >= 8) return 'Moyen'
|
return students
|
||||||
return 'Insuffisant'
|
})
|
||||||
|
|
||||||
|
function sortBy(column) {
|
||||||
|
if (sortColumn.value === column) {
|
||||||
|
sortDirection.value = sortDirection.value === 'asc' ? 'desc' : 'asc'
|
||||||
|
} else {
|
||||||
|
sortColumn.value = column
|
||||||
|
sortDirection.value = 'asc'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAssessmentScore(student, assessmentId) {
|
||||||
|
const score = student.assessment_scores?.[assessmentId]
|
||||||
|
if (!score || score.score === null) return '-'
|
||||||
|
return `${score.score.toFixed(1)}/${score.max_points.toFixed(0)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSortIcon(column) {
|
||||||
|
if (sortColumn.value !== column) return ''
|
||||||
|
return sortDirection.value === 'asc' ? '▲' : '▼'
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRelativeWidth(item, allItems) {
|
||||||
|
const maxPoints = Math.max(...allItems.map(d => d.total_points_possible || 0))
|
||||||
|
if (maxPoints === 0) return 0
|
||||||
|
return ((item.total_points_possible || 0) / maxPoints) * 100
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(fetchData)
|
onMounted(fetchData)
|
||||||
|
|||||||
@@ -3,16 +3,29 @@
|
|||||||
<LoadingSpinner v-if="loading" text="Chargement..." fullPage />
|
<LoadingSpinner v-if="loading" text="Chargement..." fullPage />
|
||||||
|
|
||||||
<template v-else>
|
<template v-else>
|
||||||
|
<!-- Header -->
|
||||||
<div class="flex justify-between items-center mb-6">
|
<div class="flex justify-between items-center mb-6">
|
||||||
<div>
|
<div>
|
||||||
<h1 class="text-2xl font-bold">{{ classData?.name }}</h1>
|
<h1 class="text-2xl font-bold">{{ classData?.name }}</h1>
|
||||||
<p class="text-gray-500">{{ students.length }} élève(s)</p>
|
<p class="text-gray-500">{{ activeStudentsCount }} élève(s) actif(s)</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="flex gap-3">
|
||||||
|
<label class="flex items-center gap-2 px-4 py-2 bg-gray-100 rounded-lg cursor-pointer hover:bg-gray-200 transition-colors">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
v-model="includeDeparted"
|
||||||
|
@change="loadStudents"
|
||||||
|
class="rounded border-gray-300 text-indigo-600 focus:ring-indigo-500"
|
||||||
|
/>
|
||||||
|
<span class="text-sm font-medium text-gray-700">Inclure les élèves partis</span>
|
||||||
|
</label>
|
||||||
<button @click="showAddModal = true" class="btn btn-primary">
|
<button @click="showAddModal = true" class="btn btn-primary">
|
||||||
+ Ajouter un élève
|
+ Ajouter un élève
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Students table -->
|
||||||
<div class="card overflow-hidden">
|
<div class="card overflow-hidden">
|
||||||
<table class="table">
|
<table class="table">
|
||||||
<thead>
|
<thead>
|
||||||
@@ -20,54 +33,577 @@
|
|||||||
<th>Nom</th>
|
<th>Nom</th>
|
||||||
<th>Prénom</th>
|
<th>Prénom</th>
|
||||||
<th>Email</th>
|
<th>Email</th>
|
||||||
|
<th>Statut</th>
|
||||||
<th>Inscrit le</th>
|
<th>Inscrit le</th>
|
||||||
|
<th>Parti le</th>
|
||||||
|
<th class="text-right">Actions</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody class="divide-y divide-gray-200">
|
<tbody class="divide-y divide-gray-200">
|
||||||
<tr v-for="student in students" :key="student.id">
|
<tr v-for="student in students" :key="student.id" :class="{ 'bg-gray-50': !student.is_active }">
|
||||||
|
<!-- Nom -->
|
||||||
<td class="font-medium">{{ student.last_name }}</td>
|
<td class="font-medium">{{ student.last_name }}</td>
|
||||||
|
|
||||||
|
<!-- Prénom -->
|
||||||
<td>{{ student.first_name }}</td>
|
<td>{{ student.first_name }}</td>
|
||||||
<td class="text-gray-500">{{ student.email || '-' }}</td>
|
|
||||||
|
<!-- Email with inline editing -->
|
||||||
|
<td>
|
||||||
|
<div class="flex items-center gap-2 group">
|
||||||
|
<template v-if="editingEmailId === student.id">
|
||||||
|
<input
|
||||||
|
ref="emailInput"
|
||||||
|
v-model="editingEmailValue"
|
||||||
|
@blur="saveEmail(student)"
|
||||||
|
@keyup.enter="saveEmail(student)"
|
||||||
|
@keyup.escape="cancelEditEmail"
|
||||||
|
type="email"
|
||||||
|
class="flex-1 px-2 py-1 border border-indigo-300 rounded focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
||||||
|
placeholder="email@exemple.com"
|
||||||
|
/>
|
||||||
|
<button @click="cancelEditEmail" class="text-gray-400 hover:text-gray-600">
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<span class="flex-1 text-gray-600">{{ student.email || '-' }}</span>
|
||||||
|
<button
|
||||||
|
@click="startEditEmail(student)"
|
||||||
|
class="opacity-0 group-hover:opacity-100 text-gray-400 hover:text-indigo-600 transition-opacity"
|
||||||
|
title="Modifier l'email"
|
||||||
|
>
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<!-- Feedback icons -->
|
||||||
|
<span v-if="emailFeedback[student.id] === 'success'" class="text-green-500">
|
||||||
|
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
<span v-if="emailFeedback[student.id] === 'error'" class="text-red-500">
|
||||||
|
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<!-- Statut -->
|
||||||
|
<td>
|
||||||
|
<span
|
||||||
|
v-if="student.is_active"
|
||||||
|
class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800"
|
||||||
|
>
|
||||||
|
Actif
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
v-else
|
||||||
|
class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800"
|
||||||
|
>
|
||||||
|
Parti
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<!-- Inscrit le -->
|
||||||
<td class="text-gray-500">{{ formatDate(student.enrollment_date) }}</td>
|
<td class="text-gray-500">{{ formatDate(student.enrollment_date) }}</td>
|
||||||
|
|
||||||
|
<!-- Parti le -->
|
||||||
|
<td class="text-gray-500">{{ formatDate(student.departure_date) }}</td>
|
||||||
|
|
||||||
|
<!-- Actions -->
|
||||||
|
<td class="text-right">
|
||||||
|
<button
|
||||||
|
v-if="student.is_active"
|
||||||
|
@click="openDepartureModal(student)"
|
||||||
|
class="text-sm text-red-600 hover:text-red-800 font-medium"
|
||||||
|
>
|
||||||
|
Sortir de la classe
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-else
|
||||||
|
@click="openReenrollModal(student)"
|
||||||
|
class="text-sm text-indigo-600 hover:text-indigo-800 font-medium"
|
||||||
|
>
|
||||||
|
Réinscrire
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
|
<!-- Empty state -->
|
||||||
|
<div v-if="students.length === 0" class="p-8 text-center text-gray-500">
|
||||||
|
Aucun élève trouvé
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<!-- Add modal placeholder -->
|
<!-- Add/Enroll Student Modal -->
|
||||||
<Modal v-model="showAddModal" title="Ajouter un élève">
|
<Modal v-model="showAddModal" title="Ajouter un élève" size="large">
|
||||||
<p class="text-gray-500">Formulaire d'ajout d'élève (à implémenter)</p>
|
<div class="space-y-4">
|
||||||
|
<!-- Tabs -->
|
||||||
|
<div class="border-b border-gray-200">
|
||||||
|
<nav class="-mb-px flex space-x-8">
|
||||||
|
<button
|
||||||
|
@click="addMode = 'new'"
|
||||||
|
:class="[
|
||||||
|
'py-4 px-1 border-b-2 font-medium text-sm',
|
||||||
|
addMode === 'new'
|
||||||
|
? 'border-indigo-500 text-indigo-600'
|
||||||
|
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
Nouvel élève
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
@click="addMode = 'existing'"
|
||||||
|
:class="[
|
||||||
|
'py-4 px-1 border-b-2 font-medium text-sm',
|
||||||
|
addMode === 'existing'
|
||||||
|
? 'border-indigo-500 text-indigo-600'
|
||||||
|
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
Élève existant
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- New Student Form -->
|
||||||
|
<div v-if="addMode === 'new'" class="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">Nom *</label>
|
||||||
|
<input
|
||||||
|
v-model="newStudent.last_name"
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">Prénom *</label>
|
||||||
|
<input
|
||||||
|
v-model="newStudent.first_name"
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">Email</label>
|
||||||
|
<input
|
||||||
|
v-model="newStudent.email"
|
||||||
|
type="email"
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">Date d'inscription *</label>
|
||||||
|
<input
|
||||||
|
v-model="newStudent.enrollment_date"
|
||||||
|
type="date"
|
||||||
|
required
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">Raison</label>
|
||||||
|
<input
|
||||||
|
v-model="newStudent.enrollment_reason"
|
||||||
|
type="text"
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
||||||
|
placeholder="Ex: Nouvelle inscription"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Existing Student Form -->
|
||||||
|
<div v-if="addMode === 'existing'" class="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">Élève *</label>
|
||||||
|
<select
|
||||||
|
v-model="existingStudent.student_id"
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
||||||
|
>
|
||||||
|
<option :value="null">Sélectionner un élève...</option>
|
||||||
|
<option v-for="student in availableStudents" :key="student.id" :value="student.id">
|
||||||
|
{{ student.last_name }} {{ student.first_name }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">Date d'inscription *</label>
|
||||||
|
<input
|
||||||
|
v-model="existingStudent.enrollment_date"
|
||||||
|
type="date"
|
||||||
|
required
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">Raison</label>
|
||||||
|
<input
|
||||||
|
v-model="existingStudent.enrollment_reason"
|
||||||
|
type="text"
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
||||||
|
placeholder="Ex: Transfert depuis..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Actions -->
|
||||||
|
<div class="flex justify-end gap-3 pt-4">
|
||||||
|
<button
|
||||||
|
@click="showAddModal = false"
|
||||||
|
class="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50"
|
||||||
|
>
|
||||||
|
Annuler
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
@click="enrollStudent"
|
||||||
|
:disabled="!canEnroll"
|
||||||
|
class="px-4 py-2 text-sm font-medium text-white bg-indigo-600 rounded-md hover:bg-indigo-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
Inscrire
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<!-- Departure Modal -->
|
||||||
|
<Modal v-model="showDepartureModal" title="Sortir un élève de la classe">
|
||||||
|
<div class="space-y-4">
|
||||||
|
<p class="text-sm text-gray-600">
|
||||||
|
Vous êtes sur le point de marquer <strong>{{ departureStudent?.first_name }} {{ departureStudent?.last_name }}</strong> comme parti(e) de la classe.
|
||||||
|
</p>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">Date de départ *</label>
|
||||||
|
<input
|
||||||
|
v-model="departureDate"
|
||||||
|
type="date"
|
||||||
|
required
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">Raison</label>
|
||||||
|
<input
|
||||||
|
v-model="departureReason"
|
||||||
|
type="text"
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
||||||
|
placeholder="Ex: Déménagement, transfert..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex justify-end gap-3 pt-4">
|
||||||
|
<button
|
||||||
|
@click="showDepartureModal = false"
|
||||||
|
class="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50"
|
||||||
|
>
|
||||||
|
Annuler
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
@click="confirmDeparture"
|
||||||
|
:disabled="!departureDate"
|
||||||
|
class="px-4 py-2 text-sm font-medium text-white bg-red-600 rounded-md hover:bg-red-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
Confirmer le départ
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<!-- Re-enroll Modal -->
|
||||||
|
<Modal v-model="showReenrollModal" title="Réinscrire un élève">
|
||||||
|
<div class="space-y-4">
|
||||||
|
<p class="text-sm text-gray-600">
|
||||||
|
Réinscrire <strong>{{ reenrollStudent?.first_name }} {{ reenrollStudent?.last_name }}</strong> dans la classe.
|
||||||
|
</p>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">Date de réinscription *</label>
|
||||||
|
<input
|
||||||
|
v-model="reenrollDate"
|
||||||
|
type="date"
|
||||||
|
required
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">Raison</label>
|
||||||
|
<input
|
||||||
|
v-model="reenrollReason"
|
||||||
|
type="text"
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
||||||
|
placeholder="Ex: Retour après absence..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex justify-end gap-3 pt-4">
|
||||||
|
<button
|
||||||
|
@click="showReenrollModal = false"
|
||||||
|
class="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50"
|
||||||
|
>
|
||||||
|
Annuler
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
@click="confirmReenroll"
|
||||||
|
:disabled="!reenrollDate"
|
||||||
|
class="px-4 py-2 text-sm font-medium text-white bg-indigo-600 rounded-md hover:bg-indigo-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
Réinscrire
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, onMounted } from 'vue'
|
import { ref, computed, onMounted, nextTick } from 'vue'
|
||||||
import { useRoute } from 'vue-router'
|
import { useRoute } from 'vue-router'
|
||||||
import { useClassesStore } from '@/stores/classes'
|
import { useClassesStore } from '@/stores/classes'
|
||||||
|
import { useNotificationsStore } from '@/stores/notifications'
|
||||||
import classesService from '@/services/classes'
|
import classesService from '@/services/classes'
|
||||||
|
import studentsService from '@/services/students'
|
||||||
import LoadingSpinner from '@/components/common/LoadingSpinner.vue'
|
import LoadingSpinner from '@/components/common/LoadingSpinner.vue'
|
||||||
import Modal from '@/components/common/Modal.vue'
|
import Modal from '@/components/common/Modal.vue'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const classesStore = useClassesStore()
|
const classesStore = useClassesStore()
|
||||||
|
const notifications = useNotificationsStore()
|
||||||
|
|
||||||
const loading = ref(true)
|
const loading = ref(true)
|
||||||
const classData = ref(null)
|
const classData = ref(null)
|
||||||
const students = ref([])
|
const students = ref([])
|
||||||
|
const includeDeparted = ref(false)
|
||||||
|
|
||||||
|
// Email editing
|
||||||
|
const editingEmailId = ref(null)
|
||||||
|
const editingEmailValue = ref('')
|
||||||
|
const emailFeedback = ref({})
|
||||||
|
const emailInput = ref(null)
|
||||||
|
|
||||||
|
// Add/Enroll student
|
||||||
const showAddModal = ref(false)
|
const showAddModal = ref(false)
|
||||||
|
const addMode = ref('new')
|
||||||
|
const availableStudents = ref([])
|
||||||
|
const newStudent = ref({
|
||||||
|
last_name: '',
|
||||||
|
first_name: '',
|
||||||
|
email: '',
|
||||||
|
enrollment_date: new Date().toISOString().split('T')[0],
|
||||||
|
enrollment_reason: ''
|
||||||
|
})
|
||||||
|
const existingStudent = ref({
|
||||||
|
student_id: null,
|
||||||
|
enrollment_date: new Date().toISOString().split('T')[0],
|
||||||
|
enrollment_reason: ''
|
||||||
|
})
|
||||||
|
|
||||||
|
// Departure
|
||||||
|
const showDepartureModal = ref(false)
|
||||||
|
const departureStudent = ref(null)
|
||||||
|
const departureDate = ref(new Date().toISOString().split('T')[0])
|
||||||
|
const departureReason = ref('')
|
||||||
|
|
||||||
|
// Re-enroll
|
||||||
|
const showReenrollModal = ref(false)
|
||||||
|
const reenrollStudent = ref(null)
|
||||||
|
const reenrollDate = ref(new Date().toISOString().split('T')[0])
|
||||||
|
const reenrollReason = ref('')
|
||||||
|
|
||||||
|
const activeStudentsCount = computed(() => {
|
||||||
|
return students.value.filter(s => s.is_active).length
|
||||||
|
})
|
||||||
|
|
||||||
|
const canEnroll = computed(() => {
|
||||||
|
if (addMode.value === 'new') {
|
||||||
|
return newStudent.value.last_name && newStudent.value.first_name && newStudent.value.enrollment_date
|
||||||
|
} else {
|
||||||
|
return existingStudent.value.student_id && existingStudent.value.enrollment_date
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
function formatDate(dateStr) {
|
function formatDate(dateStr) {
|
||||||
if (!dateStr) return '-'
|
if (!dateStr) return '-'
|
||||||
return new Date(dateStr).toLocaleDateString('fr-FR')
|
return new Date(dateStr).toLocaleDateString('fr-FR')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function loadStudents() {
|
||||||
|
try {
|
||||||
|
const id = route.params.id
|
||||||
|
students.value = await classesService.getStudents(id, null, includeDeparted.value)
|
||||||
|
} catch (error) {
|
||||||
|
notifications.error('Erreur lors du chargement des élèves')
|
||||||
|
console.error(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadAvailableStudents() {
|
||||||
|
try {
|
||||||
|
const allStudents = await studentsService.getAll()
|
||||||
|
// Filter students without active enrollment
|
||||||
|
availableStudents.value = allStudents.filter(s => !s.current_class_id)
|
||||||
|
} catch (error) {
|
||||||
|
notifications.error('Erreur lors du chargement des élèves disponibles')
|
||||||
|
console.error(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Email editing functions
|
||||||
|
function startEditEmail(student) {
|
||||||
|
editingEmailId.value = student.id
|
||||||
|
editingEmailValue.value = student.email || ''
|
||||||
|
nextTick(() => {
|
||||||
|
emailInput.value?.[0]?.focus()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelEditEmail() {
|
||||||
|
editingEmailId.value = null
|
||||||
|
editingEmailValue.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveEmail(student) {
|
||||||
|
if (editingEmailValue.value === student.email) {
|
||||||
|
cancelEditEmail()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await studentsService.updateEmail(student.id, editingEmailValue.value)
|
||||||
|
student.email = editingEmailValue.value
|
||||||
|
|
||||||
|
// Show success feedback
|
||||||
|
emailFeedback.value[student.id] = 'success'
|
||||||
|
setTimeout(() => {
|
||||||
|
emailFeedback.value[student.id] = null
|
||||||
|
}, 2000)
|
||||||
|
|
||||||
|
cancelEditEmail()
|
||||||
|
} catch (error) {
|
||||||
|
// Show error feedback
|
||||||
|
emailFeedback.value[student.id] = 'error'
|
||||||
|
setTimeout(() => {
|
||||||
|
emailFeedback.value[student.id] = null
|
||||||
|
}, 2000)
|
||||||
|
|
||||||
|
notifications.error(error.response?.data?.detail || 'Erreur lors de la mise à jour de l\'email')
|
||||||
|
console.error(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enroll student
|
||||||
|
async function enrollStudent() {
|
||||||
|
try {
|
||||||
|
const classId = parseInt(route.params.id)
|
||||||
|
|
||||||
|
if (addMode.value === 'new') {
|
||||||
|
await studentsService.enroll({
|
||||||
|
first_name: newStudent.value.first_name,
|
||||||
|
last_name: newStudent.value.last_name,
|
||||||
|
email: newStudent.value.email || null,
|
||||||
|
class_group_id: classId,
|
||||||
|
enrollment_date: newStudent.value.enrollment_date,
|
||||||
|
enrollment_reason: newStudent.value.enrollment_reason || null
|
||||||
|
})
|
||||||
|
notifications.success('Élève créé et inscrit avec succès')
|
||||||
|
} else {
|
||||||
|
await studentsService.enroll({
|
||||||
|
student_id: existingStudent.value.student_id,
|
||||||
|
class_group_id: classId,
|
||||||
|
enrollment_date: existingStudent.value.enrollment_date,
|
||||||
|
enrollment_reason: existingStudent.value.enrollment_reason || null
|
||||||
|
})
|
||||||
|
notifications.success('Élève inscrit avec succès')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset forms
|
||||||
|
newStudent.value = {
|
||||||
|
last_name: '',
|
||||||
|
first_name: '',
|
||||||
|
email: '',
|
||||||
|
enrollment_date: new Date().toISOString().split('T')[0],
|
||||||
|
enrollment_reason: ''
|
||||||
|
}
|
||||||
|
existingStudent.value = {
|
||||||
|
student_id: null,
|
||||||
|
enrollment_date: new Date().toISOString().split('T')[0],
|
||||||
|
enrollment_reason: ''
|
||||||
|
}
|
||||||
|
|
||||||
|
showAddModal.value = false
|
||||||
|
await loadStudents()
|
||||||
|
} catch (error) {
|
||||||
|
notifications.error(error.response?.data?.detail || 'Erreur lors de l\'inscription')
|
||||||
|
console.error(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Departure functions
|
||||||
|
function openDepartureModal(student) {
|
||||||
|
departureStudent.value = student
|
||||||
|
departureDate.value = new Date().toISOString().split('T')[0]
|
||||||
|
departureReason.value = ''
|
||||||
|
showDepartureModal.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmDeparture() {
|
||||||
|
try {
|
||||||
|
await studentsService.departure({
|
||||||
|
student_id: departureStudent.value.id,
|
||||||
|
departure_date: departureDate.value,
|
||||||
|
departure_reason: departureReason.value || null
|
||||||
|
})
|
||||||
|
|
||||||
|
notifications.success('Départ enregistré avec succès')
|
||||||
|
showDepartureModal.value = false
|
||||||
|
await loadStudents()
|
||||||
|
} catch (error) {
|
||||||
|
notifications.error(error.response?.data?.detail || 'Erreur lors de l\'enregistrement du départ')
|
||||||
|
console.error(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-enroll functions
|
||||||
|
function openReenrollModal(student) {
|
||||||
|
reenrollStudent.value = student
|
||||||
|
reenrollDate.value = new Date().toISOString().split('T')[0]
|
||||||
|
reenrollReason.value = ''
|
||||||
|
showReenrollModal.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmReenroll() {
|
||||||
|
try {
|
||||||
|
const classId = parseInt(route.params.id)
|
||||||
|
|
||||||
|
await studentsService.enroll({
|
||||||
|
student_id: reenrollStudent.value.id,
|
||||||
|
class_group_id: classId,
|
||||||
|
enrollment_date: reenrollDate.value,
|
||||||
|
enrollment_reason: reenrollReason.value || null
|
||||||
|
})
|
||||||
|
|
||||||
|
notifications.success('Élève réinscrit avec succès')
|
||||||
|
showReenrollModal.value = false
|
||||||
|
await loadStudents()
|
||||||
|
} catch (error) {
|
||||||
|
notifications.error(error.response?.data?.detail || 'Erreur lors de la réinscription')
|
||||||
|
console.error(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
try {
|
try {
|
||||||
const id = route.params.id
|
const id = route.params.id
|
||||||
classData.value = await classesStore.fetchClass(id)
|
classData.value = await classesStore.fetchClass(id)
|
||||||
students.value = await classesService.getStudents(id)
|
await loadStudents()
|
||||||
|
await loadAvailableStudents()
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user