Migration v1 (Flask) -> v2 (FastAPI + Vue.js) complétée

 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!
This commit is contained in:
2025-11-25 21:09:47 +01:00
parent 60c60c1605
commit 2b08eb534a
4125 changed files with 303 additions and 453271 deletions

View File

@@ -0,0 +1,439 @@
"""
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
)
)