Migration v1 (Flask) -> v2 (FastAPI + Vue.js) complétée

 Changements majeurs:
- Suppression complète du code Flask legacy
- Migration backend FastAPI vers racine /backend
- Migration frontend Vue.js vers racine /frontend
- Suppression de notytex-v2/ (code monté à la racine)

 Validations:
- Backend démarre correctement (port 8000)
- API /api/v2/health répond healthy
- 99/99 tests unitaires passent
- Frontend configuré avec proxy Vite

📝 Documentation:
- README.md réécrit pour v2
- Instructions de démarrage mises à jour
- .gitignore adapté pour backend/frontend/

🎯 Architecture finale:
notytex/
├── backend/     # FastAPI + SQLAlchemy + Pydantic
├── frontend/    # Vue 3 + Vite + TailwindCSS
├── docs/        # Documentation
└── school_management.db  # Base de données (inchangée)

Jalon 6 complété: Application v2 prête pour utilisation!
This commit is contained in:
2025-11-25 21:09:47 +01:00
parent 60c60c1605
commit 2b08eb534a
4125 changed files with 303 additions and 453271 deletions

34
.gitignore vendored
View File

@@ -46,13 +46,39 @@ Thumbs.db
# UV
.uv/
# Flask
instance/
.webassets-cache
# Backend FastAPI
backend/__pycache__/
backend/.pytest_cache/
backend/api/__pycache__/
backend/infrastructure/__pycache__/
backend/domain/__pycache__/
backend/schemas/__pycache__/
backend/core/__pycache__/
backend/*.pyc
backend/.env
backend/.coverage
backend/htmlcov/
# Frontend Vue.js
frontend/node_modules/
frontend/dist/
frontend/.env.local
frontend/.env.*.local
frontend/.vite/
# Environment variables
.env
.env.local
# Logs
*.log
*.log
logs/
# Tests
.pytest_cache/
.coverage
htmlcov/
# Flask legacy
instance/
.webassets-cache

View File

@@ -1,43 +0,0 @@
# Dockerfile pour Notytex - Système de Gestion Scolaire
FROM python:3.11-slim-bookworm
# Variables d'environnement pour uv et Flask
ENV UV_COMPILE_BYTECODE=1
ENV UV_LINK_MODE=copy
ENV PYTHONUNBUFFERED=1
ENV PYTHONDONTWRITEBYTECODE=1
ENV FLASK_APP=app
ENV FLASK_ENV=production
# Installation d'uv depuis l'image officielle
COPY --from=ghcr.io/astral-sh/uv:latest /uv /bin/uv
# Création d'un utilisateur non-root
RUN adduser --disabled-password --gecos '' --shell /bin/bash notytex
# Définition du répertoire de travail
WORKDIR /app
RUN chown -R notytex:notytex /app
USER notytex
# Copie des fichiers de configuration
COPY --chown=notytex:notytex pyproject.toml uv.lock ./
# Installation des dépendances avec cache mount
RUN --mount=type=cache,target=/home/notytex/.cache/uv \
uv sync --frozen --no-dev --no-install-project
# Copie du code source
COPY --chown=notytex:notytex . .
# Installation du projet dans l'environnement virtuel
RUN uv sync --frozen --no-dev
# Création des répertoires nécessaires (volumes seront montés)
RUN mkdir -p instance logs
# Exposition du port Flask
EXPOSE 5000
# Démarrage de l'application (stateless)
CMD ["uv", "run", "flask", "--app", "app", "run", "--host=0.0.0.0", "--port=5000"]

619
README.md
View File

@@ -1,422 +1,349 @@
# 📚 Notytex - Système de Gestion Scolaire
# 📚 Notytex v2 - Système de Gestion Scolaire
**Notytex** est une application web Flask conçue pour la gestion complète des évaluations. Elle permet aux enseignants de créer, organiser et noter les évaluations de leurs élèves.
**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).
Cette application a été presque entièrement vibecodé. Il ne faut donc pas s'attendre à des merveilles..! Mais elle est fonctionnelle et je l'utilise pour la gestion de mes notes!
## 🎯 **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 interface moderne et réactive.
Simplifier et digitaliser le processus d'évaluation scolaire, de la création des contrôles à la saisie des notes.
---
## ⭐ Fonctionnalités Clés
## 🚀 **Démarrage Rapide**
### 🏫 Gestion Hiérarchique Complète
### **Prérequis**
- **Groupes classes** : Organisation des élèves par classe avec gestion des années scolaires
- **Évaluations par trimestre** : Chaque évaluation doit être assignée à un trimestre (1, 2 ou 3)
- **Structure modulaire** : ClassGroup → Students → Assessment → Exercise → GradingElement → Grade
- **Interface unifiée** : Création d'évaluation + exercices + barème en une seule fois
- **Indicateurs de progression** : Visualisation temps réel de l'état de correction avec code couleur
- **Python 3.11+** avec `uv` ([installation](https://docs.astral.sh/uv/))
- **Node.js 18+** avec `npm`
- **Git**
### 🎯 Système de Notation Dual Unifié
**2 Types de Notation Complémentaires :**
1. **`notes`** : Notation classique en points décimaux (ex: 15.5/20, 2.5/4)
2. **`score`** : Évaluation par compétences sur échelle 0-3 (0=Non acquis, 1=En cours, 2=Acquis, 3=Expert)
**Valeurs Spéciales Configurables :**
- **`.`** = Pas de réponse (traité comme 0 dans les calculs)
- **`d`** = Dispensé (exclu des calculs de moyenne)
- **Autres valeurs** : Configuration flexible via interface d'administration
**Configuration Centralisée :**
- **Interface d'administration** : Personnalisation complète des significations et couleurs
- **Règles de calcul** : Logique unifiée pour tous les types avec gestion des cas particuliers
- **Cohérence pédagogique** : Adaptation aux différentes pratiques d'évaluation
### 📊 Analyse des Résultats et Statistiques
- **Statistiques descriptives complètes** : Moyenne, médiane, minimum, maximum, écart-type
- **Visualisation graphique interactive** : Histogrammes de distribution avec Chart.js
- **Tableau détaillé par élève** : Classement alphabétique avec scores détaillés par exercice
- **Calculs intelligents** : Gestion automatique des types "notes" et "score" avec formules spécialisées
- **Gestion des absences** : Traitement cohérent des valeurs spéciales dans les statistiques
### 🎨 Interface Utilisateur Moderne
- **Dashboard avec statistiques** : Cartes interactives
- **Navigation intuitive** : Actions principales mises en avant avec design cohérent
- **Indicateurs de progression visuels** : Cercles de progression animés avec code couleur
- **Design responsive** : Interface adaptée à tous les écrans avec TailwindCSS
- **Mode plein écran** : Visualisation optimisée pour les présentations
## 🚀 Démarrage Rapide
### 📋 Prérequis
- **Python 3.8+** (recommandé : 3.11+)
- **uv** : Gestionnaire de paquets moderne Python ([installation](https://docs.astral.sh/uv/))
- **Git** : Pour le contrôle de version
### ⚡ Installation (5 minutes)
### **Backend (FastAPI)**
```bash
# 1. Cloner et accéder au projet
git clone <repository>
cd notytex
# 2. Installer les dépendances
uv sync
# 3. Configuration obligatoire
cp .env.example .env
# Modifier .env avec SECRET_KEY (minimum 32 caractères obligatoire)
# 4. Initialiser la base de données (voir modes ci-dessous)
uv run flask --app app init-db
# 5. Lancer l'application en mode développement
uv run flask --app app run --debug
cd backend
uv sync --all-extras
uv run uvicorn api.main:app --reload --port 8000
```
### 🎯 Modes d'Initialisation de la Base de Données
**Notytex** propose **3 modes d'initialisation** adaptés aux différents contextes d'usage :
#### 🌱 **Mode Minimal** (par défaut) - Début d'année scolaire
### **Frontend (Vue.js)**
```bash
uv run flask --app app init-db --mode minimal
# ou simplement
uv run flask --app app init-db
cd frontend
npm install
npm run dev
```
- **Usage** : Début d'année scolaire, base vierge
- **Contenu** : Configuration de base uniquement
- **Idéal pour** : Démarrer une nouvelle année avec ses vraies classes et élèves
### **Accès**
#### 📚 **Mode Milieu d'Année** - Données réalistes complètes
- **Frontend** : http://localhost:3000
- **API Backend** : http://localhost:8000
- **Documentation API** : http://localhost:8000/api/v2/docs
- **ReDoc** : http://localhost:8000/api/v2/redoc
---
## 🏗️ **Architecture Technique**
### **Stack Technologique**
**Backend :**
- FastAPI 0.109+ (API REST moderne avec support async)
- SQLAlchemy 2.0+ avec aiosqlite (ORM async)
- Pydantic 2.0+ (validation et sérialisation)
- Uvicorn (serveur ASGI)
**Frontend :**
- Vue.js 3.4+ (framework progressif)
- Vite (build tool ultra-rapide)
- Vue Router 4 (routing SPA)
- Pinia (state management)
- TailwindCSS 3 (styling moderne)
- Chart.js 4 (graphiques interactifs)
- Axios (client HTTP)
**Base de données :**
- SQLite (développement)
- PostgreSQL recommandé (production)
---
## 📊 **Modèle de Données**
```
ClassGroup (Classe)
Students (Élèves avec gestion temporelle)
Assessment (Évaluation par trimestre)
Exercise (Exercices)
GradingElement (Questions/Compétences)
Grade (Notes individuelles)
```
---
## ⭐ **Fonctionnalités Principales**
### **Gestion des Classes et Élèves**
- Création et organisation de classes par année
- Inscription, transfert et départ d'élèves avec historique
- Import CSV en masse
- Statistiques par trimestre et par classe
### **Système d'Évaluation**
- Création unifiée d'évaluations (éval + exercices + barème)
- Organisation par trimestre (1, 2, 3)
- Filtrage avancé (trimestre, classe, statut de correction)
- Indicateurs de progression de correction en temps réel
### **Notation Dual Configurable**
**2 Types de Notation :**
1. **`notes`** : Valeurs numériques décimales (ex: 15.5/20)
2. **`score`** : Échelle 0-3 pour compétences (0=Non acquis → 3=Expert)
**Valeurs Spéciales :**
- `.` = Absent (compte comme 0)
- `d` = Dispensé (exclu des calculs)
- Configuration personnalisable via interface
### **Analyse et Statistiques**
- Dashboard avec statistiques globales
- Résultats détaillés par évaluation
- Histogrammes de distribution des notes
- Heatmaps par compétences et domaines
- Statistiques descriptives (moyenne, médiane, écart-type)
### **Conseil de Classe**
- Préparation automatique avec données consolidées
- Saisie et historique des appréciations
- Vue complète des performances par élève
### **Envoi de Bilans par Email**
- Génération automatique de bilans individualisés
- Support SMTP configurable (Gmail, Outlook, etc.)
- Templates HTML responsives
- Envoi en lot avec rapport détaillé
---
## 🧪 **Tests**
### **Backend**
```bash
uv run flask --app app init-db --mode midyear
cd backend
# Tous les tests
uv run pytest tests/ -v
# Tests unitaires uniquement
uv run pytest tests/unit/ -v
# Avec couverture
uv run pytest tests/ --cov=. --cov-report=html
```
- **Usage** : Tester l'application avec des données représentatives
- **Contenu** :
- **5 classes** avec 142 élèves au total (25-32 élèves par classe)
- **30 évaluations** créées (6 par classe sur 2 trimestres)
- **4 évaluations entièrement corrigées** par classe (Trimestre 1)
- **2 évaluations partiellement corrigées** par classe (Trimestre 2)
- **Notes réalistes** générées automatiquement
- **Idéal pour** : Démonstrations, formations, tests de performance
- **Volumétrie** : Correspond aux spécifications du CLAUDE.md (milieu de 2ème trimestre)
**Résultat actuel : 99/99 tests ✅**
#### 🧪 **Mode Démonstration** - Données minimales
### **Frontend**
```bash
uv run flask --app app init-db --mode demo
cd frontend
# Tests unitaires (à venir)
npm run test:unit
# Tests E2E (à venir)
npm run test:e2e
```
- **Usage** : Tests rapides et démonstrations simples
- **Contenu** : 2 classes, 5 élèves, 1 évaluation simple
- **Idéal pour** : Premiers pas avec l'application
---
### 🌐 Accès à l'Application
- **URL de développement** : http://localhost:5000
- **Interface** : Dashboard avec navigation intuitive et indicateurs de progression
- **Données** : Variables selon le mode d'initialisation choisi
## 🏗️ Architecture Technique
### 🎯 Principes Architecturaux
- **Architecture modulaire découplée** : Séparation claire des responsabilités
- **Repository Pattern** : Accès aux données abstraites et testables
- **Configuration externalisée** : Respect des principes 12 Factor App
- **Gestion d'erreurs centralisée** : Traitement uniforme des exceptions
- **Logging structuré JSON** : Traçabilité complète avec corrélation des requêtes
### 📁 Structure du Code
## 📂 **Structure du Projet**
```
notytex/
├── 🚀 app.py # Point d'entrée Flask + routes de base
├── 📊 models.py # Modèles SQLAlchemy + logique métier
├── ⚙️ app_config_classes.py # Classes de configuration Flask (dev/prod/test)
├── 🔧 config/ # Configuration sécurisée externalisé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
└── config.py # Interface configuration système
├── 🎨 templates/ # Templates Jinja2 avec indicateurs UX intégrés
├── 🧪 tests/ # Tests pytest (100 tests ✅)
── 📋 domain/ # Exceptions métier personnalisées
├── backend/ # API FastAPI
│ ├── api/ # Routes et endpoints
│ │ ├── main.py # Application principale
│ │ └── routes/ # Routes organisées par fonctionnali
│ ├── domain/ # Logique métier pure
│ ├── services/ # Services de calcul
│ │ └── value_objects/ # Objets valeur
│ ├── infrastructure/ # Couche d'infrastructure
│ ├── database/ # Modèles SQLAlchemy + repositories
└── external/ # Services externes (email)
│ ├── schemas/ # Modèles Pydantic (validation I/O)
── core/ # Configuration
│ └── tests/ # Tests unitaires et d'intégration
├── frontend/ # SPA Vue.js
│ ├── src/
│ ├── components/ # Composants réutilisables
│ │ ├── views/ # Pages de l'application
│ ├── stores/ # State management Pinia
│ ├── services/ # Clients API
│ ├── router/ # Configuration routing
│ └── assets/ # Assets statiques
│ └── public/ # Fichiers publics
├── docs/ # Documentation complète
── school_management.db # Base de données SQLite
└── README.md # Ce fichier
```
### 📊 Modèle de Données Hiérarchique
---
```
ClassGroup (Classes: 6ème A, 5ème B...)
Students (Élèves de la classe)
Assessment (Évaluations: Contrôle Chapitre 3, Trimestre obligatoire)
Exercise (Exercices: Exercice 1, Exercice 2...)
GradingElement (Questions: a, b, c... + type: notes|score)
Grade (Notes attribuées à chaque élève + valeurs spéciales)
```
## ⚙️ **Configuration**
### 🛠️ Stack Technique
- **Framework Backend** : Flask (Python) avec architecture modulaire
- **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
## ⚙️ Configuration
### 🔧 Variables d'Environnement (.env)
### **Backend (`backend/.env`)**
```bash
# OBLIGATOIRE - Sécurité
SECRET_KEY=your-secret-key-here-minimum-32-characters-required
# Base de données
DATABASE_URL=sqlite+aiosqlite:///../school_management.db
# Optionnel - Base de données
DATABASE_URL=sqlite:///school_management.db
# Sécurité
SECRET_KEY=your-secret-key-here-min-32-chars
# Optionnel - Environnement de développement
FLASK_ENV=development
DEBUG=true
# CORS (origines autorisées)
CORS_ORIGINS=["http://localhost:3000","http://localhost:5173"]
# Logging
LOG_LEVEL=INFO
DB_ECHO=false
WTF_CSRF_TIME_LIMIT=3600
# Email (optionnel)
SMTP_HOST=smtp.gmail.com
SMTP_PORT=587
SMTP_USERNAME=your-email@gmail.com
SMTP_PASSWORD=your-app-password
EMAIL_FROM=your-email@gmail.com
```
### 🏭 Configuration de Production
### **Frontend**
Le frontend utilise un proxy Vite configuré dans `vite.config.js` pour rediriger `/api` vers le backend.
---
## 📖 **Documentation Complète**
- **Architecture détaillée** : [`REWRITE.md`](REWRITE.md) - Historique de la réécriture v1 → v2
- **Backend** : [`backend/README.md`](backend/README.md) - Documentation API
- **Parité fonctionnelle** : [`backend/docs/PARITY_CHECKLIST.md`](backend/docs/PARITY_CHECKLIST.md)
- **Documentation API** : http://localhost:8000/api/v2/docs (Swagger)
---
## 🔧 **Commandes Utiles**
### **Backend**
```bash
# Variables critiques pour la production
SECRET_KEY=your-production-secret-key-min-32-chars-strong
DATABASE_URL=postgresql://user:password@localhost/notytex_prod
FLASK_ENV=production
DEBUG=false
LOG_LEVEL=WARNING
# Démarrer l'API en mode développement
cd backend && uv run uvicorn api.main:app --reload --port 8000
# Exécuter les tests
cd backend && uv run pytest tests/ -v
# Formater le code
cd backend && uv run black .
# Linter
cd backend && uv run ruff check .
```
> ⚠️ **Important** : La variable `SECRET_KEY` est obligatoire et doit faire au minimum 32 caractères. L'application refusera de démarrer sans cette configuration.
## 🧪 Tests et Qualité
### 🚦 Lancer les Tests
### **Frontend**
```bash
# Tous les tests (100 tests ✅)
uv run pytest
# Démarrer en développement
cd frontend && npm run dev
# Tests avec rapport de couverture
uv run pytest --cov=. --cov-report=html
# Build pour production
cd frontend && npm run build
# Tests spécifiques par module
uv run pytest tests/test_models.py -v
uv run pytest tests/test_repositories.py -v
# Preview du build
cd frontend && npm run preview
```
### 🎯 Organisation des Tests
---
- **test_config.py** : Tests configuration externalisée + validation
- **test_error_handlers.py** : Tests gestion d'erreurs centralisée
- **test_logging.py** : Tests logging structuré JSON
- **test_repositories.py** : Tests Repository Pattern + CRUD
- **test_models.py** : Tests modèles SQLAlchemy + logique métier
- **test*routes*\*.py** : Tests routes Flask + contrôleurs
- **test_forms.py** : Tests validation WTForms
## 📊 **État du Projet**
### 📊 Métriques de Qualité
### **Fonctionnalités Implémentées (100%)**
-**100 tests** passent avec succès
-**Architecture découplée** avec Repository Pattern
-**Configuration sécurisée** externalisée
-**Gestion d'erreurs robuste** centralisée
| Module | Fonctionnalités | Status |
|--------|----------------|--------|
| **Classes** | CRUD, stats, dashboard | ✅ |
| **Élèves** | CRUD, inscriptions, import CSV | ✅ |
| **Évaluations** | Création unifiée, filtres, progression | ✅ |
| **Notation** | Grille interactive, valeurs spéciales | ✅ |
| **Résultats** | Statistiques, graphiques, heatmaps | ✅ |
| **Conseil** | Préparation, appréciations | ✅ |
| **Configuration** | Compétences, domaines, échelle, SMTP | ✅ |
| **Email** | Envoi bilans individualisés | ✅ |
## 🛠️ Guide de Développement
### **Tests**
### 🔄 Workflow de Développement
- **Backend** : 99/99 tests unitaires ✅
- **API** : 45 routes opérationnelles ✅
- **Frontend** : 14 vues complètes ✅
```bash
# 1. Configuration initiale
cp .env.example .env
# Éditer .env avec SECRET_KEY obligatoire
---
# 2. Choisir le mode d'initialisation selon le contexte
uv run flask --app app init-db --mode minimal # Début d'année
uv run flask --app app init-db --mode midyear # Tests avec données complètes
uv run flask --app app init-db --mode demo # Démonstration rapide
## 🎓 **Public Cible**
# 3. Développement avec rechargement automatique
uv run flask --app app run --debug
- Enseignants du secondaire (collège/lycée)
- Établissements souhaitant digitaliser leurs évaluations
- Contexte de coexistence notation classique / évaluation par compétences
# 4. Validation avant commit
uv run pytest
---
# 5. Analyse des logs en temps réel
tail -f logs/notytex.log | jq '.' # Pour formater le JSON
```
## 📝 **Notes de Version**
### 🆕 Ajouter une Nouvelle Fonctionnalité
### **Version 2.0.0 (Actuelle)**
1. **Modèles** : Définir les entités dans `models.py`
2. **Repository** : Créer l'accès données dans `repositories/`
3. **Routes** : Implémenter les contrôleurs dans `routes/`
4. **Templates** : Créer les vues dans `templates/`
5. **Forms** : Ajouter la validation dans `forms.py`
6. **Tests** : Couvrir la fonctionnalité dans `tests/`
- ✨ Réécriture complète en FastAPI + Vue.js
- ⚡ Architecture moderne avec séparation backend/frontend
- 🚀 API REST JSON pure avec documentation OpenAPI
- 🎨 Interface utilisateur réactive avec Vue 3
- 📊 Graphiques interactifs avec Chart.js
- 🔒 Validation Pydantic sur toutes les entrées
- 🧪 99 tests unitaires avec 100% de succès
### 🎯 Conventions de Code
### **Version 1.0.0 (Legacy)**
- **PEP 8** : Style Python standard
- **Type hints** : Recommandés pour les nouvelles fonctions
- **Docstrings** : Format Google pour les fonctions publiques
- **Tests** : Couverture obligatoire pour les nouvelles fonctionnalités
- Application Flask monolithique
- Code disponible dans l'historique Git (branche `main` avant migration)
## 📊 Monitoring et Observabilité
---
### 📝 Logs Structurés JSON
## 🤝 **Contribution**
- **Format** : JSON avec ID de corrélation unique par requête
- **Localisation** : `logs/notytex.log`
- **Niveaux** : DEBUG, INFO, WARNING, ERROR
- **Contexte automatique** : URL, méthode HTTP, IP, user-agent, timestamp
- **Événements métier** : Création/modification/suppression d'évaluations
Ce projet a été développé avec l'aide d'assistants IA (principalement Claude). Les contributions sont bienvenues !
### 🔍 Exemple de Log Structuré
---
```json
{
"timestamp": "2025-08-06T10:30:45.123Z",
"level": "INFO",
"correlation_id": "uuid-1234-5678-9abc",
"message": "Assessment created successfully",
"context": {
"assessment_id": 123,
"class_group_id": 45,
"trimester": 1
}
}
```
## 📄 **Licence**
## 🎓 Exemples d'Utilisation Complète
MIT
### 🌱 **Scénario Début d'Année** - Mode Minimal
---
```bash
# Initialisation pour démarrer l'année scolaire
uv run flask --app app init-db --mode minimal
```
## 🔗 **Liens Utiles**
**Étapes typiques :**
- [FastAPI Documentation](https://fastapi.tiangolo.com/)
- [Vue.js 3 Documentation](https://vuejs.org/)
- [SQLAlchemy 2.0](https://docs.sqlalchemy.org/en/20/)
- [Pydantic](https://docs.pydantic.dev/)
- [TailwindCSS](https://tailwindcss.com/)
1. **Configuration initiale** : Année scolaire 2025-2026 automatiquement définie
2. **Création des classes** : Ajouter manuellement 6ème A, 5ème B, etc.
3. **Import des élèves** : Saisir ou importer les listes d'élèves réelles
4. **Première évaluation** : Créer le premier contrôle de l'année
---
### 📚 **Scénario Milieu d'Année** - Mode Données Complètes
```bash
# Initialisation avec données réalistes de milieu d'année
uv run flask --app app init-db --mode midyear
```
**Contenu automatiquement généré :**
- **Classes** : 6ème A (28 élèves), 6ème B (25 élèves), 5ème A (30 élèves), 5ème B (27 élèves), 4ème A (32 élèves)
- **Évaluations Trimestre 1** (100% corrigées) :
- Contrôle Nombres entiers (coefficient 2.0)
- Évaluation Géométrie (coefficient 2.5)
- Contrôle Fractions (coefficient 3.0)
- Devoir Maison Recherche (coefficient 1.5)
- **Évaluations Trimestre 2** (partiellement corrigées) :
- Contrôle Proportionnalité (coefficient 2.5)
- Évaluation Statistiques (coefficient 2.0)
**Utilisation immédiate :**
1. **Dashboard** : Aperçu des 142 élèves, 30 évaluations, 5 classes
2. **Indicateurs de progression** : Visualisation des corrections en cours
3. **Analyse des résultats** : Statistiques et graphiques sur les évaluations terminées
4. **Saisie de notes** : Continuer la correction des évaluations partielles
### 📝 **Scénario Création d'Évaluation** - Workflow Standard
1. **🏗️ Création de l'évaluation**
- Titre : "Contrôle Chapitre 3 - Fonctions"
- Paramètres : Date, **trimestre obligatoire** (ex: 2), classe, coefficient
2. **⚙️ Structure de l'évaluation**
- **Exercice 1** : "Calculs de base" (notes en points)
- Question 1a : 2 points
- Question 1b : 3 points
- **Exercice 2** : "Compétences graphiques" (score 0-3)
- Compétence "Lire un graphique" : échelle 0-3
3. **📊 Suivi de la progression**
- **Indicateur initial** : "Correction 0%" (rouge) visible sur toutes les pages
- **Saisie des notes** : Clic sur l'indicateur → accès direct à la page de notation
- **Progression temps réel** : "Correction 45%" (orange) → "Correction 100%" (vert)
4. **📈 Analyse des résultats**
- **Statistiques automatiques** : Moyenne, médiane, écart-type
- **Visualisation graphique** : Histogramme de distribution des notes
- **Tableau détaillé** : Classement alphabétique avec scores par exercice
## 🛡️ Sécurité et Bonnes Pratiques
### 🔐 Sécurité Applicative
- **Configuration externalisée** : Variables d'environnement (.env) - aucune donnée sensible en dur
- **Validation centralisée** : WTForms + validation des données en entrée
- **Gestion d'erreurs sécurisée** : Pas de fuite d'informations sensibles dans les messages
- **Logs sécurisés** : Exclusion des données personnelles/sensibles des logs
- **CSRF Protection** : Protection native Flask-WTF contre les attaques CSRF
### 🏗️ Architecture Robuste
- **Respect des principes 12 Factor App** : Configuration, logs, processus
- **Repository Pattern** : Couche d'abstraction pour l'accès aux données
- **Gestion d'erreurs centralisée** : Traitement uniforme des exceptions
- **Tests complets** : Couverture de 100% des fonctionnalités critiques
## 👥 Public Cible et Contexte d'Usage
### 🎓 Utilisateurs Principaux
- **Enseignants** du secondaire (collège, lycée)
### 📚 Contextes Pédagogiques
- **Coexistence des pratiques** : Notation classique ET évaluation par compétences
- **Gestion par trimestre** : Organisation structurée des évaluations scolaires
- **Diversité des exercices** : Support des différents formats de contrôles
**Développé avec ❤️ pour simplifier la vie des enseignants**

117
app.py
View File

@@ -1,117 +0,0 @@
import os
from flask import Flask, render_template
from models import db
from repositories import AssessmentRepository, StudentRepository, ClassRepository
from commands import init_db, create_large_test_data
from app_config_classes import config
from app_config import config_manager
from exceptions.handlers import register_error_handlers
from core.logging import setup_logging
# Import blueprints
from routes.assessments import bp as assessments_bp
from routes.grading import bp as grading_bp
from routes.config import bp as config_bp
from routes.domains import bp as domains_bp
from routes.classes import bp as classes_bp
def create_app(config_name=None):
if config_name is None:
config_name = os.environ.get('FLASK_ENV', 'development')
app = Flask(__name__)
app.config.from_object(config[config_name])
# Initialiser la configuration de l'application
app.app_config = config_manager
# Configuration du logging structuré
setup_logging(app)
# Initialize extensions
db.init_app(app)
# Register error handlers
register_error_handlers(app)
# Initialiser la configuration par défaut après l'initialisation de la DB
with app.app_context():
db.create_all()
config_manager.initialize_default_config()
# Register blueprints
app.register_blueprint(assessments_bp)
app.register_blueprint(grading_bp)
app.register_blueprint(config_bp)
app.register_blueprint(domains_bp)
app.register_blueprint(classes_bp)
# Register CLI commands
app.cli.add_command(init_db)
app.cli.add_command(create_large_test_data)
# Main routes
@app.route('/')
def index():
try:
assessment_repo = AssessmentRepository()
student_repo = StudentRepository()
class_repo = ClassRepository()
recent_assessments = assessment_repo.find_recent(5)
total_students = student_repo.model_class.query.count() # Keeping simple count
total_assessments = assessment_repo.model_class.query.count() # Keeping simple count
total_classes = class_repo.count_all()
return render_template('index.html',
recent_assessments=recent_assessments,
total_students=total_students,
total_assessments=total_assessments,
total_classes=total_classes)
except Exception as e:
app.logger.error(f'Erreur lors du chargement de la page d\'accueil: {e}')
return render_template('error.html', error="Erreur lors du chargement de la page"), 500
@app.route('/classes')
def classes():
try:
class_repo = ClassRepository()
classes = class_repo.find_all_ordered('year_name')
return render_template('classes.html', classes=classes)
except Exception as e:
app.logger.error(f'Erreur lors du chargement des classes: {e}')
return render_template('error.html', error="Erreur lors du chargement des classes"), 500
@app.route('/students')
def students():
try:
# Utilisation du repository temporel pour récupérer tous les élèves avec leur classe actuelle
from repositories.temporal_student_repository import TemporalStudentRepository
from models import Student
temporal_repo = TemporalStudentRepository()
all_students = Student.query.order_by(Student.last_name, Student.first_name).all()
# Enrichir chaque élève avec sa classe actuelle
students_with_classes = []
for student in all_students:
current_class = student.get_current_class()
students_with_classes.append({
'student': student,
'current_class': current_class
})
return render_template('students.html', students_with_classes=students_with_classes)
except Exception as e:
app.logger.error(f'Erreur lors du chargement des étudiants: {e}')
return render_template('error.html', error="Erreur lors du chargement des étudiants"), 500
return app
if __name__ == '__main__':
app = create_app()
with app.app_context():
db.create_all()
# Le mode debug est géré par la configuration
port = int(os.environ.get('PORT', 5000))
app.run(host='0.0.0.0', port=port)

View File

@@ -1,653 +0,0 @@
from typing import Dict, Any, Optional, List
from models import db, AppConfig, CompetenceScaleValue, Competence
class ConfigManager:
"""Gestionnaire de configuration de l'application Notytex utilisant SQLAlchemy."""
@property
def default_config(self) -> Dict[str, Any]:
"""Configuration par défaut de l'application."""
return {
'context': {
'school_year': '2025-2026'
},
'grading_system': {
'types': {
'notes': {
'label': 'Notes numériques',
'description': 'Valeurs décimales (ex: 15.5/20)',
'input_type': 'number',
'validation': 'decimal'
},
'score': {
'label': 'Échelle de compétences (0-3)',
'description': 'Échelle fixe : 0=Non acquis, 1=En cours, 2=Acquis, 3=Expert',
'max_value': 3,
'input_type': 'select'
}
},
'special_values': {
'.': {
'label': 'Pas de réponse',
'description': 'Aucune réponse fournie',
'color': '#6b7280',
'counts': True,
'value': 0
},
'd': {
'label': 'Dispensé',
'description': 'Élève dispensé de cet exercice',
'color': '#c0bfbc',
'counts': False,
'value': None
},
'a': {
'label': 'Absent',
'description': 'Élève absent lors de l\'évaluation',
'color': '#f87171',
'counts': True,
'value': 0
}
},
'score_meanings': {
0: {'label': 'Non acquis', 'color': '#ef4444', 'description': 'Compétence non maîtrisée'},
1: {'label': 'En cours d\'acquisition', 'color': '#f6d32d', 'description': 'Compétence en cours d\'apprentissage'},
2: {'label': 'Acquis', 'color': '#22c55e', 'description': 'Compétence maîtrisée'},
3: {'label': 'Expert', 'color': '#059669', 'description': 'Compétence parfaitement maîtrisée'}
}
},
'evaluations': {
'default_grading_system': 'competences',
'competence_scale': {
'values': {
'0': {
'label': 'Non acquis',
'color': '#ef4444',
'included_in_total': True
},
'1': {
'label': 'En cours d\'acquisition',
'color': '#f6d32d',
'included_in_total': True
},
'2': {
'label': 'Acquis',
'color': '#22c55e',
'included_in_total': True
},
'3': {
'label': 'Expert',
'color': '#059669',
'included_in_total': True
},
'.': {
'label': 'Pas de réponse',
'color': '#6b7280',
'included_in_total': True
},
'd': {
'label': 'Dispensé',
'color': '#c0bfbc',
'included_in_total': False
},
'a': {
'label': 'Absent',
'color': '#f87171',
'included_in_total': True
}
}
},
'competences': [
{
'name': 'Calculer',
'color': '#3b82f6',
'icon': 'calculator'
},
{
'name': 'Raisonner',
'color': '#8b5cf6',
'icon': 'lightbulb'
},
{
'name': 'Communiquer',
'color': '#06b6d4',
'icon': 'chat-bubble-left-right'
},
{
'name': 'Modéliser',
'color': '#10b981',
'icon': 'cube'
},
{
'name': 'Représenter',
'color': '#f59e0b',
'icon': 'chart-bar'
},
{
'name': 'Chercher',
'color': '#ef4444',
'icon': 'magnifying-glass'
}
]
},
'domains': {
'default_domains': [
{
'name': 'Algèbre',
'color': '#3b82f6',
'description': 'Calculs algébriques, équations, expressions'
},
{
'name': 'Géométrie',
'color': '#10b981',
'description': 'Figures, mesures, constructions géométriques'
},
{
'name': 'Statistiques',
'color': '#f59e0b',
'description': 'Données, moyennes, graphiques statistiques'
},
{
'name': 'Fonctions',
'color': '#8b5cf6',
'description': 'Fonctions, graphiques, tableaux de valeurs'
},
{
'name': 'Problèmes',
'color': '#ef4444',
'description': 'Résolution de problèmes concrets'
},
{
'name': 'Calcul mental',
'color': '#06b6d4',
'description': 'Calculs rapides, estimations'
}
]
}
}
def initialize_default_config(self) -> None:
"""Initialise la configuration par défaut en base de données si elle n'existe pas."""
# Configuration simple
for section, values in self.default_config.items():
if section == 'context':
for key, value in values.items():
config_key = f"{section}.{key}"
if not AppConfig.query.filter_by(key=config_key).first():
config = AppConfig(key=config_key, value=str(value))
db.session.add(config)
elif section == 'evaluations':
# Système de notation par défaut
if 'default_grading_system' in values:
config_key = f"{section}.default_grading_system"
if not AppConfig.query.filter_by(key=config_key).first():
config = AppConfig(key=config_key, value=values['default_grading_system'])
db.session.add(config)
# Échelle des compétences
if CompetenceScaleValue.query.count() == 0:
scale_values = self.default_config['evaluations']['competence_scale']['values']
for value, config in scale_values.items():
scale_value = CompetenceScaleValue(
value=value,
label=config['label'],
color=config['color'],
included_in_total=config['included_in_total']
)
db.session.add(scale_value)
# Compétences
if Competence.query.count() == 0:
competences = self.default_config['evaluations']['competences']
for i, comp in enumerate(competences):
competence = Competence(
name=comp['name'],
color=comp['color'],
icon=comp['icon'],
order_index=i
)
db.session.add(competence)
# Domaines par défaut
from models import Domain
if Domain.query.count() == 0:
default_domains = self.default_config['domains']['default_domains']
for domain_data in default_domains:
domain = Domain(
name=domain_data['name'],
color=domain_data['color'],
description=domain_data.get('description', '')
)
db.session.add(domain)
db.session.commit()
def get(self, key_path: str, default: Any = None) -> Any:
"""
Récupère une valeur de configuration.
Args:
key_path: Chemin vers la valeur (ex: 'context.school_year')
default: Valeur par défaut si la clé n'existe pas
Returns:
La valeur de configuration ou la valeur par défaut
"""
# Configuration simple stockée dans AppConfig
config = AppConfig.query.filter_by(key=key_path).first()
if config:
return config.value
# Valeurs par défaut pour les cas non trouvés
return self._get_default_value(key_path, default)
def _get_default_value(self, key_path: str, fallback: Any) -> Any:
"""Récupère une valeur par défaut depuis la configuration par défaut."""
if fallback is not None:
return fallback
keys = key_path.split('.')
value = self.default_config
try:
for key in keys:
value = value[key]
return value
except (KeyError, TypeError):
return None
def set(self, key_path: str, value: Any) -> None:
"""
Définit une valeur de configuration.
Args:
key_path: Chemin vers la valeur (ex: 'context.school_year')
value: Nouvelle valeur
"""
config = AppConfig.query.filter_by(key=key_path).first()
if config:
config.value = str(value)
else:
config = AppConfig(key=key_path, value=str(value))
db.session.add(config)
def save(self) -> bool:
"""Sauvegarde la configuration en base de données."""
try:
db.session.commit()
return True
except Exception as e:
db.session.rollback()
from flask import current_app
current_app.logger.error(f"Erreur lors de la sauvegarde de la configuration: {e}", exc_info=True)
return False
def get_competence_scale_values(self) -> Dict[str, Dict[str, Any]]:
"""Récupère les valeurs de l'échelle des compétences."""
scale_values = CompetenceScaleValue.query.all()
# Si aucune valeur n'existe, initialiser avec les valeurs par défaut
if not scale_values:
self.initialize_default_config()
scale_values = CompetenceScaleValue.query.all()
result = {}
for scale_value in scale_values:
# Garder toutes les clés comme des strings pour la cohérence
result[scale_value.value] = {
'label': scale_value.label,
'color': scale_value.color,
'included_in_total': scale_value.included_in_total
}
return result
def get_competences_list(self) -> List[Dict[str, Any]]:
"""Récupère la liste des compétences configurées."""
competences = Competence.query.order_by(Competence.order_index).all()
return [
{
'name': comp.name,
'color': comp.color,
'icon': comp.icon
}
for comp in competences
]
def get_domains_list(self) -> List[Dict[str, Any]]:
"""Récupère la liste des domaines configurés."""
from models import Domain
domains = Domain.query.order_by(Domain.name).all()
return [
{
'id': domain.id,
'name': domain.name,
'color': domain.color,
'description': domain.description or ''
}
for domain in domains
]
def add_domain(self, name: str, color: str = '#6B7280', description: str = '') -> bool:
"""Ajoute un nouveau domaine."""
try:
from models import Domain
domain = Domain(name=name, color=color, description=description)
db.session.add(domain)
db.session.commit()
return True
except Exception as e:
db.session.rollback()
from flask import current_app
current_app.logger.error(f"Erreur lors de l'ajout du domaine: {e}")
return False
def get_or_create_domain(self, name: str, color: str = '#6B7280') -> 'Domain':
"""Récupère un domaine existant ou le crée s'il n'existe pas."""
from models import Domain
domain = Domain.query.filter_by(name=name).first()
if not domain:
domain = Domain(name=name, color=color)
db.session.add(domain)
db.session.commit()
return domain
def get_school_year(self) -> str:
"""Récupère l'année scolaire courante."""
return self.get('context.school_year', '2025-2026')
def get_default_grading_system(self) -> str:
"""Récupère le système de notation par défaut."""
return self.get('evaluations.default_grading_system', 'competences')
# === Nouveau système de notation unifié ===
def get_grading_types(self) -> Dict[str, Dict[str, Any]]:
"""Récupère les types de notation disponibles (notes, score)."""
return self.default_config['grading_system']['types']
def get_special_values(self) -> Dict[str, Dict[str, Any]]:
"""Récupère les valeurs spéciales configurables (., d, a) depuis la base de données."""
# Récupérer les valeurs spéciales depuis la base de données
special_values_keys = ['.', 'd', 'a']
scale_values = CompetenceScaleValue.query.filter(
CompetenceScaleValue.value.in_(special_values_keys)
).all()
# Si aucune valeur n'existe, initialiser avec les valeurs par défaut
if not scale_values:
self.initialize_default_config()
scale_values = CompetenceScaleValue.query.filter(
CompetenceScaleValue.value.in_(special_values_keys)
).all()
# Construire le dict avec mapping included_in_total -> counts
result = {}
for scale_value in scale_values:
# Récupérer les valeurs par défaut pour compléter les informations manquantes
default_config = self.default_config['grading_system']['special_values'].get(scale_value.value, {})
result[scale_value.value] = {
'label': scale_value.label,
'color': scale_value.color,
'counts': scale_value.included_in_total, # Mapping: included_in_total -> counts
'description': default_config.get('description', scale_value.label),
'value': default_config.get('value', 0 if scale_value.included_in_total else None)
}
# Ajouter les valeurs manquantes depuis la config par défaut si elles n'existent pas en base
default_special = self.default_config['grading_system']['special_values']
for key, default_value in default_special.items():
if key not in result:
result[key] = default_value
return result
def get_score_meanings(self) -> Dict[int, Dict[str, str]]:
"""Récupère les significations des scores (0-3) depuis la base de données."""
# Récupérer les valeurs depuis la base de données
scale_values = CompetenceScaleValue.query.filter(
CompetenceScaleValue.value.in_(['0', '1', '2', '3'])
).all()
# Convertir en dict avec clés entières
meanings = {}
for scale_value in scale_values:
try:
score_int = int(scale_value.value)
meanings[score_int] = {
'label': scale_value.label,
'color': scale_value.color
}
except ValueError:
continue # Ignorer les valeurs non numériques
# Fallback vers la config par défaut si pas de données en base
if not meanings:
default_meanings = self.default_config['grading_system']['score_meanings']
for key, value in default_meanings.items():
meanings[int(key)] = value
return meanings
def validate_grade_value(self, value: str, grading_type: str, max_points: float = None) -> bool:
"""
Valide une valeur de note selon le type de notation.
Args:
value: Valeur à valider
grading_type: Type de notation ('notes' ou 'score')
max_points: Points maximum pour le type 'notes' (optionnel)
Returns:
True si la valeur est valide, False sinon
"""
# Valeurs vides sont acceptées
if not value or value.strip() == '':
return True
value = value.strip()
# Valeurs spéciales toujours valides
if self.is_special_value(value):
return True
# Validation selon le type
if grading_type == 'notes':
try:
# Normaliser virgule en point pour les décimaux français
normalized_value = value.replace(',', '.')
# Vérifier le format strict avec regex
import re
if not re.match(r'^[0-9]+([.,][0-9]+)?$', value):
return False
float_value = float(normalized_value)
# Vérifier la plage
if float_value < 0:
return False
# Si max_points est spécifié, vérifier la limite supérieure
if max_points is not None and float_value > max_points:
return False
return True
except (ValueError, TypeError):
return False
elif grading_type == 'score':
try:
int_value = int(value)
return 0 <= int_value <= 3
except (ValueError, TypeError):
return False
return False
def is_special_value(self, value: str) -> bool:
"""Vérifie si une valeur est une valeur spéciale."""
return value in self.get_special_values()
def get_numeric_value(self, value: str, grading_type: str) -> Optional[float]:
"""
Convertit une valeur de note en valeur numérique.
Args:
value: Valeur à convertir
grading_type: Type de notation
Returns:
Valeur numérique ou None pour les valeurs dispensées
"""
# Valeurs spéciales
if self.is_special_value(value):
special_config = self.get_special_values()[value]
return special_config['value']
# Conversion selon le type
try:
if grading_type == 'notes':
return float(value)
elif grading_type == 'score':
return float(value)
except (ValueError, TypeError):
pass
return None
def get_display_info(self, value: str, grading_type: str) -> Dict[str, Any]:
"""
Récupère les informations d'affichage pour une valeur.
Returns:
Dict avec 'color', 'label', 'description'
"""
# Récupérer les valeurs d'échelle de la base de données
scale_values = self.get_competence_scale_values()
# Si la valeur existe dans l'échelle, utiliser ses informations
if value in scale_values:
scale_config = scale_values[value]
return {
'color': scale_config['color'],
'label': scale_config['label'],
'description': scale_config.get('description', scale_config['label'])
}
# Valeur par défaut pour notes numériques ou valeurs non configurées
return {
'color': '#374151', # Gris neutre
'label': str(value),
'description': f'Note : {value}'
}
# Méthodes spécifiques pour la gestion des compétences
def add_competence(self, name: str, color: str, icon: str) -> bool:
"""Ajoute une nouvelle compétence."""
try:
# Obtenir le prochain index d'ordre
max_order = db.session.query(db.func.max(Competence.order_index)).scalar() or 0
competence = Competence(
name=name,
color=color,
icon=icon,
order_index=max_order + 1
)
db.session.add(competence)
db.session.commit()
return True
except Exception as e:
db.session.rollback()
from flask import current_app
current_app.logger.error(f"Erreur lors de l'ajout de la compétence: {e}", exc_info=True)
return False
def update_competence(self, competence_id: int, name: str, color: str, icon: str) -> bool:
"""Met à jour une compétence."""
try:
competence = Competence.query.get(competence_id)
if competence:
competence.name = name
competence.color = color
competence.icon = icon
db.session.commit()
return True
return False
except Exception as e:
db.session.rollback()
from flask import current_app
current_app.logger.error(f"Erreur lors de la mise à jour de la compétence: {e}", exc_info=True)
return False
def delete_competence(self, competence_id: int) -> bool:
"""Supprime une compétence."""
try:
competence = Competence.query.get(competence_id)
if competence:
db.session.delete(competence)
db.session.commit()
return True
return False
except Exception as e:
db.session.rollback()
from flask import current_app
current_app.logger.error(f"Erreur lors de la suppression de la compétence: {e}", exc_info=True)
return False
# Méthodes spécifiques pour la gestion de l'échelle
def add_scale_value(self, value: str, label: str, color: str, included_in_total: bool = True) -> bool:
"""Ajoute une nouvelle valeur à l'échelle."""
try:
scale_value = CompetenceScaleValue(
value=value,
label=label,
color=color,
included_in_total=included_in_total
)
db.session.add(scale_value)
db.session.commit()
return True
except Exception as e:
db.session.rollback()
from flask import current_app
current_app.logger.error(f"Erreur lors de l'ajout de la valeur d'échelle: {e}", exc_info=True)
return False
def update_scale_value(self, value: str, label: str, color: str, included_in_total: bool) -> bool:
"""Met à jour une valeur de l'échelle."""
try:
scale_value = CompetenceScaleValue.query.get(value)
if scale_value:
scale_value.label = label
scale_value.color = color
scale_value.included_in_total = included_in_total
db.session.commit()
return True
return False
except Exception as e:
db.session.rollback()
from flask import current_app
current_app.logger.error(f"Erreur lors de la mise à jour de la valeur d'échelle: {e}", exc_info=True)
return False
def delete_scale_value(self, value: str) -> bool:
"""Supprime une valeur de l'échelle."""
try:
scale_value = CompetenceScaleValue.query.get(value)
if scale_value:
db.session.delete(scale_value)
db.session.commit()
return True
return False
except Exception as e:
db.session.rollback()
from flask import current_app
current_app.logger.error(f"Erreur lors de la suppression de la valeur d'échelle: {e}", exc_info=True)
return False
# Instance globale de configuration
config_manager = ConfigManager()

View File

@@ -1,45 +0,0 @@
from config.settings import settings
class Config:
"""Configuration de base"""
SECRET_KEY = settings.SECRET_KEY
SQLALCHEMY_DATABASE_URI = settings.DATABASE_URL
SQLALCHEMY_TRACK_MODIFICATIONS = False
WTF_CSRF_TIME_LIMIT = settings.WTF_CSRF_TIME_LIMIT
DEBUG = settings.DEBUG
LOG_LEVEL = settings.LOG_LEVEL
class DevelopmentConfig(Config):
"""Configuration pour le développement"""
DEBUG = settings.DEBUG
SQLALCHEMY_ECHO = settings.DB_ECHO
class ProductionConfig(Config):
"""Configuration pour la production"""
DEBUG = False
SQLALCHEMY_ECHO = False
@classmethod
def init_app(cls, app):
Config.init_app(app)
# Log vers stderr en production
import logging
from logging import StreamHandler
file_handler = StreamHandler()
file_handler.setLevel(logging.WARNING)
app.logger.addHandler(file_handler)
class TestingConfig(Config):
"""Configuration pour les tests"""
TESTING = True
SQLALCHEMY_DATABASE_URI = 'sqlite:///:memory:'
WTF_CSRF_ENABLED = False
DEBUG = True
config = {
'development': DevelopmentConfig,
'production': ProductionConfig,
'testing': TestingConfig,
'default': DevelopmentConfig
}

View File

@@ -1,739 +0,0 @@
import click
from flask.cli import with_appcontext
from models import db, ClassGroup, Student, StudentEnrollment, Assessment, Exercise, GradingElement, Domain, CouncilAppreciation
from datetime import date, datetime
@click.command()
@click.option('--mode', default='minimal', type=click.Choice(['minimal', 'midyear', 'demo']),
help='Type d\'initialisation : minimal (début d\'année), midyear (milieu d\'année), demo (données de démonstration)')
@with_appcontext
def init_db(mode):
"""Initialize the database with different data sets."""
db.create_all()
# Check if data already exists
if ClassGroup.query.first():
click.echo("Database already initialized!")
return
if mode == 'minimal':
init_minimal_data()
elif mode == 'midyear':
init_midyear_data()
else: # demo
init_demo_data()
def init_minimal_data():
"""Initialize database for start of school year - configuration only."""
from app_config import config_manager
# Ensure basic configuration is set
config_manager.set('context.school_year', '2025-2026')
config_manager.set('context.school_name', 'Collège')
config_manager.save()
click.echo("Database initialized for start of school year - ready to create classes and students!")
def init_midyear_data():
"""Initialize database with realistic mid-year data including temporal enrollments."""
from app_config import config_manager
from models import Grade
import random
# Set configuration
config_manager.set('context.school_year', '2024-2025')
config_manager.set('context.school_name', 'Collège Jean Moulin')
config_manager.save()
# Dates importantes pour les inscriptions temporelles
school_year_start = date(2024, 9, 1) # Début d'année scolaire
first_term_end = date(2024, 12, 20) # Fin du 1er trimestre
second_term_start = date(2025, 1, 8) # Début du 2e trimestre
today = date(2025, 2, 15) # Date actuelle (milieu 2e trimestre)
# Create 5 classes with realistic sizes
classes_data = [
("6ème A", "Classe de 6ème A", 28),
("6ème B", "Classe de 6ème B", 25),
("5ème A", "Classe de 5ème A", 30),
("5ème B", "Classe de 5ème B", 27),
("4ème A", "Classe de 4ème A", 32),
]
# French first and last names for realistic data
first_names = [
"Adrien", "Alice", "Alexandre", "Amélie", "Antoine", "Anna", "Baptiste", "Camille",
"Charlotte", "Clément", "Chloé", "David", "Emma", "Ethan", "Elise", "Enzo", "Eva",
"Fabien", "Fanny", "Gabriel", "Giulia", "Hugo", "Inès", "Jules", "Jade", "Kévin",
"Léa", "Louis", "Marie", "Mathis", "Nina", "Oscar", "Paul", "Romane", "Sophie",
"Thomas", "Valentine", "Victor", "Zoé"
]
last_names = [
"Martin", "Bernard", "Dubois", "Thomas", "Robert", "Richard", "Petit", "Durand",
"Leroy", "Moreau", "Simon", "Laurent", "Lefebvre", "Michel", "Garcia", "David",
"Bertrand", "Roux", "Vincent", "Fournier", "Morel", "Girard", "André", "Lefèvre",
"Mercier", "Dupont", "Lambert", "Bonnet", "François", "Martinez", "Rousseau",
"Garnier", "Faure", "Muller", "Henry", "Rodriguez", "Perrin"
]
classes = []
all_students = []
# Global sets to ensure uniqueness across all classes
used_names = set()
used_emails = set()
# Create classes first
for class_name, description, nb_students in classes_data:
classe = ClassGroup(name=class_name, description=description, year="2024-2025")
db.session.add(classe)
classes.append(classe)
db.session.commit()
# Create students without class assignment (temporal model)
total_students = sum(nb for _, _, nb in classes_data)
for i in range(total_students):
# Generate unique name combinations and emails
while True:
first_name = random.choice(first_names)
last_name = random.choice(last_names)
name_combo = f"{first_name}_{last_name}"
base_email = f"{first_name.lower()}.{last_name.lower()}@college.edu"
# Make email unique if needed
email = base_email
counter = 1
while email in used_emails:
email = f"{first_name.lower()}.{last_name.lower()}{counter}@college.edu"
counter += 1
if name_combo not in used_names:
used_names.add(name_combo)
used_emails.add(email)
break
student = Student(
last_name=last_name,
first_name=first_name,
email=email
# Pas de class_group_id dans le nouveau modèle temporel
)
db.session.add(student)
all_students.append(student)
db.session.commit()
# Create temporal enrollments with realistic movements
student_index = 0
movements_log = []
for i, (class_name, description, nb_students) in enumerate(classes_data):
classe = classes[i]
# Répartir les élèves pour cette classe
class_students = all_students[student_index:student_index + nb_students]
student_index += nb_students
for student in class_students:
# 90% des élèves : inscrits depuis le début d'année, toujours présents
if random.random() < 0.90:
enrollment = StudentEnrollment(
student_id=student.id,
class_group_id=classe.id,
enrollment_date=school_year_start,
departure_date=None, # Toujours inscrit
enrollment_reason="Inscription début d'année",
)
db.session.add(enrollment)
# 7% des élèves : ont quitté pendant le 1er trimestre
elif random.random() < 0.97: # 7% des 10% restants
departure_date = date(2024, random.randint(10, 11), random.randint(1, 28))
enrollment = StudentEnrollment(
student_id=student.id,
class_group_id=classe.id,
enrollment_date=school_year_start,
departure_date=departure_date,
enrollment_reason="Inscription début d'année",
departure_reason="Déménagement"
)
db.session.add(enrollment)
movements_log.append(f"Élève {student.full_name} : départ de {classe.name} le {departure_date}")
# Créer un nouvel élève pour remplacer (arrivé en janvier)
new_student = Student(
last_name=random.choice(last_names),
first_name=random.choice(first_names),
email=f"nouveau.{random.randint(1000,9999)}@college.edu"
)
db.session.add(new_student)
db.session.commit() # Pour avoir l'ID
new_enrollment = StudentEnrollment(
student_id=new_student.id,
class_group_id=classe.id,
enrollment_date=second_term_start,
departure_date=None,
enrollment_reason="Nouvel élève - arrivée en cours d'année"
)
db.session.add(new_enrollment)
movements_log.append(f"Élève {new_student.full_name} : arrivée en {classe.name} le {second_term_start}")
# 3% des élèves : arrivés en cours d'année (janvier)
else:
arrival_date = date(2025, 1, random.randint(8, 31))
enrollment = StudentEnrollment(
student_id=student.id,
class_group_id=classe.id,
enrollment_date=arrival_date,
departure_date=None,
enrollment_reason="Arrivée en cours d'année"
)
db.session.add(enrollment)
movements_log.append(f"Élève {student.full_name} : arrivée en {classe.name} le {arrival_date}")
db.session.commit()
# Get domains for realistic grading elements
domain_calcul = Domain.query.filter_by(name='Algèbre').first()
domain_geometrie = Domain.query.filter_by(name='Géométrie').first()
domain_fonctions = Domain.query.filter_by(name='Fonctions').first()
domain_stats = Domain.query.filter_by(name='Statistiques').first()
domain_problemes = Domain.query.filter_by(name='Problèmes').first()
# Create assessments for each class
assessments_templates = [
# Trimester 1 - completed assessments (4 per class)
{
"title": "Contrôle Chapitre 1 - Nombres entiers",
"description": "Évaluation sur les opérations et la divisibilité",
"trimester": 1,
"coefficient": 2.0,
"exercises": [
{
"title": "Ex1 - Opérations",
"description": "Calculs et priorités opératoires",
"elements": [
("Additions", "Calculs simples", "Calculer", 3.0, "notes", domain_calcul),
("Multiplications", "Tables et calculs", "Calculer", 4.0, "notes", domain_calcul),
("Priorités", "Parenthèses et ordres", "Calculer", 3.0, "notes", domain_calcul),
("Méthode", "Justification des étapes", "Raisonner", 2.0, "score", domain_problemes),
]
},
{
"title": "Ex2 - Divisibilité",
"description": "Critères de divisibilité et factorisation",
"elements": [
("Critères", "Divisibilité par 2,3,5,9", "Calculer", 4.0, "notes", domain_calcul),
("Division euclidienne", "Quotient et reste", "Calculer", 4.0, "notes", domain_calcul),
("Problème", "Application concrète", "Modéliser", 3.0, "score", domain_problemes),
]
}
],
"corrected": True
},
{
"title": "Évaluation Chapitre 2 - Géométrie",
"description": "Figures géométriques et mesures",
"trimester": 1,
"coefficient": 2.5,
"exercises": [
{
"title": "Ex1 - Périmètres et aires",
"description": "Calculs géométriques",
"elements": [
("Périmètre rectangle", "Formule et calcul", "Calculer", 2.0, "notes", domain_geometrie),
("Aire triangle", "Formule et application", "Calculer", 3.0, "notes", domain_geometrie),
("Construction", "Tracer au compas", "Représenter", 2.0, "score", domain_geometrie),
("Rédaction", "Présentation des calculs", "Communiquer", 2.0, "score", domain_problemes),
]
},
{
"title": "Ex2 - Problème de synthèse",
"description": "Application des formules",
"elements": [
("Modélisation", "Schéma et données", "Modéliser", 3.0, "score", domain_problemes),
("Calculs", "Périmètre et aire", "Calculer", 5.0, "notes", domain_geometrie),
("Interprétation", "Sens du résultat", "Raisonner", 2.0, "score", domain_problemes),
]
}
],
"corrected": True
},
{
"title": "Contrôle Chapitre 3 - Fractions",
"description": "Opérations sur les fractions",
"trimester": 1,
"coefficient": 3.0,
"exercises": [
{
"title": "Ex1 - Simplification",
"description": "Fractions égales et simplification",
"elements": [
("Reconnaissance", "Fractions égales", "Calculer", 2.0, "notes", domain_calcul),
("Simplification", "Réduction au plus simple", "Calculer", 4.0, "notes", domain_calcul),
("Justification", "Expliquer la méthode", "Raisonner", 2.0, "score", domain_problemes),
]
},
{
"title": "Ex2 - Opérations",
"description": "Addition et soustraction",
"elements": [
("Même dénominateur", "Calculs directs", "Calculer", 3.0, "notes", domain_calcul),
("Dénominateurs différents", "Mise au même dénominateur", "Calculer", 5.0, "notes", domain_calcul),
("Problème", "Application pratique", "Modéliser", 3.0, "score", domain_problemes),
]
}
],
"corrected": True
},
{
"title": "Devoir Maison - Recherche",
"description": "Travail de recherche et d'approfondissement",
"trimester": 1,
"coefficient": 1.5,
"exercises": [
{
"title": "Recherche documentaire",
"description": "Histoire des mathématiques",
"elements": [
("Contenu", "Richesse des informations", "Raisonner", 3.0, "score", domain_problemes),
("Sources", "Qualité et diversité", "Raisonner", 2.0, "score", domain_problemes),
("Présentation", "Mise en forme", "Communiquer", 3.0, "score", domain_problemes),
("Originalité", "Approche personnelle", "Raisonner", 2.0, "score", domain_problemes),
]
}
],
"corrected": True
},
# Trimester 2 - partially corrected assessments (2 per class)
{
"title": "Contrôle Chapitre 4 - Proportionnalité",
"description": "Tableaux de proportionnalité et applications",
"trimester": 2,
"coefficient": 2.5,
"exercises": [
{
"title": "Ex1 - Reconnaissance",
"description": "Identifier la proportionnalité",
"elements": [
("Tableaux", "Coefficients de proportionnalité", "Raisonner", 3.0, "notes", domain_calcul),
("Graphiques", "Droites passant par l'origine", "Représenter", 2.0, "score", domain_geometrie),
("Règle de trois", "Calculs simples", "Calculer", 4.0, "notes", domain_calcul),
]
},
{
"title": "Ex2 - Applications",
"description": "Problèmes de proportionnalité",
"elements": [
("Échelle", "Plans et cartes", "Modéliser", 4.0, "notes", domain_geometrie),
("Vitesse", "Distance-temps", "Modéliser", 3.0, "notes", domain_calcul),
("Pourcentages", "Calculs et applications", "Calculer", 4.0, "notes", domain_calcul),
("Méthode", "Présentation claire", "Communiquer", 2.0, "score", domain_problemes),
]
}
],
"corrected": False # Partiellement corrigé
},
{
"title": "Évaluation Chapitre 5 - Statistiques",
"description": "Moyenne, médiane, graphiques",
"trimester": 2,
"coefficient": 2.0,
"exercises": [
{
"title": "Ex1 - Calculs statistiques",
"description": "Indicateurs de position",
"elements": [
("Moyenne", "Calcul et interprétation", "Calculer", 3.0, "notes", domain_stats),
("Médiane", "Valeur centrale", "Calculer", 3.0, "notes", domain_stats),
("Étendue", "Maximum - minimum", "Calculer", 2.0, "notes", domain_stats),
]
},
{
"title": "Ex2 - Représentations",
"description": "Graphiques statistiques",
"elements": [
("Diagramme", "Construction soignée", "Représenter", 3.0, "score", domain_stats),
("Lecture", "Extraire des informations", "Raisonner", 2.0, "score", domain_stats),
("Interprétation", "Analyser les résultats", "Raisonner", 3.0, "score", domain_stats),
("Communication", "Explication claire", "Communiquer", 2.0, "score", domain_problemes),
]
}
],
"corrected": False # Non corrigé
}
]
# Create assessments for each class with temporal logic
for classe in classes:
for assessment_template in assessments_templates:
# Create assessment with realistic date
assessment_date = school_year_start
if assessment_template["trimester"] == 1:
assessment_date = date(2024, random.randint(10, 11), random.randint(1, 28))
elif assessment_template["trimester"] == 2:
assessment_date = date(2025, random.randint(1, 2), random.randint(1, 28))
assessment = Assessment(
title=assessment_template["title"],
description=assessment_template["description"],
date=assessment_date,
trimester=assessment_template["trimester"],
class_group_id=classe.id,
coefficient=assessment_template["coefficient"]
)
db.session.add(assessment)
db.session.commit()
# Create exercises and grading elements
for ex_template in assessment_template["exercises"]:
exercise = Exercise(
assessment_id=assessment.id,
title=ex_template["title"],
description=ex_template["description"],
order=assessment_template["exercises"].index(ex_template) + 1
)
db.session.add(exercise)
db.session.commit()
# Create grading elements
grading_elements = []
for element_data in ex_template["elements"]:
label, desc, skill, max_points, grading_type, domain = element_data
element = GradingElement(
exercise_id=exercise.id,
label=label,
description=desc,
skill=skill,
max_points=max_points,
grading_type=grading_type,
domain_id=domain.id if domain else None
)
db.session.add(element)
grading_elements.append(element)
db.session.commit()
# Add grades if assessment should be corrected
if assessment_template["corrected"]:
# Get students eligible for this assessment using temporal logic
from repositories.temporal_student_repository import TemporalStudentRepository
temporal_repo = TemporalStudentRepository()
eligible_students = temporal_repo.find_eligible_for_assessment(assessment)
# Generate realistic grades only for eligible students
for student in eligible_students:
for element in grading_elements:
# Generate realistic grade based on element type
if element.grading_type == "notes":
# Points: usually between 60-95% of max_points
base_ratio = random.uniform(0.6, 0.95)
grade_value = round(base_ratio * element.max_points, 1)
# Some students get perfect scores (10% chance)
if random.random() < 0.1:
grade_value = element.max_points
# Some students struggle (15% chance)
elif random.random() < 0.15:
grade_value = round(random.uniform(0.2, 0.6) * element.max_points, 1)
else: # score type (0-3)
# Competence scores: weighted towards 1 and 2
score_weights = [0.1, 0.35, 0.45, 0.1] # Probabilities for 0,1,2,3
grade_value = random.choices([0, 1, 2, 3], weights=score_weights)[0]
# Occasionally add special values (5% chance)
if random.random() < 0.05:
grade_value = random.choice([".", "d"])
grade = Grade(
student_id=student.id,
grading_element_id=element.id,
value=str(grade_value)
)
db.session.add(grade)
elif assessment_template.get("corrected") == False and random.random() < 0.4:
# Partially grade some assessments (40% of non-corrected ones get partial grades)
from repositories.temporal_student_repository import TemporalStudentRepository
temporal_repo = TemporalStudentRepository()
eligible_students = temporal_repo.find_eligible_for_assessment(assessment)
if eligible_students:
students_to_grade = random.sample(eligible_students, len(eligible_students) // 2)
for student in students_to_grade:
for element in grading_elements:
if random.random() < 0.7: # Grade 70% of elements for selected students
if element.grading_type == "notes":
base_ratio = random.uniform(0.6, 0.9)
grade_value = round(base_ratio * element.max_points, 1)
else:
grade_value = random.choices([0, 1, 2, 3], weights=[0.1, 0.3, 0.5, 0.1])[0]
grade = Grade(
student_id=student.id,
grading_element_id=element.id,
value=str(grade_value)
)
db.session.add(grade)
db.session.commit()
total_students = sum(nb for _, _, nb in classes_data)
total_assessments = len(classes) * len(assessments_templates)
# Compter les inscriptions actuelles et mouvements
total_current_students = StudentEnrollment.query.filter(StudentEnrollment.departure_date.is_(None)).count()
total_movements = len(movements_log)
click.echo(f"Database initialized for mid-year scenario with temporal enrollments:")
click.echo(f" - {len(classes)} classes with {total_students} students initially")
click.echo(f" - {total_current_students} students currently enrolled")
click.echo(f" - {total_movements} student movements simulated")
click.echo(f" - {total_assessments} assessments created with temporal logic")
click.echo(f" - 4 fully corrected assessments per class (Trimester 1)")
click.echo(f" - 2 partially corrected assessments per class (Trimester 2)")
click.echo(f"")
click.echo(f"Student movements summary:")
for movement in movements_log[:5]: # Montrer les 5 premiers mouvements
click.echo(f" - {movement}")
if len(movements_log) > 5:
click.echo(f" ... and {len(movements_log) - 5} more movements")
def init_demo_data():
"""Initialize database with simple demo data using temporal enrollment model."""
# Create sample class groups
classe_6a = ClassGroup(name="6ème A", description="Classe de 6ème A", year="2024-2025")
classe_5b = ClassGroup(name="5ème B", description="Classe de 5ème B", year="2024-2025")
db.session.add(classe_6a)
db.session.add(classe_5b)
db.session.commit()
# Create sample students (without class_group_id)
students_data = [
("Dupont", "Marie", "marie.dupont@email.com", classe_6a.id),
("Martin", "Pierre", "pierre.martin@email.com", classe_6a.id),
("Durand", "Sophie", "sophie.durand@email.com", classe_6a.id),
("Moreau", "Lucas", "lucas.moreau@email.com", classe_5b.id),
("Bernard", "Emma", "emma.bernard@email.com", classe_5b.id),
]
school_start = date(2024, 9, 1)
for last_name, first_name, email, intended_class_id in students_data:
# Create student without class assignment
student = Student(
last_name=last_name,
first_name=first_name,
email=email
# Pas de class_group_id dans le nouveau modèle temporel
)
db.session.add(student)
db.session.commit() # Pour avoir l'ID du student
# Create temporal enrollment
enrollment = StudentEnrollment(
student_id=student.id,
class_group_id=intended_class_id,
enrollment_date=school_start,
departure_date=None, # Toujours inscrit
enrollment_reason="Inscription demo"
)
db.session.add(enrollment)
db.session.commit()
# Create sample assessment
assessment = Assessment(
title="Évaluation de mathématiques",
description="Évaluation sur les fractions et les décimaux",
trimester=1,
class_group_id=classe_6a.id,
coefficient=2.0
)
db.session.add(assessment)
db.session.commit()
# Create sample exercise
exercise = Exercise(
assessment_id=assessment.id,
title="Exercice 1 - Fractions",
description="Calculs avec les fractions",
order=1
)
db.session.add(exercise)
db.session.commit()
# Récupérer des domaines existants (créés automatiquement par la configuration)
domain_calcul = Domain.query.filter_by(name='Algèbre').first()
domain_methode = Domain.query.filter_by(name='Problèmes').first()
# Create sample grading elements with domains (optionnels)
elements_data = [
("Calcul de base", "Addition et soustraction de fractions", "Calculer", 4.0, "notes", domain_calcul.id if domain_calcul else None),
("Méthode", "Justification de la méthode utilisée", "Raisonner", 2.0, "score", domain_methode.id if domain_methode else None),
("Présentation", "Clarté de la présentation", "Communiquer", 2.0, "score", None), # Pas de domaine spécifique
]
for label, description, skill, max_points, grading_type, domain_id in elements_data:
element = GradingElement(
exercise_id=exercise.id,
label=label,
description=description,
skill=skill,
max_points=max_points,
grading_type=grading_type,
domain_id=domain_id
)
db.session.add(element)
db.session.commit()
click.echo("Database initialized with demo data!")
@click.command()
@with_appcontext
def create_large_test_data():
"""Create large test data: 30 students and complex assessment."""
# Create a large class with 30 students
classe_test = ClassGroup(name="3ème Test", description="Classe de test avec 30 élèves", year="2024-2025")
db.session.add(classe_test)
db.session.commit()
# Generate 30 students with realistic French names
first_names = ["Alice", "Antoine", "Amélie", "Alexandre", "Anna", "Adrien", "Camille", "Clément", "Charlotte", "Clément",
"Emma", "Ethan", "Elise", "Enzo", "Eva", "Fabien", "Fanny", "Gabriel", "Giulia", "Hugo",
"Inès", "Jules", "Jade", "Kévin", "Léa", "Louis", "Marie", "Mathis", "Nina", "Oscar"]
last_names = ["Martin", "Bernard", "Dubois", "Thomas", "Robert", "Richard", "Petit", "Durand", "Leroy", "Moreau",
"Simon", "Laurent", "Lefebvre", "Michel", "Garcia", "David", "Bertrand", "Roux", "Vincent", "Fournier",
"Morel", "Girard", "André", "Lefèvre", "Mercier", "Dupont", "Lambert", "Bonnet", "François", "Martinez"]
for i in range(30):
student = Student(
last_name=last_names[i],
first_name=first_names[i],
email=f"{first_names[i].lower()}.{last_names[i].lower()}@test.com",
class_group_id=classe_test.id
)
db.session.add(student)
db.session.commit()
# Récupérer les domaines existants (créés automatiquement par la configuration)
domain_fonctions = Domain.query.filter_by(name='Fonctions').first()
domain_calcul = Domain.query.filter_by(name='Algèbre').first()
domain_geometrie = Domain.query.filter_by(name='Géométrie').first()
domain_stats = Domain.query.filter_by(name='Statistiques').first()
domain_problemes = Domain.query.filter_by(name='Problèmes').first()
# Create a complex assessment with 4 exercises, 5 elements each
assessment = Assessment(
title="Contrôle de Mathématiques - Fonctions et Statistiques",
description="Évaluation complète sur les fonctions affines et les statistiques descriptives",
trimester=2,
class_group_id=classe_test.id,
coefficient=3.0
)
db.session.add(assessment)
db.session.commit()
# Exercise 1: Fonctions affines
ex1 = Exercise(
assessment_id=assessment.id,
title="Ex1 - Fonctions affines",
description="Calculs et représentations graphiques",
order=1
)
db.session.add(ex1)
db.session.commit()
ex1_elements = [
("1a - Calcul image", "Calculer f(3)", "Calculer", 2.0, "notes", domain_fonctions.id if domain_fonctions else None),
("1b - Antécédent", "Résoudre f(x)=5", "Calculer", 3.0, "notes", domain_calcul.id if domain_calcul else None),
("1c - Graphique", "Tracer la droite", "Représenter", 3.0, "score", domain_fonctions.id if domain_fonctions else None),
("1d - Lecture graph", "Lire coordonnées", "Modéliser", 2.0, "notes", domain_fonctions.id if domain_fonctions else None),
("1e - Méthode", "Justification", "Raisonner", 2.0, "score", domain_problemes.id if domain_problemes else None)
]
for label, desc, skill, points, gtype, domain_id in ex1_elements:
elem = GradingElement(exercise_id=ex1.id, label=label, description=desc,
skill=skill, max_points=points, grading_type=gtype, domain_id=domain_id)
db.session.add(elem)
# Exercise 2: Équations
ex2 = Exercise(
assessment_id=assessment.id,
title="Ex2 - Équations du 1er degré",
description="Résolution d'équations",
order=2
)
db.session.add(ex2)
db.session.commit()
ex2_elements = [
("2a - Équation simple", "Résoudre 2x+3=7", "Calculer", 2.0, "notes", domain_calcul.id if domain_calcul else None),
("2b - Avec parenthèses", "3(x-1)=2x+5", "Calculer", 4.0, "notes", domain_calcul.id if domain_calcul else None),
("2c - Vérification", "Contrôler solution", "Raisonner", 1.0, "score", domain_problemes.id if domain_problemes else None),
("2d - Méthode", "Étapes de résolution", "Communiquer", 2.0, "score", domain_problemes.id if domain_problemes else None),
("2e - Application", "Problème concret", "Modéliser", 3.0, "score", domain_problemes.id if domain_problemes else None)
]
for label, desc, skill, points, gtype, domain_id in ex2_elements:
elem = GradingElement(exercise_id=ex2.id, label=label, description=desc,
skill=skill, max_points=points, grading_type=gtype, domain_id=domain_id)
db.session.add(elem)
# Exercise 3: Statistiques
ex3 = Exercise(
assessment_id=assessment.id,
title="Ex3 - Statistiques descriptives",
description="Moyennes, médianes, quartiles",
order=3
)
db.session.add(ex3)
db.session.commit()
ex3_elements = [
("3a - Moyenne", "Calculer moyenne", "Calculer", 3.0, "notes", domain_stats.id if domain_stats else None),
("3b - Médiane", "Déterminer médiane", "Calculer", 2.0, "notes", domain_stats.id if domain_stats else None),
("3c - Quartiles", "Q1 et Q3", "Calculer", 4.0, "notes", domain_stats.id if domain_stats else None),
("3d - Interprétation", "Analyser résultats", "Raisonner", 3.0, "score", domain_stats.id if domain_stats else None),
("3e - Graphique", "Diagramme en boîte", "Représenter", 2.0, "score", domain_stats.id if domain_stats else None)
]
for label, desc, skill, points, gtype, domain_id in ex3_elements:
elem = GradingElement(exercise_id=ex3.id, label=label, description=desc,
skill=skill, max_points=points, grading_type=gtype, domain_id=domain_id)
db.session.add(elem)
# Exercise 4: Problème de synthèse
ex4 = Exercise(
assessment_id=assessment.id,
title="Ex4 - Problème de synthèse",
description="Application des notions",
order=4
)
db.session.add(ex4)
db.session.commit()
ex4_elements = [
("4a - Modélisation", "Mise en équation", "Modéliser", 4.0, "score", domain_problemes.id if domain_problemes else None),
("4b - Résolution", "Calculs", "Calculer", 5.0, "notes", domain_calcul.id if domain_calcul else None),
("4c - Interprétation", "Sens du résultat", "Raisonner", 3.0, "score", domain_problemes.id if domain_problemes else None),
("4d - Communication", "Rédaction", "Communiquer", 3.0, "score", domain_problemes.id if domain_problemes else None),
("4e - Démarche", "Organisation", "Raisonner", 3.0, "score", domain_problemes.id if domain_problemes else None)
]
for label, desc, skill, points, gtype, domain_id in ex4_elements:
elem = GradingElement(exercise_id=ex4.id, label=label, description=desc,
skill=skill, max_points=points, grading_type=gtype, domain_id=domain_id)
db.session.add(elem)
db.session.commit()
click.echo(f"Created test data: {classe_test.name} with 30 students and complex assessment with 4 exercises (20 grading elements total)!")

View File

@@ -1 +0,0 @@
# Configuration module

View File

@@ -1,388 +0,0 @@
"""
Système de Feature Flags pour Migration Progressive (JOUR 1-2)
Ce module implémente un système de feature flags robust pour permettre
l'activation/désactivation contrôlée des nouvelles fonctionnalités pendant
la migration vers l'architecture refactorisée.
Architecture:
- Enum typé pour toutes les feature flags
- Configuration centralisée avec validation
- Support pour rollback instantané
- Logging automatique des changements d'état
Utilisé pour la migration progressive selon MIGRATION_PROGRESSIVE.md
"""
import os
from enum import Enum
from typing import Dict, Any, Optional
from dataclasses import dataclass
from datetime import datetime
import logging
logger = logging.getLogger(__name__)
class FeatureFlag(Enum):
"""
Énumération de tous les feature flags disponibles.
Conventions de nommage:
- USE_NEW_<SERVICE_NAME> pour les migrations de services
- ENABLE_<FEATURE_NAME> pour les nouvelles fonctionnalités
"""
# === MIGRATION PROGRESSIVE SERVICES ===
# JOUR 3-4: Migration Services Core
USE_STRATEGY_PATTERN = "use_strategy_pattern"
USE_REFACTORED_ASSESSMENT = "use_refactored_assessment"
# JOUR 5-6: Services Avancés
USE_NEW_STUDENT_SCORE_CALCULATOR = "use_new_student_score_calculator"
USE_NEW_ASSESSMENT_STATISTICS_SERVICE = "use_new_assessment_statistics_service"
# === FONCTIONNALITÉS AVANCÉES ===
# Performance et monitoring
ENABLE_PERFORMANCE_MONITORING = "enable_performance_monitoring"
ENABLE_QUERY_OPTIMIZATION = "enable_query_optimization"
# Interface utilisateur
ENABLE_BULK_OPERATIONS = "enable_bulk_operations"
ENABLE_ADVANCED_FILTERS = "enable_advanced_filters"
@dataclass
class FeatureFlagConfig:
"""Configuration d'un feature flag avec métadonnées."""
enabled: bool
description: str
migration_day: Optional[int] = None # Jour de migration selon le plan (1-7)
rollback_safe: bool = True # Peut être désactivé sans risque
created_at: datetime = None
updated_at: datetime = None
def __post_init__(self):
if self.created_at is None:
self.created_at = datetime.utcnow()
if self.updated_at is None:
self.updated_at = datetime.utcnow()
class FeatureFlagManager:
"""
Gestionnaire centralisé des feature flags.
Fonctionnalités:
- Configuration via variables d'environnement
- Fallback vers configuration par défaut
- Logging des changements d'état
- Validation des flags
- Support pour tests unitaires
"""
def __init__(self):
self._flags: Dict[FeatureFlag, FeatureFlagConfig] = {}
self._initialize_defaults()
self._load_from_environment()
def _initialize_defaults(self) -> None:
"""Initialise la configuration par défaut des feature flags."""
# Configuration par défaut - TOUT DÉSACTIVÉ pour sécurité maximale
default_configs = {
# MIGRATION PROGRESSIVE - JOUR 3-4
FeatureFlag.USE_STRATEGY_PATTERN: FeatureFlagConfig(
enabled=False,
description="Utilise les nouvelles stratégies de notation (Pattern Strategy)",
migration_day=3,
rollback_safe=True
),
FeatureFlag.USE_REFACTORED_ASSESSMENT: FeatureFlagConfig(
enabled=False,
description="Utilise le nouveau service de calcul de progression",
migration_day=4,
rollback_safe=True
),
# MIGRATION PROGRESSIVE - JOUR 5-6
FeatureFlag.USE_NEW_STUDENT_SCORE_CALCULATOR: FeatureFlagConfig(
enabled=False,
description="Utilise le nouveau calculateur de scores étudiants",
migration_day=5,
rollback_safe=True
),
FeatureFlag.USE_NEW_ASSESSMENT_STATISTICS_SERVICE: FeatureFlagConfig(
enabled=False,
description="Utilise le nouveau service de statistiques d'évaluation",
migration_day=6,
rollback_safe=True
),
# FONCTIONNALITÉS AVANCÉES
FeatureFlag.ENABLE_PERFORMANCE_MONITORING: FeatureFlagConfig(
enabled=False,
description="Active le monitoring des performances",
rollback_safe=True
),
FeatureFlag.ENABLE_QUERY_OPTIMIZATION: FeatureFlagConfig(
enabled=False,
description="Active les optimisations de requêtes",
rollback_safe=True
),
FeatureFlag.ENABLE_BULK_OPERATIONS: FeatureFlagConfig(
enabled=False,
description="Active les opérations en masse",
rollback_safe=True
),
FeatureFlag.ENABLE_ADVANCED_FILTERS: FeatureFlagConfig(
enabled=False,
description="Active les filtres avancés",
rollback_safe=True
),
}
self._flags.update(default_configs)
logger.info("Feature flags initialisés avec configuration par défaut")
def _load_from_environment(self) -> None:
"""Charge la configuration depuis les variables d'environnement."""
for flag in FeatureFlag:
env_var = f"FEATURE_FLAG_{flag.value.upper()}"
env_value = os.environ.get(env_var)
if env_value is not None:
# Parse boolean depuis l'environnement
enabled = env_value.lower() in ('true', '1', 'yes', 'on', 'enabled')
if flag in self._flags:
old_state = self._flags[flag].enabled
self._flags[flag].enabled = enabled
self._flags[flag].updated_at = datetime.utcnow()
if old_state != enabled:
logger.info(
f"Feature flag {flag.value} modifié par env: {old_state} -> {enabled}",
extra={
'event_type': 'feature_flag_changed',
'flag_name': flag.value,
'old_value': old_state,
'new_value': enabled,
'source': 'environment'
}
)
def is_enabled(self, flag: FeatureFlag) -> bool:
"""
Vérifie si un feature flag est activé.
Args:
flag: Le feature flag à vérifier
Returns:
bool: True si le flag est activé, False sinon
"""
if flag not in self._flags:
logger.warning(
f"Feature flag inconnu: {flag.value}. Retour False par défaut.",
extra={'event_type': 'unknown_feature_flag', 'flag_name': flag.value}
)
return False
return self._flags[flag].enabled
def enable(self, flag: FeatureFlag, reason: str = "") -> bool:
"""
Active un feature flag.
Args:
flag: Le feature flag à activer
reason: Raison de l'activation (pour logs)
Returns:
bool: True si l'activation a réussi
"""
if flag not in self._flags:
logger.error(f"Impossible d'activer un feature flag inconnu: {flag.value}")
return False
old_state = self._flags[flag].enabled
self._flags[flag].enabled = True
self._flags[flag].updated_at = datetime.utcnow()
logger.info(
f"Feature flag {flag.value} activé. Raison: {reason}",
extra={
'event_type': 'feature_flag_enabled',
'flag_name': flag.value,
'old_value': old_state,
'new_value': True,
'reason': reason,
'migration_day': self._flags[flag].migration_day
}
)
return True
def disable(self, flag: FeatureFlag, reason: str = "") -> bool:
"""
Désactive un feature flag.
Args:
flag: Le feature flag à désactiver
reason: Raison de la désactivation (pour logs)
Returns:
bool: True si la désactivation a réussi
"""
if flag not in self._flags:
logger.error(f"Impossible de désactiver un feature flag inconnu: {flag.value}")
return False
if not self._flags[flag].rollback_safe:
logger.warning(
f"Désactivation d'un flag non-rollback-safe: {flag.value}",
extra={'event_type': 'unsafe_rollback_attempt', 'flag_name': flag.value}
)
old_state = self._flags[flag].enabled
self._flags[flag].enabled = False
self._flags[flag].updated_at = datetime.utcnow()
logger.info(
f"Feature flag {flag.value} désactivé. Raison: {reason}",
extra={
'event_type': 'feature_flag_disabled',
'flag_name': flag.value,
'old_value': old_state,
'new_value': False,
'reason': reason,
'rollback_safe': self._flags[flag].rollback_safe
}
)
return True
def get_config(self, flag: FeatureFlag) -> Optional[FeatureFlagConfig]:
"""Récupère la configuration complète d'un feature flag."""
return self._flags.get(flag)
def get_status_summary(self) -> Dict[str, Any]:
"""
Retourne un résumé de l'état de tous les feature flags.
Returns:
Dict contenant le statut de chaque flag avec métadonnées
"""
summary = {
'flags': {},
'migration_status': {
'day_3_ready': False,
'day_4_ready': False,
'day_5_ready': False,
'day_6_ready': False
},
'total_enabled': 0,
'last_updated': None
}
latest_update = None
enabled_count = 0
for flag, config in self._flags.items():
summary['flags'][flag.value] = {
'enabled': config.enabled,
'description': config.description,
'migration_day': config.migration_day,
'rollback_safe': config.rollback_safe,
'updated_at': config.updated_at.isoformat() if config.updated_at else None
}
if config.enabled:
enabled_count += 1
if latest_update is None or (config.updated_at and config.updated_at > latest_update):
latest_update = config.updated_at
# Calcul du statut de migration par jour
day_3_flags = [FeatureFlag.USE_STRATEGY_PATTERN]
day_4_flags = [FeatureFlag.USE_REFACTORED_ASSESSMENT]
day_5_flags = [FeatureFlag.USE_NEW_STUDENT_SCORE_CALCULATOR]
day_6_flags = [FeatureFlag.USE_NEW_ASSESSMENT_STATISTICS_SERVICE]
summary['migration_status']['day_3_ready'] = all(self.is_enabled(flag) for flag in day_3_flags)
summary['migration_status']['day_4_ready'] = all(self.is_enabled(flag) for flag in day_4_flags)
summary['migration_status']['day_5_ready'] = all(self.is_enabled(flag) for flag in day_5_flags)
summary['migration_status']['day_6_ready'] = all(self.is_enabled(flag) for flag in day_6_flags)
summary['total_enabled'] = enabled_count
summary['last_updated'] = latest_update.isoformat() if latest_update else None
return summary
def enable_migration_day(self, day: int, reason: str = "") -> Dict[str, bool]:
"""
Active tous les feature flags pour un jour de migration donné.
Args:
day: Numéro du jour de migration (3-6)
reason: Raison de l'activation
Returns:
Dict[flag_name, success] indiquant quels flags ont été activés
"""
day_flags_map = {
3: [FeatureFlag.USE_STRATEGY_PATTERN],
4: [FeatureFlag.USE_REFACTORED_ASSESSMENT],
5: [FeatureFlag.USE_NEW_STUDENT_SCORE_CALCULATOR],
6: [FeatureFlag.USE_NEW_ASSESSMENT_STATISTICS_SERVICE]
}
if day not in day_flags_map:
logger.error(f"Jour de migration invalide: {day}. Jours supportés: 3-6")
return {}
results = {}
migration_reason = f"Migration Jour {day}: {reason}" if reason else f"Migration Jour {day}"
for flag in day_flags_map[day]:
success = self.enable(flag, migration_reason)
results[flag.value] = success
logger.info(
f"Activation des flags pour le jour {day} terminée",
extra={
'event_type': 'migration_day_activation',
'migration_day': day,
'results': results,
'reason': reason
}
)
return results
# Instance globale du gestionnaire de feature flags
feature_flags = FeatureFlagManager()
def is_feature_enabled(flag: FeatureFlag) -> bool:
"""
Fonction utilitaire pour vérifier l'état d'un feature flag.
Usage dans le code:
from config.feature_flags import is_feature_enabled, FeatureFlag
if is_feature_enabled(FeatureFlag.USE_NEW_GRADING_STRATEGIES):
# Utiliser la nouvelle implémentation
result = new_grading_service.calculate()
else:
# Utiliser l'ancienne implémentation
result = old_grading_method()
"""
return feature_flags.is_enabled(flag)

View File

@@ -1,54 +0,0 @@
import os
from typing import Optional
from dotenv import load_dotenv
class Settings:
"""Configuration centralisée de l'application."""
def __init__(self):
load_dotenv()
@property
def SECRET_KEY(self) -> str:
key = os.environ.get('SECRET_KEY')
if not key:
raise ValueError("SECRET_KEY est obligatoire")
if len(key) < 32:
raise ValueError("SECRET_KEY doit faire au moins 32 caractères")
return key
@property
def DATABASE_URL(self) -> str:
return os.environ.get('DATABASE_URL', 'sqlite:///school_management.db')
@property
def DEBUG(self) -> bool:
return os.environ.get('DEBUG', 'false').lower() == 'true'
@property
def LOG_LEVEL(self) -> str:
return os.environ.get('LOG_LEVEL', 'INFO').upper()
@property
def FLASK_ENV(self) -> str:
return os.environ.get('FLASK_ENV', 'development')
@property
def DB_ECHO(self) -> bool:
return os.environ.get('DB_ECHO', 'false').lower() == 'true'
@property
def WTF_CSRF_TIME_LIMIT(self) -> int:
return int(os.environ.get('WTF_CSRF_TIME_LIMIT', '3600'))
def validate_config(self):
"""Valide la configuration complète."""
# Forcer l'évaluation de toutes les propriétés pour déclencher les validations
_ = self.SECRET_KEY
_ = self.DATABASE_URL
_ = self.DEBUG
# Instance globale
settings = Settings()

View File

@@ -1 +0,0 @@
# Core module

View File

@@ -1,99 +0,0 @@
import logging
import json
import uuid
from datetime import datetime, timezone
from flask import request, g, has_request_context
from typing import Dict, Any
class StructuredFormatter(logging.Formatter):
"""Formateur de logs structurés en JSON."""
def format(self, record):
log_data = {
'timestamp': datetime.now(timezone.utc).isoformat(),
'level': record.levelname,
'logger': record.name,
'message': record.getMessage(),
'module': record.module,
'function': record.funcName,
'line': record.lineno
}
# Ajouter le contexte de la requête si disponible
if has_request_context() and request:
log_data['request'] = {
'method': request.method,
'url': request.url,
'remote_addr': request.remote_addr,
'user_agent': request.headers.get('User-Agent', '')
}
# Ajouter l'ID de corrélation si disponible
if hasattr(g, 'correlation_id'):
log_data['correlation_id'] = g.correlation_id
# Ajouter les données d'exception si présentes
if record.exc_info:
log_data['exception'] = {
'type': record.exc_info[0].__name__,
'message': str(record.exc_info[1]),
'traceback': self.formatException(record.exc_info)
}
# Ajouter les données personnalisées
if hasattr(record, 'extra_data'):
log_data['extra'] = record.extra_data
return json.dumps(log_data, ensure_ascii=False)
def setup_logging(app):
"""Configure le logging structuré."""
# Configuration du formateur
formatter = StructuredFormatter()
# Créer le dossier logs si nécessaire
import os
if not os.path.exists('logs'):
os.makedirs('logs')
# Handler pour fichier
file_handler = logging.FileHandler('logs/notytex.log')
file_handler.setFormatter(formatter)
file_handler.setLevel(logging.INFO)
# Handler pour console (développement)
if app.debug:
console_handler = logging.StreamHandler()
console_formatter = logging.Formatter(
'%(asctime)s - %(name)s - %(levelname)s - %(message)s'
)
console_handler.setFormatter(console_formatter)
console_handler.setLevel(logging.DEBUG)
app.logger.addHandler(console_handler)
app.logger.addHandler(file_handler)
app.logger.setLevel(logging.INFO)
# Middleware pour générer un ID de corrélation pour chaque requête
@app.before_request
def before_request():
g.correlation_id = str(uuid.uuid4())
app.logger.info(f"Début de requête {request.method} {request.url}")
@app.after_request
def after_request(response):
app.logger.info(f"Fin de requête - Status: {response.status_code}")
return response
def log_business_event(event_type: str, details: Dict[str, Any]):
"""Log un événement métier."""
logger = logging.getLogger('notytex.business')
extra_data = {
'event_type': event_type,
'details': details
}
logger.info(f"Événement métier : {event_type}", extra={'extra_data': extra_data})

View File

@@ -1,57 +0,0 @@
#!/usr/bin/env python3
"""
Serveur SMTP de débogage simple pour tester l'envoi d'emails en local.
Usage: python debug_smtp_server.py
"""
import asyncore
import smtpd
import sys
from datetime import datetime
class DebuggingServer(smtpd.SMTPServer):
"""Serveur SMTP qui affiche tous les emails reçus dans le terminal."""
def process_message(self, peer, mailfrom, rcpttos, data, **kwargs):
"""Traite et affiche les messages reçus."""
print('=' * 80)
print(f'📧 EMAIL REÇU le {datetime.now().strftime("%Y-%m-%d %H:%M:%S")}')
print('=' * 80)
print(f'🔗 Connexion depuis: {peer}')
print(f'📨 De: {mailfrom}')
print(f'📬 Vers: {", ".join(rcpttos)}')
print('-' * 80)
print('📄 CONTENU:')
print(data.decode('utf-8', errors='replace'))
print('=' * 80)
print()
def main():
"""Lance le serveur SMTP de débogage."""
host = 'localhost'
port = 1025
print(f'🚀 Démarrage du serveur SMTP de débogage sur {host}:{port}')
print('📧 Tous les emails envoyés à ce serveur seront affichés ici')
print('❌ Aucun email ne sera réellement envoyé')
print('🛑 Appuyez sur Ctrl+C pour arrêter')
print('=' * 80)
try:
server = DebuggingServer((host, port), None)
print(f'✅ Serveur prêt ! Configurez Notytex avec:')
print(f' - Serveur SMTP: {host}')
print(f' - Port: {port}')
print(f' - TLS: Désactivé')
print(f' - Authentification: Aucune')
print('=' * 80)
asyncore.loop()
except KeyboardInterrupt:
print('\n🛑 Serveur arrêté par l\'utilisateur')
sys.exit(0)
except Exception as e:
print(f'❌ Erreur: {e}')
sys.exit(1)
if __name__ == '__main__':
main()

View File

@@ -1,44 +0,0 @@
#!/usr/bin/env python3
"""
Script pour décoder le contenu Base64 des emails du serveur de débogage.
Usage: python decode_email.py
Copiez le contenu Base64 et appuyez sur Ctrl+D pour décoder.
"""
import base64
import sys
def decode_email_content():
"""Lit le contenu Base64 depuis stdin et l'affiche décodé."""
print("📧 Décodeur d'email Base64")
print("Collez le contenu Base64 de l'email et appuyez sur Ctrl+D :")
print("-" * 60)
try:
# Lire tout le contenu depuis stdin
base64_content = sys.stdin.read().strip()
if not base64_content:
print("❌ Aucun contenu fourni")
return
# Décoder le Base64
decoded_bytes = base64.b64decode(base64_content)
decoded_html = decoded_bytes.decode('utf-8')
print("=" * 80)
print("📄 CONTENU HTML DÉCODÉ :")
print("=" * 80)
print(decoded_html)
print("=" * 80)
# Optionnel : sauvegarder dans un fichier
with open('email_decoded.html', 'w', encoding='utf-8') as f:
f.write(decoded_html)
print("✅ Email sauvegardé dans 'email_decoded.html'")
except Exception as e:
print(f"❌ Erreur lors du décodage : {e}")
if __name__ == '__main__':
decode_email_content()

View File

@@ -1,35 +0,0 @@
services:
notytex:
build: .
ports:
- "5000:5000"
environment:
- SECRET_KEY=${SECRET_KEY}
- DATABASE_URL=sqlite:///instance/school_management.db
- FLASK_ENV=production
- DEBUG=false
- LOG_LEVEL=INFO
volumes:
- ./instance:/app/instance
- ./logs:/app/logs
restart: unless-stopped
healthcheck:
test: ["CMD-SHELL", "curl -f http://localhost:5000/ || exit 1"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
# Service d'initialisation (à lancer une seule fois)
notytex-init:
build: .
environment:
- SECRET_KEY=${SECRET_KEY}
- DATABASE_URL=sqlite:///instance/school_management.db
- FLASK_ENV=development
volumes:
- ./instance:/app/instance
- ./logs:/app/logs
command: ["uv", "run", "flask", "--app", "app", "init-db"]
profiles:
- init

View File

@@ -1 +0,0 @@
# Utils module

View File

@@ -1,18 +0,0 @@
class NotytexException(Exception):
"""Exception de base pour Notytex."""
pass
class ValidationError(NotytexException):
"""Erreur de validation des données."""
pass
class NotFoundError(NotytexException):
"""Ressource introuvable."""
pass
class BusinessError(NotytexException):
"""Erreur de logique métier."""
pass

View File

@@ -1 +0,0 @@
# Exceptions module

View File

@@ -1,67 +0,0 @@
from flask import Flask, jsonify, request, current_app, render_template
from pydantic import ValidationError as PydanticValidationError
from domain.exceptions import ValidationError, NotFoundError, BusinessError
from sqlalchemy.exc import IntegrityError, SQLAlchemyError
def register_error_handlers(app: Flask):
"""Enregistre les gestionnaires d'erreurs globaux."""
@app.errorhandler(ValidationError)
def handle_validation_error(error):
current_app.logger.warning(f"Erreur de validation : {error}")
if request.is_json:
return jsonify({'success': False, 'error': str(error)}), 400
return render_template('error.html', error=str(error)), 400
@app.errorhandler(PydanticValidationError)
def handle_pydantic_validation_error(error):
errors = []
for err in error.errors():
field = '.'.join(str(x) for x in err['loc'])
errors.append(f"{field}: {err['msg']}")
message = "Données invalides : " + ", ".join(errors)
current_app.logger.warning(f"Erreur Pydantic : {message}")
if request.is_json:
return jsonify({'success': False, 'error': message, 'details': error.errors()}), 400
return render_template('error.html', error=message), 400
@app.errorhandler(NotFoundError)
def handle_not_found_error(error):
current_app.logger.info(f"Ressource introuvable : {error}")
if request.is_json:
return jsonify({'success': False, 'error': str(error)}), 404
return render_template('error.html', error=str(error)), 404
@app.errorhandler(BusinessError)
def handle_business_error(error):
current_app.logger.warning(f"Erreur métier : {error}")
if request.is_json:
return jsonify({'success': False, 'error': str(error)}), 422
return render_template('error.html', error=str(error)), 422
@app.errorhandler(IntegrityError)
def handle_integrity_error(error):
current_app.logger.error(f"Erreur d'intégrité DB : {error}")
message = "Erreur de données : contrainte de base de données violée"
if request.is_json:
return jsonify({'success': False, 'error': message}), 409
return render_template('error.html', error=message), 409
@app.errorhandler(SQLAlchemyError)
def handle_sqlalchemy_error(error):
current_app.logger.error(f"Erreur SQLAlchemy : {error}")
message = "Erreur de base de données"
if request.is_json:
return jsonify({'success': False, 'error': message}), 500
return render_template('error.html', error=message), 500
@app.errorhandler(500)
def handle_internal_error(error):
current_app.logger.error(f"Erreur interne : {error}")
message = "Erreur interne du serveur"
if request.is_json:
return jsonify({'success': False, 'error': message}), 500
return render_template('error.html', error=message), 500

View File

@@ -1,85 +0,0 @@
from flask_wtf import FlaskForm
from flask_wtf.file import FileField, FileRequired, FileAllowed
from wtforms import StringField, TextAreaField, FloatField, SelectField, DateField, SubmitField, BooleanField
from wtforms.validators import DataRequired, Email, NumberRange, Optional, Length
from datetime import date
from typing import TYPE_CHECKING
# Import conditionnel pour éviter les imports circulaires
if TYPE_CHECKING:
from repositories.class_repository import ClassRepository
# Utilitaire pour éviter la duplication dans l'initialisation des choix de classe
def _populate_class_choices(field, class_repo: 'ClassRepository'):
"""Remplit les choix d'un champ SelectField avec les classes disponibles.
Args:
field: Le champ SelectField à peupler
class_repo: Repository pour accéder aux données ClassGroup
"""
classes = class_repo.find_for_form_choices()
field.choices = [(cg.id, cg.name) for cg in classes]
class AssessmentForm(FlaskForm):
title = StringField('Titre', validators=[DataRequired(), Length(max=200)])
description = TextAreaField('Description', validators=[Optional()])
date = DateField('Date', validators=[DataRequired()], default=date.today)
trimester = SelectField('Trimestre', validators=[DataRequired()], coerce=int,
choices=[(1, '1er trimestre'), (2, '2ème trimestre'), (3, '3ème trimestre')])
class_group_id = SelectField('Classe', validators=[DataRequired()], coerce=int)
coefficient = FloatField('Coefficient', validators=[DataRequired(), NumberRange(min=0.1, max=10)], default=1.0)
submit = SubmitField('Enregistrer')
def populate_class_choices(self, class_repo: 'ClassRepository'):
"""Peuple les choix de classes en utilisant le repository.
Args:
class_repo: Repository pour accéder aux données ClassGroup
"""
_populate_class_choices(self.class_group_id, class_repo)
class ClassGroupForm(FlaskForm):
name = StringField('Nom de la classe', validators=[DataRequired(), Length(max=100)])
description = TextAreaField('Description', validators=[Optional()])
year = StringField('Année scolaire', validators=[DataRequired(), Length(max=20)], default="2024-2025")
submit = SubmitField('Enregistrer')
class StudentForm(FlaskForm):
first_name = StringField('Prénom', validators=[DataRequired(), Length(max=100)])
last_name = StringField('Nom', validators=[DataRequired(), Length(max=100)])
email = StringField('Email', validators=[Optional(), Email(), Length(max=120)])
class_group_id = SelectField('Classe', validators=[DataRequired()], coerce=int)
submit = SubmitField('Enregistrer')
def populate_class_choices(self, class_repo: 'ClassRepository'):
"""Peuple les choix de classes en utilisant le repository.
Args:
class_repo: Repository pour accéder aux données ClassGroup
"""
_populate_class_choices(self.class_group_id, class_repo)
# Formulaires ExerciseForm et GradingElementForm supprimés
# Ces éléments sont maintenant gérés via le formulaire unifié AssessmentForm
class CSVImportForm(FlaskForm):
"""Formulaire d'import d'élèves depuis un fichier CSV."""
csv_file = FileField(
'Fichier CSV',
validators=[
FileRequired(message='Veuillez sélectionner un fichier CSV'),
FileAllowed(['csv'], message='Seuls les fichiers CSV sont autorisés')
]
)
enrollment_date = DateField(
'Date d\'inscription',
validators=[DataRequired()],
default=date.today,
description='Date d\'inscription des élèves dans la classe'
)
skip_duplicates = BooleanField(
'Ignorer les doublons',
default=True,
description='Si coché, les élèves déjà existants seront ignorés. Sinon, l\'import échouera.'
)
submit = SubmitField('Importer les élèves')

Some files were not shown because too many files have changed in this diff Show More