Compare commits

...

4 Commits

Author SHA1 Message Date
bb15933e69 fix(ux): improve destructive action safety, accents, navigation and feedback
All checks were successful
Build and Publish Docker Images / Build Backend Image (push) Successful in 2m58s
Build and Publish Docker Images / Build Frontend Image (push) Successful in 3m19s
Build and Publish Docker Images / Build Summary (push) Successful in 3s
Replace window.confirm() with Modal dialogs for class deletion and grade
reset. Add unsaved-changes guards to AssessmentFormView. Warn before
deleting exercises/elements with existing grades. Surface invalid grades
in a detailed Modal after save. Replace GradingView local toasts with
global notification store. Fix missing French accents across CouncilView,
CouncilStudentDetail, and ConfigView. Make dashboard "À corriger" card
and student list rows clickable. Add visual hierarchy to assessment
detail actions. Add loading overlay to trimester switch. Simplify email
sending workflow by removing mode-switch pattern.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 14:25:56 +01:00
a0ab7224e1 refactor: extract duplicated patterns into shared helpers
Backend: create api/helpers.py with eligible_enrollment_filter,
count_eligible_students, get_active_enrollment, ensure_unique_name,
upsert_app_configs, and build_heatmap. Add full_name properties to
Student model. Apply across all route files (-481/+184 lines).

Frontend: create stores/helpers.js with withLoading composable,
apply to assessments and classes Pinia stores.

96/96 tests pass.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 14:05:10 +01:00
b1b7d12a9f feat(api): add concil view
All checks were successful
Build and Publish Docker Images / Build Frontend Image (push) Successful in 3m25s
Build and Publish Docker Images / Build Backend Image (push) Successful in 3m35s
Build and Publish Docker Images / Build Summary (push) Successful in 4s
2026-02-12 17:18:59 +01:00
a30dd4af19 clean v1 artifacts 2026-02-09 17:57:02 +01:00
31 changed files with 1816 additions and 2409 deletions

BIN
.coverage

Binary file not shown.

View File

@@ -1,17 +0,0 @@
# Configuration de développement
SECRET_KEY=your-secret-key-here-min-32-chars-dev-example-key-2025
DATABASE_URL=sqlite:///school_management.db
FLASK_ENV=development
LOG_LEVEL=INFO
DEBUG=true
# Configuration de production (décommentez et ajustez pour la production)
# SECRET_KEY=your-production-secret-key-min-32-chars-prod
# DATABASE_URL=postgresql://user:pass@localhost/notytex_prod
# FLASK_ENV=production
# LOG_LEVEL=WARNING
# DEBUG=false
# Configuration optionnelle
# DB_ECHO=false
# WTF_CSRF_TIME_LIMIT=3600

4
.gitignore vendored
View File

@@ -82,7 +82,3 @@ htmlcov/
# Docker data # Docker data
data/ data/
!data/.gitkeep !data/.gitkeep
# Flask legacy
instance/
.webassets-cache

923
CLAUDE.md
View File

@@ -1,628 +1,397 @@
# 📚 Notytex - Système de Gestion Scolaire # Notytex v2 - Système de Gestion Scolaire
**Notytex** est une application web Flask moderne conçue pour la gestion complète des évaluations scolaires. Elle permet aux enseignants de créer, organiser et noter les évaluations de leurs élèves avec une interface intuitive et des fonctionnalités avancées. **Notytex** est une application web moderne pour la gestion complète des évaluations scolaires. Version 2.0 entièrement réécrite avec **FastAPI** (backend) et **Vue.js 3** (frontend).
## 🎯 **Objectif Principal** ## Objectif Principal
Simplifier et digitaliser le processus d'évaluation scolaire, de la création des contrôles à la saisie des notes, en offrant une structure hiérarchique flexible et deux modes de notation. Simplifier et digitaliser le processus d'évaluation scolaire, de la création des contrôles à la saisie des notes, en offrant une interface moderne, réactive et une API REST documentée.
## 🏗️ **Architecture Technique ** ---
**Framework :** Flask (Python) avec architecture modulaire découplée ## Architecture Technique
**Base de données :** SQLite avec SQLAlchemy ORM + Repository Pattern
**Frontend :** Templates Jinja2 + TailwindCSS + JavaScript + Chart.js
**Tests :** Pytest avec couverture complète (100 tests ✅)
**Configuration :** Variables d'environnement externalisées (.env)
**Logging :** Structuré JSON avec corrélation des requêtes
**Sécurité :** Configuration sécurisée + gestion d'erreurs centralisée
## 📊 **Modèle de Données Hiérarchique** | Couche | Technologie |
|--------|-------------|
| **Backend** | FastAPI 0.115+ (Python 3.11-3.13) |
| **ORM** | SQLAlchemy 2.0.36+ avec aiosqlite (async) |
| **Validation** | Pydantic 2.10+ / pydantic-settings |
| **Serveur** | Uvicorn 0.32+ (ASGI) |
| **Frontend** | Vue.js 3.5+ (Composition API) + Vite 6.0+ |
| **State** | Pinia 2.2+ |
| **CSS** | TailwindCSS 3.4+ |
| **Graphiques** | Chart.js 4.4+ (vue-chartjs) |
| **HTTP client** | Axios 1.7+ |
| **Base de données** | SQLite (dev/prod), PostgreSQL possible |
| **Déploiement** | Docker / Podman avec Nginx |
| **Tests** | pytest + pytest-asyncio (99/99 tests) |
| **Qualité** | Black, Ruff, ESLint |
---
## Modèle de Données (12 tables)
### Entités principales (7)
``` ```
ClassGroup (6ème A, 5ème B...) ClassGroup (6ème A, 5ème B...)
↓ StudentEnrollment (inscription temporelle : arrivée/départ)
Student (élèves)
Students (Élèves de la classe) Assessment (évaluation, rattachée à un trimestre 1/2/3)
Assessment (Contrôle de mathématiques, Trimestre 1...) Exercise (exercices ordonnés)
Exercise (Exercice 1, Exercice 2...) GradingElement (question/compétence, type "notes" ou "score", domaine optionnel)
GradingElement (Question a, b, c...) Grade (note individuelle par élève : valeur string + commentaire)
Grade (Note attribuée à chaque élève)
``` ```
## ⭐ **Fonctionnalités Clés** - **CouncilAppreciation** : appréciation de conseil de classe (élève + classe + trimestre, statut draft/finalized)
### **Gestion des Évaluations** ### Tables de configuration (5)
- Création d'évaluations complètes avec exercices multiples - **AppConfig** : clé-valeur (ex: `context.school_year`, `grading.special_values`)
- **Organisation par trimestre** : Chaque évaluation doit être assignée à un trimestre (1, 2 ou 3) - **CompetenceScaleValue** : échelle de notation (0=Non acquis → 3=Expert, couleurs, `.`, `d`, `a`)
- Structure hiérarchique : Assessment → Exercise → GradingElement - **Competence** : compétences pédagogiques (Calculer, Raisonner...) avec couleur et icône
- Interface unifiée pour créer évaluation + exercices + barème en une fois - **Domain** : domaines/tags pour les éléments de notation
- Modification et suppression avec gestion des cascades
### **Système de Notation Unifié ** Tous les modèles sont dans `backend/infrastructure/database/models.py`.
**2 Types de Notation Fixes :**
1. **`notes`** : Valeurs numériques décimales (ex: 2.5/4, 18/20, 15.5/20)
2. **`score`** : Échelle fixe de 0 à 3 pour l'évaluation par compétences
**Valeurs Spéciales Configurables :**
- **`.`** = Pas de réponse (traité comme 0 dans les calculs)
- **`d`** = Dispensé (ne compte pas dans la note finale)
- **Autres valeurs** : Entièrement configurables via l'interface d'administration
**Configuration Centralisée :**
- **Signification des scores** : 0=Non acquis, 1=En cours, 2=Acquis, 3=Expert (modifiable)
- **Couleurs associées** : Chaque niveau peut avoir sa couleur personnalisée
- **Règles de calcul** : Logique unifiée pour tous les types de notation
- **Interface d'administration** : Gestion complète des paramètres de notation
### **Filtrage Avancé des Évaluations**
**Filtres Dynamiques Disponibles :**
- **Trimestre** : Filtrage par trimestre (1, 2, 3) pour organiser par période scolaire
- **Classe** : Filtrage par groupe de classe pour se concentrer sur une classe spécifique
- **Statut de Correction** : **NOUVEAU** - Filtre essentiel pour la gestion des corrections :
- **Non terminées** : Évaluations partiellement corrigées ou non commencées (idéal pour mode midyear)
- **Terminées** : Évaluations 100% corrigées
- **Non commencées** : Évaluations sans aucune note saisie
- **Tri** : Organisation par date (récent/ancien), titre alphabétique, ou classe
**JavaScript Dynamique :**
- **Filtrage temps réel** : Les filtres s'appliquent automatiquement au changement
- **Persistence des filtres** : État maintenu dans l'URL pour navigation intuitive
- **Interface responsive** : Adaptée aux appareils mobiles et desktop
### **Interface Utilisateur & UX Moderne **
- **Dashboard avec statistiques en temps réel** : Cartes cliquables avec animations et gradients
- **Pages hero modernisées** : Sections d'accueil avec gradients colorés et informations contextuelles
- **Navigation intuitive** : Actions principales mises en avant avec boutons colorés et icônes
- **Templates responsive** avec TailwindCSS et animations fluides
- **Page de présentation d'évaluation repensée** :
- Hero section avec gradient et informations clés
- Actions principales (Noter, Résultats, Modifier, Supprimer) en cards colorées
- Indicateur de progression central avec visualisation circulaire animée
- Structure d'évaluation en cards compactes avec compétences visibles
- **Suppression des pages intermédiaires** : Plus de pages de détail d'exercices, navigation directe
- **Indicateurs de progression de correction** : Visualisation immédiate avec cercles de progression et actions intégrées
- **Interface cohérente** : Design system unifié avec espacements, couleurs et animations harmonieux
### **Gestion des Élèves et Import CSV **
**Gestion Individuelle des Élèves :**
- **Inscription manuelle** : Création d'élèves un par un avec prénom, nom, email optionnel
- **Système d'inscription temporel** : Historique complet avec dates d'arrivée/départ
- **Gestion des mouvements** : Transferts entre classes, départs, réintégrations
- **Interface moderne** : Modal avec onglets (nouvel élève / élève existant)
**Import en Lot depuis CSV :**
- **Format CSV supporté** : Séparateur `;`, première colonne "NOM Prénoms"
- **Extraction intelligente** : Reconnaissance automatique nom/prénom ("DUPONT Marie Claire" → nom: "DUPONT", prénom: "Marie Claire")
- **Gestion des doublons** : Option pour ignorer ou échouer en cas d'élève existant
- **Rapport détaillé** : Statistiques complètes (importés, ignorés, erreurs) avec détail ligne par ligne
- **Validation robuste** : Contrôle format CSV, taille fichier, types de données
- **Interface intuitive** : Modal avec drag & drop, instructions du format attendu
**Points d'Accès Multiples :**
- **Dashboard de classe** : Carte d'action violette "Import CSV"
- **Page gestion élèves** : Bouton "Import CSV" dans la barre d'actions
- **Navigation cohérente** : Accès contextuel selon le workflow utilisateur
### **Analyse des Résultats Avancée**
- **Page de résultats complète** : Vue d'ensemble des performances de l'évaluation
- **Statistiques descriptives** : Moyenne, médiane, minimum, maximum, écart-type
- **Visualisation graphique** : Histogramme de distribution des notes (groupes de 1 point, de 0 au maximum)
- **Tableau détaillé** : Classement alphabétique avec scores par exercice au format "score/total"
- **Calcul intelligent des scores** : Gestion des types "points" et "compétences" avec formules spécialisées
- **Traitement des absences** : Score "." = 0 point mais compte dans le total possible
## 🔧 **Structure du Code **
```
app.py # Application Flask principale + routes de base
models.py # Modèles SQLAlchemy (5 entités principales + calcul progression)
app_config_classes.py # Classes de configuration Flask (dev/prod/test)
🔧 config/ # Configuration externalisée sécurisée
├── __init__.py
└── settings.py # Gestion variables d'environnement + validation
🛡️ exceptions/ # Gestion d'erreurs centralisée
├── __init__.py
└── handlers.py # Gestionnaires d'erreurs globaux (JSON/HTML)
🔍 core/ # Utilitaires centraux
├── __init__.py
└── logging.py # Logging structuré JSON + corrélation requêtes
📦 repositories/ # Pattern Repository pour accès données
├── __init__.py
├── base_repository.py # Repository générique CRUD
└── assessment_repository.py # Repository spécialisé Assessment
📁 routes/ # Blueprints organisés par fonctionnalité
├── assessments.py # CRUD évaluations (création unifiée)
├── exercises.py # Gestion des exercices
├── grading.py # Saisie et gestion des notes
├── classes.py # Gestion classes + import CSV élèves
└── config.py # Interface configuration système
forms.py # Formulaires WTForms pour validation (+ CSVImportForm)
📋 services/ # Services métier modulaires
├── csv_import_service.py # Service d'import CSV élèves
└── assessment_services.py # Logique métier évaluations
utils.py # Utilitaires existants
commands.py # Commandes CLI Flask (init-db)
templates/ # Templates Jinja2 avec indicateurs UX intégrés
📋 domain/ # Exceptions métier personnalisées
🧪 tests/ # Tests pytest (100 tests ✅)
```
## 🚀 **Installation & Lancement **
```bash
# Installation avec uv (gestionnaire moderne)
uv sync
# Configuration obligatoire (.env)
cp .env.example .env
# Modifier .env avec SECRET_KEY (obligatoire, min 32 caractères)
# Initialisation base de données + données de démo
uv run flask --app app init-db
# Lancement développement avec logging structuré
uv run flask --app app run --debug
# Lancement des tests (100 tests ✅)
uv run pytest
# Consultation des logs structurés JSON
tail -f logs/notytex.log
```
## 🧪 **Qualité du Code **
- **Tests pytest avec 100% de réussite** (100 tests ✅)
- **Architecture découplée** : Repository Pattern + Dependency Injection
- **Gestion d'erreurs centralisée** : Gestionnaires globaux JSON/HTML
- **Logging structuré JSON** : Corrélation des requêtes + contexte complet
- **Configuration sécurisée** : Variables d'environnement externalisées
- **Validation robuste** : WTForms + Pydantic + services métier
- **Séparation des responsabilités** : Modèles/Repositories/Services/Controllers
## 📝 **Cas d'Usage Typiques**
### **Scénario A : Évaluation Complète**
1. **Professeur crée une évaluation** : "Contrôle Chapitre 3 - Fonctions" pour le 2ème trimestre
2. **Définit les paramètres** : Date, trimestre (obligatoire), classe, coefficient
3. **Ajoute des exercices** : "Exercice 1: Calculs", "Exercice 2: Graphiques"
4. **Définit le barème** : Question 1a (2 pts), Question 1b (3 pts), Compétence graphique (score 0-3)
5. **Voit l'indicateur de progression** : "Correction 0%" en rouge sur toutes les pages
6. **Saisit les notes** pour chaque élève sur chaque élément via clic sur l'indicateur
7. **Suit la progression** : L'indicateur passe à "Correction 45%" en orange, puis "Correction 100%" en vert
8. **Consulte les résultats détaillés** : Accès direct à la page de résultats avec statistiques et histogramme
9. **Analyse les performances** : Statistiques descriptives, distribution des notes et classement alphabétique
### **Scénario B : Import d'Élèves en Masse (Nouveau)**
1. **Professeur accède au dashboard** de la classe "6ème A"
2. **Clique sur "Import CSV"** depuis la carte d'action violette ou la page des élèves
3. **Prépare le fichier CSV** : Export depuis le logiciel administratif avec colonnes séparées par `;`
4. **Glisse le fichier** dans la zone de drag & drop ou sélectionne via le bouton
5. **Configure l'import** : Date d'inscription, option "ignorer doublons" activée
6. **Lance l'import** : Validation automatique format + extraction intelligente des noms
7. **Consulte le rapport** : "15 élèves importés, 2 ignorés (doublons), 0 erreur"
8. **Vérifie la liste** : Redirection automatique vers la page des élèves mise à jour
## Volumétrie de milieu d'année
- 5 classes d'entre 25 et 35 élèves
- Les évaluations sont constitués d'entre 10 et 20 éléments de notations
- 4 évaluations corrigées par classes pour le premier trimestre
- 2 évaluations non corrigée ou partiellement corrigée par classe
## 🎓 **Public Cible**
- Enseignants du secondaire (collège/lycée)
- Établissements souhaitant digitaliser leurs évaluations
- Contexte où coexistent notation classique et évaluation par compétences
Ce projet présente une architecture solide, une interface soignée avec des **indicateurs UX avancés** pour le suivi de progression, et répond à un besoin concret du monde éducatif en combinant praticité et modernité technique.
## 🎨 **Dernières Améliorations UX**
### **Indicateurs de Progression Intégrés**
- **Calcul automatique** : Propriété `grading_progress` dans le modèle Assessment
- **Affichage multi-pages** : Présent sur index, liste évaluations, détail évaluation
- **Code couleur intuitif** :
- 🔴 Rouge : "Correction 0%" (non commencée)
- 🟠 Orange : "Correction XX%" (en cours avec cercle de progression)
- 🟢 Vert : "Correction 100%" (terminée)
- **Actions directes** : Clic sur l'indicateur → redirection vers page de notation
- **Informations détaillées** : "X/Y notes saisies (Z élèves)"
- **Responsive design** : Version complète sur liste évaluations, version compacte sur index
### **Système de Résultats et Statistiques**
- **Calculs automatisés** : Méthodes `calculate_student_scores()`, `get_assessment_statistics()` dans le modèle Assessment
- **Double logique de scoring** :
- **Points** : Sommation directe des valeurs
- **Compétences** : Formule `1/3 * score * pointMax` (score 0-3)
- **Gestion des cas particuliers** : Les scores "." comptent comme 0 mais incluent les points maximum
- **Arrondi intelligent** : Notes totales arrondies à 2 décimales pour la précision
- **Interface graphique** : Chart.js pour histogrammes interactifs avec bins de 1 point
- **Tri alphabétique** : Classement automatique par nom de famille puis prénom
Cette évolution transforme Notytex en un outil **véritablement centré utilisateur** où l'état de correction est **visible et actionnable depuis n'importe quelle page**, avec une **analyse statistique complète** des résultats.
--- ---
# 🚀 **Guide de Démarrage pour Nouveaux Développeurs** ## Fonctionnalités Clés
## 📋 **Prérequis** ### Gestion des Classes et Élèves
### **Environnement de Développement** - CRUD classes avec année scolaire
- Inscription temporelle (bitemporal) : dates d'arrivée/départ, transferts, historique
- Import CSV en masse (séparateur `;`, format "NOM Prénoms", gestion doublons)
- Statistiques par trimestre et par classe
- **Python 3.8+** : Version recommandée 3.11+ ### Système d'Évaluation
- **uv** : Gestionnaire de paquets moderne Python ([installation](https://docs.astral.sh/uv/))
- **Git** : Pour le contrôle de version
- **IDE recommandé** : VSCode avec extensions Python, Flask, Jinja2
### **Connaissances Requises** - Création unifiée (évaluation + exercices + barème en une fois)
- Organisation par trimestre (1, 2, 3)
- Filtrage avancé (trimestre, classe, statut de correction, tri)
- Indicateurs de progression : rouge (0%), orange (en cours), vert (100%)
- **Python** : Classes, décorateurs, gestion d'erreurs ### Notation Dual Configurable
- **Flask** : Routes, templates, blueprints, contexte d'application
- **SQLAlchemy** : ORM, relations, requêtes
- **HTML/CSS** : TailwindCSS de préférence
- **JavaScript** : Manipulation DOM, événements
## ⚡ **Démarrage Rapide (5 minutes)** **2 types de notation (par GradingElement) :**
```bash 1. **`notes`** : valeurs numériques décimales (ex: 15.5/20) — calcul direct
# 1. Cloner et installer 2. **`score`** : échelle fixe 0-3 — converti en points : `(value / 3) * max_points`
git clone <repository>
cd notytex
uv sync
# 2. Initialiser la base de données avec données de test **Valeurs spéciales (configurables via interface) :**
uv run flask --app app init-db - `.` = pas de réponse (compte comme 0 dans le total)
- `d` = dispensé (exclu des calculs)
- `a` = absent (compte comme 0)
# 3. Lancer l'application ### Analyse et Statistiques
uv run flask --app app run --debug
# 4. Ouvrir http://localhost:5000 - Statistiques descriptives (moyenne, médiane, écart-type, min/max, quartiles)
``` - Histogrammes de distribution (bins de 1 point, Chart.js)
- Heatmaps par compétences et par domaines
- Classement alphabétique avec scores par exercice
## 🏗️ **Architecture Détaillée** ### Conseil de Classe
### **Structure des Fichiers** - Préparation automatique avec données consolidées par trimestre
- Saisie et historique des appréciations (brouillon → finalisé)
- Vue complète des performances par élève
### Envoi de Bilans par Email
- Génération automatique de bilans individualisés (HTML Jinja2)
- SMTP configurable (Gmail, Outlook, serveur perso)
- Envoi en lot avec rapport détaillé succès/erreurs
- Prévisualisation avant envoi
---
## Structure du Code
``` ```
notytex/ notytex/
├── 📱 app.py # Point d'entrée Flask + routes principales ├── backend/ # API FastAPI
├── 🗄️ models.py # Modèles SQLAlchemy + logique métier │ ├── api/
├── ⚙️ app_config.py # Gestionnaire de configuration SQLite │ │ ├── main.py # App FastAPI + CORS + lifespan
├── 🔧 config.py # Configuration Flask (dev/prod/test) │ │ ├── dependencies.py # DI (AsyncSessionDep)
├── 🎯 forms.py # Formulaires WTForms + validation └── routes/
├── 🛠️ utils.py # Fonctions utilitaires + gestion erreurs │ │ ├── assessments.py # CRUD évaluations + notation + résultats + email
├── 📜 commands.py # Commandes CLI Flask │ │ ├── classes.py # CRUD classes + import CSV + stats dashboard
├── 📁 routes/ # Blueprints organisés par fonctionnalité │ │ ├── students.py # CRUD élèves + inscriptions
├── assessments.py # CRUD évaluations + création unifiée │ ├── config.py # Configuration système + compétences + domaines
├── exercises.py # Gestion exercices + éléments de notation │ └── council.py # Appréciations de conseil de classe
├── grading.py # Interface de saisie des notes
── config.py # Interface de configuration système ── schemas/ # Modèles Pydantic (validation I/O)
├── 📁 templates/ # Templates Jinja2 + composants réutilisables │ │ ├── common.py # BaseSchema, PaginationParams
│ ├── base.html # Layout principal + navigation │ ├── assessment.py # AssessmentRead, AssessmentCreate...
│ ├── components/ # Composants réutilisables │ ├── student.py # StudentRead, StudentCreate...
└── config/ # Interface de configuration │ ├── grading.py # GradeRead, BulkGradeCreate
├── 📁 static/ # Assets statiques (CSS, JS, images) │ │ ├── class_group.py # ClassGroupRead, ClassDashboardStats
├── 🧪 tests/ # Tests pytest + fixtures ├── config.py # ConfigRead, CompetenceCreate
└── 📝 pyproject.toml # Configuration uv + dépendances │ │ ├── council.py # CouncilAppreciationRead
│ │ └── csv_import.py # CSVImportResponse
│ │
│ ├── domain/ # Logique métier pure (aucune dépendance framework)
│ │ ├── services/
│ │ │ ├── grading_calculator.py # Strategy Pattern (Notes/Score)
│ │ │ ├── statistics_service.py # Statistiques descriptives
│ │ │ ├── score_calculator.py # Calcul scores élèves
│ │ │ ├── config_service.py # Valeurs spéciales, signification scores
│ │ │ ├── student_report_service.py # Génération bilans email
│ │ │ └── class_statistics_service.py # Stats par classe
│ │ └── value_objects/
│ │ ├── progress.py # ProgressResult, ProgressStatus
│ │ ├── score.py # GradeValue, ExerciseScore, StudentScore
│ │ └── statistics.py # StatisticsResult, HistogramBin
│ │
│ ├── infrastructure/
│ │ ├── database/
│ │ │ ├── models.py # 12 modèles SQLAlchemy
│ │ │ └── session.py # AsyncSession factory
│ │ └── external/
│ │ └── email_service.py # Service SMTP
│ │
│ ├── core/
│ │ └── config.py # pydantic-settings (Settings)
│ │
│ ├── tests/ # 99 tests
│ │ ├── conftest.py # Fixtures (SQLite in-memory, AsyncClient)
│ │ ├── unit/ # Tests services domaine
│ │ ├── integration/ # Tests endpoints API
│ │ └── comparison/ # Tests parité v1 ↔ v2
│ │
│ ├── pyproject.toml # Dépendances + config Black/Ruff/pytest
│ └── uv.lock
├── frontend/ # SPA Vue.js 3
│ ├── src/
│ │ ├── main.js # Initialisation app
│ │ ├── App.vue # Composant racine
│ │ ├── router/index.js # 14 routes Vue Router
│ │ ├── stores/ # Pinia (classes, assessments, config, notifications)
│ │ ├── services/api.js # Instance Axios (/api/v2)
│ │ ├── views/ # 14 pages (Dashboard, Grading, Results...)
│ │ ├── components/ # 16+ composants réutilisables
│ │ └── assets/
│ ├── vite.config.js # Proxy dev /api → localhost:8000
│ ├── tailwind.config.js
│ └── package.json
├── docker/
│ ├── docker-compose.yaml # Production (ports 8080/8081)
│ ├── docker-compose.dev.yaml # Développement avec hot reload
│ └── .env.example # Template variables Docker
├── school_management.db # Base SQLite partagée
├── CLAUDE.md # Ce fichier
└── README.md # Documentation utilisateur
``` ```
### **Flux de Données Typique**
```
1. Route Flask (routes/*.py)
2. Validation Form (forms.py)
3. Logique Métier (models.py)
4. Accès Base de Données (SQLAlchemy)
5. Rendu Template (templates/*.html)
```
## 🎯 **Points d'Entrée pour Contribuer**
### **🌟 Débutant - Familiarisation**
1. **Ajouter un champ à un modèle existant**
- Fichier : `models.py`
- Exemple : Ajouter un champ "commentaire" à Student
- Impact : Migration DB + template + form
2. **Modifier l'apparence d'une page**
- Fichiers : `templates/*.html`
- Technologie : TailwindCSS
- Exemple : Changer les couleurs du dashboard
3. **Ajouter une validation de formulaire**
- Fichier : `forms.py`
- Technologie : WTForms
- Exemple : Validation format email étudiant
### **🔥 Intermédiaire - Nouvelles Fonctionnalités**
1. **Créer une nouvelle page**
- Blueprint dans `routes/`
- Template correspondant
- Formulaire si nécessaire
- Tests
2. **Ajouter un système d'export**
- Route d'export (PDF, Excel, CSV)
- Template de génération
- Boutons dans l'interface
3. **Étendre le système de configuration**
- Nouveau modèle dans `models.py`
- Interface dans `routes/config.py`
- Template de configuration
### **⚡ Avancé - Architecture**
1. **Optimiser les performances**
- Requêtes SQLAlchemy (N+1 queries)
- Cache des calculs coûteux
- Lazy loading intelligent
2. **Ajouter des API REST**
- Endpoints JSON
- Authentification
- Documentation OpenAPI
3. **Système de notifications**
- Modèles de notifications
- Interface utilisateur
- Système de stockage
## 📚 **Concepts Clés à Maîtriser**
### **Configuration Dynamique**
```python
# Configuration stockée en base SQLite
from app_config import config_manager
# Lecture
school_year = config_manager.get('context.school_year')
competences = config_manager.get_competences_list()
# Écriture
config_manager.set('context.school_year', '2025-2026')
config_manager.save()
```
### **Calcul de Progression**
```python
# Dans models.py - Assessment
@property
def grading_progress(self):
# Calcul automatique du % de correction
# Utilisé partout dans l'interface
return {
'percentage': 75,
'status': 'in_progress',
'completed': 45,
'total': 60
}
```
### **Système de Notation Unifié**
```python
# Type "notes" - Valeurs numériques
grade.value = "15.5" # Points décimaux
grade.grading_element.grading_type = "notes"
grade.grading_element.max_points = 20
# Type "score" - Échelle 0-3 fixe
grade.value = "2" # 0=Non acquis, 1=En cours, 2=Acquis, 3=Expert
grade.grading_element.grading_type = "score"
grade.grading_element.max_points = 3 # Toujours 3 pour les scores
# Valeurs spéciales configurables
grade.value = "." # Pas de réponse (= 0)
grade.value = "d" # Dispensé (ne compte pas)
# Configuration centralisée
from app_config import config_manager
score_meanings = config_manager.get('grading.score_meanings')
special_values = config_manager.get('grading.special_values')
```
## 🧪 **Tests et Débogage**
### **Lancer les Tests**
```bash
# Tous les tests
uv run pytest
# Tests avec couverture
uv run pytest --cov=. --cov-report=html
# Test spécifique de l'import CSV
uv run python -c "
from services.csv_import_service import CSVImportService
service = CSVImportService()
print('✅ Test extraction:', service.extract_name_parts('DUPONT Marie Claire'))
"
# Tests spécifiques
uv run pytest tests/test_models.py -v
```
### **Débogage**
```bash
# Mode debug avec rechargement auto
uv run flask --app app run --debug
# Console interactive
uv run flask --app app shell
# Logs détaillés
tail -f logs/school_management.log
```
### **Base de Données**
```bash
# Réinitialiser complètement
rm school_management.db
uv run flask --app app init-db
# Inspecter la DB
sqlite3 school_management.db
.tables
.schema assessment
```
## 🎨 **Conventions de Code**
### **Style Python**
- **PEP 8** : Formatage automatique avec black
- **Type hints** : Recommandés pour les nouvelles fonctions
- **Docstrings** : Format Google pour les fonctions publiques
- **Noms explicites** : `calculate_student_scores()` plutôt que `calc()`
### **Templates Jinja2**
- **Indentation** : 4 espaces
- **Noms de variables** : snake_case
- **Blocs réutilisables** : Utiliser les includes et macros
- **Classes CSS** : TailwindCSS avec composition
### **Base de Données**
- **Noms de tables** : Pluriel en anglais (`students`, `assessments`)
- **Relations** : Toujours avec `backref` explicite
- **Cascades** : Définir explicitement le comportement
## 🐛 **Problèmes Courants**
### **Erreur : Template Not Found**
```python
# ❌ Mauvais
return render_template('config.html')
# ✅ Correct
return render_template('config/index.html')
```
### **Erreur : SQLAlchemy Session**
```python
# ❌ Oublier de commit
db.session.add(new_student)
# ✅ Correct
db.session.add(new_student)
db.session.commit()
```
### **Erreur : Import Circulaire**
```python
# ❌ Import direct dans models.py
from app import app
# ✅ Import dans fonction
def get_current_app():
from flask import current_app
return current_app
```
## 📖 **Ressources Utiles**
### **Documentation Officielle**
- [Flask](https://flask.palletsprojects.com/) - Framework web
- [SQLAlchemy](https://docs.sqlalchemy.org/) - ORM Python
- [TailwindCSS](https://tailwindcss.com/) - Framework CSS
- [Jinja2](https://jinja.palletsprojects.com/) - Moteur de templates
### **Outils de Développement**
- [uv](https://docs.astral.sh/uv/) - Gestionnaire de paquets
- [pytest](https://docs.pytest.org/) - Framework de tests
- [Flask-Shell](https://flask.palletsprojects.com/en/2.3.x/shell/) - Console interactive
--- ---
## 📧 **Système d'Envoi de Bilans par Email** ## Installation et Lancement
**Notytex** intègre désormais un système complet d'envoi automatique des bilans d'évaluation individuels par email aux élèves et à leurs familles. ### Prérequis
### **🎯 Fonctionnalités Principales** - Python 3.11-3.13 avec [uv](https://docs.astral.sh/uv/)
- Node.js 22 LTS avec npm
- Git
-**Configuration SMTP flexible** via interface web (Gmail, Outlook, serveurs personnalisés) ### Backend
-**Génération automatique** de bilans individualisés avec analyses détaillées
-**Templates HTML responsives** optimisés pour tous clients email
-**Analyses par compétences et domaines** avec visualisations graphiques
-**Serveur de test intégré** (`debug_smtp_server.py`) pour développement
-**Gestion d'erreurs robuste** avec logs structurés et rapports détaillés
### **🏗️ Architecture** ```bash
cd backend
``` uv sync --all-extras
services/ uv run python -m uvicorn api.main:app --reload --port 8000
├── email_service.py # Service d'envoi SMTP
└── student_report_service.py # Génération de bilans
templates/email/
├── base_email.html # Template de base responsive
└── student_report.html # Template de bilan détaillé
``` ```
### **📊 Contenu des Bilans** - API : http://localhost:8000/api/v2/docs (Swagger)
- ReDoc : http://localhost:8000/api/v2/redoc
Chaque bilan inclut : ### Frontend
- **Note globale** avec visualisation colorée et position dans la classe ```bash
- **Détail par exercice** et questions individuelles cd frontend
- **Analyses par compétences** avec système d'étoiles visuelles npm install
- **Performances par domaines** avec codes couleur npm run dev
- **Statistiques de classe** (moyenne, médiane, écart-type) ```
- **Message personnalisé** du professeur (optionnel)
### **📧 Workflow d'Envoi** - Application : http://localhost:3000 (proxy automatique `/api` vers le backend)
1. **Configuration SMTP** via `Configuration > Email` ### Docker / Podman
2. **Sélection d'élèves** depuis la page de résultats d'évaluation
3. **Prévisualisation** des bilans via `/preview-report`
4. **Envoi groupé** avec rapport détaillé des succès/erreurs
5. **Réception professionnelle** par les élèves et familles
**📖 Documentation complète** : [docs/features/EMAIL_REPORTS.md](docs/features/EMAIL_REPORTS.md) ```bash
cd docker
cp .env.example .env
# Modifier SECRET_KEY dans .env
docker compose up -d # ou podman-compose up -d
```
- Frontend : http://localhost:8081
- API : http://localhost:8080/api/v2/docs
---
## Tests
```bash
cd backend
# Tous les tests
uv run pytest tests/ -v
# Tests unitaires
uv run pytest tests/unit/ -v
# Tests de parité v1 ↔ v2
uv run pytest tests/comparison/ -v
# Avec couverture
uv run pytest tests/ --cov=. --cov-report=html
```
**Résultat actuel : 99/99 tests**
Les tests utilisent une base SQLite in-memory avec `AsyncClient` + `ASGITransport` pour tester les endpoints sans serveur.
---
## Configuration
### pydantic-settings (`backend/core/config.py`)
```python
class Settings(BaseSettings):
# Base de données
database_url: Optional[str] # URL complète SQLAlchemy async
database_path: Optional[str] # Chemin simple vers le .db
# API
api_v2_prefix: str = "/api/v2"
# CORS
cors_origins: list[str] = ["http://localhost:3000", "http://localhost:5173"]
# Logging
log_level: str = "INFO"
# Email (optionnel)
smtp_server, smtp_port, smtp_username, smtp_password, email_from
model_config = SettingsConfigDict(env_file=".env")
```
Toute la config est lue depuis `backend/.env` et validée par Pydantic au démarrage.
### Configuration dynamique (table AppConfig)
Paramètres modifiables à chaud via l'interface web :
- `context.school_year` — année scolaire en cours
- `grading.special_values` — valeurs spéciales (`.`, `d`, `a`)
- `grading.score_meanings` — signification des scores 0-3
- Configuration SMTP pour l'envoi d'emails
---
## Patterns Architecturaux
### Strategy Pattern — Calcul de notation
`domain/services/grading_calculator.py` : `GradingStrategy` (ABC) avec `NotesStrategy` et `ScoreStrategy`, sélectionnées par `GradingStrategyFactory` selon le `grading_type` du GradingElement.
### Value Objects — Objets immuables du domaine
`domain/value_objects/` : `ProgressResult`, `GradeValue`, `StudentScore`, `StatisticsResult`, `HistogramBin`. Représentent des résultats de calcul sans identité propre.
### Dependency Injection — FastAPI natif
```python
AsyncSessionDep = Annotated[AsyncSession, Depends(get_async_session)]
```
Injection de la session DB dans chaque route, facilitant le remplacement en tests.
### Service Layer — Logique métier découplée
Les services dans `domain/services/` n'ont aucune dépendance sur FastAPI ou SQLAlchemy. Ils reçoivent des données brutes et retournent des Value Objects.
### Clean Architecture — Séparation en couches
`api/` (routes) → `schemas/` (validation) → `domain/` (métier) → `infrastructure/` (DB, email). Les dépendances pointent toujours vers l'intérieur.
---
## Conventions de Code
### Backend (Python)
- **Formatage** : `black` (line-length 88, target py311)
- **Linting** : `ruff` (pycodestyle, pyflakes, isort, bugbear, comprehensions)
- **Types** : `mypy` recommandé pour les nouvelles fonctions
- **Async** : toutes les routes et accès DB sont `async/await`
- **Noms** : snake_case, noms explicites (`calculate_student_scores`, pas `calc`)
### Frontend (JavaScript/Vue)
- **Composition API** avec `<script setup>`
- **Pinia** stores en style composition (pas Options API)
- **TailwindCSS** pour le styling (pas de CSS custom sauf nécessité)
- **Axios** via `services/api.js` pour tous les appels API
### Base de données
- Noms de tables : pluriel anglais (`students`, `assessments`)
- Relations avec `relationship()` et cascade explicite
- Modèles identiques à v1 pour compatibilité de la base partagée
---
## Volumétrie (milieu d'année)
- 5 classes de 25 à 35 élèves
- 10 à 20 éléments de notation par évaluation
- 4 évaluations corrigées par classe pour le 1er trimestre
- 2 évaluations non corrigées ou partiellement corrigées par classe
---
## Public Cible
- Enseignants du secondaire (collège/lycée)
- Établissements souhaitant digitaliser leurs évaluations
- Contexte de coexistence notation classique et évaluation par compétences
---
## Workflow Développeur
### Ajouter un endpoint API
1. Créer le schéma Pydantic dans `backend/schemas/`
2. Ajouter la route dans `backend/api/routes/`
3. Utiliser `AsyncSessionDep` pour l'accès DB
4. Écrire les tests dans `backend/tests/`
### Ajouter une page frontend
1. Créer le composant Vue dans `frontend/src/views/`
2. Ajouter la route dans `frontend/src/router/index.js`
3. Créer un store Pinia si nécessaire dans `frontend/src/stores/`
4. Réutiliser les composants existants de `frontend/src/components/`
### Modifier la logique de notation
1. Modifier `backend/domain/services/grading_calculator.py`
2. Mettre à jour les tests dans `backend/tests/unit/test_grading_calculator.py`
3. Lancer les tests de parité (`tests/comparison/`) pour vérifier la compatibilité v1
### Débogage
```bash
# Backend avec rechargement auto
cd backend && uv run python -m uvicorn api.main:app --reload --port 8000
# Inspecter la base
sqlite3 school_management.db ".schema assessment"
# Console interactive
cd backend && uv run python -c "from infrastructure.database.models import *; print('OK')"
```

196
backend/api/helpers.py Normal file
View File

@@ -0,0 +1,196 @@
"""
Fonctions utilitaires partagées entre les routes API.
Élimine la duplication de code pour les patterns courants.
"""
from datetime import date
from typing import Optional, Dict, List, Tuple, Any, Callable
from fastapi import HTTPException
from sqlalchemy import select, func
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from infrastructure.database.models import (
AppConfig,
StudentEnrollment,
Student,
)
from schemas.assessment import HeatmapCell, HeatmapData
def eligible_enrollment_filter(class_group_id: int, at_date: date):
"""
Retourne les clauses WHERE pour filtrer les inscriptions actives à une date donnée.
Usage:
query = select(StudentEnrollment).where(
*eligible_enrollment_filter(class_id, assessment.date)
)
"""
return (
StudentEnrollment.class_group_id == class_group_id,
StudentEnrollment.enrollment_date <= at_date,
(
StudentEnrollment.departure_date.is_(None)
| (StudentEnrollment.departure_date >= at_date)
),
)
async def count_eligible_students(
session: AsyncSession, class_group_id: int, at_date: date
) -> int:
"""Compte les élèves inscrits dans une classe à une date donnée."""
query = select(func.count(StudentEnrollment.id)).where(
*eligible_enrollment_filter(class_group_id, at_date)
)
result = await session.execute(query)
return result.scalar() or 0
def eligible_student_ids_subquery(class_group_id: int, at_date: date):
"""Retourne une sous-requête des student_id éligibles à une date donnée."""
return select(StudentEnrollment.student_id).where(
*eligible_enrollment_filter(class_group_id, at_date)
)
async def get_eligible_enrollments(
session: AsyncSession, class_group_id: int, at_date: date
):
"""Récupère les inscriptions éligibles avec les étudiants chargés."""
query = (
select(StudentEnrollment)
.options(selectinload(StudentEnrollment.student))
.where(*eligible_enrollment_filter(class_group_id, at_date))
)
result = await session.execute(query)
return result.scalars().all()
def get_active_enrollment(
student: Student,
) -> Optional[StudentEnrollment]:
"""Retourne l'inscription active (sans date de départ) d'un élève."""
for enrollment in student.enrollments:
if enrollment.departure_date is None:
return enrollment
return None
async def ensure_unique_name(
session: AsyncSession,
model_class,
name: str,
*,
field_name: str = "name",
exclude_id: Optional[int] = None,
entity_label: str = "enregistrement",
):
"""
Vérifie qu'un nom est unique pour un modèle donné.
Lève HTTPException 400 si un doublon est trouvé.
"""
field = getattr(model_class, field_name)
query = select(model_class).where(field == name)
if exclude_id is not None:
query = query.where(model_class.id != exclude_id)
result = await session.execute(query)
if result.scalar_one_or_none():
raise HTTPException(
status_code=400,
detail=f"Un(e) {entity_label} avec le nom '{name}' existe déjà",
)
async def upsert_app_configs(
session: AsyncSession, updates: Dict[str, str]
) -> None:
"""
Met à jour ou crée des entrées AppConfig en lot.
Ne fait PAS de commit — l'appelant doit appeler session.commit().
"""
for key, value in updates.items():
query = select(AppConfig).where(AppConfig.key == key)
result = await session.execute(query)
config = result.scalar_one_or_none()
if config:
config.value = value
else:
session.add(AppConfig(key=key, value=value))
def build_heatmap(
enrollments,
assessment,
items: dict,
item_extractor: Callable,
grading_calc,
sorted_student_names: List[str],
*,
color_map: Optional[Dict[str, str]] = None,
) -> Optional[HeatmapData]:
"""
Construit une HeatmapData pour des compétences ou domaines.
Args:
enrollments: liste d'inscriptions avec .student chargé
assessment: l'évaluation avec .exercises chargés
items: set ou dict des items à évaluer (noms)
item_extractor: fonction (element) -> nom de l'item ou None
grading_calc: instance de GradingCalculator
sorted_student_names: noms triés pour l'axe Y
color_map: optionnel, dict nom -> couleur pour les cellules
"""
if not items:
return None
item_names = set(items) if isinstance(items, dict) else items
cells = []
for enrollment in enrollments:
student = enrollment.student
student_name = student.full_name_reversed
item_scores = {name: {"score": 0.0, "max": 0.0} for name in item_names}
for exercise in assessment.exercises:
for element in exercise.grading_elements:
item_name = item_extractor(element)
if item_name and item_name in item_scores:
for g in element.grades:
if g.student_id == student.id and g.value:
calc_score = grading_calc.calculate_score(
g.value.strip(),
element.grading_type,
element.max_points,
)
if calc_score is not None:
item_scores[item_name]["score"] += calc_score
item_scores[item_name]["max"] += element.max_points
break
for name, data in item_scores.items():
if data["max"] > 0:
pct = round((data["score"] / data["max"]) * 100, 1)
cell_kwargs = dict(
student_id=student.id,
student_name=student_name,
item_name=name,
score=round(data["score"], 2),
max_points=data["max"],
percentage=pct,
)
if color_map and name in color_map:
cell_kwargs["color"] = color_map[name]
cells.append(HeatmapCell(**cell_kwargs))
return HeatmapData(
items=sorted(list(item_names)),
students=sorted_student_names,
cells=cells,
)

View File

@@ -11,6 +11,12 @@ from sqlalchemy import select, func
from sqlalchemy.orm import selectinload from sqlalchemy.orm import selectinload
from api.dependencies import AsyncSessionDep from api.dependencies import AsyncSessionDep
from api.helpers import (
count_eligible_students,
eligible_student_ids_subquery,
get_eligible_enrollments,
build_heatmap,
)
from infrastructure.database.models import ( from infrastructure.database.models import (
Assessment, Assessment,
Exercise, Exercise,
@@ -36,8 +42,6 @@ from schemas.assessment import (
SendReportsRequest, SendReportsRequest,
SendReportResult, SendReportResult,
SendReportsResponse, SendReportsResponse,
HeatmapCell,
HeatmapData,
) )
from schemas.grading import BulkGradeCreate, BulkGradeResponse, GradeRead from schemas.grading import BulkGradeCreate, BulkGradeResponse, GradeRead
from domain.services import GradingCalculator, StatisticsService, StudentReportService, generate_report_html, ConfigService from domain.services import GradingCalculator, StatisticsService, StudentReportService, generate_report_html, ConfigService
@@ -124,21 +128,13 @@ async def get_assessments(
total_elements += 1 total_elements += 1
# Compter les élèves éligibles (inscrits à la date de l'évaluation) # Compter les élèves éligibles (inscrits à la date de l'évaluation)
eligible_query = select(func.count(StudentEnrollment.id)).where( eligible_students_count = await count_eligible_students(
StudentEnrollment.class_group_id == assessment.class_group_id, session, assessment.class_group_id, assessment.date
StudentEnrollment.enrollment_date <= assessment.date,
(StudentEnrollment.departure_date.is_(None) |
(StudentEnrollment.departure_date >= assessment.date))
) )
eligible_result = await session.execute(eligible_query)
eligible_students_count = eligible_result.scalar() or 0
# Compter les notes saisies uniquement pour les élèves éligibles # Compter les notes saisies uniquement pour les élèves éligibles
eligible_student_ids = select(StudentEnrollment.student_id).where( eligible_student_ids = eligible_student_ids_subquery(
StudentEnrollment.class_group_id == assessment.class_group_id, assessment.class_group_id, assessment.date
StudentEnrollment.enrollment_date <= assessment.date,
(StudentEnrollment.departure_date.is_(None) |
(StudentEnrollment.departure_date >= assessment.date))
) )
grades_query = select(func.count(Grade.id)).where( grades_query = select(func.count(Grade.id)).where(
Grade.grading_element_id.in_( Grade.grading_element_id.in_(
@@ -239,21 +235,13 @@ async def get_assessment(
) )
# Calculer la progression # Calculer la progression
eligible_query = select(func.count(StudentEnrollment.id)).where( eligible_students_count = await count_eligible_students(
StudentEnrollment.class_group_id == assessment.class_group_id, session, assessment.class_group_id, assessment.date
StudentEnrollment.enrollment_date <= assessment.date,
(StudentEnrollment.departure_date.is_(None) |
(StudentEnrollment.departure_date >= assessment.date))
) )
eligible_result = await session.execute(eligible_query)
eligible_students_count = eligible_result.scalar() or 0
# Compter les notes uniquement pour les élèves éligibles # Compter les notes uniquement pour les élèves éligibles
eligible_student_ids = select(StudentEnrollment.student_id).where( eligible_student_ids = eligible_student_ids_subquery(
StudentEnrollment.class_group_id == assessment.class_group_id, assessment.class_group_id, assessment.date
StudentEnrollment.enrollment_date <= assessment.date,
(StudentEnrollment.departure_date.is_(None) |
(StudentEnrollment.departure_date >= assessment.date))
) )
grades_query = select(func.count(Grade.id)).where( grades_query = select(func.count(Grade.id)).where(
Grade.grading_element_id.in_( Grade.grading_element_id.in_(
@@ -322,18 +310,9 @@ async def get_assessment_results(
raise HTTPException(status_code=404, detail="Évaluation non trouvée") raise HTTPException(status_code=404, detail="Évaluation non trouvée")
# Récupérer les élèves éligibles # Récupérer les élèves éligibles
eligible_query = ( enrollments = await get_eligible_enrollments(
select(StudentEnrollment) session, assessment.class_group_id, assessment.date
.options(selectinload(StudentEnrollment.student))
.where(
StudentEnrollment.class_group_id == assessment.class_group_id,
StudentEnrollment.enrollment_date <= assessment.date,
(StudentEnrollment.departure_date.is_(None) |
(StudentEnrollment.departure_date >= assessment.date))
)
) )
eligible_result = await session.execute(eligible_query)
enrollments = eligible_result.scalars().all()
# Calculer le total des points maximum # Calculer le total des points maximum
total_max_points = 0 total_max_points = 0
@@ -392,7 +371,7 @@ async def get_assessment_results(
student_scores[student_id] = StudentScore( student_scores[student_id] = StudentScore(
student_id=student_id, student_id=student_id,
student_name=f"{student.last_name} {student.first_name}", student_name=student.full_name_reversed,
email=student.email, # Ajouter l'email de l'élève email=student.email, # Ajouter l'email de l'élève
total_score=round(total_score, 2), total_score=round(total_score, 2),
total_max_points=counted_max, total_max_points=counted_max,
@@ -440,97 +419,25 @@ async def get_assessment_results(
all_domains[element.domain.name] = element.domain.color all_domains[element.domain.name] = element.domain.color
# Calculer heatmap des compétences si présentes # Calculer heatmap des compétences si présentes
if all_competences: competences_heatmap = build_heatmap(
competences_cells = [] enrollments=enrollments,
assessment=assessment,
for enrollment in enrollments: items=all_competences,
student = enrollment.student item_extractor=lambda el: el.skill,
student_name = f"{student.last_name} {student.first_name}" grading_calc=grading_calc,
sorted_student_names=[s.student_name for s in sorted_scores],
# Calculer score par compétence pour cet élève )
competence_scores = {}
for comp in all_competences:
competence_scores[comp] = {"score": 0.0, "max": 0.0}
for exercise in assessment.exercises:
for element in exercise.grading_elements:
if element.skill and element.skill in competence_scores:
# Trouver la note
for g in element.grades:
if g.student_id == student.id and g.value:
calc_score = grading_calc.calculate_score(
g.value.strip(), element.grading_type, element.max_points
)
if calc_score is not None:
competence_scores[element.skill]["score"] += calc_score
competence_scores[element.skill]["max"] += element.max_points
break
# Créer les cellules
for comp, data in competence_scores.items():
if data["max"] > 0:
pct = round((data["score"] / data["max"]) * 100, 1)
competences_cells.append(HeatmapCell(
student_id=student.id,
student_name=student_name,
item_name=comp,
score=round(data["score"], 2),
max_points=data["max"],
percentage=pct
))
competences_heatmap = HeatmapData(
items=sorted(list(all_competences)),
students=[s.student_name for s in sorted_scores],
cells=competences_cells
)
# Calculer heatmap des domaines si présents # Calculer heatmap des domaines si présents
if all_domains: domains_heatmap = build_heatmap(
domains_cells = [] enrollments=enrollments,
assessment=assessment,
for enrollment in enrollments: items=all_domains,
student = enrollment.student item_extractor=lambda el: el.domain.name if el.domain else None,
student_name = f"{student.last_name} {student.first_name}" grading_calc=grading_calc,
sorted_student_names=[s.student_name for s in sorted_scores],
# Calculer score par domaine pour cet élève color_map=all_domains,
domain_scores = {} )
for dom in all_domains:
domain_scores[dom] = {"score": 0.0, "max": 0.0}
for exercise in assessment.exercises:
for element in exercise.grading_elements:
if element.domain and element.domain.name in domain_scores:
# Trouver la note
for g in element.grades:
if g.student_id == student.id and g.value:
calc_score = grading_calc.calculate_score(
g.value.strip(), element.grading_type, element.max_points
)
if calc_score is not None:
domain_scores[element.domain.name]["score"] += calc_score
domain_scores[element.domain.name]["max"] += element.max_points
break
# Créer les cellules
for dom, data in domain_scores.items():
if data["max"] > 0:
pct = round((data["score"] / data["max"]) * 100, 1)
domains_cells.append(HeatmapCell(
student_id=student.id,
student_name=student_name,
item_name=dom,
score=round(data["score"], 2),
max_points=data["max"],
percentage=pct,
color=all_domains.get(dom)
))
domains_heatmap = HeatmapData(
items=sorted(list(all_domains.keys())),
students=[s.student_name for s in sorted_scores],
cells=domains_cells
)
return AssessmentResults( return AssessmentResults(
assessment_id=assessment.id, assessment_id=assessment.id,
@@ -827,21 +734,13 @@ async def update_assessment(
) )
# Calculer la progression # Calculer la progression
eligible_query = select(func.count(StudentEnrollment.id)).where( eligible_students_count = await count_eligible_students(
StudentEnrollment.class_group_id == assessment.class_group_id, session, assessment.class_group_id, assessment.date
StudentEnrollment.enrollment_date <= assessment.date,
(StudentEnrollment.departure_date.is_(None) |
(StudentEnrollment.departure_date >= assessment.date))
) )
eligible_result = await session.execute(eligible_query)
eligible_students_count = eligible_result.scalar() or 0
# Compter les notes uniquement pour les élèves éligibles # Compter les notes uniquement pour les élèves éligibles
eligible_student_ids = select(StudentEnrollment.student_id).where( eligible_student_ids = eligible_student_ids_subquery(
StudentEnrollment.class_group_id == assessment.class_group_id, assessment.class_group_id, assessment.date
StudentEnrollment.enrollment_date <= assessment.date,
(StudentEnrollment.departure_date.is_(None) |
(StudentEnrollment.departure_date >= assessment.date))
) )
grades_query = select(func.count(Grade.id)).where( grades_query = select(func.count(Grade.id)).where(
Grade.grading_element_id.in_( Grade.grading_element_id.in_(
@@ -1132,7 +1031,7 @@ async def send_reports(
total_failed = 0 total_failed = 0
for student in students: for student in students:
student_name = f"{student.first_name} {student.last_name}" student_name = student.full_name
# Vérifier que l'élève a une adresse email # Vérifier que l'élève a une adresse email
if not student.email: if not student.email:
@@ -1307,7 +1206,7 @@ async def preview_report(
if student_id not in all_students_grades: if student_id not in all_students_grades:
raise HTTPException( raise HTTPException(
status_code=400, status_code=400,
detail=f"L'élève {student.first_name} {student.last_name} n'a pas de notes pour cette évaluation" detail=f"L'élève {student.full_name} n'a pas de notes pour cette évaluation"
) )
# Créer le service de rapport # Créer le service de rapport

View File

@@ -12,6 +12,7 @@ from sqlalchemy import select, func
from sqlalchemy.orm import selectinload from sqlalchemy.orm import selectinload
from api.dependencies import AsyncSessionDep from api.dependencies import AsyncSessionDep
from api.helpers import ensure_unique_name
from infrastructure.database.models import ( from infrastructure.database.models import (
ClassGroup, ClassGroup,
Student, Student,
@@ -201,7 +202,7 @@ async def get_class_students(
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=student.full_name,
current_class_id=class_id if is_active else None, current_class_id=class_id if is_active else None,
current_class_name=cls.name if is_active else None, current_class_name=cls.name if is_active else None,
enrollment_id=enrollment.id, enrollment_id=enrollment.id,
@@ -419,13 +420,7 @@ async def create_class(
Crée une nouvelle classe. Crée une nouvelle classe.
""" """
# Vérifier l'unicité du nom # Vérifier l'unicité du nom
existing_query = select(ClassGroup).where(ClassGroup.name == class_data.name) await ensure_unique_name(session, ClassGroup, class_data.name, entity_label="classe")
existing_result = await session.execute(existing_query)
if existing_result.scalar_one_or_none():
raise HTTPException(
status_code=400,
detail=f"Une classe avec le nom '{class_data.name}' existe déjà"
)
# Créer la nouvelle classe # Créer la nouvelle classe
new_class = ClassGroup( new_class = ClassGroup(
@@ -466,16 +461,10 @@ async def update_class(
# Vérifier l'unicité du nouveau nom si modifié # Vérifier l'unicité du nouveau nom si modifié
if class_data.name and class_data.name != cls.name: if class_data.name and class_data.name != cls.name:
existing_query = select(ClassGroup).where( await ensure_unique_name(
ClassGroup.name == class_data.name, session, ClassGroup, class_data.name,
ClassGroup.id != class_id exclude_id=class_id, entity_label="classe"
) )
existing_result = await session.execute(existing_query)
if existing_result.scalar_one_or_none():
raise HTTPException(
status_code=400,
detail=f"Une autre classe avec le nom '{class_data.name}' existe déjà"
)
# Appliquer les modifications # Appliquer les modifications
if class_data.name is not None: if class_data.name is not None:

View File

@@ -8,6 +8,7 @@ from fastapi import APIRouter, HTTPException
from sqlalchemy import select, func, delete from sqlalchemy import select, func, delete
from api.dependencies import AsyncSessionDep from api.dependencies import AsyncSessionDep
from api.helpers import ensure_unique_name, upsert_app_configs
from infrastructure.database.models import ( from infrastructure.database.models import (
AppConfig, AppConfig,
Competence, Competence,
@@ -228,13 +229,7 @@ async def create_competence(
Crée une nouvelle compétence. Crée une nouvelle compétence.
""" """
# Vérifier l'unicité du nom # Vérifier l'unicité du nom
existing_query = select(Competence).where(Competence.name == data.name) await ensure_unique_name(session, Competence, data.name, entity_label="compétence")
existing_result = await session.execute(existing_query)
if existing_result.scalar_one_or_none():
raise HTTPException(
status_code=400,
detail=f"Une compétence avec le nom '{data.name}' existe déjà"
)
# Déterminer l'index d'ordre # Déterminer l'index d'ordre
if data.order_index is None: if data.order_index is None:
@@ -284,16 +279,7 @@ async def update_competence(
# Vérifier l'unicité du nouveau nom # Vérifier l'unicité du nouveau nom
if data.name and data.name != competence.name: if data.name and data.name != competence.name:
existing_query = select(Competence).where( await ensure_unique_name(session, Competence, data.name, exclude_id=competence_id, entity_label="compétence")
Competence.name == data.name,
Competence.id != competence_id
)
existing_result = await session.execute(existing_query)
if existing_result.scalar_one_or_none():
raise HTTPException(
status_code=400,
detail=f"Une autre compétence avec le nom '{data.name}' existe déjà"
)
# Appliquer les modifications # Appliquer les modifications
if data.name is not None: if data.name is not None:
@@ -353,13 +339,7 @@ async def create_domain(
Crée un nouveau domaine. Crée un nouveau domaine.
""" """
# Vérifier l'unicité du nom # Vérifier l'unicité du nom
existing_query = select(Domain).where(Domain.name == data.name) await ensure_unique_name(session, Domain, data.name, entity_label="domaine")
existing_result = await session.execute(existing_query)
if existing_result.scalar_one_or_none():
raise HTTPException(
status_code=400,
detail=f"Un domaine avec le nom '{data.name}' existe déjà"
)
# Créer le domaine # Créer le domaine
domain = Domain( domain = Domain(
@@ -398,16 +378,7 @@ async def update_domain(
# Vérifier l'unicité du nouveau nom # Vérifier l'unicité du nouveau nom
if data.name and data.name != domain.name: if data.name and data.name != domain.name:
existing_query = select(Domain).where( await ensure_unique_name(session, Domain, data.name, exclude_id=domain_id, entity_label="domaine")
Domain.name == data.name,
Domain.id != domain_id
)
existing_result = await session.execute(existing_query)
if existing_result.scalar_one_or_none():
raise HTTPException(
status_code=400,
detail=f"Un autre domaine avec le nom '{data.name}' existe déjà"
)
# Appliquer les modifications # Appliquer les modifications
if data.name is not None: if data.name is not None:
@@ -669,18 +640,7 @@ async def update_app_config(
if data.default_grading_system is not None: if data.default_grading_system is not None:
updates.append(("default_grading_system", data.default_grading_system)) updates.append(("default_grading_system", data.default_grading_system))
for key, value in updates: await upsert_app_configs(session, dict(updates))
# Chercher la config existante
query = select(AppConfig).where(AppConfig.key == key)
result = await session.execute(query)
config = result.scalar_one_or_none()
if config:
config.value = value
else:
# Créer si n'existe pas
new_config = AppConfig(key=key, value=value)
session.add(new_config)
await session.commit() await session.commit()
@@ -755,18 +715,7 @@ async def update_smtp_config(
if data.from_address is not None: if data.from_address is not None:
updates.append(("email.from_address", data.from_address)) updates.append(("email.from_address", data.from_address))
for key, value in updates: await upsert_app_configs(session, dict(updates))
# Chercher la config existante
query = select(AppConfig).where(AppConfig.key == key)
result = await session.execute(query)
config = result.scalar_one_or_none()
if config:
config.value = value
else:
# Créer si n'existe pas
new_config = AppConfig(key=key, value=value)
session.add(new_config)
await session.commit() await session.commit()
@@ -843,16 +792,7 @@ async def update_notes_gradient(
if data.enabled is not None: if data.enabled is not None:
updates.append(("grading.notes_gradient.enabled", "true" if data.enabled else "false")) updates.append(("grading.notes_gradient.enabled", "true" if data.enabled else "false"))
for key, value in updates: await upsert_app_configs(session, dict(updates))
query = select(AppConfig).where(AppConfig.key == key)
result = await session.execute(query)
config = result.scalar_one_or_none()
if config:
config.value = value
else:
new_config = AppConfig(key=key, value=value)
session.add(new_config)
await session.commit() await session.commit()

View File

@@ -202,7 +202,7 @@ async def get_council_preparation(
student_id=student.id, student_id=student.id,
first_name=student.first_name, first_name=student.first_name,
last_name=student.last_name, last_name=student.last_name,
full_name=f"{student.first_name} {student.last_name}", full_name=student.full_name,
overall_average=overall_average, overall_average=overall_average,
assessment_count=assessment_count, assessment_count=assessment_count,
grades_by_assessment=grades_by_assessment, grades_by_assessment=grades_by_assessment,

View File

@@ -10,6 +10,7 @@ from sqlalchemy import select, func
from sqlalchemy.orm import selectinload from sqlalchemy.orm import selectinload
from api.dependencies import AsyncSessionDep from api.dependencies import AsyncSessionDep
from api.helpers import get_active_enrollment
from infrastructure.database.models import ( from infrastructure.database.models import (
Student, Student,
StudentEnrollment, StudentEnrollment,
@@ -62,11 +63,7 @@ async def get_students(
students_list = [] students_list = []
for student in students: for student in students:
# Trouver l'inscription active # Trouver l'inscription active
current_enrollment = None current_enrollment = get_active_enrollment(student)
for enrollment in student.enrollments:
if enrollment.departure_date is None:
current_enrollment = enrollment
break
# Filtrer par classe si demandé # Filtrer par classe si demandé
if class_id and (not current_enrollment or current_enrollment.class_group_id != class_id): if class_id and (not current_enrollment or current_enrollment.class_group_id != class_id):
@@ -78,7 +75,7 @@ async def get_students(
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=student.full_name,
current_class_id=current_enrollment.class_group_id if current_enrollment else None, 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 current_class_name=current_enrollment.class_group.name if current_enrollment else None
) )
@@ -113,13 +110,10 @@ async def get_student(
raise HTTPException(status_code=404, detail="Étudiant non trouvé") raise HTTPException(status_code=404, detail="Étudiant non trouvé")
# Trouver l'inscription active # Trouver l'inscription active
current_enrollment = None current_enrollment = get_active_enrollment(student)
enrollments_list = [] enrollments_list = []
for enrollment in student.enrollments: for enrollment in student.enrollments:
if enrollment.departure_date is None:
current_enrollment = enrollment
enrollments_list.append( enrollments_list.append(
EnrollmentRead( EnrollmentRead(
id=enrollment.id, id=enrollment.id,
@@ -144,7 +138,7 @@ async def get_student(
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=student.full_name,
current_class_id=current_enrollment.class_group_id if current_enrollment else None, 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, current_class_name=current_enrollment.class_group.name if current_enrollment else None,
enrollments=enrollments_list enrollments=enrollments_list
@@ -210,7 +204,7 @@ async def create_student(
last_name=new_student.last_name, last_name=new_student.last_name,
first_name=new_student.first_name, first_name=new_student.first_name,
email=new_student.email, email=new_student.email,
full_name=f"{new_student.first_name} {new_student.last_name}", full_name=new_student.full_name,
current_class_id=current_class_id, current_class_id=current_class_id,
current_class_name=current_class_name current_class_name=current_class_name
) )
@@ -264,18 +258,14 @@ async def update_student(
await session.refresh(student) await session.refresh(student)
# Trouver la classe actuelle # Trouver la classe actuelle
current_enrollment = None current_enrollment = get_active_enrollment(student)
for enrollment in student.enrollments:
if enrollment.departure_date is None:
current_enrollment = enrollment
break
return StudentWithClass( return StudentWithClass(
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=student.full_name,
current_class_id=current_enrollment.class_group_id if current_enrollment else None, 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 current_class_name=current_enrollment.class_group.name if current_enrollment else None
) )
@@ -325,18 +315,14 @@ async def update_student_email(
await session.refresh(student) await session.refresh(student)
# Trouver la classe actuelle # Trouver la classe actuelle
current_enrollment = None current_enrollment = get_active_enrollment(student)
for enrollment in student.enrollments:
if enrollment.departure_date is None:
current_enrollment = enrollment
break
return StudentWithClass( return StudentWithClass(
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=student.full_name,
current_class_id=current_enrollment.class_group_id if current_enrollment else None, 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 current_class_name=current_enrollment.class_group.name if current_enrollment else None
) )
@@ -403,7 +389,7 @@ async def enroll_student(
if active_result.scalar_one_or_none(): if active_result.scalar_one_or_none():
raise HTTPException( raise HTTPException(
status_code=400, status_code=400,
detail=f"L'élève {student.first_name} {student.last_name} est déjà inscrit dans une classe" detail=f"L'élève {student.full_name} est déjà inscrit dans une classe"
) )
else: else:
# Nouvel élève # Nouvel élève
@@ -447,9 +433,9 @@ async def enroll_student(
return EnrollmentResponse( return EnrollmentResponse(
enrollment_id=enrollment.id, enrollment_id=enrollment.id,
student_id=student.id, student_id=student.id,
student_name=f"{student.first_name} {student.last_name}", student_name=student.full_name,
class_name=class_group.name, class_name=class_group.name,
message=f"Élève {student.first_name} {student.last_name} inscrit en {class_group.name}", message=f"Élève {student.full_name} inscrit en {class_group.name}",
is_new_student=is_new_student is_new_student=is_new_student
) )
@@ -493,7 +479,7 @@ async def transfer_student(
if not old_enrollment: if not old_enrollment:
raise HTTPException( raise HTTPException(
status_code=400, status_code=400,
detail=f"Aucune inscription active trouvée pour l'élève {student.first_name} {student.last_name}" detail=f"Aucune inscription active trouvée pour l'élève {student.full_name}"
) )
old_class_name = old_enrollment.class_group.name old_class_name = old_enrollment.class_group.name
@@ -516,10 +502,10 @@ async def transfer_student(
return TransferResponse( return TransferResponse(
old_enrollment_id=old_enrollment.id, old_enrollment_id=old_enrollment.id,
new_enrollment_id=new_enrollment.id, new_enrollment_id=new_enrollment.id,
student_name=f"{student.first_name} {student.last_name}", student_name=student.full_name,
old_class_name=old_class_name, old_class_name=old_class_name,
new_class_name=new_class.name, new_class_name=new_class.name,
message=f"Élève {student.first_name} {student.last_name} transféré de {old_class_name} vers {new_class.name}" message=f"Élève {student.full_name} transféré de {old_class_name} vers {new_class.name}"
) )
@@ -554,7 +540,7 @@ async def record_departure(
if not enrollment: if not enrollment:
raise HTTPException( raise HTTPException(
status_code=400, status_code=400,
detail=f"Aucune inscription active trouvée pour l'élève {student.first_name} {student.last_name}" detail=f"Aucune inscription active trouvée pour l'élève {student.full_name}"
) )
class_name = enrollment.class_group.name class_name = enrollment.class_group.name
@@ -567,7 +553,7 @@ async def record_departure(
return DepartureResponse( return DepartureResponse(
enrollment_id=enrollment.id, enrollment_id=enrollment.id,
student_name=f"{student.first_name} {student.last_name}", student_name=student.full_name,
class_name=class_name, class_name=class_name,
message=f"Départ de {student.first_name} {student.last_name} de {class_name} enregistré" message=f"Départ de {student.full_name} de {class_name} enregistré"
) )

View File

@@ -123,6 +123,16 @@ class Student(Base):
"CouncilAppreciation", back_populates="student", lazy="selectin" "CouncilAppreciation", back_populates="student", lazy="selectin"
) )
@property
def full_name(self) -> str:
"""Prénom Nom"""
return f"{self.first_name} {self.last_name}"
@property
def full_name_reversed(self) -> str:
"""Nom Prénom"""
return f"{self.last_name} {self.first_name}"
def __repr__(self): def __repr__(self):
return f"<Student {self.first_name} {self.last_name}>" return f"<Student {self.first_name} {self.last_name}>"

View File

@@ -8,6 +8,7 @@
"name": "notytex-frontend", "name": "notytex-frontend",
"version": "2.0.0", "version": "2.0.0",
"dependencies": { "dependencies": {
"@sgratzl/chartjs-chart-boxplot": "^4.4.5",
"axios": "^1.7.9", "axios": "^1.7.9",
"chart.js": "^4.4.7", "chart.js": "^4.4.7",
"pinia": "^2.2.6", "pinia": "^2.2.6",
@@ -1143,6 +1144,24 @@
"win32" "win32"
] ]
}, },
"node_modules/@sgratzl/boxplots": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@sgratzl/boxplots/-/boxplots-2.0.0.tgz",
"integrity": "sha512-XHQTNTk0OtDEZyT7v+BKNKt4OEXYhn4GtfI+iQ1eFiFsx8Aq/csz+mOJYbfpkiP8Popd/8Nky8vLh1zzkYn0gw==",
"license": "MIT"
},
"node_modules/@sgratzl/chartjs-chart-boxplot": {
"version": "4.4.5",
"resolved": "https://registry.npmjs.org/@sgratzl/chartjs-chart-boxplot/-/chartjs-chart-boxplot-4.4.5.tgz",
"integrity": "sha512-hvHcUIPIyzNPRmvPNIbA/R68zZKv7XHUcNrYJZTB8QsVE0KKuuGuEK98yCeCy32vZXQBl/PrHGOYMXeuqZrU0w==",
"license": "MIT",
"dependencies": {
"@sgratzl/boxplots": "^2.0.0"
},
"peerDependencies": {
"chart.js": "^4.1.1"
}
},
"node_modules/@types/estree": { "node_modules/@types/estree": {
"version": "1.0.8", "version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",

View File

@@ -10,20 +10,21 @@
"lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs --fix --ignore-path .gitignore" "lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs --fix --ignore-path .gitignore"
}, },
"dependencies": { "dependencies": {
"vue": "^3.5.13", "@sgratzl/chartjs-chart-boxplot": "^4.4.5",
"vue-router": "^4.5.0",
"pinia": "^2.2.6",
"axios": "^1.7.9", "axios": "^1.7.9",
"chart.js": "^4.4.7", "chart.js": "^4.4.7",
"vue-chartjs": "^5.3.2" "pinia": "^2.2.6",
"vue": "^3.5.13",
"vue-chartjs": "^5.3.2",
"vue-router": "^4.5.0"
}, },
"devDependencies": { "devDependencies": {
"@vitejs/plugin-vue": "^5.2.1", "@vitejs/plugin-vue": "^5.2.1",
"vite": "^6.0.3",
"tailwindcss": "^3.4.17",
"postcss": "^8.4.49",
"autoprefixer": "^10.4.20", "autoprefixer": "^10.4.20",
"eslint": "^9.16.0", "eslint": "^9.16.0",
"eslint-plugin-vue": "^9.31.0" "eslint-plugin-vue": "^9.31.0",
"postcss": "^8.4.49",
"tailwindcss": "^3.4.17",
"vite": "^6.0.3"
} }
} }

View File

@@ -0,0 +1,473 @@
<template>
<div class="bg-white rounded-lg shadow-sm overflow-hidden h-full flex flex-col">
<!-- Compact header with inline navigation -->
<div class="px-4 py-2 border-b border-gray-200 bg-gray-50 flex items-center gap-3 flex-shrink-0">
<button
@click="$emit('prev')"
:disabled="!hasPrev"
class="p-1 rounded hover:bg-gray-200 text-gray-500 disabled:opacity-30 disabled:cursor-not-allowed flex-shrink-0"
>
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" /></svg>
</button>
<div class="flex-1 flex items-center justify-between min-w-0">
<div class="min-w-0">
<span class="font-bold text-gray-900">{{ student.last_name }} {{ student.first_name }}</span>
<span class="text-xs text-gray-500 ml-2">
#{{ student.rank }}/{{ totalStudents }}
</span>
<span v-if="gapToMean !== null" class="text-xs ml-1" :class="gapToMean >= 0 ? 'text-green-600' : 'text-red-500'">
({{ gapToMean >= 0 ? '+' : '' }}{{ gapToMean.toFixed(1) }})
</span>
</div>
<div class="flex items-baseline gap-1 flex-shrink-0 ml-3">
<span class="text-2xl font-bold tabular-nums" :class="averageColor(student.average)">
{{ student.average !== null ? student.average.toFixed(2) : '-' }}
</span>
<span class="text-xs text-gray-400">/20</span>
</div>
</div>
<button
@click="$emit('next')"
:disabled="!hasNext"
class="p-1 rounded hover:bg-gray-200 text-gray-500 disabled:opacity-30 disabled:cursor-not-allowed flex-shrink-0"
>
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" /></svg>
</button>
</div>
<!-- Content: 2-column grid on xl -->
<div class="flex-1 overflow-y-auto p-3">
<div class="grid grid-cols-1 xl:grid-cols-5 gap-3">
<!-- Left column: chart + trimester summary -->
<div class="xl:col-span-3 space-y-2">
<!-- Chart -->
<section v-if="chartData.labels.length > 0">
<h3 class="text-[11px] font-semibold text-gray-500 uppercase tracking-wide mb-1">Parcours sur l'année</h3>
<div class="bg-gray-50 rounded-lg p-2" style="height: 220px">
<Bar :key="student.student_id" :data="chartData" :options="chartOptions" />
</div>
<!-- Trimester summary: inline compact -->
<div class="flex gap-2 mt-2">
<div
v-for="tri in trimesterSummary"
:key="tri.trimester"
class="flex-1 flex items-center justify-center gap-1.5 px-2 py-1.5 rounded text-center"
:class="tri.trimester === currentTrimester ? 'bg-blue-50 ring-1 ring-blue-200' : 'bg-gray-50'"
>
<span class="text-[10px] text-gray-400">T{{ tri.trimester }}</span>
<span class="text-sm font-bold tabular-nums" :class="averageColor(tri.studentAvg)">
{{ tri.studentAvg !== null ? tri.studentAvg.toFixed(1) : '-' }}
</span>
<span class="text-[10px] text-gray-400">(cls {{ tri.classAvg !== null ? tri.classAvg.toFixed(1) : '-' }})</span>
<span v-if="tri.rank" class="text-[10px] text-gray-400">#{{ tri.rank }}</span>
<span v-if="tri.delta !== null" class="text-[10px]" :class="tri.delta > 0 ? 'text-green-600' : tri.delta < 0 ? 'text-red-500' : 'text-gray-400'">
{{ tri.delta > 0 ? '+' : '' }}{{ tri.delta.toFixed(1) }}
</span>
</div>
</div>
</section>
</div>
<!-- Right column: evaluations + domains + competences -->
<div class="xl:col-span-2 space-y-3">
<!-- Evaluations -->
<section v-if="assessmentList.length">
<h3 class="text-[11px] font-semibold text-gray-500 uppercase tracking-wide mb-1">Évaluations</h3>
<div class="space-y-0.5">
<div
v-for="a in assessmentList"
:key="a.assessment_id"
class="flex items-center justify-between py-1 px-2 rounded text-xs"
:class="assessmentBg(a)"
>
<span class="text-gray-700 truncate mr-1">{{ a.assessment_title }}</span>
<div class="flex items-center gap-1 flex-shrink-0">
<span class="font-semibold tabular-nums" :class="averageColor(a.score_on_20)">
{{ a.score !== null ? `${a.score.toFixed(1)}/${a.max_points.toFixed(0)}` : '-' }}
</span>
<span v-if="a.score_on_20 !== null" class="text-[10px] text-gray-400 tabular-nums">
({{ a.score_on_20.toFixed(1) }})
</span>
</div>
</div>
</div>
</section>
</div>
</div>
<!-- Box plots section - full width below chart+evals grid -->
<div v-if="domainList.length || competenceList.length"
class="grid grid-cols-1 lg:grid-cols-2 gap-3 mt-3">
<section v-if="domainList.length">
<h3 class="text-[11px] font-semibold text-gray-500 uppercase tracking-wide mb-1">Domaines</h3>
<div class="bg-gray-50 rounded-lg p-2" :style="{ height: domainChartHeight }">
<ChartGeneric type="boxplot" :data="domainBoxPlotData" :options="boxPlotOptions" />
</div>
</section>
<section v-if="competenceList.length">
<h3 class="text-[11px] font-semibold text-gray-500 uppercase tracking-wide mb-1">Compétences</h3>
<div class="bg-gray-50 rounded-lg p-2" :style="{ height: competenceChartHeight }">
<ChartGeneric type="boxplot" :data="competenceBoxPlotData" :options="boxPlotOptions" />
</div>
</section>
</div>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue'
import { Bar, Chart as ChartGeneric } from 'vue-chartjs'
import { Chart as ChartJS, CategoryScale, LinearScale, BarElement, LineElement, PointElement, Title, Tooltip, Legend } from 'chart.js'
import { BoxPlotController, BoxAndWiskers } from '@sgratzl/chartjs-chart-boxplot'
ChartJS.register(CategoryScale, LinearScale, BarElement, LineElement, PointElement, Title, Tooltip, Legend, BoxPlotController, BoxAndWiskers)
const props = defineProps({
student: { type: Object, required: true },
classStats: { type: Object, required: true },
allTrimesterStats: { type: Array, default: () => [] },
currentTrimester: { type: Number, required: true },
totalStudents: { type: Number, required: true },
hasPrev: { type: Boolean, default: false },
hasNext: { type: Boolean, default: false },
classDomainsStats: { type: Array, default: () => [] },
classCompetencesStats: { type: Array, default: () => [] }
})
defineEmits(['prev', 'next'])
const TRIMESTER_COLORS = {
1: 'rgba(99, 102, 241, 0.7)',
2: 'rgba(59, 130, 246, 0.7)',
3: 'rgba(139, 92, 246, 0.7)'
}
const gapToMean = computed(() => {
if (props.student.average === null || props.classStats.mean === null) return null
return props.student.average - props.classStats.mean
})
// Chart data
const chartData = computed(() => {
const labels = []
const studentScores = []
const classAvgs = []
const barBgColors = []
for (const { trimester, stats } of props.allTrimesterStats) {
const studentData = stats.student_averages?.find(s => s.student_id === props.student.student_id)
const refStudent = stats.student_averages?.find(s => s.assessment_scores && Object.keys(s.assessment_scores).length > 0)
if (!refStudent) continue
const assessments = Object.values(refStudent.assessment_scores)
.sort((a, b) => a.assessment_id - b.assessment_id)
for (const a of assessments) {
labels.push(`T${trimester}: ${a.assessment_title}`)
studentScores.push(studentData?.assessment_scores?.[a.assessment_id]?.score_on_20 ?? null)
barBgColors.push(TRIMESTER_COLORS[trimester] || TRIMESTER_COLORS[1])
let sum = 0, count = 0
for (const s of stats.student_averages) {
const score = s.assessment_scores?.[a.assessment_id]
if (score?.score_on_20 != null) { sum += score.score_on_20; count++ }
}
classAvgs.push(count > 0 ? sum / count : null)
}
}
return {
labels,
datasets: [
{
label: 'Élève',
data: studentScores,
backgroundColor: barBgColors,
borderRadius: 3,
borderWidth: 0,
order: 2
},
{
label: 'Moyenne classe',
data: classAvgs,
type: 'line',
borderColor: 'rgba(249, 115, 22, 0.8)',
pointBackgroundColor: 'rgba(249, 115, 22, 0.8)',
borderWidth: 2,
pointRadius: 3,
fill: false,
tension: 0.1,
order: 1
}
]
}
})
const chartOptions = {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
position: 'bottom',
labels: { boxWidth: 10, font: { size: 10 }, padding: 12 }
},
tooltip: {
callbacks: {
label(ctx) {
if (ctx.raw === null) return null
return `${ctx.dataset.label}: ${ctx.raw.toFixed(1)}/20`
}
}
}
},
scales: {
y: {
beginAtZero: true,
max: 20,
ticks: { stepSize: 5, font: { size: 9 } }
},
x: {
ticks: {
callback(value) {
const label = this.getLabelForValue(value)
return label.length > 18 ? label.slice(0, 16) + '...' : label
},
maxRotation: 35,
font: { size: 8 }
}
}
}
}
// Trimester summary
const trimesterSummary = computed(() => {
const summaries = []
let prevStudentAvg = null
for (const { trimester, stats } of props.allTrimesterStats) {
const studentData = stats.student_averages?.find(s => s.student_id === props.student.student_id)
const studentAvg = studentData?.average ?? null
let rank = null
if (studentAvg !== null) {
const withAvg = (stats.student_averages || []).filter(s => s.average !== null)
withAvg.sort((a, b) => b.average - a.average)
const idx = withAvg.findIndex(s => s.student_id === props.student.student_id)
if (idx >= 0) rank = idx + 1
}
const delta = (studentAvg !== null && prevStudentAvg !== null) ? studentAvg - prevStudentAvg : null
summaries.push({ trimester, studentAvg, classAvg: stats.mean ?? null, rank, delta })
prevStudentAvg = studentAvg
}
return summaries
})
// Assessment list (current trimester only)
const assessmentList = computed(() => {
if (!props.student.assessment_scores) return []
return Object.values(props.student.assessment_scores).sort((a, b) => a.assessment_id - b.assessment_id)
})
// Domain stats
const domainList = computed(() => {
if (!props.student.domain_stats) return []
const lookup = {}
for (const d of props.classDomainsStats) { lookup[d.id] = d }
return Object.values(props.student.domain_stats)
.filter(d => d.total_points_possible > 0)
.map(d => {
const meta = lookup[d.domain_id] || {}
return {
id: d.domain_id,
name: meta.name || `Domaine ${d.domain_id}`,
color: meta.color || '#6B7280',
pct: (d.total_points_obtained / d.total_points_possible) * 100
}
})
.sort((a, b) => a.name.localeCompare(b.name, 'fr'))
})
// Competence stats
const competenceList = computed(() => {
if (!props.student.competence_stats) return []
const lookup = {}
for (const c of props.classCompetencesStats) { lookup[c.id] = c }
return Object.values(props.student.competence_stats)
.filter(c => c.total_points_possible > 0)
.map(c => {
const meta = lookup[c.competence_id] || {}
return {
id: c.competence_id,
name: meta.name || `Compétence ${c.competence_id}`,
color: meta.color || '#6B7280',
pct: (c.total_points_obtained / c.total_points_possible) * 100
}
})
.sort((a, b) => a.name.localeCompare(b.name, 'fr'))
})
// Domain distributions from all students in the class
const domainDistributions = computed(() => {
const distributions = {}
for (const student of props.classStats.student_averages || []) {
for (const [domainId, stats] of Object.entries(student.domain_stats || {})) {
if (stats.total_points_possible <= 0) continue
if (!distributions[domainId]) distributions[domainId] = []
distributions[domainId].push(
(stats.total_points_obtained / stats.total_points_possible) * 100
)
}
}
return distributions
})
// Competence distributions from all students in the class
const competenceDistributions = computed(() => {
const distributions = {}
for (const student of props.classStats.student_averages || []) {
for (const [compId, stats] of Object.entries(student.competence_stats || {})) {
if (stats.total_points_possible <= 0) continue
if (!distributions[compId]) distributions[compId] = []
distributions[compId].push(
(stats.total_points_obtained / stats.total_points_possible) * 100
)
}
}
return distributions
})
// Box plot data for domains
const domainBoxPlotData = computed(() => {
const domains = domainList.value
return {
labels: domains.map(d => d.name),
datasets: [
{
label: 'Classe',
data: domains.map(d => domainDistributions.value[d.id] || []),
backgroundColor: domains.map(d => `${d.color}33`),
borderColor: domains.map(d => d.color),
borderWidth: 1,
outlierRadius: 2,
itemRadius: 0,
meanRadius: 0
},
{
type: 'scatter',
label: 'Élève',
data: domains.map((d, i) => ({ x: d.pct, y: i })),
pointRadius: 7,
pointStyle: 'rectRot',
pointBackgroundColor: 'rgba(239, 68, 68, 0.9)',
pointBorderColor: 'white',
pointBorderWidth: 2
}
]
}
})
// Box plot data for competences
const competenceBoxPlotData = computed(() => {
const comps = competenceList.value
return {
labels: comps.map(c => c.name),
datasets: [
{
label: 'Classe',
data: comps.map(c => competenceDistributions.value[c.id] || []),
backgroundColor: comps.map(c => `${c.color}33`),
borderColor: comps.map(c => c.color),
borderWidth: 1,
outlierRadius: 2,
itemRadius: 0,
meanRadius: 0
},
{
type: 'scatter',
label: 'Élève',
data: comps.map((c, i) => ({ x: c.pct, y: i })),
pointRadius: 7,
pointStyle: 'rectRot',
pointBackgroundColor: 'rgba(239, 68, 68, 0.9)',
pointBorderColor: 'white',
pointBorderWidth: 2
}
]
}
})
const boxPlotOptions = {
responsive: true,
maintainAspectRatio: false,
indexAxis: 'y',
plugins: {
legend: { display: false },
tooltip: {
callbacks: {
label(ctx) {
if (ctx.dataset.type === 'scatter') {
return `Élève : ${ctx.raw.x.toFixed(0)}%`
}
const item = ctx.raw
return [
`Min: ${item.min?.toFixed(0)}%`,
`Q1: ${item.q1?.toFixed(0)}%`,
`Med: ${item.median?.toFixed(0)}%`,
`Q3: ${item.q3?.toFixed(0)}%`,
`Max: ${item.max?.toFixed(0)}%`
]
}
}
}
},
scales: {
x: {
min: 0,
max: 100,
ticks: { callback: v => `${v}%`, font: { size: 9 } }
},
y: {
ticks: { font: { size: 10 } }
}
}
}
const domainChartHeight = computed(() =>
`${Math.max(100, domainList.value.length * 35 + 40)}px`
)
const competenceChartHeight = computed(() =>
`${Math.max(100, competenceList.value.length * 35 + 40)}px`
)
function assessmentBg(a) {
if (a.score_on_20 === null) return 'bg-gray-50'
const classAvg = getClassAssessmentAvg(a.assessment_id)
if (classAvg === null) return 'bg-gray-50'
if (a.score_on_20 >= classAvg + 0.5) return 'bg-green-50'
if (a.score_on_20 <= classAvg - 0.5) return 'bg-red-50'
return 'bg-gray-50'
}
function getClassAssessmentAvg(assessmentId) {
const students = props.classStats.student_averages
if (!students?.length) return null
let sum = 0, count = 0
for (const s of students) {
const score = s.assessment_scores?.[assessmentId]
if (score?.score_on_20 != null) { sum += score.score_on_20; count++ }
}
return count > 0 ? sum / count : null
}
function averageColor(avg) {
if (avg === null || avg === undefined) return 'text-gray-400'
if (avg < 10) return 'text-red-600'
if (avg >= 14) return 'text-green-600'
return 'text-gray-900'
}
</script>

View File

@@ -0,0 +1,74 @@
<template>
<div class="bg-white rounded-lg shadow-sm overflow-hidden h-full flex flex-col">
<div class="px-3 py-2 border-b border-gray-200 flex-shrink-0">
<div class="relative">
<input
type="text"
:value="search"
@input="$emit('update:search', $event.target.value)"
placeholder="Rechercher..."
class="w-full text-xs border border-gray-300 rounded py-1.5 pl-7 pr-2 focus:ring-purple-500 focus:border-purple-500"
/>
<svg class="absolute left-2 top-2 h-3.5 w-3.5 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
</div>
</div>
<div class="overflow-y-auto flex-1">
<button
v-for="student in students"
:key="student.student_id"
@click="$emit('select', student.student_id)"
class="w-full text-left px-3 py-1.5 border-b border-gray-50 hover:bg-gray-50 transition-colors"
:class="student.student_id === selectedStudentId ? 'bg-blue-50 border-l-2 border-l-blue-500' : 'border-l-2 border-l-transparent'"
>
<div class="flex items-center justify-between gap-1">
<span class="text-xs font-medium text-gray-900 truncate">
{{ student.last_name }} {{ student.first_name }}
</span>
<div class="flex items-center gap-1 flex-shrink-0">
<span v-if="student.trend" class="text-[10px]" :class="trendClass(student.trend)">
{{ trendIcon(student.trend) }}
</span>
<span class="text-xs font-bold tabular-nums" :class="averageColor(student.average)">
{{ student.average !== null ? student.average.toFixed(1) : '-' }}
</span>
<span class="text-[10px] text-gray-400 tabular-nums w-7 text-right">
#{{ student.rank ?? '-' }}
</span>
</div>
</div>
</button>
</div>
</div>
</template>
<script setup>
defineProps({
students: { type: Array, required: true },
selectedStudentId: { type: Number, default: null },
classMean: { type: Number, default: null },
search: { type: String, default: '' }
})
defineEmits(['select', 'update:search'])
function averageColor(avg) {
if (avg === null) return 'text-gray-400'
if (avg < 10) return 'text-red-600'
if (avg >= 14) return 'text-green-600'
return 'text-gray-900'
}
function trendIcon(trend) {
if (trend === 'up') return '\u2197'
if (trend === 'down') return '\u2198'
return '\u2192'
}
function trendClass(trend) {
if (trend === 'up') return 'text-green-600'
if (trend === 'down') return 'text-red-500'
return 'text-gray-400'
}
</script>

View File

@@ -1,6 +1,7 @@
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import { ref, computed } from 'vue' import { ref, computed } from 'vue'
import assessmentsService from '@/services/assessments' import assessmentsService from '@/services/assessments'
import { withLoading } from './helpers'
export const useAssessmentsStore = defineStore('assessments', () => { export const useAssessmentsStore = defineStore('assessments', () => {
// State // State
@@ -44,130 +45,61 @@ export const useAssessmentsStore = defineStore('assessments', () => {
) )
// Actions // Actions
async function fetchAssessments(customFilters = null) { const fetchAssessments = withLoading(loading, error, async (customFilters = null) => {
loading.value = true const queryFilters = customFilters || filters.value
error.value = null assessments.value = await assessmentsService.getAll(queryFilters)
try { })
const queryFilters = customFilters || filters.value
assessments.value = await assessmentsService.getAll(queryFilters)
} catch (e) {
error.value = e.message
throw e
} finally {
loading.value = false
}
}
async function fetchAssessment(id) { const fetchAssessment = withLoading(loading, error, async (id) => {
loading.value = true currentAssessment.value = await assessmentsService.getById(id)
error.value = null return currentAssessment.value
try { })
currentAssessment.value = await assessmentsService.getById(id)
return currentAssessment.value
} catch (e) {
error.value = e.message
throw e
} finally {
loading.value = false
}
}
async function fetchResults(id) { const fetchResults = withLoading(loading, error, async (id) => {
loading.value = true currentResults.value = await assessmentsService.getResults(id)
error.value = null return currentResults.value
try { })
currentResults.value = await assessmentsService.getResults(id)
return currentResults.value
} catch (e) {
error.value = e.message
throw e
} finally {
loading.value = false
}
}
async function fetchGrades(id) { const fetchGrades = withLoading(loading, error, async (id) => {
loading.value = true currentGrades.value = await assessmentsService.getGrades(id)
error.value = null return currentGrades.value
try { })
currentGrades.value = await assessmentsService.getGrades(id)
return currentGrades.value
} catch (e) {
error.value = e.message
throw e
} finally {
loading.value = false
}
}
async function createAssessment(data) { const createAssessment = withLoading(loading, error, async (data) => {
loading.value = true const newAssessment = await assessmentsService.create(data)
error.value = null assessments.value.unshift(newAssessment)
try { return newAssessment
const newAssessment = await assessmentsService.create(data) })
assessments.value.unshift(newAssessment)
return newAssessment
} catch (e) {
error.value = e.message
throw e
} finally {
loading.value = false
}
}
async function updateAssessment(id, data) { const updateAssessment = withLoading(loading, error, async (id, data) => {
loading.value = true const updated = await assessmentsService.update(id, data)
error.value = null const index = assessments.value.findIndex(a => a.id === id)
try { if (index !== -1) {
const updated = await assessmentsService.update(id, data) assessments.value[index] = updated
const index = assessments.value.findIndex(a => a.id === id)
if (index !== -1) {
assessments.value[index] = updated
}
if (currentAssessment.value?.id === id) {
currentAssessment.value = updated
}
return updated
} catch (e) {
error.value = e.message
throw e
} finally {
loading.value = false
} }
} if (currentAssessment.value?.id === id) {
currentAssessment.value = updated
}
return updated
})
async function deleteAssessment(id) { const deleteAssessment = withLoading(loading, error, async (id) => {
loading.value = true await assessmentsService.delete(id)
error.value = null assessments.value = assessments.value.filter(a => a.id !== id)
try { if (currentAssessment.value?.id === id) {
await assessmentsService.delete(id) currentAssessment.value = null
assessments.value = assessments.value.filter(a => a.id !== id)
if (currentAssessment.value?.id === id) {
currentAssessment.value = null
}
} catch (e) {
error.value = e.message
throw e
} finally {
loading.value = false
} }
} })
async function saveGrades(id, grades) { // fetchAssessment is declared above so it can be called here safely.
loading.value = true // withLoading sets loading=false in its finally block, so the inner call
error.value = null // to fetchAssessment runs as a nested operation within the outer wrapper.
try { const saveGrades = withLoading(loading, error, async (id, grades) => {
const result = await assessmentsService.saveGrades(id, grades) const result = await assessmentsService.saveGrades(id, grades)
// Refresh assessment to update progress // Refresh assessment to update progress
await fetchAssessment(id) await fetchAssessment(id)
return result return result
} catch (e) { })
error.value = e.message
throw e
} finally {
loading.value = false
}
}
function setFilters(newFilters) { function setFilters(newFilters) {
filters.value = { ...filters.value, ...newFilters } filters.value = { ...filters.value, ...newFilters }

View File

@@ -1,6 +1,7 @@
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import { ref, computed } from 'vue' import { ref, computed } from 'vue'
import classesService from '@/services/classes' import classesService from '@/services/classes'
import { withLoading } from './helpers'
export const useClassesStore = defineStore('classes', () => { export const useClassesStore = defineStore('classes', () => {
// State // State
@@ -17,99 +18,45 @@ export const useClassesStore = defineStore('classes', () => {
) )
// Actions // Actions
async function fetchClasses() { const fetchClasses = withLoading(loading, error, async () => {
loading.value = true classes.value = await classesService.getAll()
error.value = null })
try {
classes.value = await classesService.getAll()
} catch (e) {
error.value = e.message
throw e
} finally {
loading.value = false
}
}
async function fetchClass(id) { const fetchClass = withLoading(loading, error, async (id) => {
loading.value = true currentClass.value = await classesService.getById(id)
error.value = null return currentClass.value
try { })
currentClass.value = await classesService.getById(id)
return currentClass.value
} catch (e) {
error.value = e.message
throw e
} finally {
loading.value = false
}
}
async function fetchClassStats(id, trimester = null) { const fetchClassStats = withLoading(loading, error, async (id, trimester = null) => {
loading.value = true currentStats.value = await classesService.getStats(id, trimester)
error.value = null return currentStats.value
try { })
currentStats.value = await classesService.getStats(id, trimester)
return currentStats.value
} catch (e) {
error.value = e.message
throw e
} finally {
loading.value = false
}
}
async function createClass(data) { const createClass = withLoading(loading, error, async (data) => {
loading.value = true const newClass = await classesService.create(data)
error.value = null classes.value.push(newClass)
try { return newClass
const newClass = await classesService.create(data) })
classes.value.push(newClass)
return newClass
} catch (e) {
error.value = e.message
throw e
} finally {
loading.value = false
}
}
async function updateClass(id, data) { const updateClass = withLoading(loading, error, async (id, data) => {
loading.value = true const updated = await classesService.update(id, data)
error.value = null const index = classes.value.findIndex(c => c.id === id)
try { if (index !== -1) {
const updated = await classesService.update(id, data) classes.value[index] = updated
const index = classes.value.findIndex(c => c.id === id)
if (index !== -1) {
classes.value[index] = updated
}
if (currentClass.value?.id === id) {
currentClass.value = updated
}
return updated
} catch (e) {
error.value = e.message
throw e
} finally {
loading.value = false
} }
} if (currentClass.value?.id === id) {
currentClass.value = updated
}
return updated
})
async function deleteClass(id) { const deleteClass = withLoading(loading, error, async (id) => {
loading.value = true await classesService.delete(id)
error.value = null classes.value = classes.value.filter(c => c.id !== id)
try { if (currentClass.value?.id === id) {
await classesService.delete(id) currentClass.value = null
classes.value = classes.value.filter(c => c.id !== id)
if (currentClass.value?.id === id) {
currentClass.value = null
}
} catch (e) {
error.value = e.message
throw e
} finally {
loading.value = false
} }
} })
function clearCurrent() { function clearCurrent() {
currentClass.value = null currentClass.value = null

View File

@@ -0,0 +1,22 @@
/**
* Wraps an async function with loading/error state management.
*
* @param {import('vue').Ref<boolean>} loading - shared loading ref
* @param {import('vue').Ref<string|null>} error - shared error ref
* @param {Function} fn - async function to wrap
* @returns {Function} wrapped function with identical signature
*/
export function withLoading(loading, error, fn) {
return async (...args) => {
loading.value = true
error.value = null
try {
return await fn(...args)
} catch (e) {
error.value = e.message
throw e
} finally {
loading.value = false
}
}
}

View File

@@ -26,7 +26,7 @@
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-8"> <div class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-8">
<router-link <router-link
:to="`/assessments/${assessment.id}/grading`" :to="`/assessments/${assessment.id}/grading`"
class="card card-body text-center hover:shadow-md transition-shadow" class="card card-body text-center hover:shadow-md transition-shadow bg-primary-50 border-2 border-primary-200"
> >
<PencilIcon class="w-8 h-8 text-primary-600 mx-auto mb-2" /> <PencilIcon class="w-8 h-8 text-primary-600 mx-auto mb-2" />
<span class="font-medium">Noter</span> <span class="font-medium">Noter</span>
@@ -50,7 +50,7 @@
<button <button
@click="confirmDelete" @click="confirmDelete"
class="card card-body text-center hover:shadow-md transition-shadow" class="card card-body text-center hover:shadow-md transition-shadow border-dashed border-red-200 opacity-75"
> >
<TrashIcon class="w-8 h-8 text-danger-600 mx-auto mb-2" /> <TrashIcon class="w-8 h-8 text-danger-600 mx-auto mb-2" />
<span class="font-medium">Supprimer</span> <span class="font-medium">Supprimer</span>

View File

@@ -277,8 +277,8 @@
</template> </template>
<script setup> <script setup>
import { ref, onMounted, computed, nextTick } from 'vue' import { ref, onMounted, onUnmounted, computed, nextTick, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter, onBeforeRouteLeave } from 'vue-router'
import { useAssessmentsStore } from '@/stores/assessments' import { useAssessmentsStore } from '@/stores/assessments'
import { useClassesStore } from '@/stores/classes' import { useClassesStore } from '@/stores/classes'
import { useNotificationsStore } from '@/stores/notifications' import { useNotificationsStore } from '@/stores/notifications'
@@ -295,6 +295,7 @@ const isEdit = computed(() => !!route.params.id)
const submitting = ref(false) const submitting = ref(false)
const classes = computed(() => classesStore.classes) const classes = computed(() => classesStore.classes)
const competences = ref([]) const competences = ref([])
const formDirty = ref(false)
// Computed pour le récapitulatif // Computed pour le récapitulatif
const totalElements = computed(() => { const totalElements = computed(() => {
@@ -368,6 +369,29 @@ const form = ref({
exercises: [] exercises: []
}) })
watch(form, () => {
formDirty.value = true
}, { deep: true })
onBeforeRouteLeave((to, from, next) => {
if (formDirty.value && !submitting.value) {
if (confirm('Vous avez des modifications non sauvegardées. Quitter cette page ?')) {
next()
} else {
next(false)
}
} else {
next()
}
})
function handleBeforeUnload(event) {
if (formDirty.value) {
event.preventDefault()
event.returnValue = 'Vous avez des modifications non sauvegardées.'
}
}
function addExercise() { function addExercise() {
const newOrder = form.value.exercises.length + 1 const newOrder = form.value.exercises.length + 1
form.value.exercises.push({ form.value.exercises.push({
@@ -387,8 +411,13 @@ function addExercise() {
} }
function removeExercise(idx) { function removeExercise(idx) {
const exercise = form.value.exercises[idx]
if (isEdit.value && exercise.id) {
if (!confirm('Cet exercice contient potentiellement des notes. Supprimer ?')) {
return
}
}
form.value.exercises.splice(idx, 1) form.value.exercises.splice(idx, 1)
// Renumber exercises
form.value.exercises.forEach((ex, i) => { form.value.exercises.forEach((ex, i) => {
ex.order = i + 1 ex.order = i + 1
}) })
@@ -417,6 +446,12 @@ function addElement(exIdx) {
} }
function removeElement(exIdx, elIdx) { function removeElement(exIdx, elIdx) {
const element = form.value.exercises[exIdx].grading_elements[elIdx]
if (isEdit.value && element.id) {
if (!confirm('Cet élément contient potentiellement des notes. Supprimer ?')) {
return
}
}
form.value.exercises[exIdx].grading_elements.splice(elIdx, 1) form.value.exercises[exIdx].grading_elements.splice(elIdx, 1)
} }
@@ -470,10 +505,12 @@ async function submit() {
if (isEdit.value) { if (isEdit.value) {
await assessmentsStore.updateAssessment(route.params.id, data) await assessmentsStore.updateAssessment(route.params.id, data)
notifications.success('Évaluation modifiée avec succès') notifications.success('Évaluation modifiée avec succès')
formDirty.value = false
router.push(`/assessments/${route.params.id}`) router.push(`/assessments/${route.params.id}`)
} else { } else {
const created = await assessmentsStore.createAssessment(data) const created = await assessmentsStore.createAssessment(data)
notifications.success('Évaluation créée avec succès') notifications.success('Évaluation créée avec succès')
formDirty.value = false
router.push(`/assessments/${created.id}`) router.push(`/assessments/${created.id}`)
} }
} catch (e) { } catch (e) {
@@ -526,6 +563,13 @@ onMounted(async () => {
// Add first exercise for new assessment // Add first exercise for new assessment
addExercise() addExercise()
} }
window.addEventListener('beforeunload', handleBeforeUnload)
// Reset dirty flag after initial load
nextTick(() => { formDirty.value = false })
})
onUnmounted(() => {
window.removeEventListener('beforeunload', handleBeforeUnload)
}) })
async function loadCompetences() { async function loadCompetences() {

View File

@@ -124,9 +124,14 @@
<ProgressIndicator <ProgressIndicator
:progress="assessment.progress" :progress="assessment.progress"
size="md" size="md"
:clickable="true"
@click.prevent="goToGrading(assessment.id)"
/> />
<router-link
:to="`/assessments/${assessment.id}/grading`"
class="text-xs font-medium text-primary-600 hover:text-primary-800 whitespace-nowrap"
@click.stop
>
Corriger
</router-link>
<ChevronRightIcon class="w-5 h-5 text-gray-400" /> <ChevronRightIcon class="w-5 h-5 text-gray-400" />
</div> </div>
</div> </div>

View File

@@ -57,7 +57,11 @@
</div> </div>
<!-- Stats principales - Grid 4 colonnes --> <!-- Stats principales - Grid 4 colonnes -->
<div v-if="stats" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8"> <div v-if="stats" class="relative">
<div v-if="statsLoading" class="absolute inset-0 bg-white/60 z-10 flex items-center justify-center rounded-xl">
<LoadingSpinner />
</div>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
<!-- Moyenne classe --> <!-- Moyenne classe -->
<div class="bg-white rounded-xl shadow-md p-6 hover:shadow-lg transition-all duration-300"> <div class="bg-white rounded-xl shadow-md p-6 hover:shadow-lg transition-all duration-300">
<p class="text-sm text-gray-500 mb-1">Moyenne classe</p> <p class="text-sm text-gray-500 mb-1">Moyenne classe</p>
@@ -96,6 +100,7 @@
</div> </div>
</div> </div>
</div> </div>
</div>
<!-- Domaines et Compétences en 2 colonnes --> <!-- Domaines et Compétences en 2 colonnes -->
<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">
@@ -243,6 +248,7 @@ const stats = ref(null)
const trimester = ref(null) // null = vision annuelle par défaut const trimester = ref(null) // null = vision annuelle par défaut
const sortColumn = ref('name') const sortColumn = ref('name')
const sortDirection = ref('asc') const sortDirection = ref('asc')
const statsLoading = ref(false)
async function fetchData() { async function fetchData() {
loading.value = true loading.value = true
@@ -257,7 +263,12 @@ async function fetchData() {
async function selectTrimester(t) { async function selectTrimester(t) {
trimester.value = t trimester.value = t
stats.value = await classesStore.fetchClassStats(route.params.id, t) statsLoading.value = true
try {
stats.value = await classesStore.fetchClassStats(route.params.id, t)
} finally {
statsLoading.value = false
}
} }
// Récupérer la liste des évaluations triée par date // Récupérer la liste des évaluations triée par date

View File

@@ -97,6 +97,27 @@
</div> </div>
</div> </div>
</div> </div>
<Modal v-model="showDeleteModal" title="Confirmer la suppression" size="sm">
<p class="text-gray-600">
Êtes-vous sûr de vouloir supprimer la classe
<strong>{{ classToDelete?.name }}</strong> ?
</p>
<p class="mt-2 text-sm text-gray-500">
Cette classe contient <strong>{{ classToDelete?.students_count || 0 }}</strong> élève(s).
Cette action est irréversible.
</p>
<template #footer>
<div class="flex justify-end gap-3">
<button @click="showDeleteModal = 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="executeDelete" class="px-4 py-2 text-sm font-medium text-white bg-red-600 rounded-md hover:bg-red-700">
Supprimer
</button>
</div>
</template>
</Modal>
</div> </div>
</template> </template>
@@ -105,6 +126,7 @@ import { computed, onMounted, ref } from 'vue'
import { useClassesStore } from '@/stores/classes' import { useClassesStore } from '@/stores/classes'
import SkeletonLoader from '@/components/common/SkeletonLoader.vue' import SkeletonLoader from '@/components/common/SkeletonLoader.vue'
import EmptyState from '@/components/common/EmptyState.vue' import EmptyState from '@/components/common/EmptyState.vue'
import Modal from '@/components/common/Modal.vue'
// Icons // Icons
const PlusIcon = { template: '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" /></svg>' } const PlusIcon = { template: '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" /></svg>' }
@@ -113,6 +135,8 @@ const ClipboardIcon = { template: '<svg xmlns="http://www.w3.org/2000/svg" fill=
const classesStore = useClassesStore() const classesStore = useClassesStore()
const loading = ref(true) const loading = ref(true)
const showDeleteModal = ref(false)
const classToDelete = ref(null)
const classes = computed(() => classesStore.classes) const classes = computed(() => classesStore.classes)
const totalStudents = computed(() => classesStore.totalStudents) const totalStudents = computed(() => classesStore.totalStudents)
@@ -153,9 +177,16 @@ function getAccentBgClass(className) {
// Confirmation de suppression // Confirmation de suppression
function confirmDelete(cls) { function confirmDelete(cls) {
if (confirm(`Êtes-vous sûr de vouloir supprimer la classe "${cls.name}" ?`)) { classToDelete.value = cls
classesStore.deleteClass(cls.id) showDeleteModal.value = true
}
function executeDelete() {
if (classToDelete.value) {
classesStore.deleteClass(classToDelete.value.id)
} }
showDeleteModal.value = false
classToDelete.value = null
} }
onMounted(async () => { onMounted(async () => {

View File

@@ -63,10 +63,10 @@ import ConfigEmailTab from '@/components/config/ConfigEmailTab.vue'
const configStore = useConfigStore() const configStore = useConfigStore()
const tabs = [ const tabs = [
{ id: 'general', label: 'General' }, { id: 'general', label: 'Général' },
{ id: 'competences', label: 'Competences' }, { id: 'competences', label: 'Compétences' },
{ id: 'domains', label: 'Domaines' }, { id: 'domains', label: 'Domaines' },
{ id: 'scale', label: 'Echelle' }, { id: 'scale', label: 'Échelle' },
{ id: 'email', label: 'Email' } { id: 'email', label: 'Email' }
] ]

View File

@@ -1,12 +1,240 @@
<template> <template>
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8"> <div class="px-4 pt-2 pb-1 flex flex-col overflow-hidden" style="height: calc(100vh - 4rem)">
<div class="bg-gradient-to-r from-purple-600 to-purple-800 rounded-2xl p-8 mb-8 text-white"> <LoadingSpinner v-if="loading" text="Chargement..." fullPage />
<h1 class="text-3xl font-bold mb-2">Conseil de classe</h1>
<p class="text-purple-100">Préparation et appréciations</p>
</div>
<div class="card card-body text-center py-12"> <template v-else-if="classData">
<p class="text-gray-500">Interface de conseil de classe à implémenter</p> <!-- Compact toolbar: class info + trimester + stats on one line -->
</div> <div v-if="currentStats" class="flex items-center gap-3 py-2 border-b border-gray-200 flex-shrink-0 flex-wrap">
<router-link :to="`/classes/${classData.id}`" class="text-gray-400 hover:text-gray-600 flex-shrink-0">
<svg class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" /></svg>
</router-link>
<span class="font-semibold text-gray-900 truncate">{{ classData.name }}</span>
<span class="text-xs text-gray-400 flex-shrink-0">{{ classData.year }}</span>
<div class="flex gap-1 flex-shrink-0">
<button
v-for="t in [1, 2, 3]"
:key="t"
@click="selectTrimester(t)"
class="px-2.5 py-1 rounded text-xs font-medium transition-colors"
:class="trimester === t
? 'bg-purple-600 text-white'
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'"
>
T{{ t }}
</button>
</div>
<div class="border-l border-gray-200 h-5 flex-shrink-0"></div>
<div class="flex gap-4 text-xs flex-shrink-0">
<span><span class="text-gray-400">Moy</span> <span class="font-bold text-orange-600">{{ currentStats.mean?.toFixed(1) || '-' }}</span></span>
<span><span class="text-gray-400">Med</span> <span class="font-bold text-blue-600">{{ currentStats.median?.toFixed(1) || '-' }}</span></span>
<span class="hidden sm:inline"><span class="text-gray-400">&sigma;</span> <span class="font-bold text-gray-700">{{ currentStats.std_dev?.toFixed(1) || '-' }}</span></span>
<span><span class="text-gray-400">Eleves</span> <span class="font-bold text-gray-900">{{ currentStats.students_count }}</span></span>
<span><span class="text-gray-400">Evals</span> <span class="font-bold text-gray-900">{{ currentStats.assessments_total }}</span></span>
</div>
</div>
<!-- Main content: list + detail filling remaining space -->
<div v-if="rankedStudents.length" class="flex-1 flex flex-col lg:flex-row gap-3 min-h-0 pt-2">
<!-- Student list -->
<div class="w-full lg:w-64 xl:w-72 lg:flex-shrink-0 overflow-hidden">
<CouncilStudentList
:students="displayedStudents"
:selectedStudentId="selectedStudentId"
:classMean="currentStats?.mean ?? null"
v-model:search="searchQuery"
@select="selectStudent"
/>
</div>
<!-- Student detail -->
<div class="flex-1 min-w-0 overflow-hidden">
<CouncilStudentDetail
v-if="selectedStudent"
:student="selectedStudent"
:classStats="currentStats"
:allTrimesterStats="allTrimesterStatsArray"
:currentTrimester="trimester"
:totalStudents="rankedStudents.length"
:hasPrev="selectedIndex > 0"
:hasNext="selectedIndex < displayedStudents.length - 1"
:classDomainsStats="currentStats?.domains_stats || []"
:classCompetencesStats="currentStats?.competences_stats || []"
@prev="navigateStudent(-1)"
@next="navigateStudent(1)"
/>
<div v-else class="bg-white rounded-xl shadow-md p-12 text-center">
<p class="text-gray-400">Sélectionnez un élève dans la liste</p>
</div>
</div>
</div>
<!-- Empty state -->
<div v-else-if="currentStats" class="flex-1 flex items-center justify-center">
<p class="text-gray-500">Aucune donnée disponible pour le trimestre {{ trimester }}</p>
</div>
</template>
</div> </div>
</template> </template>
<script setup>
import { ref, computed, watch, onMounted, onUnmounted } from 'vue'
import { useRoute } from 'vue-router'
import { useClassesStore } from '@/stores/classes'
import { classesService } from '@/services/classes'
import LoadingSpinner from '@/components/common/LoadingSpinner.vue'
import CouncilStudentList from '@/components/council/CouncilStudentList.vue'
import CouncilStudentDetail from '@/components/council/CouncilStudentDetail.vue'
const route = useRoute()
const classesStore = useClassesStore()
const loading = ref(true)
const classData = ref(null)
const trimester = ref(1)
const currentStats = ref(null)
const allTrimesterStats = ref({})
const selectedStudentId = ref(null)
const searchQuery = ref('')
// All students with rank + trend (unfiltered, used for global rank)
const rankedStudents = computed(() => {
if (!currentStats.value?.student_averages) return []
const students = currentStats.value.student_averages.map(s => ({ ...s }))
const withAvg = students.filter(s => s.average !== null)
withAvg.sort((a, b) => b.average - a.average)
withAvg.forEach((s, i) => { s.rank = i + 1 })
const withoutAvg = students.filter(s => s.average === null)
const prevTrimester = trimester.value > 1 ? allTrimesterStats.value[trimester.value - 1] : null
for (const s of students) {
if (prevTrimester) {
const prevStudent = prevTrimester.student_averages?.find(ps => ps.student_id === s.student_id)
if (prevStudent?.average != null && s.average !== null) {
const diff = s.average - prevStudent.average
s.trend = diff > 0.5 ? 'up' : diff < -0.5 ? 'down' : 'stable'
} else {
s.trend = null
}
} else {
s.trend = null
}
}
return [...withAvg, ...withoutAvg]
})
// Displayed students: filtered by search, sorted alphabetically
const displayedStudents = computed(() => {
let list = rankedStudents.value
if (searchQuery.value.trim()) {
const q = searchQuery.value.trim().toLowerCase()
list = list.filter(s =>
`${s.last_name} ${s.first_name}`.toLowerCase().includes(q)
)
}
return [...list].sort((a, b) => {
const nameA = `${a.last_name} ${a.first_name}`.toLowerCase()
const nameB = `${b.last_name} ${b.first_name}`.toLowerCase()
return nameA.localeCompare(nameB, 'fr')
})
})
watch(searchQuery, () => {
if (!displayedStudents.value.length) return
const still = displayedStudents.value.find(s => s.student_id === selectedStudentId.value)
if (!still) {
selectedStudentId.value = displayedStudents.value[0].student_id
}
})
const allTrimesterStatsArray = computed(() => {
const result = []
for (let t = 1; t <= 3; t++) {
const stats = allTrimesterStats.value[t]
if (stats) result.push({ trimester: t, stats })
}
return result
})
const selectedStudent = computed(() => {
if (selectedStudentId.value === null) return null
return displayedStudents.value.find(s => s.student_id === selectedStudentId.value) || null
})
const selectedIndex = computed(() => {
if (selectedStudentId.value === null) return -1
return displayedStudents.value.findIndex(s => s.student_id === selectedStudentId.value)
})
function selectStudent(id) {
selectedStudentId.value = id
}
function navigateStudent(delta) {
const newIndex = selectedIndex.value + delta
if (newIndex >= 0 && newIndex < displayedStudents.value.length) {
selectedStudentId.value = displayedStudents.value[newIndex].student_id
}
}
function handleKeydown(e) {
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA' || e.target.tagName === 'SELECT') return
if (e.key === 'ArrowLeft') {
e.preventDefault()
navigateStudent(-1)
} else if (e.key === 'ArrowRight') {
e.preventDefault()
navigateStudent(1)
}
}
async function fetchData() {
loading.value = true
try {
const id = route.params.id
classData.value = await classesStore.fetchClass(id)
const promises = [1, 2, 3].map(t =>
classesService.getStats(id, t).then(data => ({ t, data })).catch(() => ({ t, data: null }))
)
const results = await Promise.all(promises)
for (const { t, data } of results) {
if (data) allTrimesterStats.value[t] = data
}
currentStats.value = allTrimesterStats.value[trimester.value] || null
if (displayedStudents.value.length && !selectedStudentId.value) {
selectedStudentId.value = displayedStudents.value[0].student_id
}
} finally {
loading.value = false
}
}
function selectTrimester(t) {
trimester.value = t
currentStats.value = allTrimesterStats.value[t] || null
if (selectedStudentId.value) {
const stillExists = displayedStudents.value.find(s => s.student_id === selectedStudentId.value)
if (!stillExists && displayedStudents.value.length) {
selectedStudentId.value = displayedStudents.value[0].student_id
}
} else if (displayedStudents.value.length) {
selectedStudentId.value = displayedStudents.value[0].student_id
}
}
onMounted(() => {
fetchData()
window.addEventListener('keydown', handleKeydown)
})
onUnmounted(() => {
window.removeEventListener('keydown', handleKeydown)
})
</script>

View File

@@ -57,9 +57,12 @@
</div> </div>
</router-link> </router-link>
<div class="card card-body"> <router-link
to="/assessments"
class="card card-body hover:shadow-md transition-shadow group"
>
<div class="flex items-center"> <div class="flex items-center">
<div class="p-3 rounded-lg bg-danger-100 text-danger-600"> <div class="p-3 rounded-lg bg-danger-100 text-danger-600 group-hover:bg-danger-200 transition-colors">
<PencilIcon class="w-6 h-6" /> <PencilIcon class="w-6 h-6" />
</div> </div>
<div class="ml-4"> <div class="ml-4">
@@ -67,7 +70,7 @@
<p class="text-2xl font-bold">{{ assessmentsStore.incompleteAssessments.length }}</p> <p class="text-2xl font-bold">{{ assessmentsStore.incompleteAssessments.length }}</p>
</div> </div>
</div> </div>
</div> </router-link>
</div> </div>
<!-- Quick actions --> <!-- Quick actions -->

View File

@@ -314,8 +314,8 @@
</div> </div>
<div class="flex space-x-3"> <div class="flex space-x-3">
<button <button
@click="resetForm" @click="showResetModal = true"
class="px-3 py-1 text-xs border border-gray-300 rounded hover:bg-gray-50 transition-colors" class="px-3 py-1 text-xs border border-red-200 rounded bg-red-50 text-red-600 hover:bg-red-100 transition-colors"
> >
Réinitialiser Réinitialiser
</button> </button>
@@ -396,26 +396,66 @@
</template> </template>
</Modal> </Modal>
<!-- Toast --> <!-- Modal réinitialisation -->
<Transition name="toast"> <Modal v-model="showResetModal" title="Réinitialiser les notes" size="sm">
<div <p class="text-gray-600">
v-if="toast.show" Réinitialiser toutes les notes ? Cette action est irréversible.
class="fixed bottom-4 right-4 z-50" </p>
> <template #footer>
<div <div class="flex justify-end space-x-3">
class="px-4 py-3 rounded-lg shadow-lg flex items-center" <button
:class="toastClass" @click="showResetModal = 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"
<svg v-if="toast.type === 'success'" class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"> >
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" /> Annuler
</svg> </button>
<svg v-else-if="toast.type === 'error'" class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <button
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" /> @click="resetForm"
</svg> class="px-4 py-2 text-sm font-medium text-white bg-red-600 rounded-md hover:bg-red-700"
<span>{{ toast.message }}</span> >
Réinitialiser
</button>
</div> </div>
</template>
</Modal>
<!-- Modal erreurs de validation -->
<Modal v-model="showErrorsModal" title="Valeurs invalides" size="md">
<p class="text-gray-600 mb-3">
{{ invalidEntries.length }} valeur(s) invalide(s) n'ont pas été sauvegardées :
</p>
<div class="max-h-60 overflow-y-auto">
<table class="w-full text-sm">
<thead class="bg-gray-50">
<tr>
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500">Élève</th>
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500">Élément</th>
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500">Valeur</th>
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500">Erreur</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200">
<tr v-for="(entry, idx) in invalidEntries" :key="idx">
<td class="px-3 py-2 text-gray-900">{{ entry.studentName }}</td>
<td class="px-3 py-2 text-gray-700">{{ entry.elementLabel }}</td>
<td class="px-3 py-2 font-mono text-red-600">{{ entry.value }}</td>
<td class="px-3 py-2 text-red-500 text-xs">{{ entry.error }}</td>
</tr>
</tbody>
</table>
</div> </div>
</Transition> <template #footer>
<div class="flex justify-end">
<button
@click="showErrorsModal = false"
class="px-4 py-2 text-sm font-medium text-white bg-primary-600 rounded-md hover:bg-primary-700"
>
Compris
</button>
</div>
</template>
</Modal>
</template> </template>
</div> </div>
</template> </template>
@@ -425,6 +465,7 @@ import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { useAssessmentsStore } from '@/stores/assessments' import { useAssessmentsStore } from '@/stores/assessments'
import { useConfigStore } from '@/stores/config' import { useConfigStore } from '@/stores/config'
import { useNotificationsStore } from '@/stores/notifications'
import classesService from '@/services/classes' import classesService from '@/services/classes'
import assessmentsService from '@/services/assessments' import assessmentsService from '@/services/assessments'
import LoadingSpinner from '@/components/common/LoadingSpinner.vue' import LoadingSpinner from '@/components/common/LoadingSpinner.vue'
@@ -434,6 +475,7 @@ const route = useRoute()
const router = useRouter() const router = useRouter()
const assessmentsStore = useAssessmentsStore() const assessmentsStore = useAssessmentsStore()
const configStore = useConfigStore() const configStore = useConfigStore()
const notifications = useNotificationsStore()
// Color interpolation functions // Color interpolation functions
function hexToRgb(hex) { function hexToRgb(hex) {
@@ -527,13 +569,14 @@ const studentFilter = ref('')
const filterFocused = ref(false) const filterFocused = ref(false)
const showKeyboardHelp = ref(false) const showKeyboardHelp = ref(false)
const showQuickComplete = ref(false) const showQuickComplete = ref(false)
const showResetModal = ref(false)
const showErrorsModal = ref(false)
const invalidEntries = ref([])
const quickCompleteStudentId = ref(null) const quickCompleteStudentId = ref(null)
const quickCompleteValue = ref('.') const quickCompleteValue = ref('.')
const quickCompleteOverwrite = ref(false) const quickCompleteOverwrite = ref(false)
const currentPosition = ref(null) const currentPosition = ref(null)
// Toast
const toast = ref({ show: false, message: '', type: 'success' })
// Computed // Computed
const allElements = computed(() => { const allElements = computed(() => {
@@ -597,15 +640,6 @@ const progressColorClass = computed(() => {
const hasUnsavedChanges = computed(() => unsavedChanges.value.size > 0) const hasUnsavedChanges = computed(() => unsavedChanges.value.size > 0)
const toastClass = computed(() => {
const classes = {
success: 'bg-green-500 text-white',
error: 'bg-red-500 text-white',
info: 'bg-blue-500 text-white',
warning: 'bg-orange-500 text-white'
}
return classes[toast.value.type] || classes.info
})
// Options d'échelle depuis la config // Options d'échelle depuis la config
const scaleOptions = computed(() => { const scaleOptions = computed(() => {
@@ -1091,7 +1125,7 @@ async function saveAll() {
saving.value = true saving.value = true
try { try {
const gradesArray = [] const gradesArray = []
const errors = [] const collectedErrors = []
for (const key in grades.value) { for (const key in grades.value) {
const [studentId, elementId] = key.split('_').map(Number) const [studentId, elementId] = key.split('_').map(Number)
@@ -1104,7 +1138,12 @@ async function saveAll() {
const validation = validateGradeValue(value, element.grading_type, element.max_points) const validation = validateGradeValue(value, element.grading_type, element.max_points)
if (!validation.valid) { if (!validation.valid) {
const student = students.value.find(s => s.id === studentId) const student = students.value.find(s => s.id === studentId)
errors.push(`${student?.last_name || 'Élève'} - ${element.label || element.name}: ${validation.error}`) collectedErrors.push({
studentName: `${student?.last_name || 'Élève'} ${student?.first_name || ''}`.trim(),
elementLabel: element.label || element.name,
value: value,
error: validation.error
})
continue continue
} }
} }
@@ -1117,16 +1156,16 @@ async function saveAll() {
} }
} }
if (errors.length > 0) { if (collectedErrors.length > 0) {
showToast(`${errors.length} valeur(s) invalide(s) ignorée(s)`, 'warning') invalidEntries.value = collectedErrors
console.warn('Erreurs de validation:', errors) showErrorsModal.value = true
} }
if (gradesArray.length > 0) { if (gradesArray.length > 0) {
await assessmentsStore.saveGrades(assessment.value.id, gradesArray) await assessmentsStore.saveGrades(assessment.value.id, gradesArray)
unsavedChanges.value.clear() unsavedChanges.value.clear()
showToast('Notes sauvegardées avec succès', 'success') showToast('Notes sauvegardées avec succès', 'success')
} else if (errors.length === 0) { } else if (collectedErrors.length === 0) {
showToast('Aucune note à sauvegarder', 'info') showToast('Aucune note à sauvegarder', 'info')
} }
} catch (e) { } catch (e) {
@@ -1137,22 +1176,17 @@ async function saveAll() {
} }
function resetForm() { function resetForm() {
if (!confirm('Êtes-vous sûr de vouloir réinitialiser toutes les notes ? Cette action est irréversible.')) {
return
}
grades.value = {} grades.value = {}
unsavedChanges.value.clear() unsavedChanges.value.clear()
undoStack.value = [] undoStack.value = []
showResetModal.value = false
showToast('Formulaire réinitialisé', 'info') showToast('Formulaire réinitialisé', 'info')
} }
// Toast // Notification helper
function showToast(message, type = 'success') { function showToast(message, type = 'success') {
toast.value = { show: true, message, type } const fn = { success: 'success', error: 'error', warning: 'warning', info: 'info' }
setTimeout(() => { notifications[fn[type] || 'info'](message)
toast.value.show = false
}, 3000)
} }
// Protection fermeture // Protection fermeture
@@ -1229,14 +1263,4 @@ onUnmounted(() => {
transform: translateX(20px); transform: translateX(20px);
} }
.toast-enter-active,
.toast-leave-active {
transition: all 0.3s ease;
}
.toast-enter-from,
.toast-leave-to {
opacity: 0;
transform: translateY(20px);
}
</style> </style>

View File

@@ -202,80 +202,24 @@
</div> </div>
</div> </div>
<!-- Toolbar de sélection (mode sélection activé) -->
<div v-if="selectionMode && gradedStudentsWithEmail.length > 0" class="card mb-6 border-2 border-blue-500 bg-blue-50">
<div class="card-body">
<div class="flex items-center justify-between">
<div class="flex items-center gap-4">
<div class="flex items-center gap-2">
<svg class="w-6 h-6 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<div>
<h3 class="font-semibold text-blue-900">Mode Sélection Activé</h3>
<p class="text-sm text-blue-700">
Cliquez sur les cases pour sélectionner les élèves qui recevront leur bilan
<span v-if="selectedStudents.length > 0" class="font-semibold">
({{ selectedStudents.length }} sélectionné{{selectedStudents.length > 1 ? 's' : ''}})
</span>
</p>
</div>
</div>
</div>
<div class="flex items-center gap-3">
<button
@click="toggleSelectAll"
class="btn btn-sm btn-secondary"
>
{{ allSelected ? 'Tout désélectionner' : 'Tout sélectionner' }}
</button>
<button
@click="cancelSelectionMode"
class="btn btn-sm btn-secondary"
>
<svg class="w-4 h-4 mr-1 inline" 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>
Annuler
</button>
<button
@click="openSendModal"
:disabled="selectedStudents.length === 0"
class="btn btn-primary shadow-lg"
:class="{ 'opacity-50 cursor-not-allowed': selectedStudents.length === 0 }"
>
<svg class="w-5 h-5 mr-2 inline" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
</svg>
Envoyer à {{ selectedStudents.length }} élève{{selectedStudents.length > 1 ? 's' : ''}}
</button>
</div>
</div>
</div>
</div>
<!-- Student scores table --> <!-- Student scores table -->
<div class="card"> <div class="card">
<div class="card-header flex justify-between items-center"> <div class="card-header flex justify-between items-center">
<h2 class="text-lg font-semibold">Détail par élève</h2> <h2 class="text-lg font-semibold">Détail par élève</h2>
<!-- Mode Normal : Bouton pour activer la sélection -->
<button <button
v-if="!selectionMode && gradedStudentsWithEmail.length > 0" v-if="gradedStudentsWithEmail.length > 0"
@click="activateSelectionMode" @click="toggleSelectAll"
class="btn btn-primary" class="btn btn-sm btn-secondary"
> >
<svg class="w-5 h-5 mr-2 inline" fill="none" stroke="currentColor" viewBox="0 0 24 24"> {{ allSelected ? 'Tout désélectionner' : 'Tout sélectionner' }}
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
</svg>
📧 Envoyer des bilans
</button> </button>
</div> </div>
<div class="overflow-x-auto"> <div class="overflow-x-auto">
<table class="table"> <table class="table">
<thead> <thead>
<tr> <tr>
<th v-if="selectionMode && gradedStudentsWithEmail.length > 0" class="w-16"> <th v-if="gradedStudentsWithEmail.length > 0" class="w-16">
<div class="flex items-center gap-1"> <div class="flex items-center gap-1">
<svg class="w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" /> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
@@ -295,10 +239,10 @@
:key="student.student_id" :key="student.student_id"
:class="{ :class="{
'bg-gray-50 opacity-60': !isStudentGraded(student), 'bg-gray-50 opacity-60': !isStudentGraded(student),
'bg-blue-50 border-l-4 border-blue-500': selectionMode && isSelected(student.student_id) 'bg-blue-50 border-l-4 border-blue-500': isSelected(student.student_id)
}" }"
> >
<td v-if="selectionMode && gradedStudentsWithEmail.length > 0" class="w-16"> <td v-if="gradedStudentsWithEmail.length > 0" class="w-16">
<input <input
v-if="isStudentGraded(student) && student.email" v-if="isStudentGraded(student) && student.email"
type="checkbox" type="checkbox"
@@ -379,6 +323,31 @@
</div> </div>
</template> </template>
<!-- Sticky bottom bar for sending emails -->
<Transition name="slide-up">
<div
v-if="selectedStudents.length > 0"
class="fixed bottom-0 left-0 right-0 bg-white border-t border-gray-200 shadow-lg z-40 px-6 py-3"
>
<div class="max-w-7xl mx-auto flex items-center justify-between">
<div class="flex items-center gap-3">
<span class="text-sm text-gray-600">
<strong>{{ selectedStudents.length }}</strong> élève{{ selectedStudents.length > 1 ? 's' : '' }} sélectionné{{ selectedStudents.length > 1 ? 's' : '' }}
</span>
<button @click="selectedStudents = []" class="text-sm text-gray-500 hover:text-gray-700">
Effacer
</button>
</div>
<button
@click="openSendModal"
class="btn btn-primary"
>
Envoyer les bilans à {{ selectedStudents.length }} élève{{ selectedStudents.length > 1 ? 's' : '' }}
</button>
</div>
</div>
</Transition>
<!-- Modal d'envoi de bilans --> <!-- Modal d'envoi de bilans -->
<SendReportsModal <SendReportsModal
v-if="showSendModal" v-if="showSendModal"
@@ -409,7 +378,6 @@ const configStore = useConfigStore()
// État pour la sélection d'élèves et l'envoi d'emails // État pour la sélection d'élèves et l'envoi d'emails
const selectedStudents = ref([]) const selectedStudents = ref([])
const showSendModal = ref(false) const showSendModal = ref(false)
const selectionMode = ref(false) // Mode sélection activé/désactivé
// Color interpolation functions // Color interpolation functions
function hexToRgb(hex) { function hexToRgb(hex) {
@@ -735,23 +703,6 @@ const selectedStudentsData = computed(() => {
})) }))
}) })
// Activer le mode sélection
function activateSelectionMode() {
selectionMode.value = true
selectedStudents.value = [] // Réinitialiser la sélection
}
// Annuler le mode sélection
function cancelSelectionMode() {
selectionMode.value = false
selectedStudents.value = []
}
// Vider la sélection
function clearSelection() {
selectedStudents.value = []
}
// Vérifier si un élève est sélectionné // Vérifier si un élève est sélectionné
function isSelected(studentId) { function isSelected(studentId) {
return selectedStudents.value.includes(studentId) return selectedStudents.value.includes(studentId)
@@ -776,7 +727,6 @@ function openSendModal() {
function handleReportsSent(result) { function handleReportsSent(result) {
showSendModal.value = false showSendModal.value = false
selectedStudents.value = [] selectedStudents.value = []
selectionMode.value = false // Désactiver le mode sélection après envoi
// Le modal affiche déjà les résultats, pas besoin de notification supplémentaire // Le modal affiche déjà les résultats, pas besoin de notification supplémentaire
} }
@@ -801,3 +751,14 @@ onMounted(async () => {
} }
}) })
</script> </script>
<style scoped>
.slide-up-enter-active,
.slide-up-leave-active {
transition: transform 0.3s ease;
}
.slide-up-enter-from,
.slide-up-leave-to {
transform: translateY(100%);
}
</style>

View File

@@ -35,7 +35,12 @@
</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="student.current_class_id ? 'hover:bg-gray-50 cursor-pointer' : ''"
@click="student.current_class_id && $router.push(`/classes/${student.current_class_id}/students`)"
>
<td class="font-medium">{{ student.last_name }}</td> <td class="font-medium">{{ student.last_name }}</td>
<td>{{ student.first_name }}</td> <td>{{ student.first_name }}</td>
<td> <td>

View File

@@ -1,37 +0,0 @@
[project]
name = "school-management"
version = "0.1.0"
description = "Application web de gestion scolaire"
requires-python = ">=3.9"
dependencies = [
"Flask>=2.3.3",
"Flask-SQLAlchemy>=3.0.5",
"Flask-WTF>=1.1.1",
"WTForms>=3.0.1",
"python-dotenv>=1.0.0",
"pydantic>=2.0.0",
"Flask-Mail>=0.9.1",
"premailer>=3.10.0",
]
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["."]
[tool.uv]
dev-dependencies = [
"pytest>=7.4.0",
"pytest-flask>=1.2.0",
"pytest-cov>=4.1.0",
]
[tool.poetry.group.dev.dependencies]
aiosmtpd = "^1.4.6"
[dependency-groups]
dev = [
"psutil>=7.0.0",
]

1104
uv.lock generated

File diff suppressed because it is too large Load Diff