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:
34
.gitignore
vendored
34
.gitignore
vendored
@@ -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
|
||||
43
Dockerfile
43
Dockerfile
@@ -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
619
README.md
@@ -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 fonctionnalité
|
||||
│ ├── 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
117
app.py
@@ -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)
|
||||
653
app_config.py
653
app_config.py
@@ -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()
|
||||
@@ -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
|
||||
}
|
||||
739
commands.py
739
commands.py
@@ -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)!")
|
||||
@@ -1 +0,0 @@
|
||||
# Configuration module
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -1 +0,0 @@
|
||||
# Core module
|
||||
@@ -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})
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -1 +0,0 @@
|
||||
# Utils module
|
||||
@@ -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
|
||||
@@ -1 +0,0 @@
|
||||
# Exceptions module
|
||||
@@ -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
|
||||
85
forms.py
85
forms.py
@@ -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
Reference in New Issue
Block a user