✨ Changements majeurs: - Suppression complète du code Flask legacy - Migration backend FastAPI vers racine /backend - Migration frontend Vue.js vers racine /frontend - Suppression de notytex-v2/ (code monté à la racine) ✅ Validations: - Backend démarre correctement (port 8000) - API /api/v2/health répond healthy - 99/99 tests unitaires passent - Frontend configuré avec proxy Vite 📝 Documentation: - README.md réécrit pour v2 - Instructions de démarrage mises à jour - .gitignore adapté pour backend/frontend/ 🎯 Architecture finale: notytex/ ├── backend/ # FastAPI + SQLAlchemy + Pydantic ├── frontend/ # Vue 3 + Vite + TailwindCSS ├── docs/ # Documentation └── school_management.db # Base de données (inchangée) Jalon 6 complété: Application v2 prête pour utilisation!
440 lines
15 KiB
Python
440 lines
15 KiB
Python
"""
|
|
Routes API pour le conseil de classe (préparation et appréciations).
|
|
"""
|
|
|
|
from typing import Optional
|
|
|
|
from fastapi import APIRouter, HTTPException, Query
|
|
from sqlalchemy import select, func
|
|
from sqlalchemy.orm import selectinload
|
|
|
|
from api.dependencies import AsyncSessionDep
|
|
from infrastructure.database.models import (
|
|
ClassGroup,
|
|
Student,
|
|
StudentEnrollment,
|
|
Assessment,
|
|
Exercise,
|
|
GradingElement,
|
|
Grade,
|
|
CouncilAppreciation,
|
|
)
|
|
from domain.services.grading_calculator import GradingCalculator
|
|
from schemas.council import (
|
|
CouncilPreparationRead,
|
|
StudentTrimesterSummaryRead,
|
|
ClassStatisticsRead,
|
|
AppreciationStatsRead,
|
|
PerformanceDistribution,
|
|
AssessmentGradeInfo,
|
|
AppreciationCreate,
|
|
AppreciationRead,
|
|
AppreciationResponse,
|
|
)
|
|
|
|
router = APIRouter(prefix="/council", tags=["Conseil de Classe"])
|
|
|
|
|
|
async def get_current_students_in_class(session, class_id: int):
|
|
"""Récupère les élèves actuellement inscrits dans une classe."""
|
|
query = (
|
|
select(Student)
|
|
.join(StudentEnrollment, Student.id == StudentEnrollment.student_id)
|
|
.where(
|
|
StudentEnrollment.class_group_id == class_id,
|
|
StudentEnrollment.departure_date.is_(None)
|
|
)
|
|
.order_by(Student.last_name, Student.first_name)
|
|
)
|
|
result = await session.execute(query)
|
|
return result.scalars().all()
|
|
|
|
|
|
async def calculate_student_assessment_score(session, student_id: int, assessment_id: int) -> Optional[float]:
|
|
"""Calcule le score d'un élève pour une évaluation, ramené sur 20."""
|
|
# Récupérer les notes de l'élève pour cette évaluation
|
|
query = (
|
|
select(Grade, GradingElement)
|
|
.join(GradingElement, Grade.grading_element_id == GradingElement.id)
|
|
.join(Exercise, GradingElement.exercise_id == Exercise.id)
|
|
.where(
|
|
Exercise.assessment_id == assessment_id,
|
|
Grade.student_id == student_id
|
|
)
|
|
)
|
|
result = await session.execute(query)
|
|
grades_data = result.all()
|
|
|
|
if not grades_data:
|
|
return None
|
|
|
|
calculator = GradingCalculator()
|
|
total_score = 0.0
|
|
total_max_points = 0.0
|
|
|
|
for grade, element in grades_data:
|
|
if grade.value:
|
|
score = calculator.calculate_score(
|
|
grade.value, element.grading_type, element.max_points
|
|
)
|
|
if score is not None and calculator.is_counted_in_total(grade.value):
|
|
total_score += score
|
|
total_max_points += element.max_points
|
|
|
|
if total_max_points > 0:
|
|
return round(total_score / total_max_points * 20, 2)
|
|
return None
|
|
|
|
|
|
async def get_student_trimester_average(session, student_id: int, class_id: int, trimester: int) -> Optional[float]:
|
|
"""Calcule la moyenne pondérée d'un élève pour un trimestre."""
|
|
# Récupérer les évaluations du trimestre avec leurs coefficients
|
|
assessments_query = select(Assessment).where(
|
|
Assessment.class_group_id == class_id,
|
|
Assessment.trimester == trimester
|
|
)
|
|
result = await session.execute(assessments_query)
|
|
assessments = result.scalars().all()
|
|
|
|
if not assessments:
|
|
return None
|
|
|
|
weighted_sum = 0.0
|
|
total_coefficient = 0.0
|
|
|
|
for assessment in assessments:
|
|
score = await calculate_student_assessment_score(session, student_id, assessment.id)
|
|
if score is not None:
|
|
weighted_sum += score * assessment.coefficient
|
|
total_coefficient += assessment.coefficient
|
|
|
|
if total_coefficient > 0:
|
|
return round(weighted_sum / total_coefficient, 2)
|
|
return None
|
|
|
|
|
|
def determine_performance_status(average: Optional[float]) -> str:
|
|
"""Détermine le statut de performance d'un élève."""
|
|
if average is None:
|
|
return "no_data"
|
|
if average >= 16:
|
|
return "excellent"
|
|
elif average >= 14:
|
|
return "good"
|
|
elif average >= 10:
|
|
return "average"
|
|
else:
|
|
return "struggling"
|
|
|
|
|
|
@router.get("/classes/{class_id}", response_model=CouncilPreparationRead)
|
|
async def get_council_preparation(
|
|
class_id: int,
|
|
trimester: int,
|
|
session: AsyncSessionDep,
|
|
):
|
|
"""
|
|
Récupère les données complètes pour la préparation du conseil de classe.
|
|
|
|
Inclut:
|
|
- Résumé de chaque élève (moyenne, nombre d'évaluations, performances)
|
|
- Statistiques de classe (moyenne, médiane, écart-type)
|
|
- État des appréciations (complétées/en cours)
|
|
"""
|
|
# Vérifier que la classe existe
|
|
class_query = select(ClassGroup).where(ClassGroup.id == class_id)
|
|
class_result = await session.execute(class_query)
|
|
cls = class_result.scalar_one_or_none()
|
|
|
|
if not cls:
|
|
raise HTTPException(status_code=404, detail="Classe non trouvée")
|
|
|
|
# Récupérer les élèves actuellement dans la classe
|
|
students = await get_current_students_in_class(session, class_id)
|
|
|
|
# Récupérer les évaluations du trimestre
|
|
assessments_query = select(Assessment).where(
|
|
Assessment.class_group_id == class_id,
|
|
Assessment.trimester == trimester
|
|
).order_by(Assessment.date)
|
|
assessments_result = await session.execute(assessments_query)
|
|
assessments = assessments_result.scalars().all()
|
|
|
|
# Récupérer les appréciations existantes
|
|
appreciations_query = select(CouncilAppreciation).where(
|
|
CouncilAppreciation.class_group_id == class_id,
|
|
CouncilAppreciation.trimester == trimester
|
|
)
|
|
appreciations_result = await session.execute(appreciations_query)
|
|
appreciations = {a.student_id: a for a in appreciations_result.scalars().all()}
|
|
|
|
# Générer les résumés par élève
|
|
student_summaries = []
|
|
all_averages = []
|
|
|
|
for student in students:
|
|
# Calculer les notes par évaluation
|
|
grades_by_assessment = {}
|
|
assessment_count = 0
|
|
|
|
for assessment in assessments:
|
|
score = await calculate_student_assessment_score(session, student.id, assessment.id)
|
|
if score is not None:
|
|
assessment_count += 1
|
|
grades_by_assessment[assessment.id] = AssessmentGradeInfo(
|
|
id=assessment.id,
|
|
title=assessment.title,
|
|
date=assessment.date,
|
|
score=score,
|
|
max_score=20.0,
|
|
coefficient=assessment.coefficient
|
|
)
|
|
|
|
# Calculer la moyenne générale
|
|
overall_average = await get_student_trimester_average(session, student.id, class_id, trimester)
|
|
if overall_average is not None:
|
|
all_averages.append(overall_average)
|
|
|
|
# Récupérer l'appréciation
|
|
appreciation = appreciations.get(student.id)
|
|
|
|
student_summaries.append(StudentTrimesterSummaryRead(
|
|
student_id=student.id,
|
|
first_name=student.first_name,
|
|
last_name=student.last_name,
|
|
full_name=f"{student.first_name} {student.last_name}",
|
|
overall_average=overall_average,
|
|
assessment_count=assessment_count,
|
|
grades_by_assessment=grades_by_assessment,
|
|
performance_status=determine_performance_status(overall_average),
|
|
has_appreciation=appreciation is not None and (
|
|
appreciation.general_appreciation or
|
|
appreciation.strengths or
|
|
appreciation.areas_for_improvement
|
|
),
|
|
appreciation_status=appreciation.status if appreciation else None
|
|
))
|
|
|
|
# Calculer les statistiques de classe
|
|
if all_averages:
|
|
mean = round(sum(all_averages) / len(all_averages), 2)
|
|
sorted_averages = sorted(all_averages)
|
|
n = len(sorted_averages)
|
|
|
|
if n % 2 == 0:
|
|
median = (sorted_averages[n//2 - 1] + sorted_averages[n//2]) / 2
|
|
else:
|
|
median = sorted_averages[n//2]
|
|
median = round(median, 2)
|
|
|
|
min_avg = min(all_averages)
|
|
max_avg = max(all_averages)
|
|
|
|
# Écart-type
|
|
variance = sum((x - mean) ** 2 for x in all_averages) / len(all_averages)
|
|
std_dev = round(variance ** 0.5, 2)
|
|
else:
|
|
mean = median = min_avg = max_avg = std_dev = None
|
|
|
|
# Distribution des performances
|
|
performance_dist = {
|
|
"excellent": 0,
|
|
"good": 0,
|
|
"average": 0,
|
|
"struggling": 0,
|
|
"no_data": 0
|
|
}
|
|
for summary in student_summaries:
|
|
performance_dist[summary.performance_status] += 1
|
|
|
|
class_statistics = ClassStatisticsRead(
|
|
mean=mean,
|
|
median=median,
|
|
min=min_avg,
|
|
max=max_avg,
|
|
std_dev=std_dev,
|
|
performance_distribution=PerformanceDistribution(**performance_dist),
|
|
student_count_with_data=len(all_averages),
|
|
total_students=len(students)
|
|
)
|
|
|
|
# Statistiques des appréciations
|
|
draft_count = sum(1 for a in appreciations.values() if a.status == "draft")
|
|
finalized_count = sum(1 for a in appreciations.values() if a.status == "finalized")
|
|
completed_count = sum(1 for s in student_summaries if s.has_appreciation)
|
|
|
|
appreciation_stats = AppreciationStatsRead(
|
|
total_students=len(students),
|
|
completed_appreciations=completed_count,
|
|
draft_appreciations=draft_count,
|
|
finalized_appreciations=finalized_count,
|
|
completion_percentage=round(completed_count / len(students) * 100, 1) if students else 0
|
|
)
|
|
|
|
return CouncilPreparationRead(
|
|
class_group_id=class_id,
|
|
class_name=cls.name,
|
|
trimester=trimester,
|
|
student_summaries=student_summaries,
|
|
class_statistics=class_statistics,
|
|
appreciation_stats=appreciation_stats,
|
|
total_students=len(students),
|
|
completed_appreciations=completed_count
|
|
)
|
|
|
|
|
|
@router.get("/classes/{class_id}/appreciations/{student_id}", response_model=AppreciationRead)
|
|
async def get_appreciation(
|
|
class_id: int,
|
|
student_id: int,
|
|
trimester: int,
|
|
session: AsyncSessionDep,
|
|
):
|
|
"""
|
|
Récupère l'appréciation d'un élève pour un trimestre.
|
|
"""
|
|
query = select(CouncilAppreciation).where(
|
|
CouncilAppreciation.class_group_id == class_id,
|
|
CouncilAppreciation.student_id == student_id,
|
|
CouncilAppreciation.trimester == trimester
|
|
)
|
|
result = await session.execute(query)
|
|
appreciation = result.scalar_one_or_none()
|
|
|
|
if not appreciation:
|
|
raise HTTPException(status_code=404, detail="Appréciation non trouvée")
|
|
|
|
return AppreciationRead(
|
|
id=appreciation.id,
|
|
student_id=appreciation.student_id,
|
|
class_group_id=appreciation.class_group_id,
|
|
trimester=appreciation.trimester,
|
|
general_appreciation=appreciation.general_appreciation,
|
|
strengths=appreciation.strengths,
|
|
areas_for_improvement=appreciation.areas_for_improvement,
|
|
status=appreciation.status,
|
|
last_modified=appreciation.last_modified,
|
|
created_at=appreciation.created_at
|
|
)
|
|
|
|
|
|
@router.post("/classes/{class_id}/appreciations/{student_id}", response_model=AppreciationResponse)
|
|
async def create_or_update_appreciation(
|
|
class_id: int,
|
|
student_id: int,
|
|
trimester: int,
|
|
data: AppreciationCreate,
|
|
session: AsyncSessionDep,
|
|
):
|
|
"""
|
|
Crée ou met à jour l'appréciation d'un élève pour un trimestre.
|
|
"""
|
|
# Vérifier que la classe existe
|
|
class_query = select(ClassGroup).where(ClassGroup.id == class_id)
|
|
class_result = await session.execute(class_query)
|
|
cls = class_result.scalar_one_or_none()
|
|
|
|
if not cls:
|
|
raise HTTPException(status_code=404, detail="Classe non trouvée")
|
|
|
|
# Vérifier que l'élève existe
|
|
student_query = select(Student).where(Student.id == student_id)
|
|
student_result = await session.execute(student_query)
|
|
student = student_result.scalar_one_or_none()
|
|
|
|
if not student:
|
|
raise HTTPException(status_code=404, detail="Élève non trouvé")
|
|
|
|
# Chercher une appréciation existante
|
|
existing_query = select(CouncilAppreciation).where(
|
|
CouncilAppreciation.class_group_id == class_id,
|
|
CouncilAppreciation.student_id == student_id,
|
|
CouncilAppreciation.trimester == trimester
|
|
)
|
|
existing_result = await session.execute(existing_query)
|
|
appreciation = existing_result.scalar_one_or_none()
|
|
|
|
if appreciation:
|
|
# Mettre à jour l'existante
|
|
appreciation.general_appreciation = data.general_appreciation
|
|
appreciation.strengths = data.strengths
|
|
appreciation.areas_for_improvement = data.areas_for_improvement
|
|
appreciation.status = data.status
|
|
message = "Appréciation mise à jour"
|
|
else:
|
|
# Créer une nouvelle
|
|
appreciation = CouncilAppreciation(
|
|
student_id=student_id,
|
|
class_group_id=class_id,
|
|
trimester=trimester,
|
|
general_appreciation=data.general_appreciation,
|
|
strengths=data.strengths,
|
|
areas_for_improvement=data.areas_for_improvement,
|
|
status=data.status
|
|
)
|
|
session.add(appreciation)
|
|
message = "Appréciation créée"
|
|
|
|
await session.commit()
|
|
await session.refresh(appreciation)
|
|
|
|
return AppreciationResponse(
|
|
success=True,
|
|
message=message,
|
|
appreciation=AppreciationRead(
|
|
id=appreciation.id,
|
|
student_id=appreciation.student_id,
|
|
class_group_id=appreciation.class_group_id,
|
|
trimester=appreciation.trimester,
|
|
general_appreciation=appreciation.general_appreciation,
|
|
strengths=appreciation.strengths,
|
|
areas_for_improvement=appreciation.areas_for_improvement,
|
|
status=appreciation.status,
|
|
last_modified=appreciation.last_modified,
|
|
created_at=appreciation.created_at
|
|
)
|
|
)
|
|
|
|
|
|
@router.put("/classes/{class_id}/appreciations/{student_id}/finalize", response_model=AppreciationResponse)
|
|
async def finalize_appreciation(
|
|
class_id: int,
|
|
student_id: int,
|
|
trimester: int,
|
|
session: AsyncSessionDep,
|
|
):
|
|
"""
|
|
Finalise une appréciation (change le statut à 'finalized').
|
|
"""
|
|
query = select(CouncilAppreciation).where(
|
|
CouncilAppreciation.class_group_id == class_id,
|
|
CouncilAppreciation.student_id == student_id,
|
|
CouncilAppreciation.trimester == trimester
|
|
)
|
|
result = await session.execute(query)
|
|
appreciation = result.scalar_one_or_none()
|
|
|
|
if not appreciation:
|
|
raise HTTPException(status_code=404, detail="Appréciation non trouvée")
|
|
|
|
appreciation.status = "finalized"
|
|
await session.commit()
|
|
await session.refresh(appreciation)
|
|
|
|
return AppreciationResponse(
|
|
success=True,
|
|
message="Appréciation finalisée",
|
|
appreciation=AppreciationRead(
|
|
id=appreciation.id,
|
|
student_id=appreciation.student_id,
|
|
class_group_id=appreciation.class_group_id,
|
|
trimester=appreciation.trimester,
|
|
general_appreciation=appreciation.general_appreciation,
|
|
strengths=appreciation.strengths,
|
|
areas_for_improvement=appreciation.areas_for_improvement,
|
|
status=appreciation.status,
|
|
last_modified=appreciation.last_modified,
|
|
created_at=appreciation.created_at
|
|
)
|
|
)
|