Files
notytex/models.py

346 lines
14 KiB
Python

from flask_sqlalchemy import SQLAlchemy
from datetime import datetime
from sqlalchemy import Index, CheckConstraint, Enum
from decimal import Decimal
from typing import Optional, Dict, Any
from flask import current_app
db = SQLAlchemy()
class GradingCalculator:
"""Calculateur unifié pour tous types de notation."""
@staticmethod
def calculate_score(grade_value: str, grading_type: str, max_points: float) -> Optional[float]:
"""
UN seul point d'entrée pour tous les calculs de score.
Args:
grade_value: Valeur de la note (ex: '15.5', '2', '.', 'd')
grading_type: Type de notation ('notes' ou 'score')
max_points: Points maximum de l'élément de notation
Returns:
Score calculé ou None pour les valeurs dispensées
"""
# Éviter les imports circulaires en important à l'utilisation
from app_config import config_manager
# Valeurs spéciales en premier
if config_manager.is_special_value(grade_value):
special_config = config_manager.get_special_values()[grade_value]
special_value = special_config['value']
if special_value is None: # Dispensé
return None
return float(special_value) # 0 pour '.', 'a'
# Calcul selon type
try:
if grading_type == 'notes':
return float(grade_value)
elif grading_type == 'score':
# Score 0-3 converti en proportion du max_points
score_int = int(grade_value)
if 0 <= score_int <= 3:
return (score_int / 3) * max_points
return 0.0
except (ValueError, TypeError):
return 0.0
return 0.0
@staticmethod
def is_counted_in_total(grade_value: str, grading_type: str) -> bool:
"""
Détermine si une note doit être comptée dans le total.
Returns:
True si la note compte dans le total, False sinon (ex: dispensé)
"""
from app_config import config_manager
# Valeurs spéciales
if config_manager.is_special_value(grade_value):
special_config = config_manager.get_special_values()[grade_value]
return special_config['counts']
# Toutes les autres valeurs comptent
return True
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.
Logique de calcul simplifiée avec 2 types seulement."""
from collections import defaultdict
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 valeurs spéciales)
if grade and grade.value and grade.value != '':
# Utiliser la nouvelle logique unifiée
calculated_score = GradingCalculator.calculate_score(
grade.value.strip(),
element.grading_type,
element.max_points
)
# Vérifier si cette note compte dans le total
if GradingCalculator.is_counted_in_total(grade.value.strip(), element.grading_type):
if calculated_score is not None: # Pas dispensé
exercise_score += calculated_score
exercise_max_points += element.max_points
# Si pas compté ou dispensé, on ignore complètement
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:
# Logique simplifiée avec 2 types : notes et score
total += element.max_points
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é
# NOUVEAU : Types enum directement
grading_type = db.Column(Enum('notes', 'score', name='grading_types'), nullable=False, default='notes')
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}>'