feat: add domain
This commit is contained in:
		
							
								
								
									
										2
									
								
								app.py
									
									
									
									
									
								
							
							
						
						
									
										2
									
								
								app.py
									
									
									
									
									
								
							| @@ -12,6 +12,7 @@ from core.logging import setup_logging | |||||||
| from routes.assessments import bp as assessments_bp | from routes.assessments import bp as assessments_bp | ||||||
| from routes.grading import bp as grading_bp | from routes.grading import bp as grading_bp | ||||||
| from routes.config import bp as config_bp | from routes.config import bp as config_bp | ||||||
|  | from routes.domains import bp as domains_bp | ||||||
|  |  | ||||||
| def create_app(config_name=None): | def create_app(config_name=None): | ||||||
|     if config_name is None: |     if config_name is None: | ||||||
| @@ -41,6 +42,7 @@ def create_app(config_name=None): | |||||||
|     app.register_blueprint(assessments_bp) |     app.register_blueprint(assessments_bp) | ||||||
|     app.register_blueprint(grading_bp) |     app.register_blueprint(grading_bp) | ||||||
|     app.register_blueprint(config_bp) |     app.register_blueprint(config_bp) | ||||||
|  |     app.register_blueprint(domains_bp) | ||||||
|  |  | ||||||
|     # Register CLI commands |     # Register CLI commands | ||||||
|     app.cli.add_command(init_db) |     app.cli.add_command(init_db) | ||||||
|   | |||||||
| @@ -130,6 +130,40 @@ class ConfigManager: | |||||||
|                         'icon': 'magnifying-glass' |                         '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' | ||||||
|  |                     } | ||||||
|  |                 ] | ||||||
|             } |             } | ||||||
|         } |         } | ||||||
|      |      | ||||||
| @@ -175,6 +209,18 @@ class ConfigManager: | |||||||
|                 ) |                 ) | ||||||
|                 db.session.add(competence) |                 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() |         db.session.commit() | ||||||
|      |      | ||||||
|     def get(self, key_path: str, default: Any = None) -> Any: |     def get(self, key_path: str, default: Any = None) -> Any: | ||||||
| @@ -267,6 +313,44 @@ class ConfigManager: | |||||||
|             for comp in competences |             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: |     def get_school_year(self) -> str: | ||||||
|         """Récupère l'année scolaire courante.""" |         """Récupère l'année scolaire courante.""" | ||||||
|         return self.get('context.school_year', '2025-2026') |         return self.get('context.school_year', '2025-2026') | ||||||
|   | |||||||
							
								
								
									
										82
									
								
								commands.py
									
									
									
									
									
								
							
							
						
						
									
										82
									
								
								commands.py
									
									
									
									
									
								
							| @@ -1,6 +1,6 @@ | |||||||
| import click | import click | ||||||
| from flask.cli import with_appcontext | from flask.cli import with_appcontext | ||||||
| from models import db, ClassGroup, Student, Assessment, Exercise, GradingElement | from models import db, ClassGroup, Student, Assessment, Exercise, GradingElement, Domain | ||||||
|  |  | ||||||
| @click.command() | @click.command() | ||||||
| @with_appcontext | @with_appcontext | ||||||
| @@ -62,21 +62,26 @@ def init_db(): | |||||||
|     db.session.add(exercise) |     db.session.add(exercise) | ||||||
|     db.session.commit() |     db.session.commit() | ||||||
|      |      | ||||||
|     # Create sample grading elements |     # 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 = [ |     elements_data = [ | ||||||
|         ("Calcul de base", "Addition et soustraction de fractions", "Calculer", 4.0, "notes"), |         ("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"), |         ("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"), |         ("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 in elements_data: |     for label, description, skill, max_points, grading_type, domain_id in elements_data: | ||||||
|         element = GradingElement( |         element = GradingElement( | ||||||
|             exercise_id=exercise.id, |             exercise_id=exercise.id, | ||||||
|             label=label, |             label=label, | ||||||
|             description=description, |             description=description, | ||||||
|             skill=skill, |             skill=skill, | ||||||
|             max_points=max_points, |             max_points=max_points, | ||||||
|             grading_type=grading_type |             grading_type=grading_type, | ||||||
|  |             domain_id=domain_id | ||||||
|         ) |         ) | ||||||
|         db.session.add(element) |         db.session.add(element) | ||||||
|      |      | ||||||
| @@ -113,6 +118,13 @@ def create_large_test_data(): | |||||||
|      |      | ||||||
|     db.session.commit() |     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 |     # Create a complex assessment with 4 exercises, 5 elements each | ||||||
|     assessment = Assessment( |     assessment = Assessment( | ||||||
|         title="Contrôle de Mathématiques - Fonctions et Statistiques", |         title="Contrôle de Mathématiques - Fonctions et Statistiques", | ||||||
| @@ -135,16 +147,16 @@ def create_large_test_data(): | |||||||
|     db.session.commit() |     db.session.commit() | ||||||
|      |      | ||||||
|     ex1_elements = [ |     ex1_elements = [ | ||||||
|         ("1a - Calcul image", "Calculer f(3)", "Calculer", 2.0, "notes"), |         ("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"),  |         ("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"), |         ("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"), |         ("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") |         ("1e - Méthode", "Justification", "Raisonner", 2.0, "score", domain_problemes.id if domain_problemes else None) | ||||||
|     ] |     ] | ||||||
|      |      | ||||||
|     for label, desc, skill, points, gtype in ex1_elements: |     for label, desc, skill, points, gtype, domain_id in ex1_elements: | ||||||
|         elem = GradingElement(exercise_id=ex1.id, label=label, description=desc,  |         elem = GradingElement(exercise_id=ex1.id, label=label, description=desc,  | ||||||
|                              skill=skill, max_points=points, grading_type=gtype) |                              skill=skill, max_points=points, grading_type=gtype, domain_id=domain_id) | ||||||
|         db.session.add(elem) |         db.session.add(elem) | ||||||
|      |      | ||||||
|     # Exercise 2: Équations |     # Exercise 2: Équations | ||||||
| @@ -158,16 +170,16 @@ def create_large_test_data(): | |||||||
|     db.session.commit() |     db.session.commit() | ||||||
|      |      | ||||||
|     ex2_elements = [ |     ex2_elements = [ | ||||||
|         ("2a - Équation simple", "Résoudre 2x+3=7", "Calculer", 2.0, "notes"), |         ("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"), |         ("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"), |         ("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"), |         ("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") |         ("2e - Application", "Problème concret", "Modéliser", 3.0, "score", domain_problemes.id if domain_problemes else None) | ||||||
|     ] |     ] | ||||||
|      |      | ||||||
|     for label, desc, skill, points, gtype in ex2_elements: |     for label, desc, skill, points, gtype, domain_id in ex2_elements: | ||||||
|         elem = GradingElement(exercise_id=ex2.id, label=label, description=desc, |         elem = GradingElement(exercise_id=ex2.id, label=label, description=desc, | ||||||
|                              skill=skill, max_points=points, grading_type=gtype) |                              skill=skill, max_points=points, grading_type=gtype, domain_id=domain_id) | ||||||
|         db.session.add(elem) |         db.session.add(elem) | ||||||
|      |      | ||||||
|     # Exercise 3: Statistiques   |     # Exercise 3: Statistiques   | ||||||
| @@ -181,16 +193,16 @@ def create_large_test_data(): | |||||||
|     db.session.commit() |     db.session.commit() | ||||||
|      |      | ||||||
|     ex3_elements = [ |     ex3_elements = [ | ||||||
|         ("3a - Moyenne", "Calculer moyenne", "Calculer", 3.0, "notes"), |         ("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"), |         ("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"), |         ("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"), |         ("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") |         ("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 in ex3_elements: |     for label, desc, skill, points, gtype, domain_id in ex3_elements: | ||||||
|         elem = GradingElement(exercise_id=ex3.id, label=label, description=desc, |         elem = GradingElement(exercise_id=ex3.id, label=label, description=desc, | ||||||
|                              skill=skill, max_points=points, grading_type=gtype) |                              skill=skill, max_points=points, grading_type=gtype, domain_id=domain_id) | ||||||
|         db.session.add(elem) |         db.session.add(elem) | ||||||
|      |      | ||||||
|     # Exercise 4: Problème de synthèse |     # Exercise 4: Problème de synthèse | ||||||
| @@ -204,16 +216,16 @@ def create_large_test_data(): | |||||||
|     db.session.commit() |     db.session.commit() | ||||||
|      |      | ||||||
|     ex4_elements = [ |     ex4_elements = [ | ||||||
|         ("4a - Modélisation", "Mise en équation", "Modéliser", 4.0, "score"), |         ("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"), |         ("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"), |         ("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"), |         ("4d - Communication", "Rédaction", "Communiquer", 3.0, "score", domain_problemes.id if domain_problemes else None), | ||||||
|         ("4e - Démarche", "Organisation", "Raisonner", 3.0, "score") |         ("4e - Démarche", "Organisation", "Raisonner", 3.0, "score", domain_problemes.id if domain_problemes else None) | ||||||
|     ] |     ] | ||||||
|      |      | ||||||
|     for label, desc, skill, points, gtype in ex4_elements: |     for label, desc, skill, points, gtype, domain_id in ex4_elements: | ||||||
|         elem = GradingElement(exercise_id=ex4.id, label=label, description=desc, |         elem = GradingElement(exercise_id=ex4.id, label=label, description=desc, | ||||||
|                              skill=skill, max_points=points, grading_type=gtype) |                              skill=skill, max_points=points, grading_type=gtype, domain_id=domain_id) | ||||||
|         db.session.add(elem) |         db.session.add(elem) | ||||||
|      |      | ||||||
|     db.session.commit() |     db.session.commit() | ||||||
|   | |||||||
							
								
								
									
										20
									
								
								models.py
									
									
									
									
									
								
							
							
						
						
									
										20
									
								
								models.py
									
									
									
									
									
								
							| @@ -285,6 +285,8 @@ class GradingElement(db.Model): | |||||||
|     max_points = db.Column(db.Float, nullable=False)  # Garder Float pour compatibilité |     max_points = db.Column(db.Float, nullable=False)  # Garder Float pour compatibilité | ||||||
|     # NOUVEAU : Types enum directement |     # NOUVEAU : Types enum directement | ||||||
|     grading_type = db.Column(Enum('notes', 'score', name='grading_types'), nullable=False, default='notes') |     grading_type = db.Column(Enum('notes', 'score', name='grading_types'), nullable=False, default='notes') | ||||||
|  |     # Ajout du champ domain_id | ||||||
|  |     domain_id = db.Column(db.Integer, db.ForeignKey('domains.id'), nullable=True)  # Optionnel | ||||||
|     grades = db.relationship('Grade', backref='grading_element', lazy=True, cascade='all, delete-orphan') |     grades = db.relationship('Grade', backref='grading_element', lazy=True, cascade='all, delete-orphan') | ||||||
|  |  | ||||||
|     def __repr__(self): |     def __repr__(self): | ||||||
| @@ -344,3 +346,21 @@ class Competence(db.Model): | |||||||
|      |      | ||||||
|     def __repr__(self): |     def __repr__(self): | ||||||
|         return f'<Competence {self.name}>' |         return f'<Competence {self.name}>' | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class Domain(db.Model): | ||||||
|  |     """Domaines/tags pour les éléments de notation.""" | ||||||
|  |     __tablename__ = 'domains' | ||||||
|  |      | ||||||
|  |     id = db.Column(db.Integer, primary_key=True) | ||||||
|  |     name = db.Column(db.String(100), unique=True, nullable=False) | ||||||
|  |     color = db.Column(db.String(7), nullable=False, default='#6B7280')  # Format #RRGGBB | ||||||
|  |     description = db.Column(db.Text) | ||||||
|  |     created_at = db.Column(db.DateTime, default=datetime.utcnow) | ||||||
|  |     updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) | ||||||
|  |      | ||||||
|  |     # Relation inverse | ||||||
|  |     grading_elements = db.relationship('GradingElement', backref='domain', lazy=True) | ||||||
|  |      | ||||||
|  |     def __repr__(self): | ||||||
|  |         return f'<Domain {self.name}>' | ||||||
| @@ -155,14 +155,16 @@ def edit(id): | |||||||
|                 'description': element.description or '', |                 'description': element.description or '', | ||||||
|                 'skill': element.skill or '', |                 'skill': element.skill or '', | ||||||
|                 'max_points': float(element.max_points), |                 'max_points': float(element.max_points), | ||||||
|                 'grading_type': element.grading_type |                 'grading_type': element.grading_type, | ||||||
|  |                 'domain_id': element.domain_id | ||||||
|             } |             } | ||||||
|             exercise_data['grading_elements'].append(element_data) |             exercise_data['grading_elements'].append(element_data) | ||||||
|         exercises_data.append(exercise_data) |         exercises_data.append(exercise_data) | ||||||
|      |      | ||||||
|     # Récupérer les compétences configurées |     # Récupérer les compétences et domaines configurées | ||||||
|     from app_config import config_manager |     from app_config import config_manager | ||||||
|     competences = config_manager.get_competences_list() |     competences = config_manager.get_competences_list() | ||||||
|  |     domains = config_manager.get_domains_list() | ||||||
|      |      | ||||||
|     return render_template('assessment_form_unified.html',  |     return render_template('assessment_form_unified.html',  | ||||||
|                          form=form,  |                          form=form,  | ||||||
| @@ -170,7 +172,8 @@ def edit(id): | |||||||
|                          assessment=assessment,  |                          assessment=assessment,  | ||||||
|                          exercises_json=exercises_data, |                          exercises_json=exercises_data, | ||||||
|                          is_edit=True, |                          is_edit=True, | ||||||
|                          competences=competences) |                          competences=competences, | ||||||
|  |                          domains=domains) | ||||||
|  |  | ||||||
| @bp.route('/new', methods=['GET', 'POST']) | @bp.route('/new', methods=['GET', 'POST']) | ||||||
| @handle_db_errors | @handle_db_errors | ||||||
| @@ -182,13 +185,15 @@ def new(): | |||||||
|     if result: |     if result: | ||||||
|         return result |         return result | ||||||
|      |      | ||||||
|     # Récupérer les compétences configurées |     # Récupérer les compétences et domaines configurées | ||||||
|     competences = config_manager.get_competences_list() |     competences = config_manager.get_competences_list() | ||||||
|  |     domains = config_manager.get_domains_list() | ||||||
|      |      | ||||||
|     return render_template('assessment_form_unified.html',  |     return render_template('assessment_form_unified.html',  | ||||||
|                          form=form,  |                          form=form,  | ||||||
|                          title='Nouvelle évaluation complète', |                          title='Nouvelle évaluation complète', | ||||||
|                          competences=competences) |                          competences=competences, | ||||||
|  |                          domains=domains) | ||||||
|  |  | ||||||
| @bp.route('/<int:id>/results') | @bp.route('/<int:id>/results') | ||||||
| @handle_db_errors | @handle_db_errors | ||||||
|   | |||||||
							
								
								
									
										156
									
								
								routes/domains.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										156
									
								
								routes/domains.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,156 @@ | |||||||
|  | from flask import Blueprint, jsonify, request, current_app | ||||||
|  | from models import db, Domain | ||||||
|  | from app_config import config_manager | ||||||
|  | from utils import handle_db_errors | ||||||
|  |  | ||||||
|  | bp = Blueprint('domains', __name__, url_prefix='/api/domains') | ||||||
|  |  | ||||||
|  | @bp.route('/', methods=['GET']) | ||||||
|  | @handle_db_errors | ||||||
|  | def list_domains(): | ||||||
|  |     """Liste tous les domaines disponibles.""" | ||||||
|  |     domains = config_manager.get_domains_list() | ||||||
|  |     return jsonify({'success': True, 'domains': domains}) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @bp.route('/', methods=['POST']) | ||||||
|  | @handle_db_errors | ||||||
|  | def create_domain(): | ||||||
|  |     """Crée un nouveau domaine dynamiquement.""" | ||||||
|  |     data = request.get_json() | ||||||
|  |      | ||||||
|  |     if not data or not data.get('name'): | ||||||
|  |         return jsonify({'success': False, 'error': 'Nom du domaine requis'}), 400 | ||||||
|  |      | ||||||
|  |     name = data['name'].strip() | ||||||
|  |     color = data.get('color', '#6B7280') | ||||||
|  |     description = data.get('description', '') | ||||||
|  |      | ||||||
|  |     # Vérifier que le domaine n'existe pas déjà | ||||||
|  |     if Domain.query.filter_by(name=name).first(): | ||||||
|  |         return jsonify({'success': False, 'error': 'Un domaine avec ce nom existe déjà'}), 400 | ||||||
|  |      | ||||||
|  |     success = config_manager.add_domain(name, color, description) | ||||||
|  |      | ||||||
|  |     if success: | ||||||
|  |         # Récupérer le domaine créé | ||||||
|  |         domain = Domain.query.filter_by(name=name).first() | ||||||
|  |         return jsonify({ | ||||||
|  |             'success': True,  | ||||||
|  |             'domain': { | ||||||
|  |                 'id': domain.id, | ||||||
|  |                 'name': domain.name, | ||||||
|  |                 'color': domain.color, | ||||||
|  |                 'description': domain.description or '' | ||||||
|  |             } | ||||||
|  |         }) | ||||||
|  |     else: | ||||||
|  |         return jsonify({'success': False, 'error': 'Erreur lors de la création du domaine'}), 500 | ||||||
|  |  | ||||||
|  | @bp.route('/search', methods=['GET']) | ||||||
|  | @handle_db_errors | ||||||
|  | def search_domains(): | ||||||
|  |     """Recherche des domaines avec autocomplétion avancée.""" | ||||||
|  |     query = request.args.get('q', '').strip() | ||||||
|  |      | ||||||
|  |     if not query or len(query) < 1: | ||||||
|  |         return jsonify({'success': True, 'domains': []}) | ||||||
|  |      | ||||||
|  |     # Utiliser la recherche directe en base pour plus d'efficacité | ||||||
|  |     domains_query = Domain.query.filter( | ||||||
|  |         Domain.name.ilike(f'%{query}%') | ||||||
|  |     ).order_by( | ||||||
|  |         # Prioriser les correspondances exactes, puis celles qui commencent par la requête | ||||||
|  |         db.case( | ||||||
|  |             (Domain.name.ilike(query), 1), | ||||||
|  |             (Domain.name.ilike(f'{query}%'), 2), | ||||||
|  |             else_=3 | ||||||
|  |         ), | ||||||
|  |         Domain.name.asc() | ||||||
|  |     ).limit(8) | ||||||
|  |      | ||||||
|  |     domains = domains_query.all() | ||||||
|  |      | ||||||
|  |     # Convertir en format JSON | ||||||
|  |     domains_data = [{ | ||||||
|  |         'id': domain.id, | ||||||
|  |         'name': domain.name, | ||||||
|  |         'color': domain.color, | ||||||
|  |         'description': domain.description or '' | ||||||
|  |     } for domain in domains] | ||||||
|  |      | ||||||
|  |     return jsonify({'success': True, 'domains': domains_data}) | ||||||
|  |  | ||||||
|  | @bp.route('/<int:domain_id>', methods=['PUT']) | ||||||
|  | @handle_db_errors | ||||||
|  | def update_domain(domain_id): | ||||||
|  |     """Met à jour un domaine existant.""" | ||||||
|  |     data = request.get_json() | ||||||
|  |     domain = Domain.query.get_or_404(domain_id) | ||||||
|  |      | ||||||
|  |     if data.get('name'): | ||||||
|  |         domain.name = data['name'].strip() | ||||||
|  |     if data.get('color'): | ||||||
|  |         domain.color = data['color'] | ||||||
|  |     if 'description' in data: | ||||||
|  |         domain.description = data['description'] | ||||||
|  |      | ||||||
|  |     try: | ||||||
|  |         db.session.commit() | ||||||
|  |         return jsonify({'success': True}) | ||||||
|  |     except Exception as e: | ||||||
|  |         db.session.rollback() | ||||||
|  |         current_app.logger.error(f"Erreur lors de la mise à jour du domaine: {e}") | ||||||
|  |         return jsonify({'success': False, 'error': 'Erreur lors de la sauvegarde'}), 500 | ||||||
|  |  | ||||||
|  | @bp.route('/<int:domain_id>', methods=['DELETE']) | ||||||
|  | @handle_db_errors | ||||||
|  | def delete_domain(domain_id): | ||||||
|  |     """Supprime un domaine (si non utilisé).""" | ||||||
|  |     domain = Domain.query.get_or_404(domain_id) | ||||||
|  |      | ||||||
|  |     # Vérifier que le domaine n'est pas utilisé | ||||||
|  |     if domain.grading_elements: | ||||||
|  |         return jsonify({ | ||||||
|  |             'success': False,  | ||||||
|  |             'error': f'Ce domaine est utilisé par {len(domain.grading_elements)} éléments de notation' | ||||||
|  |         }), 400 | ||||||
|  |      | ||||||
|  |     try: | ||||||
|  |         db.session.delete(domain) | ||||||
|  |         db.session.commit() | ||||||
|  |         return jsonify({'success': True}) | ||||||
|  |     except Exception as e: | ||||||
|  |         db.session.rollback() | ||||||
|  |         current_app.logger.error(f"Erreur lors de la suppression du domaine: {e}") | ||||||
|  |         return jsonify({'success': False, 'error': 'Erreur lors de la suppression'}), 500 | ||||||
|  |  | ||||||
|  | @bp.route('/<int:domain_id>/usage', methods=['GET']) | ||||||
|  | @handle_db_errors | ||||||
|  | def domain_usage(domain_id): | ||||||
|  |     """Récupère les informations d'utilisation d'un domaine.""" | ||||||
|  |     domain = Domain.query.get_or_404(domain_id) | ||||||
|  |      | ||||||
|  |     # Compter les éléments de notation utilisant ce domaine | ||||||
|  |     elements_count = len(domain.grading_elements) | ||||||
|  |      | ||||||
|  |     # Récupérer les évaluations concernées | ||||||
|  |     assessments = set() | ||||||
|  |     for element in domain.grading_elements: | ||||||
|  |         assessments.add(element.exercise.assessment) | ||||||
|  |      | ||||||
|  |     return jsonify({ | ||||||
|  |         'success': True, | ||||||
|  |         'usage': { | ||||||
|  |             'elements_count': elements_count, | ||||||
|  |             'assessments_count': len(assessments), | ||||||
|  |             'assessments': [ | ||||||
|  |                 { | ||||||
|  |                     'id': assessment.id, | ||||||
|  |                     'title': assessment.title, | ||||||
|  |                     'class_name': assessment.class_group.name | ||||||
|  |                 } | ||||||
|  |                 for assessment in assessments | ||||||
|  |             ] | ||||||
|  |         } | ||||||
|  |     }) | ||||||
							
								
								
									
										14
									
								
								services.py
									
									
									
									
									
								
							
							
						
						
									
										14
									
								
								services.py
									
									
									
									
									
								
							| @@ -137,12 +137,26 @@ class AssessmentService: | |||||||
|             except ValueError as e: |             except ValueError as e: | ||||||
|                 raise ValidationError(str(e)) |                 raise ValidationError(str(e)) | ||||||
|              |              | ||||||
|  |             # Gestion du domaine | ||||||
|  |             domain_id = None | ||||||
|  |             if 'domain_name' in elem_data and elem_data['domain_name']: | ||||||
|  |                 # Récupérer ou créer le domaine | ||||||
|  |                 from app_config import config_manager | ||||||
|  |                 domain = config_manager.get_or_create_domain( | ||||||
|  |                     elem_data['domain_name'],  | ||||||
|  |                     elem_data.get('domain_color', '#6B7280') | ||||||
|  |                 ) | ||||||
|  |                 domain_id = domain.id | ||||||
|  |             elif 'domain_id' in elem_data: | ||||||
|  |                 domain_id = elem_data['domain_id'] | ||||||
|  |              | ||||||
|             grading_element = GradingElement( |             grading_element = GradingElement( | ||||||
|                 label=elem_data['label'].strip(), |                 label=elem_data['label'].strip(), | ||||||
|                 description=elem_data.get('description', '').strip(), |                 description=elem_data.get('description', '').strip(), | ||||||
|                 skill=elem_data.get('skill', '').strip(), |                 skill=elem_data.get('skill', '').strip(), | ||||||
|                 max_points=max_points, |                 max_points=max_points, | ||||||
|                 grading_type=grading_type, |                 grading_type=grading_type, | ||||||
|  |                 domain_id=domain_id, | ||||||
|                 exercise_id=exercise.id |                 exercise_id=exercise.id | ||||||
|             ) |             ) | ||||||
|             db.session.add(grading_element) |             db.session.add(grading_element) | ||||||
| @@ -280,6 +280,12 @@ | |||||||
|                                                 <div class="flex-1 min-w-0"> |                                                 <div class="flex-1 min-w-0"> | ||||||
|                                                     <div class="font-medium text-gray-900 truncate">{{ element.label }}</div> |                                                     <div class="font-medium text-gray-900 truncate">{{ element.label }}</div> | ||||||
|                                                     <div class="flex items-center space-x-1 mt-1"> |                                                     <div class="flex items-center space-x-1 mt-1"> | ||||||
|  |                                                         {% if element.domain %} | ||||||
|  |                                                             <span class="text-xs px-2 py-0.5 rounded-full"  | ||||||
|  |                                                                   style="background-color: {{ element.domain.color }}20; color: {{ element.domain.color }};"> | ||||||
|  |                                                                 {{ element.domain.name }} | ||||||
|  |                                                             </span> | ||||||
|  |                                                         {% endif %} | ||||||
|                                                         {% if element.skill %} |                                                         {% if element.skill %} | ||||||
|                                                             <span class="text-xs text-purple-700 bg-purple-100 px-2 py-0.5 rounded-full"> |                                                             <span class="text-xs text-purple-700 bg-purple-100 px-2 py-0.5 rounded-full"> | ||||||
|                                                                 {{ element.skill }} |                                                                 {{ element.skill }} | ||||||
|   | |||||||
| @@ -250,6 +250,16 @@ | |||||||
|                     {% endfor %} |                     {% endfor %} | ||||||
|                 </select> |                 </select> | ||||||
|             </div> |             </div> | ||||||
|  |             <div> | ||||||
|  |                 <label class="block text-xs font-medium text-gray-700 mb-1">Domaine</label> | ||||||
|  |                 <div class="relative"> | ||||||
|  |                     <input type="text" class="element-domain-input block w-full border border-gray-300 rounded-md px-2 py-1 text-sm focus:ring-purple-500 focus:border-purple-500" placeholder="Saisissez un domaine..." autocomplete="off"> | ||||||
|  |                     <input type="hidden" class="element-domain-id"> | ||||||
|  |                     <div class="element-domain-suggestions absolute z-50 w-full mt-1 bg-white border border-gray-300 rounded-md shadow-lg max-h-48 overflow-y-auto hidden"> | ||||||
|  |                         <!-- Suggestions dynamiques --> | ||||||
|  |                     </div> | ||||||
|  |                 </div> | ||||||
|  |             </div> | ||||||
|             <div> |             <div> | ||||||
|                 <label class="block text-xs font-medium text-gray-700 mb-1">Points max</label> |                 <label class="block text-xs font-medium text-gray-700 mb-1">Points max</label> | ||||||
|                 <input type="number" step="0.1" min="0" class="element-max-points block w-full border border-gray-300 rounded-md px-2 py-1 text-sm focus:ring-purple-500 focus:border-purple-500" required> |                 <input type="number" step="0.1" min="0" class="element-max-points block w-full border border-gray-300 rounded-md px-2 py-1 text-sm focus:ring-purple-500 focus:border-purple-500" required> | ||||||
| @@ -271,9 +281,427 @@ | |||||||
|     </div> |     </div> | ||||||
| </template> | </template> | ||||||
|  |  | ||||||
|  | <!-- Modal pour création de domaine --> | ||||||
|  | <div id="domain-creation-modal" class="fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full z-50 hidden"> | ||||||
|  |     <div class="relative top-20 mx-auto p-5 border w-96 shadow-lg rounded-md bg-white"> | ||||||
|  |         <div class="mt-3"> | ||||||
|  |             <div class="flex items-center justify-between mb-4"> | ||||||
|  |                 <h3 class="text-lg font-medium text-gray-900">Créer un nouveau domaine</h3> | ||||||
|  |                 <button type="button" class="close-modal text-gray-400 hover:text-gray-600"> | ||||||
|  |                     <svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"> | ||||||
|  |                         <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path> | ||||||
|  |                     </svg> | ||||||
|  |                 </button> | ||||||
|  |             </div> | ||||||
|  |              | ||||||
|  |             <div class="space-y-4"> | ||||||
|  |                 <div> | ||||||
|  |                     <label class="block text-sm font-medium text-gray-700 mb-2">Nom du domaine</label> | ||||||
|  |                     <input type="text" id="new-domain-name" class="block w-full border border-gray-300 rounded-md px-3 py-2 focus:ring-purple-500 focus:border-purple-500" placeholder="Ex: Calcul mental"> | ||||||
|  |                 </div> | ||||||
|  |                  | ||||||
|  |                 <div> | ||||||
|  |                     <label class="block text-sm font-medium text-gray-700 mb-2">Couleur associée</label> | ||||||
|  |                     <div class="flex flex-wrap gap-2 mb-3"> | ||||||
|  |                         <button type="button" class="color-option w-8 h-8 rounded-full border-2 border-gray-300 hover:border-gray-400" style="background-color: #3b82f6" data-color="#3b82f6"></button> | ||||||
|  |                         <button type="button" class="color-option w-8 h-8 rounded-full border-2 border-gray-300 hover:border-gray-400" style="background-color: #10b981" data-color="#10b981"></button> | ||||||
|  |                         <button type="button" class="color-option w-8 h-8 rounded-full border-2 border-gray-300 hover:border-gray-400" style="background-color: #f59e0b" data-color="#f59e0b"></button> | ||||||
|  |                         <button type="button" class="color-option w-8 h-8 rounded-full border-2 border-gray-300 hover:border-gray-400" style="background-color: #8b5cf6" data-color="#8b5cf6"></button> | ||||||
|  |                         <button type="button" class="color-option w-8 h-8 rounded-full border-2 border-gray-300 hover:border-gray-400" style="background-color: #ef4444" data-color="#ef4444"></button> | ||||||
|  |                         <button type="button" class="color-option w-8 h-8 rounded-full border-2 border-gray-300 hover:border-gray-400" style="background-color: #06b6d4" data-color="#06b6d4"></button> | ||||||
|  |                         <button type="button" class="color-option w-8 h-8 rounded-full border-2 border-gray-300 hover:border-gray-400" style="background-color: #84cc16" data-color="#84cc16"></button> | ||||||
|  |                         <button type="button" class="color-option w-8 h-8 rounded-full border-2 border-gray-300 hover:border-gray-400" style="background-color: #f97316" data-color="#f97316"></button> | ||||||
|  |                     </div> | ||||||
|  |                     <input type="color" id="custom-color" class="w-full h-10 border border-gray-300 rounded cursor-pointer" value="#3b82f6"> | ||||||
|  |                 </div> | ||||||
|  |                  | ||||||
|  |                 <div> | ||||||
|  |                     <label class="block text-sm font-medium text-gray-700 mb-2">Description (optionnel)</label> | ||||||
|  |                     <textarea id="new-domain-description" class="block w-full border border-gray-300 rounded-md px-3 py-2 focus:ring-purple-500 focus:border-purple-500" rows="2" placeholder="Description du domaine..."></textarea> | ||||||
|  |                 </div> | ||||||
|  |             </div> | ||||||
|  |              | ||||||
|  |             <div class="flex justify-end space-x-3 mt-6"> | ||||||
|  |                 <button type="button" class="close-modal px-4 py-2 text-sm font-medium text-gray-700 bg-gray-100 hover:bg-gray-200 rounded-md">Annuler</button> | ||||||
|  |                 <button type="button" id="confirm-domain-creation" class="px-4 py-2 text-sm font-medium text-white bg-purple-600 hover:bg-purple-700 rounded-md">Créer</button> | ||||||
|  |             </div> | ||||||
|  |         </div> | ||||||
|  |     </div> | ||||||
|  | </div> | ||||||
|  |  | ||||||
| <script> | <script> | ||||||
| let exerciseCounter = 0; | let exerciseCounter = 0; | ||||||
|  |  | ||||||
|  | // Variables globales pour les domaines | ||||||
|  | let availableDomains = []; | ||||||
|  | let currentDomainInput = null; | ||||||
|  | let selectedColor = '#3b82f6'; | ||||||
|  |  | ||||||
|  | // Charger les domaines disponibles | ||||||
|  | async function loadDomains() { | ||||||
|  |     try { | ||||||
|  |         const response = await fetch('/api/domains/'); | ||||||
|  |         const data = await response.json(); | ||||||
|  |         if (data.success) { | ||||||
|  |             availableDomains = data.domains; | ||||||
|  |         } | ||||||
|  |     } catch (error) { | ||||||
|  |         console.error('Erreur lors du chargement des domaines:', error); | ||||||
|  |     } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | // Configuration de l'autocomplétion pour les domaines | ||||||
|  | function setupDomainAutocomplete(container) { | ||||||
|  |     const inputElement = container.querySelector('.element-domain-input'); | ||||||
|  |     const hiddenElement = container.querySelector('.element-domain-id'); | ||||||
|  |     const suggestionsElement = container.querySelector('.element-domain-suggestions'); | ||||||
|  |      | ||||||
|  |     if (!inputElement || !hiddenElement || !suggestionsElement) { | ||||||
|  |         return; | ||||||
|  |     } | ||||||
|  |      | ||||||
|  |     let debounceTimer = null; | ||||||
|  |      | ||||||
|  |     inputElement.addEventListener('input', function() { | ||||||
|  |         const query = this.value.trim(); | ||||||
|  |         clearTimeout(debounceTimer); | ||||||
|  |          | ||||||
|  |         if (query.length === 0) { | ||||||
|  |             hideSuggestions(suggestionsElement); | ||||||
|  |             hiddenElement.value = ''; | ||||||
|  |             return; | ||||||
|  |         } | ||||||
|  |          | ||||||
|  |         debounceTimer = setTimeout(() => { | ||||||
|  |             searchAndShowSuggestions(query, inputElement, hiddenElement, suggestionsElement); | ||||||
|  |         }, 300); | ||||||
|  |     }); | ||||||
|  |      | ||||||
|  |     inputElement.addEventListener('blur', function(e) { | ||||||
|  |         // Délai plus long pour permettre le clic sur les suggestions | ||||||
|  |         setTimeout(() => { | ||||||
|  |             if (!suggestionsElement.contains(document.activeElement) &&  | ||||||
|  |                 !suggestionsElement.matches(':hover')) { | ||||||
|  |                 hideSuggestions(suggestionsElement); | ||||||
|  |             } | ||||||
|  |         }, 200); | ||||||
|  |     }); | ||||||
|  |      | ||||||
|  |     inputElement.addEventListener('focus', function() { | ||||||
|  |         if (this.value.trim()) { | ||||||
|  |             searchAndShowSuggestions(this.value.trim(), inputElement, hiddenElement, suggestionsElement); | ||||||
|  |         } | ||||||
|  |     }); | ||||||
|  |      | ||||||
|  |     // Gestion des touches clavier avec navigation | ||||||
|  |     let selectedIndex = -1; | ||||||
|  |      | ||||||
|  |     inputElement.addEventListener('keydown', function(e) { | ||||||
|  |         const suggestions = suggestionsElement.querySelectorAll('.suggestion-item'); | ||||||
|  |          | ||||||
|  |         if (e.key === 'Escape') { | ||||||
|  |             hideSuggestions(suggestionsElement); | ||||||
|  |             selectedIndex = -1; | ||||||
|  |             this.blur(); | ||||||
|  |         } else if (e.key === 'ArrowDown') { | ||||||
|  |             e.preventDefault(); | ||||||
|  |             if (suggestions.length > 0) { | ||||||
|  |                 selectedIndex = Math.min(selectedIndex + 1, suggestions.length - 1); | ||||||
|  |                 updateSelection(suggestions, selectedIndex); | ||||||
|  |             } | ||||||
|  |         } else if (e.key === 'ArrowUp') { | ||||||
|  |             e.preventDefault(); | ||||||
|  |             if (suggestions.length > 0) { | ||||||
|  |                 selectedIndex = Math.max(selectedIndex - 1, -1); | ||||||
|  |                 updateSelection(suggestions, selectedIndex); | ||||||
|  |             } | ||||||
|  |         } else if (e.key === 'Enter') { | ||||||
|  |             e.preventDefault(); | ||||||
|  |             if (selectedIndex >= 0 && suggestions[selectedIndex]) { | ||||||
|  |                 suggestions[selectedIndex].dispatchEvent(new Event('mousedown')); | ||||||
|  |             } else if (suggestions.length > 0) { | ||||||
|  |                 suggestions[0].dispatchEvent(new Event('mousedown')); | ||||||
|  |             } | ||||||
|  |         } else if (e.key === 'Tab') { | ||||||
|  |             if (suggestions.length > 0 && selectedIndex === -1) { | ||||||
|  |                 e.preventDefault(); | ||||||
|  |                 suggestions[0].dispatchEvent(new Event('mousedown')); | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |     }); | ||||||
|  |      | ||||||
|  |     // Reset de la sélection quand on tape | ||||||
|  |     inputElement.addEventListener('input', function() { | ||||||
|  |         selectedIndex = -1; | ||||||
|  |     }); | ||||||
|  |      | ||||||
|  |     function updateSelection(suggestions, index) { | ||||||
|  |         suggestions.forEach((suggestion, i) => { | ||||||
|  |             suggestion.classList.remove('bg-purple-100', 'bg-blue-100', 'ring-2', 'ring-purple-500'); | ||||||
|  |             if (i === index) { | ||||||
|  |                 suggestion.classList.add('bg-blue-100', 'ring-2', 'ring-blue-500'); | ||||||
|  |             } | ||||||
|  |         }); | ||||||
|  |     } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | async function searchAndShowSuggestions(query, inputElement, hiddenElement, suggestionsElement) { | ||||||
|  |     try { | ||||||
|  |         const response = await fetch(`/api/domains/search?q=${encodeURIComponent(query)}`); | ||||||
|  |         const data = await response.json(); | ||||||
|  |          | ||||||
|  |         if (!data.success) { | ||||||
|  |             console.error('Erreur lors de la recherche de domaines:', data.error); | ||||||
|  |             return; | ||||||
|  |         } | ||||||
|  |          | ||||||
|  |         suggestionsElement.innerHTML = ''; | ||||||
|  |         const domains = data.domains || []; | ||||||
|  |          | ||||||
|  |         if (domains.length > 0) { | ||||||
|  |             domains.forEach(domain => { | ||||||
|  |                 const suggestionDiv = createSuggestionItem(domain, query, inputElement, hiddenElement, suggestionsElement); | ||||||
|  |                 suggestionsElement.appendChild(suggestionDiv); | ||||||
|  |             }); | ||||||
|  |         } | ||||||
|  |          | ||||||
|  |         // Vérifier s'il y a une correspondance exacte | ||||||
|  |         const exactMatch = domains.find(domain =>  | ||||||
|  |             domain.name.toLowerCase() === query.toLowerCase() | ||||||
|  |         ); | ||||||
|  |          | ||||||
|  |         // Option "Créer nouveau domaine" si pas de correspondance exacte | ||||||
|  |         if (!exactMatch) { | ||||||
|  |             const createDiv = document.createElement('div'); | ||||||
|  |             createDiv.className = 'suggestion-item px-3 py-2 text-sm cursor-pointer hover:bg-purple-100 border-t border-gray-200'; | ||||||
|  |             createDiv.innerHTML = ` | ||||||
|  |                 <div class="flex items-center"> | ||||||
|  |                     <svg class="w-4 h-4 text-purple-600 mr-2" fill="currentColor" viewBox="0 0 20 20"> | ||||||
|  |                         <path fill-rule="evenodd" d="M10 3a1 1 0 011 1v5h5a1 1 0 110 2h-5v5a1 1 0 11-2 0v-5H4a1 1 0 110-2h5V4a1 1 0 011-1z" clip-rule="evenodd"/> | ||||||
|  |                     </svg> | ||||||
|  |                     <span class="text-purple-600 font-medium">Créer "${query}"</span> | ||||||
|  |                 </div> | ||||||
|  |             `; | ||||||
|  |              | ||||||
|  |             createDiv.addEventListener('mousedown', function(e) { | ||||||
|  |                 e.preventDefault(); | ||||||
|  |                 currentDomainInput = inputElement; | ||||||
|  |                 showDomainCreationModal(query); | ||||||
|  |                 hideSuggestions(suggestionsElement); | ||||||
|  |             }); | ||||||
|  |              | ||||||
|  |             suggestionsElement.appendChild(createDiv); | ||||||
|  |         } | ||||||
|  |          | ||||||
|  |         // Affichage conditionnel des suggestions | ||||||
|  |         if (domains.length > 0 || !exactMatch) { | ||||||
|  |             suggestionsElement.classList.remove('hidden'); | ||||||
|  |         } else { | ||||||
|  |             suggestionsElement.classList.add('hidden'); | ||||||
|  |         } | ||||||
|  |          | ||||||
|  |     } catch (error) { | ||||||
|  |         console.error('Erreur lors de la recherche de domaines:', error); | ||||||
|  |         suggestionsElement.classList.add('hidden'); | ||||||
|  |     } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | function createSuggestionItem(domain, query, inputElement, hiddenElement, suggestionsElement) { | ||||||
|  |     const div = document.createElement('div'); | ||||||
|  |     div.className = 'suggestion-item px-3 py-2 text-sm cursor-pointer hover:bg-gray-100 flex items-center'; | ||||||
|  |      | ||||||
|  |     const colorIndicator = `<div class="w-3 h-3 rounded-full mr-2" style="background-color: ${domain.color}"></div>`; | ||||||
|  |     const highlightedName = highlightMatch(domain.name, query); | ||||||
|  |      | ||||||
|  |     div.innerHTML = `${colorIndicator}<span>${highlightedName}</span>`; | ||||||
|  |      | ||||||
|  |     div.addEventListener('mousedown', function(e) { | ||||||
|  |         e.preventDefault(); | ||||||
|  |         inputElement.value = domain.name; | ||||||
|  |         hiddenElement.value = domain.id; | ||||||
|  |         hideSuggestions(suggestionsElement); | ||||||
|  |         inputElement.focus(); | ||||||
|  |     }); | ||||||
|  |      | ||||||
|  |     return div; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | function highlightMatch(text, query) { | ||||||
|  |     const lowerText = text.toLowerCase(); | ||||||
|  |     const lowerQuery = query.toLowerCase(); | ||||||
|  |      | ||||||
|  |     if (lowerText.includes(lowerQuery)) { | ||||||
|  |         const index = lowerText.indexOf(lowerQuery); | ||||||
|  |         return text.substring(0, index) +  | ||||||
|  |                '<strong>' + text.substring(index, index + query.length) + '</strong>' +  | ||||||
|  |                text.substring(index + query.length); | ||||||
|  |     } | ||||||
|  |      | ||||||
|  |     return text; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | function hideSuggestions(suggestionsElement) { | ||||||
|  |     suggestionsElement.classList.add('hidden'); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | // Gestion du modal de création de domaine | ||||||
|  | function showDomainCreationModal(suggestedName = '') { | ||||||
|  |     const modal = document.getElementById('domain-creation-modal'); | ||||||
|  |     const nameInput = document.getElementById('new-domain-name'); | ||||||
|  |     const customColorInput = document.getElementById('custom-color'); | ||||||
|  |      | ||||||
|  |     if (nameInput) { | ||||||
|  |         nameInput.value = suggestedName; | ||||||
|  |     } | ||||||
|  |     if (customColorInput) { | ||||||
|  |         customColorInput.value = selectedColor; | ||||||
|  |     } | ||||||
|  |     updateColorSelection(selectedColor); | ||||||
|  |      | ||||||
|  |     setupModalEvents(); | ||||||
|  |      | ||||||
|  |     modal.classList.remove('hidden'); | ||||||
|  |     if (nameInput) { | ||||||
|  |         nameInput.focus(); | ||||||
|  |     } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | function setupModalEvents() { | ||||||
|  |     // Bouton créer | ||||||
|  |     const confirmBtn = document.getElementById('confirm-domain-creation'); | ||||||
|  |     if (confirmBtn) { | ||||||
|  |         confirmBtn.replaceWith(confirmBtn.cloneNode(true)); | ||||||
|  |         const newConfirmBtn = document.getElementById('confirm-domain-creation'); | ||||||
|  |         newConfirmBtn.addEventListener('click', function(e) { | ||||||
|  |             e.preventDefault(); | ||||||
|  |             e.stopPropagation(); | ||||||
|  |             createNewDomain(); | ||||||
|  |         }); | ||||||
|  |     } | ||||||
|  |      | ||||||
|  |     // Boutons fermer | ||||||
|  |     document.querySelectorAll('.close-modal').forEach(btn => { | ||||||
|  |         btn.replaceWith(btn.cloneNode(true)); | ||||||
|  |     }); | ||||||
|  |      | ||||||
|  |     document.querySelectorAll('.close-modal').forEach(btn => { | ||||||
|  |         btn.addEventListener('click', function(e) { | ||||||
|  |             e.preventDefault(); | ||||||
|  |             hideDomainCreationModal(); | ||||||
|  |         }); | ||||||
|  |     }); | ||||||
|  |      | ||||||
|  |     // Couleurs prédéfinies | ||||||
|  |     document.querySelectorAll('.color-option').forEach(btn => { | ||||||
|  |         btn.addEventListener('click', function(e) { | ||||||
|  |             e.preventDefault(); | ||||||
|  |             updateColorSelection(this.dataset.color); | ||||||
|  |         }); | ||||||
|  |     }); | ||||||
|  |      | ||||||
|  |     // Sélecteur de couleur personnalisé | ||||||
|  |     const customColorInput = document.getElementById('custom-color'); | ||||||
|  |     if (customColorInput) { | ||||||
|  |         customColorInput.addEventListener('input', function() { | ||||||
|  |             updateColorSelection(this.value); | ||||||
|  |         }); | ||||||
|  |     } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | function hideDomainCreationModal() { | ||||||
|  |     const modal = document.getElementById('domain-creation-modal'); | ||||||
|  |     modal.classList.add('hidden'); | ||||||
|  |     currentDomainInput = null; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | function updateColorSelection(color) { | ||||||
|  |     selectedColor = color; | ||||||
|  |     const customColorInput = document.getElementById('custom-color'); | ||||||
|  |     if (customColorInput) { | ||||||
|  |         customColorInput.value = color; | ||||||
|  |     } | ||||||
|  |      | ||||||
|  |     // Mettre à jour la sélection visuelle | ||||||
|  |     document.querySelectorAll('.color-option').forEach(btn => { | ||||||
|  |         btn.classList.remove('ring-2', 'ring-purple-500'); | ||||||
|  |         if (btn.dataset.color === color) { | ||||||
|  |             btn.classList.add('ring-2', 'ring-purple-500'); | ||||||
|  |         } | ||||||
|  |     }); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | // Créer un nouveau domaine via API | ||||||
|  | async function createNewDomain() { | ||||||
|  |     const nameInput = document.getElementById('new-domain-name'); | ||||||
|  |     const descriptionInput = document.getElementById('new-domain-description'); | ||||||
|  |      | ||||||
|  |     if (!nameInput) { | ||||||
|  |         alert('Erreur: champ nom non trouvé'); | ||||||
|  |         return; | ||||||
|  |     } | ||||||
|  |      | ||||||
|  |     const name = nameInput.value.trim(); | ||||||
|  |     const description = descriptionInput ? descriptionInput.value.trim() : ''; | ||||||
|  |      | ||||||
|  |     if (!name) { | ||||||
|  |         alert('Le nom du domaine est obligatoire'); | ||||||
|  |         return; | ||||||
|  |     } | ||||||
|  |      | ||||||
|  |     try { | ||||||
|  |         const response = await fetch('/api/domains/', { | ||||||
|  |             method: 'POST', | ||||||
|  |             headers: { | ||||||
|  |                 'Content-Type': 'application/json' | ||||||
|  |             }, | ||||||
|  |             body: JSON.stringify({ | ||||||
|  |                 name: name, | ||||||
|  |                 color: selectedColor, | ||||||
|  |                 description: description | ||||||
|  |             }) | ||||||
|  |         }); | ||||||
|  |          | ||||||
|  |         const data = await response.json(); | ||||||
|  |          | ||||||
|  |         if (data.success) { | ||||||
|  |             // Ajouter le nouveau domaine à la liste | ||||||
|  |             availableDomains.push(data.domain); | ||||||
|  |              | ||||||
|  |             // Mettre à jour l'input actuel s'il existe | ||||||
|  |             if (currentDomainInput) { | ||||||
|  |                 currentDomainInput.value = data.domain.name; | ||||||
|  |                 const hiddenInput = currentDomainInput.parentElement.querySelector('.element-domain-id'); | ||||||
|  |                 if (hiddenInput) { | ||||||
|  |                     hiddenInput.value = data.domain.id; | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |              | ||||||
|  |             hideDomainCreationModal(); | ||||||
|  |             showNotification('Domaine créé avec succès !', 'success'); | ||||||
|  |         } else { | ||||||
|  |             showNotification(data.error || 'Erreur lors de la création du domaine', 'error'); | ||||||
|  |         } | ||||||
|  |     } catch (error) { | ||||||
|  |         console.error('Erreur:', error); | ||||||
|  |         showNotification('Erreur de connexion', 'error'); | ||||||
|  |     } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | function showNotification(message, type) { | ||||||
|  |     // Notification améliorée avec toast | ||||||
|  |     const toast = document.createElement('div'); | ||||||
|  |     toast.className = `fixed top-4 right-4 px-4 py-2 rounded-md text-white font-medium z-50 ${ | ||||||
|  |         type === 'success' ? 'bg-green-500' : 'bg-red-500' | ||||||
|  |     }`; | ||||||
|  |     toast.textContent = message; | ||||||
|  |      | ||||||
|  |     document.body.appendChild(toast); | ||||||
|  |      | ||||||
|  |     setTimeout(() => { | ||||||
|  |         toast.remove(); | ||||||
|  |     }, 3000); | ||||||
|  | } | ||||||
|  |  | ||||||
| document.addEventListener('DOMContentLoaded', function() { | document.addEventListener('DOMContentLoaded', function() { | ||||||
|     const addExerciseBtn = document.getElementById('add-exercise'); |     const addExerciseBtn = document.getElementById('add-exercise'); | ||||||
|     const exercisesContainer = document.getElementById('exercises-container'); |     const exercisesContainer = document.getElementById('exercises-container'); | ||||||
| @@ -351,6 +779,9 @@ document.addEventListener('DOMContentLoaded', function() { | |||||||
|         elementsContainer.appendChild(elementDiv); |         elementsContainer.appendChild(elementDiv); | ||||||
|         noElementsMsg.style.display = 'none'; |         noElementsMsg.style.display = 'none'; | ||||||
|          |          | ||||||
|  |         // Configurer l'autocomplétion des domaines pour ce nouvel élément | ||||||
|  |         setupDomainAutocomplete(elementsContainer.lastElementChild); | ||||||
|  |          | ||||||
|         // Focus automatique sur le champ label du nouvel élément pour faciliter la navigation clavier |         // Focus automatique sur le champ label du nouvel élément pour faciliter la navigation clavier | ||||||
|         setTimeout(() => { |         setTimeout(() => { | ||||||
|             const labelInput = elementsContainer.lastElementChild.querySelector('.element-label'); |             const labelInput = elementsContainer.lastElementChild.querySelector('.element-label'); | ||||||
| @@ -407,16 +838,29 @@ document.addEventListener('DOMContentLoaded', function() { | |||||||
|                 const maxPoints = parseFloat(elementItem.querySelector('.element-max-points').value); |                 const maxPoints = parseFloat(elementItem.querySelector('.element-max-points').value); | ||||||
|                 const gradingType = elementItem.querySelector('.element-grading-type').value; |                 const gradingType = elementItem.querySelector('.element-grading-type').value; | ||||||
|                 const description = elementItem.querySelector('.element-description').value; |                 const description = elementItem.querySelector('.element-description').value; | ||||||
|  |                 const domainHiddenInput = elementItem.querySelector('.element-domain-id'); | ||||||
|  |                 const domainTextInput = elementItem.querySelector('.element-domain-input'); | ||||||
|                  |                  | ||||||
|                 if (!label.trim() || !maxPoints || !gradingType) return; |                 if (!label.trim() || !maxPoints || !gradingType) return; | ||||||
|                  |                  | ||||||
|                 gradingElements.push({ |                 const elementData = { | ||||||
|                     label: label.trim(), |                     label: label.trim(), | ||||||
|                     skill: skill.trim(), |                     skill: skill.trim(), | ||||||
|                     max_points: maxPoints, |                     max_points: maxPoints, | ||||||
|                     grading_type: gradingType, |                     grading_type: gradingType, | ||||||
|                     description: description.trim() |                     description: description.trim() | ||||||
|                 }); |                 }; | ||||||
|  |                  | ||||||
|  |                 // Gestion du domaine | ||||||
|  |                 if (domainHiddenInput && domainHiddenInput.value) { | ||||||
|  |                     // Domaine existant sélectionné via autocomplétion | ||||||
|  |                     elementData.domain_id = parseInt(domainHiddenInput.value); | ||||||
|  |                 } else if (domainTextInput && domainTextInput.value.trim() && !domainHiddenInput.value) { | ||||||
|  |                     // Nouveau domaine à créer (texte saisi mais pas d'ID) | ||||||
|  |                     elementData.domain_name = domainTextInput.value.trim(); | ||||||
|  |                 } | ||||||
|  |                  | ||||||
|  |                 gradingElements.push(elementData); | ||||||
|             }); |             }); | ||||||
|              |              | ||||||
|             exercises.push({ |             exercises.push({ | ||||||
| @@ -530,6 +974,20 @@ document.addEventListener('DOMContentLoaded', function() { | |||||||
|                 elementDiv.querySelector('.element-grading-type').value = element.grading_type; |                 elementDiv.querySelector('.element-grading-type').value = element.grading_type; | ||||||
|                 elementDiv.querySelector('.element-description').value = element.description || ''; |                 elementDiv.querySelector('.element-description').value = element.description || ''; | ||||||
|                  |                  | ||||||
|  |                 // Remplir le domaine s'il existe | ||||||
|  |                 if (element.domain_id) { | ||||||
|  |                     const domainHiddenInput = elementDiv.querySelector('.element-domain-id'); | ||||||
|  |                     const domainTextInput = elementDiv.querySelector('.element-domain-input'); | ||||||
|  |                     if (domainHiddenInput && domainTextInput) { | ||||||
|  |                         domainHiddenInput.value = element.domain_id; | ||||||
|  |                         // Trouver le nom du domaine correspondant | ||||||
|  |                         const domain = availableDomains.find(d => d.id === element.domain_id); | ||||||
|  |                         if (domain) { | ||||||
|  |                             domainTextInput.value = domain.name; | ||||||
|  |                         } | ||||||
|  |                     } | ||||||
|  |                 } | ||||||
|  |                  | ||||||
|                 // Ajouter l'event listener pour supprimer |                 // Ajouter l'event listener pour supprimer | ||||||
|                 const removeElementBtn = elementDiv.querySelector('.remove-grading-element'); |                 const removeElementBtn = elementDiv.querySelector('.remove-grading-element'); | ||||||
|                 removeElementBtn.addEventListener('click', function() { |                 removeElementBtn.addEventListener('click', function() { | ||||||
| @@ -537,6 +995,9 @@ document.addEventListener('DOMContentLoaded', function() { | |||||||
|                 }); |                 }); | ||||||
|                  |                  | ||||||
|                 elementsContainer.appendChild(elementDiv); |                 elementsContainer.appendChild(elementDiv); | ||||||
|  |                  | ||||||
|  |                 // Configurer l'autocomplétion pour cet élément | ||||||
|  |                 setupDomainAutocomplete(elementsContainer.lastElementChild); | ||||||
|             }); |             }); | ||||||
|              |              | ||||||
|             if (exercise.grading_elements.length > 0) { |             if (exercise.grading_elements.length > 0) { | ||||||
| @@ -548,6 +1009,22 @@ document.addEventListener('DOMContentLoaded', function() { | |||||||
|          |          | ||||||
|         updateExercisesVisibility(); |         updateExercisesVisibility(); | ||||||
|     } |     } | ||||||
|  |  | ||||||
|  |     // Initialisation des domaines | ||||||
|  |     loadDomains(); | ||||||
|  |      | ||||||
|  |     // Fermer le modal avec Escape | ||||||
|  |     document.addEventListener('keydown', function(e) { | ||||||
|  |         if (e.key === 'Escape') { | ||||||
|  |             hideDomainCreationModal(); | ||||||
|  |         } | ||||||
|  |     }); | ||||||
|  |      | ||||||
|  |     // Configurer l'autocomplétion pour les éléments existants au chargement | ||||||
|  |     setTimeout(() => { | ||||||
|  |         document.querySelectorAll('.grading-element-item').forEach(setupDomainAutocomplete); | ||||||
|  |     }, 500); | ||||||
|  |      | ||||||
|     {% endif %} |     {% endif %} | ||||||
| }); | }); | ||||||
| </script> | </script> | ||||||
|   | |||||||
| @@ -184,6 +184,15 @@ | |||||||
|                                     <th scope="col" class="grading-header px-2 py-1.5 text-center text-xs font-medium text-gray-500 uppercase tracking-wider min-w-28 {{ exercise_color }} bg-gradient-to-b from-gray-50 to-gray-100"> |                                     <th scope="col" class="grading-header px-2 py-1.5 text-center text-xs font-medium text-gray-500 uppercase tracking-wider min-w-28 {{ exercise_color }} bg-gradient-to-b from-gray-50 to-gray-100"> | ||||||
|                                         <div class="element-label text-xs font-semibold text-gray-900 truncate">{{ element.label }}</div> |                                         <div class="element-label text-xs font-semibold text-gray-900 truncate">{{ element.label }}</div> | ||||||
|                                          |                                          | ||||||
|  |                                         {% if element.domain %} | ||||||
|  |                                             <div class="mt-1"> | ||||||
|  |                                                 <span class="text-xs px-1.5 py-0.5 rounded font-medium"  | ||||||
|  |                                                       style="background-color: {{ element.domain.color }}20; color: {{ element.domain.color }};"> | ||||||
|  |                                                     {{ element.domain.name }} | ||||||
|  |                                                 </span> | ||||||
|  |                                             </div> | ||||||
|  |                                         {% endif %} | ||||||
|  |                                          | ||||||
|                                         {% if element.description %} |                                         {% if element.description %} | ||||||
|                                             <div class="text-xs text-gray-600 mt-1 leading-tight">{{ element.description }}</div> |                                             <div class="text-xs text-gray-600 mt-1 leading-tight">{{ element.description }}</div> | ||||||
|                                         {% endif %} |                                         {% endif %} | ||||||
|   | |||||||
		Reference in New Issue
	
	Block a user