feat: improve assessment_grading page

This commit is contained in:
2025-08-05 22:19:02 +02:00
parent d0a2ba428b
commit 1a281d32d0
4 changed files with 292 additions and 25 deletions

View File

@@ -126,8 +126,8 @@ class AssessmentService:
if not elem_data.get('label'):
raise ValidationError("Libellé d'élément de notation requis")
grading_type = elem_data.get('grading_type', 'points')
if grading_type not in ['points', 'score']:
grading_type = elem_data.get('grading_type', 'notes')
if grading_type not in ['notes', 'score']:
raise ValidationError("Type de notation invalide")
try:

View File

@@ -258,7 +258,7 @@
<label class="block text-xs font-medium text-gray-700 mb-1">Type de notation</label>
<select class="element-grading-type block w-full border border-gray-300 rounded-md px-2 py-1 text-sm focus:ring-purple-500 focus:border-purple-500" required>
<option value="">Choisir...</option>
<option value="points">Points</option>
<option value="notes">Points</option>
<option value="score">Score</option>
</select>
</div>

View File

@@ -100,6 +100,37 @@
</div>
</div>
<!-- Filtre des élèves -->
<div class="bg-white shadow rounded-lg overflow-hidden mb-4">
<div class="px-4 py-3 border-b border-gray-200">
<div class="flex items-center justify-between">
<h3 class="text-sm font-medium text-gray-700">Filtrer les élèves</h3>
<span id="student-count" class="text-xs text-gray-500">{{ students|length }} élève(s)</span>
</div>
</div>
<div class="p-4">
<div class="relative">
<input type="text"
id="student-filter"
placeholder="Rechercher un élève par nom ou prénom..."
class="w-full px-3 py-2 pl-10 text-sm border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-all duration-200">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<svg class="h-5 w-5 text-gray-400" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" clip-rule="evenodd" />
</svg>
</div>
<button type="button"
id="clear-filter"
class="absolute inset-y-0 right-0 pr-3 flex items-center text-gray-400 hover:text-gray-600 hidden"
onclick="clearStudentFilter()">
<svg class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd" />
</svg>
</button>
</div>
</div>
</div>
<!-- Tableau de saisie -->
<div class="bg-white shadow rounded-lg overflow-hidden">
<div class="px-4 py-3 border-b border-gray-200">
@@ -108,47 +139,94 @@
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200 table-fixed">
<thead class="bg-gray-50">
<tr>
<th scope="col" class="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider sticky left-0 bg-gray-50 border-r border-gray-200 w-48">
<!-- En-têtes des exercices -->
<thead class="bg-gradient-to-r from-indigo-50 to-purple-50">
<tr class="border-b-2 border-indigo-200">
<th scope="col" class="px-3 py-1.5 text-left text-xs font-medium text-gray-500 uppercase tracking-wider sticky left-0 bg-gradient-to-r from-indigo-50 to-purple-50 border-r border-indigo-200 w-48">
Élève
</th>
{% set current_exercise = '' %}
{% set exercise_elements = {} %}
{% for element in grading_elements %}
<th scope="col" class="grading-header px-2 py-2 text-center text-xs font-medium text-gray-500 uppercase tracking-wider min-w-28">
<div class="element-label text-xs font-semibold text-gray-900">{{ element.label }}</div>
<div class="element-type font-normal text-xs mt-1">
{% if element.exercise.title not in exercise_elements %}
{% set _ = exercise_elements.update({element.exercise.title: []}) %}
{% endif %}
{% set _ = exercise_elements[element.exercise.title].append(element) %}
{% endfor %}
{% for exercise_title, elements in exercise_elements.items() %}
<th scope="colgroup" colspan="{{ elements|length }}" class="px-2 py-1.5 text-center text-sm font-bold text-indigo-900 bg-gradient-to-r from-indigo-100 to-purple-100 border-x border-indigo-300">
{{ exercise_title }}
<div class="text-xs font-normal text-indigo-700 mt-0.5">{{ elements|length }} élément{{ 's' if elements|length > 1 else '' }}</div>
</th>
{% endfor %}
</tr>
</thead>
<!-- En-têtes des éléments de notation -->
<thead class="bg-gray-50">
<tr>
<th scope="col" class="px-3 py-1.5 text-left text-xs font-medium text-gray-500 uppercase tracking-wider sticky left-0 bg-gray-50 border-r border-gray-200 w-48">
<!-- Vide pour alignement -->
</th>
{% set current_exercise = '' %}
{% for element in grading_elements %}
{% if element.exercise.title != current_exercise %}
{% set current_exercise = element.exercise.title %}
{% set exercise_color = 'border-l-4 border-' + ['blue', 'green', 'purple', 'orange', 'pink'][loop.index0 % 5] + '-400' %}
{% endif %}
<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.description %}
<div class="text-xs text-gray-600 mt-1 leading-tight">{{ element.description }}</div>
{% endif %}
<div class="element-type font-normal text-xs mt-1 flex justify-center">
{% if element.grading_type == 'score' %}
<span class="badge-score inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-purple-100 text-purple-800">
<span class="badge-score inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-purple-100 text-purple-800">
0-3
</span>
{% else %}
<span class="badge-notes inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800">
<span class="badge-notes inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-blue-100 text-blue-800">
/{{ element.max_points }}
</span>
{% endif %}
</div>
{% if element.skill %}
<div class="text-xs text-gray-400 mt-1 truncate">{{ element.skill }}</div>
<div class="text-xs text-gray-400 mt-0.5 truncate" title="{{ element.skill }}">{{ element.skill }}</div>
{% endif %}
</th>
{% endfor %}
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-100">
<tbody class="bg-white divide-y divide-gray-50">
{% for student in students %}
<tr class="hover:bg-gray-50 text-sm">
<td class="px-3 py-2 whitespace-nowrap text-sm font-medium text-gray-900 sticky left-0 bg-white border-r border-gray-200">
{{ student.first_name }} {{ student.last_name }}
<tr class="student-row hover:bg-gray-25 text-sm transition-colors duration-150" data-student-name="{{ (student.first_name + ' ' + student.last_name)|lower }}">
<td class="px-3 py-1.5 whitespace-nowrap text-sm font-medium text-gray-900 sticky left-0 bg-white border-r border-gray-200">
<div class="flex items-center">
<div class="text-xs text-gray-500 w-6 student-index">{{ loop.index }}</div>
<div>{{ student.first_name }} {{ student.last_name }}</div>
</div>
</td>
{% set current_exercise = '' %}
{% for element in grading_elements %}
{% if element.exercise.title != current_exercise %}
{% set current_exercise = element.exercise.title %}
{% set exercise_bg_color = ['bg-blue-25', 'bg-green-25', 'bg-purple-25', 'bg-orange-25', 'bg-pink-25'][loop.index0 % 5] %}
{% set exercise_border_color = ['border-l-2 border-blue-300', 'border-l-2 border-green-300', 'border-l-2 border-purple-300', 'border-l-2 border-orange-300', 'border-l-2 border-pink-300'][loop.index0 % 5] %}
{% endif %}
{% set grade_key = student.id ~ '_' ~ element.id %}
{% set existing_grade = existing_grades.get(grade_key) %}
<td class="px-1 py-2 whitespace-nowrap text-center">
<td class="px-1 py-1.5 whitespace-nowrap text-center {{ exercise_border_color if loop.first or (grading_elements[loop.index0-1].exercise.title != element.exercise.title) else '' }} {{ exercise_bg_color }}">
<div class="space-y-1">
<!-- Champs de saisie unifiés -->
{% if element.grading_type == 'score' %}
<select name="grade_{{ student.id }}_{{ element.id }}"
class="grading-input block w-full text-xs border-gray-300 rounded-md focus:ring-2 focus:ring-purple-500 focus:border-purple-500 transition-all duration-200 py-1"
class="grading-input block w-full text-xs border-gray-300 rounded focus:ring-2 focus:ring-purple-500 focus:border-purple-500 transition-all duration-200 py-0.5 h-7"
data-type="score"
data-student-id="{{ student.id }}"
data-element-id="{{ element.id }}"
@@ -173,14 +251,14 @@
<input type="text"
name="grade_{{ student.id }}_{{ element.id }}"
value="{% if existing_grade %}{{ existing_grade.value }}{% endif %}"
class="grading-input block w-full text-xs border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-blue-500 text-center transition-all duration-200 py-1"
class="grading-input block w-full text-xs border-gray-300 rounded focus:ring-2 focus:ring-blue-500 focus:border-blue-500 text-center transition-all duration-200 py-0.5 h-7"
data-type="notes"
data-max-points="{{ element.max_points }}"
data-student-id="{{ student.id }}"
data-element-id="{{ element.id }}"
data-row="{{ loop.index0 }}"
data-col="{{ loop.index0 }}"
placeholder="0-{{ element.max_points }} ou {% for v in scale_values.keys() if not v.isdigit() %}{{ v }}{% if not loop.last %} {% endif %}{% endfor %}"
placeholder="0-{{ element.max_points }}"
oninput="handleGradeChange(this)"
onfocus="handleGradeFocus(this)"
onkeydown="handleGradeKeydown(event, this)">
@@ -188,12 +266,13 @@
<input type="text"
name="comment_{{ student.id }}_{{ element.id }}"
value="{% if existing_grade and existing_grade.comment %}{{ existing_grade.comment }}{% endif %}"
class="comment-input block w-full text-xs border-gray-300 rounded focus:ring-blue-500 focus:border-blue-500 transition-all duration-200 py-0.5"
class="comment-input block w-full text-xs border-gray-200 rounded focus:ring-blue-400 focus:border-blue-400 transition-all duration-200 py-0.5 h-6 bg-gray-25"
data-student-id="{{ student.id }}"
data-element-id="{{ element.id }}"
data-row="{{ loop.index0 }}"
data-col="{{ loop.index0 }}"
placeholder="Commentaire (optionnel)"
placeholder="💬"
title="Commentaire (optionnel)"
onkeydown="handleCommentKeydown(event, this)">
</div>
</td>
@@ -278,6 +357,7 @@ let totalCols = {{ grading_elements|length }};
let unsavedChanges = new Set();
let undoStack = [];
let isAutoSaving = false;
let filterIsActive = false; // Nouvelle variable pour tracker l'état du filtre
// Liste dynamique des valeurs spéciales (exclut les scores 0-3)
const SPECIAL_VALUES_KEYS = Object.keys(GRADING_CONFIG.scale_values).filter(key => !['0', '1', '2', '3'].includes(key));
@@ -286,9 +366,14 @@ const SPECIAL_VALUES_KEYS = Object.keys(GRADING_CONFIG.scale_values).filter(key
document.addEventListener('DOMContentLoaded', function() {
setupKeyboardNavigation();
updateProgressIndicator();
focusFirstInput();
setupAutosave();
applyInitialColors();
setupStudentFilter();
// NE PAS auto-focus - laisser l'utilisateur choisir où cliquer
// setTimeout(() => {
// focusFirstInput();
// }, 100);
});
// Applique les couleurs aux champs déjà remplis au chargement
@@ -309,6 +394,12 @@ function applyInitialColors() {
function setupKeyboardNavigation() {
// Raccourcis globaux
document.addEventListener('keydown', function(e) {
// PROTECTION ABSOLUE : Ne JAMAIS intercepter si on est dans le champ de filtre
if (e.target.id === 'student-filter' || filterIsActive) {
console.log('Navigation bloquée - filtre actif');
return;
}
// F1 : Aide
if (e.key === 'F1') {
e.preventDefault();
@@ -345,6 +436,12 @@ function handleGradeFocus(input) {
// Gestion des touches pour les champs de notes
function handleGradeKeydown(event, input) {
// PROTECTION : Ne pas traiter si le filtre est actif
if (filterIsActive) {
console.log('Événement bloqué - filtre actif');
return;
}
const e = event;
// Échap : Vider le champ
@@ -402,6 +499,11 @@ function handleCommentKeydown(event, input) {
// Navigation vers le champ suivant
function navigateToNext(direction) {
// Ne pas naviguer si le filtre est actif
if (filterIsActive) {
return;
}
let newRow = currentRow;
let newCol = currentCol;
@@ -626,6 +728,11 @@ function updateSaveStatus() {
// Focuser le premier champ
function focusFirstInput() {
// Ne pas auto-focus si le filtre est actif ou si l'utilisateur interagit avec le filtre
if (filterIsActive || (document.activeElement && document.activeElement.id === 'student-filter')) {
return;
}
const firstInput = document.querySelector('.grading-input');
if (firstInput) {
firstInput.focus();
@@ -764,6 +871,166 @@ function showToast(message, type = 'success') {
}, 3000);
}
// Configuration du filtre des élèves
function setupStudentFilter() {
const filterInput = document.getElementById('student-filter');
const clearButton = document.getElementById('clear-filter');
const studentCount = document.getElementById('student-count');
if (!filterInput) return;
console.log('Configuration du filtre des élèves');
// Isoler seulement les événements de navigation, pas les événements fonctionnels
['keydown', 'keyup', 'keypress'].forEach(eventType => {
filterInput.addEventListener(eventType, function(e) {
// Ne bloquer que les événements de navigation, pas les caractères normaux
if (['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'Tab'].includes(e.key)) {
e.stopImmediatePropagation();
}
}, true);
});
// Gestion du focus
filterInput.addEventListener('focus', function(e) {
filterIsActive = true;
this.select();
console.log('🔍 Filtre FOCUS - actif:', filterIsActive);
});
// Gestion du blur
filterInput.addEventListener('blur', function(e) {
setTimeout(() => {
if (document.activeElement !== filterInput) {
filterIsActive = false;
console.log('🔍 Filtre BLUR - inactif:', filterIsActive);
}
}, 50);
});
// Gestion de la saisie - SANS bloquer l'événement
filterInput.addEventListener('input', function(e) {
filterIsActive = true;
const searchTerm = this.value.toLowerCase().trim();
console.log('🔍 Filtre INPUT:', searchTerm, 'Longueur:', searchTerm.length);
// Appeler la fonction de filtrage
filterStudents(searchTerm);
// Gérer le bouton de nettoyage
if (searchTerm) {
clearButton.classList.remove('hidden');
} else {
clearButton.classList.add('hidden');
}
});
// Gestion des touches spéciales
filterInput.addEventListener('keydown', function(e) {
console.log('🔍 Filtre KEYDOWN:', e.key);
if (e.key === 'Escape') {
e.preventDefault();
e.stopPropagation();
clearStudentFilter();
} else if (e.key === 'Enter') {
e.preventDefault();
e.stopPropagation();
filterIsActive = false;
const firstVisibleInput = document.querySelector('.student-row:not([style*="display: none"]) .grading-input');
if (firstVisibleInput) {
firstVisibleInput.focus();
}
}
// Pour les flèches et Tab, empêcher la navigation
else if (['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'Tab'].includes(e.key)) {
e.preventDefault();
e.stopPropagation();
}
});
}
// Filtrer les élèves selon le terme de recherche
function filterStudents(searchTerm) {
console.log('🔍 filterStudents appelée avec:', searchTerm);
const studentRows = document.querySelectorAll('.student-row');
const studentCount = document.getElementById('student-count');
let visibleCount = 0;
let visibleIndex = 1;
console.log('🔍 Nombre de lignes d\'élèves trouvées:', studentRows.length);
studentRows.forEach((row, index) => {
const studentName = row.dataset.studentName;
const isVisible = !searchTerm || studentName.includes(searchTerm);
console.log(`🔍 Élève ${index + 1}: "${studentName}" - Visible: ${isVisible}`);
if (isVisible) {
row.style.display = '';
visibleCount++;
// Mettre à jour l'index visible
const indexEl = row.querySelector('.student-index');
if (indexEl) {
indexEl.textContent = visibleIndex;
}
visibleIndex++;
} else {
row.style.display = 'none';
}
});
console.log('🔍 Résultats du filtrage:', visibleCount, 'élèves visibles sur', studentRows.length);
// Mettre à jour le compteur
if (studentCount) {
const totalStudents = studentRows.length;
if (searchTerm) {
studentCount.textContent = `${visibleCount} / ${totalStudents} élève(s)`;
studentCount.className = visibleCount === 0 ? 'text-xs text-red-500' : 'text-xs text-blue-600';
} else {
studentCount.textContent = `${totalStudents} élève(s)`;
studentCount.className = 'text-xs text-gray-500';
}
}
// Recalculer les variables globales pour la navigation
totalRows = visibleCount;
// Réinitialiser la position si l'élève actuel n'est plus visible
const currentRowEl = document.querySelector(`.student-row:nth-child(${currentRow + 1})`);
if (currentRowEl && currentRowEl.style.display === 'none') {
// Trouver le premier élève visible
const firstVisibleRow = document.querySelector('.student-row:not([style*="display: none"])');
if (firstVisibleRow) {
const firstVisibleInput = firstVisibleRow.querySelector('.grading-input');
if (firstVisibleInput && !filterIsActive) {
firstVisibleInput.focus();
}
}
}
updateProgressIndicator();
}
// Nettoyer le filtre
function clearStudentFilter() {
const filterInput = document.getElementById('student-filter');
const clearButton = document.getElementById('clear-filter');
if (filterInput) {
filterInput.value = '';
filterStudents('');
clearButton.classList.add('hidden');
// Garder le focus et l'état actif
filterIsActive = true;
setTimeout(() => {
filterInput.focus();
}, 10);
}
}
// Gestion de la fermeture de la page avec modifications non sauvegardées
window.addEventListener('beforeunload', function(e) {
if (unsavedChanges.size > 0) {

View File

@@ -86,10 +86,10 @@
name="default_grading_system"
class="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
>
<option value="points" {% if default_grading_system == 'points' %}selected{% endif %}>
<option value="notes" {% if default_grading_system == 'notes' %}selected{% endif %}>
Points (notation classique)
</option>
<option value="competences" {% if default_grading_system == 'competences' %}selected{% endif %}>
<option value="score" {% if default_grading_system == 'score' %}selected{% endif %}>
Compétences (échelle 0-3)
</option>
</select>