Files
notytex/app_config.py

620 lines
24 KiB
Python

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)."""
return self.default_config['grading_system']['special_values']
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()