- Remplacer le champ texte libre par une liste déroulante des compétences configurées - Charger dynamiquement les compétences dans les routes d'assessments (new/edit) - Moderniser le calcul des scores pour utiliser l'échelle de compétences configurable - Adapter la logique de scoring aux valeurs personnalisées (0-3 ou autres) - Respecter le paramètre 'included_in_total' de chaque valeur de l'échelle - Maintenir la compatibilité descendante avec l'ancienne formule 🎯 Améliore l'intégration entre la configuration système et l'interface utilisateur 📊 Rend les calculs de scores plus flexibles et cohérents avec la configuration 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
325 lines
14 KiB
Python
325 lines
14 KiB
Python
from flask_sqlalchemy import SQLAlchemy
|
|
from datetime import datetime
|
|
from sqlalchemy import Index, CheckConstraint
|
|
from decimal import Decimal
|
|
|
|
db = SQLAlchemy()
|
|
|
|
class ClassGroup(db.Model):
|
|
id = db.Column(db.Integer, primary_key=True)
|
|
name = db.Column(db.String(100), nullable=False, unique=True)
|
|
description = db.Column(db.Text)
|
|
year = db.Column(db.String(20), nullable=False)
|
|
students = db.relationship('Student', backref='class_group', lazy=True)
|
|
assessments = db.relationship('Assessment', backref='class_group', lazy=True)
|
|
|
|
|
|
def __repr__(self):
|
|
return f'<ClassGroup {self.name}>'
|
|
|
|
class Student(db.Model):
|
|
id = db.Column(db.Integer, primary_key=True)
|
|
last_name = db.Column(db.String(100), nullable=False)
|
|
first_name = db.Column(db.String(100), nullable=False)
|
|
email = db.Column(db.String(120), unique=True)
|
|
class_group_id = db.Column(db.Integer, db.ForeignKey('class_group.id'), nullable=False)
|
|
grades = db.relationship('Grade', backref='student', lazy=True)
|
|
|
|
def __repr__(self):
|
|
return f'<Student {self.first_name} {self.last_name}>'
|
|
|
|
@property
|
|
def full_name(self):
|
|
return f"{self.first_name} {self.last_name}"
|
|
|
|
class Assessment(db.Model):
|
|
id = db.Column(db.Integer, primary_key=True)
|
|
title = db.Column(db.String(200), nullable=False)
|
|
description = db.Column(db.Text)
|
|
date = db.Column(db.Date, nullable=False, default=datetime.utcnow)
|
|
trimester = db.Column(db.Integer, nullable=False) # 1, 2, ou 3
|
|
class_group_id = db.Column(db.Integer, db.ForeignKey('class_group.id'), nullable=False)
|
|
coefficient = db.Column(db.Float, default=1.0) # Garder Float pour compatibilité
|
|
exercises = db.relationship('Exercise', backref='assessment', lazy=True, cascade='all, delete-orphan')
|
|
|
|
__table_args__ = (
|
|
CheckConstraint('trimester IN (1, 2, 3)', name='check_trimester_valid'),
|
|
)
|
|
|
|
def __repr__(self):
|
|
return f'<Assessment {self.title}>'
|
|
|
|
@property
|
|
def grading_progress(self):
|
|
"""Calcule le pourcentage de progression des notes saisies pour cette évaluation.
|
|
Retourne un dictionnaire avec les statistiques de progression."""
|
|
|
|
# Obtenir tous les éléments de notation pour cette évaluation
|
|
total_elements = 0
|
|
completed_elements = 0
|
|
total_students = len(self.class_group.students)
|
|
|
|
if total_students == 0:
|
|
return {
|
|
'percentage': 0,
|
|
'completed': 0,
|
|
'total': 0,
|
|
'status': 'no_students'
|
|
}
|
|
|
|
# Parcourir tous les exercices et leurs éléments de notation
|
|
for exercise in self.exercises:
|
|
for grading_element in exercise.grading_elements:
|
|
total_elements += total_students
|
|
|
|
# Compter les notes saisies (valeur non nulle et non vide, y compris '.')
|
|
completed_for_element = db.session.query(Grade).filter(
|
|
Grade.grading_element_id == grading_element.id,
|
|
Grade.value.isnot(None),
|
|
Grade.value != ''
|
|
).count()
|
|
|
|
completed_elements += completed_for_element
|
|
|
|
if total_elements == 0:
|
|
return {
|
|
'percentage': 0,
|
|
'completed': 0,
|
|
'total': 0,
|
|
'status': 'no_elements'
|
|
}
|
|
|
|
percentage = round((completed_elements / total_elements) * 100)
|
|
|
|
# Déterminer le statut
|
|
if percentage == 0:
|
|
status = 'not_started'
|
|
elif percentage == 100:
|
|
status = 'completed'
|
|
else:
|
|
status = 'in_progress'
|
|
|
|
return {
|
|
'percentage': percentage,
|
|
'completed': completed_elements,
|
|
'total': total_elements,
|
|
'status': status,
|
|
'students_count': total_students
|
|
}
|
|
|
|
def calculate_student_scores(self):
|
|
"""Calcule les scores de tous les élèves pour cette évaluation.
|
|
Retourne un dictionnaire avec les scores par élève et par exercice."""
|
|
from collections import defaultdict
|
|
import statistics
|
|
from app_config import config_manager
|
|
|
|
# Récupérer l'échelle des compétences configurée
|
|
competence_scale = config_manager.get_competence_scale_values()
|
|
|
|
students_scores = {}
|
|
exercise_scores = defaultdict(lambda: defaultdict(float))
|
|
|
|
for student in self.class_group.students:
|
|
total_score = 0
|
|
total_max_points = 0
|
|
student_exercises = {}
|
|
|
|
for exercise in self.exercises:
|
|
exercise_score = 0
|
|
exercise_max_points = 0
|
|
|
|
for element in exercise.grading_elements:
|
|
grade = Grade.query.filter_by(
|
|
student_id=student.id,
|
|
grading_element_id=element.id
|
|
).first()
|
|
|
|
# Si une note a été saisie pour cet élément (y compris '.')
|
|
if grade and grade.value and grade.value != '':
|
|
if element.grading_type == 'points':
|
|
if grade.value == '.':
|
|
# '.' signifie non répondu = 0 point mais on compte le max
|
|
exercise_score += 0
|
|
exercise_max_points += element.max_points
|
|
else:
|
|
try:
|
|
exercise_score += float(grade.value)
|
|
exercise_max_points += element.max_points
|
|
except ValueError:
|
|
pass
|
|
else: # compétences - utiliser la nouvelle échelle
|
|
grade_value = grade.value.strip()
|
|
|
|
# Gérer les valeurs numériques et string
|
|
scale_key = int(grade_value) if grade_value.isdigit() else grade_value
|
|
|
|
if scale_key in competence_scale:
|
|
scale_config = competence_scale[scale_key]
|
|
|
|
if scale_config['included_in_total']:
|
|
# Calculer le score selon l'échelle configurée
|
|
if grade_value == '.':
|
|
# Non évalué = 0 point
|
|
exercise_score += 0
|
|
else:
|
|
# Calculer le score proportionnel
|
|
# Trouver la valeur maximale de l'échelle
|
|
max_scale_value = max([
|
|
int(k) if str(k).isdigit() else 0
|
|
for k in competence_scale.keys()
|
|
if competence_scale[k]['included_in_total'] and k != '.'
|
|
])
|
|
|
|
if max_scale_value > 0:
|
|
if grade_value.isdigit():
|
|
score_ratio = int(grade_value) / max_scale_value
|
|
exercise_score += score_ratio * element.max_points
|
|
|
|
# Compter les points maximum (sauf pour '.')
|
|
if grade_value != '.':
|
|
exercise_max_points += element.max_points
|
|
# Si not included_in_total, on ne compte ni score ni max
|
|
else:
|
|
# Valeur non reconnue, utiliser l'ancienne logique par défaut
|
|
try:
|
|
score_value = float(grade_value)
|
|
exercise_score += (1/3) * score_value * element.max_points
|
|
exercise_max_points += element.max_points
|
|
except ValueError:
|
|
pass
|
|
|
|
student_exercises[exercise.id] = {
|
|
'score': exercise_score,
|
|
'max_points': exercise_max_points,
|
|
'title': exercise.title
|
|
}
|
|
total_score += exercise_score
|
|
total_max_points += exercise_max_points
|
|
exercise_scores[exercise.id][student.id] = exercise_score
|
|
|
|
students_scores[student.id] = {
|
|
'student': student,
|
|
'total_score': round(total_score, 2),
|
|
'total_max_points': total_max_points,
|
|
'exercises': student_exercises
|
|
}
|
|
|
|
return students_scores, dict(exercise_scores)
|
|
|
|
def get_assessment_statistics(self):
|
|
"""Calcule les statistiques descriptives pour cette évaluation."""
|
|
students_scores, _ = self.calculate_student_scores()
|
|
scores = [data['total_score'] for data in students_scores.values()]
|
|
|
|
if not scores:
|
|
return {
|
|
'count': 0,
|
|
'mean': 0,
|
|
'median': 0,
|
|
'min': 0,
|
|
'max': 0,
|
|
'std_dev': 0
|
|
}
|
|
|
|
import statistics
|
|
import math
|
|
|
|
return {
|
|
'count': len(scores),
|
|
'mean': round(statistics.mean(scores), 2),
|
|
'median': round(statistics.median(scores), 2),
|
|
'min': min(scores),
|
|
'max': max(scores),
|
|
'std_dev': round(statistics.stdev(scores) if len(scores) > 1 else 0, 2)
|
|
}
|
|
|
|
def get_total_max_points(self):
|
|
"""Calcule le total des points maximum pour cette évaluation."""
|
|
total = 0
|
|
for exercise in self.exercises:
|
|
for element in exercise.grading_elements:
|
|
if element.grading_type == 'points':
|
|
total += element.max_points
|
|
else: # compétences
|
|
total += (1/3) * 3 * element.max_points # Score max de 3
|
|
return total
|
|
|
|
class Exercise(db.Model):
|
|
id = db.Column(db.Integer, primary_key=True)
|
|
assessment_id = db.Column(db.Integer, db.ForeignKey('assessment.id'), nullable=False)
|
|
title = db.Column(db.String(200), nullable=False)
|
|
description = db.Column(db.Text)
|
|
order = db.Column(db.Integer, default=1)
|
|
grading_elements = db.relationship('GradingElement', backref='exercise', lazy=True, cascade='all, delete-orphan')
|
|
|
|
def __repr__(self):
|
|
return f'<Exercise {self.title}>'
|
|
|
|
class GradingElement(db.Model):
|
|
id = db.Column(db.Integer, primary_key=True)
|
|
exercise_id = db.Column(db.Integer, db.ForeignKey('exercise.id'), nullable=False)
|
|
label = db.Column(db.String(200), nullable=False)
|
|
description = db.Column(db.Text)
|
|
skill = db.Column(db.String(200))
|
|
max_points = db.Column(db.Float, nullable=False) # Garder Float pour compatibilité
|
|
grading_type = db.Column(db.String(10), nullable=False, default='points')
|
|
grades = db.relationship('Grade', backref='grading_element', lazy=True, cascade='all, delete-orphan')
|
|
|
|
def __repr__(self):
|
|
return f'<GradingElement {self.label}>'
|
|
|
|
class Grade(db.Model):
|
|
id = db.Column(db.Integer, primary_key=True)
|
|
student_id = db.Column(db.Integer, db.ForeignKey('student.id'), nullable=False)
|
|
grading_element_id = db.Column(db.Integer, db.ForeignKey('grading_element.id'), nullable=False)
|
|
value = db.Column(db.String(10)) # Garder l'ancien format pour compatibilité
|
|
comment = db.Column(db.Text)
|
|
|
|
|
|
def __repr__(self):
|
|
return f'<Grade {self.value} for {self.student.first_name if self.student else "Unknown"}>'
|
|
|
|
# Configuration tables
|
|
|
|
class AppConfig(db.Model):
|
|
"""Configuration simple de l'application (clé-valeur)."""
|
|
__tablename__ = 'app_config'
|
|
|
|
key = db.Column(db.String(100), primary_key=True)
|
|
value = db.Column(db.Text, nullable=False)
|
|
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)
|
|
|
|
def __repr__(self):
|
|
return f'<AppConfig {self.key}={self.value}>'
|
|
|
|
class CompetenceScaleValue(db.Model):
|
|
"""Valeurs de l'échelle des compétences (0, 1, 2, 3, ., d, etc.)."""
|
|
__tablename__ = 'competence_scale_values'
|
|
|
|
value = db.Column(db.String(10), primary_key=True) # '0', '1', '2', '3', '.', 'd', etc.
|
|
label = db.Column(db.String(100), nullable=False)
|
|
color = db.Column(db.String(7), nullable=False) # Format #RRGGBB
|
|
included_in_total = db.Column(db.Boolean, default=True, nullable=False)
|
|
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
|
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
|
|
|
def __repr__(self):
|
|
return f'<CompetenceScaleValue {self.value}: {self.label}>'
|
|
|
|
class Competence(db.Model):
|
|
"""Liste des compétences (Calculer, Raisonner, etc.)."""
|
|
__tablename__ = 'competences'
|
|
|
|
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) # Format #RRGGBB
|
|
icon = db.Column(db.String(50), nullable=False)
|
|
order_index = db.Column(db.Integer, default=0) # Pour l'ordre d'affichage
|
|
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
|
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
|
|
|
def __repr__(self):
|
|
return f'<Competence {self.name}>' |