feat: add domain

This commit is contained in:
2025-08-06 20:34:55 +02:00
parent 2c1f2a9740
commit 02a60778f9
10 changed files with 828 additions and 43 deletions

2
app.py
View File

@@ -12,6 +12,7 @@ from core.logging import setup_logging
from routes.assessments import bp as assessments_bp
from routes.grading import bp as grading_bp
from routes.config import bp as config_bp
from routes.domains import bp as domains_bp
def create_app(config_name=None):
if config_name is None:
@@ -41,6 +42,7 @@ def create_app(config_name=None):
app.register_blueprint(assessments_bp)
app.register_blueprint(grading_bp)
app.register_blueprint(config_bp)
app.register_blueprint(domains_bp)
# Register CLI commands
app.cli.add_command(init_db)

View File

@@ -130,6 +130,40 @@ class ConfigManager:
'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)
# Domaines par défaut
from models import Domain
if Domain.query.count() == 0:
default_domains = self.default_config['domains']['default_domains']
for domain_data in default_domains:
domain = Domain(
name=domain_data['name'],
color=domain_data['color'],
description=domain_data.get('description', '')
)
db.session.add(domain)
db.session.commit()
def get(self, key_path: str, default: Any = None) -> Any:
@@ -267,6 +313,44 @@ class ConfigManager:
for comp in competences
]
def get_domains_list(self) -> List[Dict[str, Any]]:
"""Récupère la liste des domaines configurés."""
from models import Domain
domains = Domain.query.order_by(Domain.name).all()
return [
{
'id': domain.id,
'name': domain.name,
'color': domain.color,
'description': domain.description or ''
}
for domain in domains
]
def add_domain(self, name: str, color: str = '#6B7280', description: str = '') -> bool:
"""Ajoute un nouveau domaine."""
try:
from models import Domain
domain = Domain(name=name, color=color, description=description)
db.session.add(domain)
db.session.commit()
return True
except Exception as e:
db.session.rollback()
from flask import current_app
current_app.logger.error(f"Erreur lors de l'ajout du domaine: {e}")
return False
def get_or_create_domain(self, name: str, color: str = '#6B7280') -> 'Domain':
"""Récupère un domaine existant ou le crée s'il n'existe pas."""
from models import Domain
domain = Domain.query.filter_by(name=name).first()
if not domain:
domain = Domain(name=name, color=color)
db.session.add(domain)
db.session.commit()
return domain
def get_school_year(self) -> str:
"""Récupère l'année scolaire courante."""
return self.get('context.school_year', '2025-2026')

View File

@@ -1,6 +1,6 @@
import click
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()
@with_appcontext
@@ -62,21 +62,26 @@ def init_db():
db.session.add(exercise)
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 = [
("Calcul de base", "Addition et soustraction de fractions", "Calculer", 4.0, "notes"),
("Méthode", "Justification de la méthode utilisée", "Raisonner", 2.0, "score"),
("Présentation", "Clarté de la présentation", "Communiquer", 2.0, "score"),
("Calcul de base", "Addition et soustraction de fractions", "Calculer", 4.0, "notes", domain_calcul.id if domain_calcul else None),
("Méthode", "Justification de la méthode utilisée", "Raisonner", 2.0, "score", domain_methode.id if domain_methode else None),
("Présentation", "Clarté de la présentation", "Communiquer", 2.0, "score", None), # Pas de domaine spécifique
]
for label, description, skill, max_points, grading_type in elements_data:
for label, description, skill, max_points, grading_type, domain_id in elements_data:
element = GradingElement(
exercise_id=exercise.id,
label=label,
description=description,
skill=skill,
max_points=max_points,
grading_type=grading_type
grading_type=grading_type,
domain_id=domain_id
)
db.session.add(element)
@@ -113,6 +118,13 @@ def create_large_test_data():
db.session.commit()
# Récupérer les domaines existants (créés automatiquement par la configuration)
domain_fonctions = Domain.query.filter_by(name='Fonctions').first()
domain_calcul = Domain.query.filter_by(name='Algèbre').first()
domain_geometrie = Domain.query.filter_by(name='Géométrie').first()
domain_stats = Domain.query.filter_by(name='Statistiques').first()
domain_problemes = Domain.query.filter_by(name='Problèmes').first()
# Create a complex assessment with 4 exercises, 5 elements each
assessment = Assessment(
title="Contrôle de Mathématiques - Fonctions et Statistiques",
@@ -135,16 +147,16 @@ def create_large_test_data():
db.session.commit()
ex1_elements = [
("1a - Calcul image", "Calculer f(3)", "Calculer", 2.0, "notes"),
("1b - Antécédent", "Résoudre f(x)=5", "Calculer", 3.0, "notes"),
("1c - Graphique", "Tracer la droite", "Représenter", 3.0, "score"),
("1d - Lecture graph", "Lire coordonnées", "Modéliser", 2.0, "notes"),
("1e - Méthode", "Justification", "Raisonner", 2.0, "score")
("1a - Calcul image", "Calculer f(3)", "Calculer", 2.0, "notes", domain_fonctions.id if domain_fonctions else None),
("1b - Antécédent", "Résoudre f(x)=5", "Calculer", 3.0, "notes", domain_calcul.id if domain_calcul else None),
("1c - Graphique", "Tracer la droite", "Représenter", 3.0, "score", domain_fonctions.id if domain_fonctions else None),
("1d - Lecture graph", "Lire coordonnées", "Modéliser", 2.0, "notes", domain_fonctions.id if domain_fonctions else None),
("1e - Méthode", "Justification", "Raisonner", 2.0, "score", domain_problemes.id if domain_problemes else None)
]
for label, desc, skill, points, gtype in ex1_elements:
for label, desc, skill, points, gtype, domain_id in ex1_elements:
elem = GradingElement(exercise_id=ex1.id, label=label, description=desc,
skill=skill, max_points=points, grading_type=gtype)
skill=skill, max_points=points, grading_type=gtype, domain_id=domain_id)
db.session.add(elem)
# Exercise 2: Équations
@@ -158,16 +170,16 @@ def create_large_test_data():
db.session.commit()
ex2_elements = [
("2a - Équation simple", "Résoudre 2x+3=7", "Calculer", 2.0, "notes"),
("2b - Avec parenthèses", "3(x-1)=2x+5", "Calculer", 4.0, "notes"),
("2c - Vérification", "Contrôler solution", "Raisonner", 1.0, "score"),
("2d - Méthode", "Étapes de résolution", "Communiquer", 2.0, "score"),
("2e - Application", "Problème concret", "Modéliser", 3.0, "score")
("2a - Équation simple", "Résoudre 2x+3=7", "Calculer", 2.0, "notes", domain_calcul.id if domain_calcul else None),
("2b - Avec parenthèses", "3(x-1)=2x+5", "Calculer", 4.0, "notes", domain_calcul.id if domain_calcul else None),
("2c - Vérification", "Contrôler solution", "Raisonner", 1.0, "score", domain_problemes.id if domain_problemes else None),
("2d - Méthode", "Étapes de résolution", "Communiquer", 2.0, "score", domain_problemes.id if domain_problemes else None),
("2e - Application", "Problème concret", "Modéliser", 3.0, "score", domain_problemes.id if domain_problemes else None)
]
for label, desc, skill, points, gtype in ex2_elements:
for label, desc, skill, points, gtype, domain_id in ex2_elements:
elem = GradingElement(exercise_id=ex2.id, label=label, description=desc,
skill=skill, max_points=points, grading_type=gtype)
skill=skill, max_points=points, grading_type=gtype, domain_id=domain_id)
db.session.add(elem)
# Exercise 3: Statistiques
@@ -181,16 +193,16 @@ def create_large_test_data():
db.session.commit()
ex3_elements = [
("3a - Moyenne", "Calculer moyenne", "Calculer", 3.0, "notes"),
("3b - Médiane", "Déterminer médiane", "Calculer", 2.0, "notes"),
("3c - Quartiles", "Q1 et Q3", "Calculer", 4.0, "notes"),
("3d - Interprétation", "Analyser résultats", "Raisonner", 3.0, "score"),
("3e - Graphique", "Diagramme en boîte", "Représenter", 2.0, "score")
("3a - Moyenne", "Calculer moyenne", "Calculer", 3.0, "notes", domain_stats.id if domain_stats else None),
("3b - Médiane", "Déterminer médiane", "Calculer", 2.0, "notes", domain_stats.id if domain_stats else None),
("3c - Quartiles", "Q1 et Q3", "Calculer", 4.0, "notes", domain_stats.id if domain_stats else None),
("3d - Interprétation", "Analyser résultats", "Raisonner", 3.0, "score", domain_stats.id if domain_stats else None),
("3e - Graphique", "Diagramme en boîte", "Représenter", 2.0, "score", domain_stats.id if domain_stats else None)
]
for label, desc, skill, points, gtype in ex3_elements:
for label, desc, skill, points, gtype, domain_id in ex3_elements:
elem = GradingElement(exercise_id=ex3.id, label=label, description=desc,
skill=skill, max_points=points, grading_type=gtype)
skill=skill, max_points=points, grading_type=gtype, domain_id=domain_id)
db.session.add(elem)
# Exercise 4: Problème de synthèse
@@ -204,16 +216,16 @@ def create_large_test_data():
db.session.commit()
ex4_elements = [
("4a - Modélisation", "Mise en équation", "Modéliser", 4.0, "score"),
("4b - Résolution", "Calculs", "Calculer", 5.0, "notes"),
("4c - Interprétation", "Sens du résultat", "Raisonner", 3.0, "score"),
("4d - Communication", "Rédaction", "Communiquer", 3.0, "score"),
("4e - Démarche", "Organisation", "Raisonner", 3.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", domain_calcul.id if domain_calcul else None),
("4c - Interprétation", "Sens du résultat", "Raisonner", 3.0, "score", domain_problemes.id if domain_problemes else None),
("4d - Communication", "Rédaction", "Communiquer", 3.0, "score", domain_problemes.id if domain_problemes else None),
("4e - Démarche", "Organisation", "Raisonner", 3.0, "score", domain_problemes.id if domain_problemes else None)
]
for label, desc, skill, points, gtype in ex4_elements:
for label, desc, skill, points, gtype, domain_id in ex4_elements:
elem = GradingElement(exercise_id=ex4.id, label=label, description=desc,
skill=skill, max_points=points, grading_type=gtype)
skill=skill, max_points=points, grading_type=gtype, domain_id=domain_id)
db.session.add(elem)
db.session.commit()

View File

@@ -285,6 +285,8 @@ class GradingElement(db.Model):
max_points = db.Column(db.Float, nullable=False) # Garder Float pour compatibilité
# NOUVEAU : Types enum directement
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')
def __repr__(self):
@@ -344,3 +346,21 @@ class Competence(db.Model):
def __repr__(self):
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}>'

View File

@@ -155,14 +155,16 @@ def edit(id):
'description': element.description or '',
'skill': element.skill or '',
'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)
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
competences = config_manager.get_competences_list()
domains = config_manager.get_domains_list()
return render_template('assessment_form_unified.html',
form=form,
@@ -170,7 +172,8 @@ def edit(id):
assessment=assessment,
exercises_json=exercises_data,
is_edit=True,
competences=competences)
competences=competences,
domains=domains)
@bp.route('/new', methods=['GET', 'POST'])
@handle_db_errors
@@ -182,13 +185,15 @@ def new():
if 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()
domains = config_manager.get_domains_list()
return render_template('assessment_form_unified.html',
form=form,
title='Nouvelle évaluation complète',
competences=competences)
competences=competences,
domains=domains)
@bp.route('/<int:id>/results')
@handle_db_errors

156
routes/domains.py Normal file
View 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
]
}
})

View File

@@ -137,12 +137,26 @@ class AssessmentService:
except ValueError as 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(
label=elem_data['label'].strip(),
description=elem_data.get('description', '').strip(),
skill=elem_data.get('skill', '').strip(),
max_points=max_points,
grading_type=grading_type,
domain_id=domain_id,
exercise_id=exercise.id
)
db.session.add(grading_element)

View File

@@ -280,6 +280,12 @@
<div class="flex-1 min-w-0">
<div class="font-medium text-gray-900 truncate">{{ element.label }}</div>
<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 %}
<span class="text-xs text-purple-700 bg-purple-100 px-2 py-0.5 rounded-full">
{{ element.skill }}

View File

@@ -250,6 +250,16 @@
{% endfor %}
</select>
</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>
<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>
@@ -271,9 +281,427 @@
</div>
</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>
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() {
const addExerciseBtn = document.getElementById('add-exercise');
const exercisesContainer = document.getElementById('exercises-container');
@@ -351,6 +779,9 @@ document.addEventListener('DOMContentLoaded', function() {
elementsContainer.appendChild(elementDiv);
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
setTimeout(() => {
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 gradingType = elementItem.querySelector('.element-grading-type').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;
gradingElements.push({
const elementData = {
label: label.trim(),
skill: skill.trim(),
max_points: maxPoints,
grading_type: gradingType,
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({
@@ -530,6 +974,20 @@ document.addEventListener('DOMContentLoaded', function() {
elementDiv.querySelector('.element-grading-type').value = element.grading_type;
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
const removeElementBtn = elementDiv.querySelector('.remove-grading-element');
removeElementBtn.addEventListener('click', function() {
@@ -537,6 +995,9 @@ document.addEventListener('DOMContentLoaded', function() {
});
elementsContainer.appendChild(elementDiv);
// Configurer l'autocomplétion pour cet élément
setupDomainAutocomplete(elementsContainer.lastElementChild);
});
if (exercise.grading_elements.length > 0) {
@@ -548,6 +1009,22 @@ document.addEventListener('DOMContentLoaded', function() {
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 %}
});
</script>

View File

@@ -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">
<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 %}
<div class="text-xs text-gray-600 mt-1 leading-tight">{{ element.description }}</div>
{% endif %}