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