620 lines
24 KiB
Python
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() |