837 lines
30 KiB
Markdown
837 lines
30 KiB
Markdown
# 🏗️ 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
|
||
```python
|
||
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
|
||
```python
|
||
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
|
||
```python
|
||
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
|
||
```python
|
||
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
|
||
```python
|
||
# /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
|
||
```python
|
||
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
|
||
```javascript
|
||
// 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
|
||
```javascript
|
||
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
|
||
```javascript
|
||
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
|
||
```javascript
|
||
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
|
||
```javascript
|
||
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
|
||
```javascript
|
||
// 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
|
||
```python
|
||
# 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
|
||
```javascript
|
||
// 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. |