602 lines
17 KiB
Markdown
602 lines
17 KiB
Markdown
# 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
|