30 KiB
30 KiB
🏗️ Architecture Technique - Conseil de Classe
Vue d'ensemble
Le module Conseil de Classe implémente une architecture en couches avec séparation des responsabilités, suivant les patterns Repository, Service Layer et Factory.
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ 🎨 Frontend │ │ 📡 Backend │ │ 🗄️ Database │
│ │ │ │ │ │
│ • Mode Focus │◄──►│ • Services │◄──►│ • Models │
│ • Auto-save │ │ • Repositories │ │ • Relationships │
│ • Sync bidirec. │ │ • API Routes │ │ • Constraints │
└─────────────────┘ └─────────────────┘ └─────────────────┘
🔧 Backend Architecture
1. Services Layer
CouncilPreparationService
Responsabilité : Orchestration principale et agrégation des données
class CouncilPreparationService:
def __init__(self,
student_evaluation_service: StudentEvaluationService,
appreciation_service: AppreciationService,
assessment_repo: AssessmentRepository):
self.student_evaluation = student_evaluation_service
self.appreciation = appreciation_service
self.assessment_repo = assessment_repo
def prepare_council_data(self, class_group_id: int, trimester: int) -> CouncilPreparationData:
"""
Point d'entrée principal - agrège toutes les données nécessaires
Flow:
1. Récupère résumés élèves via StudentEvaluationService
2. Calcule statistiques classe
3. Récupère statistiques appréciations via AppreciationService
4. Retourne CouncilPreparationData consolidé
"""
student_summaries = self.student_evaluation.get_students_summaries(class_group_id, trimester)
class_statistics = self._calculate_class_statistics(student_summaries)
appreciation_stats = self.appreciation.get_completion_stats(class_group_id, trimester)
return CouncilPreparationData(...)
StudentEvaluationService
Responsabilité : Calculs de performances et moyennes élèves
class StudentEvaluationService:
def calculate_student_trimester_average(self, student_id: int, trimester: int) -> Optional[float]:
"""
Algorithme de calcul de moyenne pondérée:
weighted_sum = Σ(score_evaluation × coefficient_evaluation)
total_coefficient = Σ(coefficient_evaluation)
moyenne = weighted_sum / total_coefficient
Gestion des cas spéciaux:
- Notes manquantes : Exclus du calcul
- Valeurs '.' : Comptent comme 0 mais incluent le coefficient
- Valeurs 'd' : Dispensé, exclu complètement
"""
assessments = self.assessment_repo.find_completed_by_class_trimester(class_id, trimester)
weighted_sum = total_coefficient = 0.0
for assessment in assessments:
score = self._calculate_assessment_score_for_student(assessment, student_id)
if score is not None:
weighted_sum += score * assessment.coefficient
total_coefficient += assessment.coefficient
return round(weighted_sum / total_coefficient, 2) if total_coefficient > 0 else None
def _determine_performance_status(self, average: Optional[float]) -> str:
"""
Classification automatique des performances:
- excellent: ≥ 16/20
- good: 14-15.99/20
- average: 10-13.99/20
- struggling: < 10/20
- no_data: Pas de notes disponibles
"""
if not average: return 'no_data'
if average >= 16: return 'excellent'
elif average >= 14: return 'good'
elif average >= 10: return 'average'
else: return 'struggling'
AppreciationService
Responsabilité : CRUD et workflow des appréciations
class AppreciationService:
def save_appreciation(self, data: Dict) -> CouncilAppreciation:
"""
Sauvegarde avec logique de création/mise à jour automatique
Business Rules:
- Création si pas d'appréciation existante
- Mise à jour si existe déjà
- Horodatage automatique (last_modified)
- Validation des champs requis
- Gestion du statut (draft/finalized)
"""
return self.appreciation_repo.create_or_update(
student_id=data['student_id'],
class_group_id=data['class_group_id'],
trimester=data['trimester'],
data={
'general_appreciation': data.get('general_appreciation'),
'strengths': data.get('strengths'),
'areas_for_improvement': data.get('areas_for_improvement'),
'status': data.get('status', 'draft')
}
)
def get_completion_stats(self, class_group_id: int, trimester: int) -> Dict:
"""
Calcul des statistiques de complétion:
- completed_appreciations: Nombre avec contenu
- total_students: Nombre total d'élèves
- completion_rate: Pourcentage de complétion
- average_length: Longueur moyenne des appréciations
"""
return self.appreciation_repo.get_completion_stats(class_group_id, trimester)
2. Repository Layer
Architecture générale
class BaseRepository:
"""Repository générique avec opérations CRUD communes"""
def get_or_404(self, id: int) -> Model:
"""Récupération avec gestion 404 automatique"""
def find_by_filters(self, **filters) -> List[Model]:
"""Requête avec filtres dynamiques"""
def create_or_update(self, **data) -> Model:
"""Upsert pattern avec gestion des conflits"""
class AppreciationRepository(BaseRepository):
def find_by_student_trimester(self, student_id: int, class_group_id: int, trimester: int) -> Optional[CouncilAppreciation]:
"""
Requête optimisée avec index composite:
INDEX idx_appreciation_lookup ON council_appreciations (student_id, class_group_id, trimester)
"""
return CouncilAppreciation.query.filter_by(
student_id=student_id,
class_group_id=class_group_id,
trimester=trimester
).first()
def get_completion_stats(self, class_group_id: int, trimester: int) -> Dict:
"""
Requête d'agrégation optimisée avec sous-requêtes:
SELECT
COUNT(CASE WHEN general_appreciation IS NOT NULL AND general_appreciation != '' THEN 1 END) as completed,
COUNT(DISTINCT s.id) as total_students,
AVG(LENGTH(general_appreciation)) as avg_length
FROM students s
LEFT JOIN council_appreciations ca ON ...
WHERE s.class_group_id = ?
"""
3. API Routes Layer
Structure des endpoints
# /routes/classes.py
@bp.route('/<int:id>/council')
def council_preparation(id):
"""
Page principale - Rendu HTML complet
Validations:
- Trimestre obligatoire et valide (1,2,3)
- Classe existe et accessible
- Données préparées via CouncilServiceFactory
Template: class_council_preparation.html
Context: class_group, trimester, council_data, student_summaries, statistics
"""
trimester = request.args.get('trimestre', type=int)
if not trimester or trimester not in [1, 2, 3]:
flash('Veuillez sélectionner un trimestre pour préparer le conseil de classe.', 'error')
return redirect(url_for('classes.dashboard', id=id))
council_service = CouncilServiceFactory.create_council_preparation_service()
council_data = council_service.prepare_council_data(id, trimester)
return render_template('class_council_preparation.html', ...)
@bp.route('/<int:class_id>/council/appreciation/<int:student_id>', methods=['POST'])
def save_appreciation_api(class_id, student_id):
"""
API AJAX - Sauvegarde d'appréciation
Input validation:
- JSON content-type requis
- student_id appartient à class_id (security)
- trimester valide (1,2,3)
- Longueur appréciation < 2000 chars
Response format:
{
"success": true,
"appreciation_id": 123,
"last_modified": "2025-08-10T14:30:00.000Z",
"status": "draft",
"has_content": true
}
Error handling:
- 400: Données invalides
- 403: Élève pas dans cette classe
- 500: Erreur serveur
"""
try:
data = request.get_json()
if not data:
return jsonify({'success': False, 'error': 'Données manquantes'}), 400
# Security: Vérifier appartenance élève à classe
student = Student.query.get_or_404(student_id)
if student.class_group_id != class_id:
return jsonify({'success': False, 'error': 'Élève non trouvé dans cette classe'}), 403
# Business logic via service
appreciation_service = CouncilServiceFactory.create_appreciation_service()
result = appreciation_service.save_appreciation({
'student_id': student_id,
'class_group_id': class_id,
'trimester': data.get('trimester'),
'general_appreciation': data.get('appreciation', '').strip() or None,
'status': 'draft'
})
return jsonify({
'success': True,
'appreciation_id': result.id,
'last_modified': result.last_modified.isoformat(),
'status': result.status,
'has_content': result.has_content
})
except Exception as e:
current_app.logger.error(f'Erreur sauvegarde appréciation élève {student_id}: {e}')
return jsonify({'success': False, 'error': 'Erreur lors de la sauvegarde'}), 500
4. Data Models
CouncilAppreciation
class CouncilAppreciation(db.Model):
__tablename__ = 'council_appreciations'
# Primary Key
id = db.Column(db.Integer, primary_key=True)
# Foreign Keys avec contraintes
student_id = db.Column(db.Integer, db.ForeignKey('students.id'), nullable=False)
class_group_id = db.Column(db.Integer, db.ForeignKey('class_groups.id'), nullable=False)
# Business Data
trimester = db.Column(db.Integer, nullable=False) # 1, 2, ou 3
general_appreciation = db.Column(db.Text)
strengths = db.Column(db.Text)
areas_for_improvement = db.Column(db.Text)
status = db.Column(db.String(20), default='draft') # draft, finalized
# Metadata
created_at = db.Column(db.DateTime, default=datetime.utcnow)
last_modified = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
# Constraints
__table_args__ = (
# Un seul appréciation par élève/classe/trimestre
db.UniqueConstraint('student_id', 'class_group_id', 'trimester'),
# Index pour les requêtes fréquentes
db.Index('idx_appreciation_lookup', 'student_id', 'class_group_id', 'trimester'),
db.Index('idx_class_trimester', 'class_group_id', 'trimester'),
# Validation trimestre
db.CheckConstraint('trimester IN (1, 2, 3)'),
# Validation statut
db.CheckConstraint("status IN ('draft', 'finalized')")
)
# Relationships
student = db.relationship('Student', backref='council_appreciations')
class_group = db.relationship('ClassGroup', backref='council_appreciations')
@property
def has_content(self) -> bool:
"""Vérifie si l'appréciation a du contenu significatif"""
return bool(
(self.general_appreciation and self.general_appreciation.strip()) or
(self.strengths and self.strengths.strip()) or
(self.areas_for_improvement and self.areas_for_improvement.strip())
)
def to_dict(self) -> Dict:
"""Sérialisation pour API JSON"""
return {
'id': self.id,
'student_id': self.student_id,
'class_group_id': self.class_group_id,
'trimester': self.trimester,
'general_appreciation': self.general_appreciation,
'strengths': self.strengths,
'areas_for_improvement': self.areas_for_improvement,
'status': self.status,
'has_content': self.has_content,
'created_at': self.created_at.isoformat() if self.created_at else None,
'last_modified': self.last_modified.isoformat() if self.last_modified else None
}
🎨 Frontend Architecture
1. Modular JavaScript Architecture
Structure générale
// Pattern: Composition over Inheritance
class CouncilPreparation {
constructor(classId, options = {}) {
// État centralisé
this.state = {
currentTrimester: 2,
expandedStudents: new Set(),
savingStates: new Map(),
modifiedAppreciations: new Set(),
// Focus mode state
isFocusMode: false,
focusCurrentIndex: 0,
filteredStudents: []
};
// Composition des gestionnaires spécialisés
this.stateManager = new StateManager(this); // URL state & persistence
this.filterManager = new FilterManager(this); // Search, sort, filters
this.autoSaveManager = new AutoSaveManager(this); // Auto-save logic
this.uiManager = new UIManager(this); // Card animations
this.focusManager = new FocusManager(this); // Focus mode
}
}
StateManager - Persistance état
class StateManager {
restoreState() {
"""
Restauration depuis URL et localStorage:
URL params: ?trimestre=2&sort=average&filter=struggling
localStorage: expanded_students, focus_mode_preference
Flow:
1. Parse URL parameters
2. Restore localStorage preferences
3. Apply initial state to DOM elements
4. Trigger initial filters/sorts
"""
const params = new URLSearchParams(location.search);
this.parent.state.sortBy = params.get('sort') || 'alphabetical';
this.parent.state.filterStatus = params.get('filter') || 'all';
// Apply to DOM
this.applyInitialState();
}
saveState() {
"""
Persistance dans URL pour bookmarking/refresh:
Format: /classes/5/council?trimestre=2&sort=average&filter=struggling
Benefits:
- État persistant sur F5
- URLs partageables
- Navigation browser (back/forward)
"""
const params = new URLSearchParams(location.search);
params.set('sort', this.parent.state.sortBy);
params.set('filter', this.parent.state.filterStatus);
history.replaceState(null, '', `${location.pathname}?${params.toString()}`);
}
}
FilterManager - Filtrage intelligent
class FilterManager {
applyFilters() {
"""
Algorithme de filtrage multi-critères avec performances optimisées:
1. Single DOM query pour tous les éléments
2. Filtrage en mémoire (shouldShowStudent)
3. Application CSS display/order en batch
4. Animations staggered pour UX fluide
Performance: O(n) où n = nombre d'élèves
"""
const students = Array.from(document.querySelectorAll('[data-student-card]'));
let visibleCount = 0;
students.forEach((studentCard, index) => {
const isVisible = this.shouldShowStudent(studentCard);
if (isVisible) {
studentCard.style.display = '';
visibleCount++;
// Staggered animation pour UX fluide
setTimeout(() => {
studentCard.style.opacity = '1';
studentCard.style.transform = 'translateY(0)';
}, index * 50);
} else {
studentCard.style.display = 'none';
}
});
this.applySorting();
this.updateResultsCounter(visibleCount, students.length);
// Notification au FocusManager pour mise à jour
this.parent.focusManager?.onFiltersChanged();
}
shouldShowStudent(studentCard) {
"""
Critères de filtrage combinés:
1. Recherche textuelle (nom/prénom)
2. Statut de performance (excellent/good/average/struggling)
3. État appréciation (completed/pending)
Logic: AND entre tous les critères actifs
"""
const studentName = studentCard.dataset.studentName?.toLowerCase() || '';
const performanceStatus = studentCard.dataset.performanceStatus;
const hasAppreciation = studentCard.dataset.hasAppreciation === 'true';
// Text search filter
if (this.parent.state.searchTerm && !studentName.includes(this.parent.state.searchTerm)) {
return false;
}
// Performance status filter
if (this.parent.state.filterStatus !== 'all') {
switch (this.parent.state.filterStatus) {
case 'completed': return hasAppreciation;
case 'pending': return !hasAppreciation;
case 'struggling': return performanceStatus === 'struggling';
}
}
return true;
}
}
AutoSaveManager - Sauvegarde intelligente
class AutoSaveManager {
constructor(councilPrep) {
this.parent = councilPrep;
this.pendingSaves = new Map(); // Par élève
this.saveQueue = []; // File FIFO
this.isSaving = false; // Mutex
}
queueSave(studentId, appreciation, immediate = false) {
"""
File de sauvegarde avec deduplication automatique:
Algorithm:
1. Remove previous queued save for same student (deduplication)
2. Add new save task to queue
3. Process queue if not already processing
4. Immediate saves bypass queue for user-triggered actions
Benefits:
- Évite les requêtes multiples pour même élève
- Throttling automatique (100ms entre saves)
- Priorité aux sauvegardes utilisateur (immediate=true)
"""
const saveTask = {
studentId,
appreciation,
timestamp: Date.now(),
immediate
};
if (immediate) {
this.executeSave(saveTask);
} else {
// Deduplication: Remove previous save for this student
this.saveQueue = this.saveQueue.filter(task => task.studentId !== studentId);
this.saveQueue.push(saveTask);
this.processSaveQueue();
}
}
async executeSave(saveTask) {
"""
Exécution HTTP avec gestion d'erreurs robuste:
Flow:
1. Show saving indicator
2. HTTP POST avec retry logic
3. Parse response et validation
4. Update UI states (success/error)
5. Sync avec élément original si mode focus
Error handling:
- Network errors: Retry + user notification
- Validation errors: Show specific message
- Server errors: Log + generic message
"""
const { studentId, appreciation } = saveTask;
try {
this.showSavingState(studentId, true);
const response = await fetch(`/classes/${this.parent.classId}/council/appreciation/${studentId}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Requested-With': 'XMLHttpRequest'
},
body: JSON.stringify({
appreciation: appreciation,
trimester: this.parent.state.currentTrimester
})
});
const result = await response.json();
if (response.ok && result.success) {
this.showSavedState(studentId);
this.parent.state.modifiedAppreciations.delete(studentId);
// Update UI metadata
this.updateLastModified(studentId, result.last_modified);
this.updateAppreciationStatus(studentId, result.has_content);
} else {
throw new Error(result.error || 'Erreur de sauvegarde');
}
} catch (error) {
console.error('Erreur sauvegarde appréciation:', error);
this.showErrorState(studentId, error.message);
this.parent.showToast('Erreur sauvegarde', 'error');
} finally {
this.showSavingState(studentId, false);
}
}
}
2. Focus Mode - Architecture avancée
Concept clé : Clonage d'éléments avec événements
class FocusManager {
showCurrentStudent() {
"""
Clonage intelligent avec attachement d'événements:
Problem: DOM cloneNode() ne clone pas les event listeners
Solution: Re-attach events avec bindFocusStudentEvents()
Flow:
1. Clone l'élément DOM élève courant
2. Marquer avec data-focus-clone-of pour sync
3. Force expand appreciation section
4. Re-attach tous les event listeners
5. Auto-focus sur textarea
6. Optimize layout (no-scroll)
"""
const currentStudent = this.parent.state.filteredStudents[this.parent.state.focusCurrentIndex];
const clonedStudent = currentStudent.cloneNode(true);
// Traçabilité pour synchronisation
const studentId = clonedStudent.dataset.studentCard;
clonedStudent.setAttribute('data-focus-clone-of', studentId);
// Force expand + styling
const detailsSection = clonedStudent.querySelector('[data-student-details]');
detailsSection.classList.remove('hidden');
detailsSection.style.height = 'auto';
clonedStudent.classList.add('focus-mode-student');
// Replace content + re-attach events
focusContainer.innerHTML = '';
focusContainer.appendChild(clonedStudent);
this.bindFocusStudentEvents(clonedStudent, studentId);
// UX enhancements
this.focusAppreciationTextarea(clonedStudent);
this.optimizeHeight();
}
bindFocusStudentEvents(clonedStudent, studentId) {
"""
Re-attachement complet des événements pour élément cloné:
Events à re-créer:
1. textarea input/blur → auto-save avec sync
2. save button click → manual save
3. finalize button → confirmation workflow
4. character counter → real-time update
Sync bidirectionnelle:
- Focus → Original: syncAppreciationToOriginal()
- Focus → Original: syncAppreciationStatusToOriginal()
"""
const textarea = clonedStudent.querySelector(`[data-appreciation-textarea][data-student-id="${studentId}"]`);
if (textarea) {
// Auto-save avec debounce
const saveHandler = this.parent.autoSaveManager.debounce(() => {
this.saveFocusAppreciation(studentId, textarea.value);
}, this.parent.options.debounceTime);
// Input avec sync temps réel
textarea.addEventListener('input', (e) => {
this.parent.state.modifiedAppreciations.add(studentId);
this.syncAppreciationToOriginal(studentId, e.target.value); // Sync bidirectionnelle
saveHandler();
});
// Blur avec save immédiat
textarea.addEventListener('blur', () => {
if (this.parent.state.modifiedAppreciations.has(studentId)) {
this.saveFocusAppreciation(studentId, textarea.value, true);
}
});
}
}
syncAppreciationToOriginal(studentId, value) {
"""
Synchronisation Focus → Liste en temps réel:
Challenge: Maintenir cohérence entre élément cloné et original
Solution: Sync immédiate sur chaque modification
Benefits:
- Pas de perte de données si switch de mode
- État cohérent entre vues
- UX fluide
"""
const originalTextarea = document.querySelector(`[data-student-card="${studentId}"] [data-appreciation-textarea]`);
if (originalTextarea && originalTextarea.value !== value) {
originalTextarea.value = value;
}
}
}
Navigation et UX
// Keyboard shortcuts avec gestion d'état
bindKeyboardShortcuts() {
document.addEventListener('keydown', (e) => {
if (!this.parent.state.isFocusMode) return;
switch (e.key) {
case 'Escape':
// Quick exit avec confirmation si modifications
if (this.parent.state.modifiedAppreciations.size > 0) {
if (confirm('Des modifications non sauvegardées seront perdues. Continuer ?')) {
this.toggleFocusMode(false);
}
} else {
this.toggleFocusMode(false);
}
break;
case 'ArrowLeft':
this.navigatePrevious();
break;
case 'ArrowRight':
this.navigateNext();
break;
}
});
}
focusAppreciationTextarea(clonedStudent) {
"""
Auto-focus intelligent avec gestion de contexte:
Features:
1. Focus sur textarea avec délai pour animation
2. Curseur positionné en fin de texte existant
3. Scroll smooth vers élément si nécessaire
4. Compatible mobile (pas de keyboard pop automatique)
"""
setTimeout(() => {
const textarea = clonedStudent.querySelector('[data-appreciation-textarea]');
if (textarea) {
textarea.focus();
// Cursor à la fin pour continuer écriture
const textLength = textarea.value.length;
textarea.setSelectionRange(textLength, textLength);
// Scroll smooth si nécessaire
textarea.scrollIntoView({
behavior: 'smooth',
block: 'center',
inline: 'nearest'
});
}
}, 100);
}
🔄 Patterns et Optimisations
1. Repository Pattern
- Avantages : Découplage, testabilité, réutilisabilité
- Implementation : BaseRepository avec méthodes communes
- Optimisations : Requêtes avec jointures, index optimaux
2. Service Layer Pattern
- Avantages : Logique métier centralisée, transactions
- Implementation : Services spécialisés avec injection de dépendances
- Factory : CouncilServiceFactory pour création avec dépendances
3. Frontend State Management
- Centralized State : Un seul object state par instance
- Immutable Updates : Pas de mutation directe de state
- Event-driven : Communication entre modules via événements
4. Performance Optimizations
Backend
- Database : Index composites sur foreign keys + trimester
- Queries : Eager loading avec joinedload() pour éviter N+1
- Caching : Pas de cache DB (données temps réel requises)
Frontend
- DOM Queries : Cache des sélecteurs dans this.elements
- Debouncing : Auto-save et recherche avec délais optimaux
- Animation : CSS transitions > JavaScript animations
- Memory : Cleanup des event listeners sur mode changes
🧪 Testing Strategy
Backend Tests
# tests/test_council_services.py
class TestStudentEvaluationService:
def test_calculate_trimester_average_with_coefficients(self):
"""Test calcul moyenne pondérée avec différents coefficients"""
# Given: Évaluations avec coefficients différents
# When: Calcul moyenne élève
# Then: Résultat pondéré correct
def test_performance_status_classification(self):
"""Test classification automatique des performances"""
# Test cases: 18.5→excellent, 14.2→good, 11.8→average, 8.5→struggling, None→no_data
class TestAppreciationService:
def test_create_or_update_logic(self):
"""Test logique création/mise à jour d'appréciation"""
def test_completion_stats_calculation(self):
"""Test calcul statistiques de completion"""
class TestCouncilPreparationService:
def test_prepare_council_data_integration(self):
"""Test d'intégration complet du workflow"""
Frontend Tests
// tests/council-preparation.test.js
describe('FocusManager', () => {
test('should sync appreciation between focus and list mode', () => {
// Given: Text entered in focus mode
// When: Switch to list mode
// Then: Same text appears in list mode
});
test('should auto-focus textarea on student navigation', () => {
// Given: Focus mode active
// When: Navigate to next student
// Then: Textarea has focus and cursor at end
});
});
describe('AutoSaveManager', () => {
test('should debounce multiple saves for same student', () => {
// Given: Multiple rapid text changes
// When: Changes stop
// Then: Only one HTTP request sent after debounce delay
});
});
🚀 Deployment & Monitoring
Performance Metrics
- Page Load Time : < 2s pour classe de 35 élèves
- Auto-save Latency : < 500ms pour sauvegarde simple
- Memory Usage : < 50MB JavaScript heap pour session complète
- Database : < 100ms pour requêtes agrégées
Error Tracking
- JavaScript Errors : Console logging + remote tracking
- API Failures : HTTP status codes + error messages
- User Experience : Toast notifications + retry mechanisms
Cette architecture garantit performance, maintenabilité et évolutivité pour le module Conseil de Classe de Notytex.