feat(mail): restauration de l'envoie de mail
All checks were successful
Build and Publish Docker Images / Build Frontend Image (push) Successful in 2m56s
Build and Publish Docker Images / Build Backend Image (push) Successful in 3m5s
Build and Publish Docker Images / Build Summary (push) Successful in 3s

This commit is contained in:
2025-12-04 06:04:13 +01:00
parent 08c8ee4931
commit f76b033d55
11 changed files with 1189 additions and 184 deletions

View File

@@ -6,6 +6,7 @@ from typing import Optional, List
from datetime import date from datetime import date
from fastapi import APIRouter, HTTPException, Query from fastapi import APIRouter, HTTPException, Query
from fastapi.responses import HTMLResponse
from sqlalchemy import select, func from sqlalchemy import select, func
from sqlalchemy.orm import selectinload from sqlalchemy.orm import selectinload
@@ -392,6 +393,7 @@ async def get_assessment_results(
student_scores[student_id] = StudentScore( student_scores[student_id] = StudentScore(
student_id=student_id, student_id=student_id,
student_name=f"{student.last_name} {student.first_name}", student_name=f"{student.last_name} {student.first_name}",
email=student.email, # Ajouter l'email de l'élève
total_score=round(total_score, 2), total_score=round(total_score, 2),
total_max_points=counted_max, total_max_points=counted_max,
percentage=percentage, percentage=percentage,
@@ -1215,3 +1217,127 @@ async def send_reports(
results=results, results=results,
message=message message=message
) )
@router.get("/{assessment_id}/preview-report/{student_id}", response_class=HTMLResponse)
async def preview_report(
assessment_id: int,
student_id: int,
session: AsyncSessionDep,
):
"""
Génère un aperçu HTML du bilan d'un élève sans l'envoyer.
Utile pour vérifier le rendu du bilan avant l'envoi groupé.
Retourne le HTML brut qui serait envoyé par email, directement affichable dans le navigateur.
"""
# Récupérer l'évaluation avec toutes les relations nécessaires
assessment_query = (
select(Assessment)
.options(
selectinload(Assessment.class_group),
selectinload(Assessment.exercises).selectinload(Exercise.grading_elements).selectinload(GradingElement.grades),
selectinload(Assessment.exercises).selectinload(Exercise.grading_elements).selectinload(GradingElement.domain)
)
.where(Assessment.id == assessment_id)
)
assessment_result = await session.execute(assessment_query)
assessment = assessment_result.scalar_one_or_none()
if not assessment:
raise HTTPException(status_code=404, detail="Évaluation non trouvée")
# Récupérer l'élève
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é")
# Préparer les données pour le service de rapport (même logique que send_reports)
assessment_data = {
'title': assessment.title,
'description': assessment.description,
'date': assessment.date.isoformat() if assessment.date else '',
'trimester': assessment.trimester,
'class_name': assessment.class_group.name,
'coefficient': assessment.coefficient
}
# Préparer les données des exercices et éléments
exercises_data = []
all_students_grades = {} # {student_id: [grades]}
for exercise in sorted(assessment.exercises, key=lambda x: x.order):
elements_data = []
for element in exercise.grading_elements:
element_data = {
'id': element.id,
'exercise_id': exercise.id,
'label': element.label,
'description': element.description,
'skill': element.skill,
'domain_name': element.domain.name if element.domain else None,
'domain_color': element.domain.color if element.domain else None,
'grading_type': element.grading_type,
'max_points': element.max_points
}
elements_data.append(element_data)
# Collecter les notes
for grade in element.grades:
if grade.student_id not in all_students_grades:
all_students_grades[grade.student_id] = []
all_students_grades[grade.student_id].append({
'element_id': element.id,
'value': grade.value,
'comment': grade.comment
})
exercises_data.append({
'id': exercise.id,
'title': exercise.title,
'description': exercise.description,
'order': exercise.order,
'elements': elements_data
})
# Vérifier que l'élève a des notes pour cette évaluation
if student_id not in all_students_grades:
raise HTTPException(
status_code=400,
detail=f"L'élève {student.first_name} {student.last_name} n'a pas de notes pour cette évaluation"
)
# Créer le service de rapport
report_service = StudentReportService()
# Préparer les données de l'élève
student_data = {
'id': student.id,
'first_name': student.first_name,
'last_name': student.last_name,
'email': student.email
}
try:
# Générer le rapport
report_data = report_service.generate_student_report(
assessment_data,
student_data,
all_students_grades,
exercises_data
)
# Générer le HTML (sans message personnalisé pour la prévisualisation)
html_body = generate_report_html(report_data, "")
# Retourner directement le HTML pour affichage dans le navigateur
return HTMLResponse(content=html_body)
except Exception as e:
raise HTTPException(
status_code=500,
detail=f"Erreur lors de la génération du rapport: {str(e)}"
)

183
backend/debug_smtp_server.py Executable file
View File

@@ -0,0 +1,183 @@
#!/usr/bin/env python3
"""
Serveur SMTP de débogage pour Notytex
======================================
Serveur SMTP de développement qui affiche les emails en console au lieu de les envoyer.
Idéal pour tester l'envoi de bilans sans configuration SMTP réelle.
Usage:
python debug_smtp_server.py
Configuration dans l'interface web:
- Serveur SMTP: localhost
- Port: 1025
- Utilisateur/Mot de passe: (laisser vides)
- TLS: Désactivé
"""
import asyncio
import email
from email.policy import default
from datetime import datetime
from aiosmtpd.controller import Controller
from aiosmtpd.smtp import SMTP as SMTPServer
class DebugSMTPHandler:
"""Gestionnaire personnalisé pour afficher les emails en console."""
async def handle_DATA(self, server, session, envelope):
"""
Traite les emails reçus et les affiche en console.
Args:
server: Instance du serveur SMTP
session: Session SMTP courante
envelope: Envelope contenant les données de l'email
Returns:
str: Code de statut SMTP
"""
print("\n" + "=" * 80)
print("📧 NOUVEL EMAIL REÇU")
print("=" * 80)
# Informations de l'envelope
print(f"\n🔹 De: {envelope.mail_from}")
print(f"🔹 À: {', '.join(envelope.rcpt_tos)}")
print(f"🔹 Timestamp: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
# Parse le contenu de l'email
try:
msg = email.message_from_bytes(envelope.content, policy=default)
# En-têtes principaux
print(f"\n📋 HEADERS:")
print(f" Subject: {msg.get('Subject', '(sans sujet)')}")
print(f" From: {msg.get('From', '(inconnu)')}")
print(f" To: {msg.get('To', '(inconnu)')}")
print(f" Date: {msg.get('Date', '(inconnue)')}")
# Parcourir les parties du message
if msg.is_multipart():
print(f"\n📦 MESSAGE MULTIPART ({len(msg.get_payload())} parties)")
for idx, part in enumerate(msg.iter_parts(), 1):
content_type = part.get_content_type()
print(f"\n Partie {idx}: {content_type}")
# Afficher le contenu texte
if content_type == 'text/plain':
text_content = part.get_content()
print(f"\n 📄 CONTENU TEXTE:")
print(" " + "-" * 76)
for line in text_content.split('\n')[:20]: # Limiter à 20 lignes
print(f" {line}")
if len(text_content.split('\n')) > 20:
print(f" ... ({len(text_content.split('\n')) - 20} lignes supplémentaires)")
print(" " + "-" * 76)
# Afficher un aperçu du contenu HTML
elif content_type == 'text/html':
html_content = part.get_content()
print(f"\n 🎨 CONTENU HTML ({len(html_content)} caractères)")
print(" " + "-" * 76)
# Extraire et afficher le titre si présent
if '<title>' in html_content:
import re
title_match = re.search(r'<title>(.*?)</title>', html_content, re.IGNORECASE)
if title_match:
print(f" Titre: {title_match.group(1)}")
# Afficher un extrait du HTML
lines = html_content.split('\n')
preview_lines = [l.strip() for l in lines if l.strip() and not l.strip().startswith('<!')][:10]
for line in preview_lines:
print(f" {line[:100]}")
print(f" ... (aperçu du HTML)")
print(" " + "-" * 76)
# Sauvegarder le HTML pour consultation
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
filename = f"debug_email_{timestamp}.html"
try:
with open(filename, 'w', encoding='utf-8') as f:
f.write(html_content)
print(f"\n 💾 HTML sauvegardé dans: {filename}")
except Exception as e:
print(f"\n ⚠️ Erreur sauvegarde HTML: {e}")
else:
# Message simple (non multipart)
content = msg.get_content()
print(f"\n📄 CONTENU:")
print("-" * 80)
print(content[:1000]) # Limiter à 1000 caractères
if len(content) > 1000:
print(f"... ({len(content) - 1000} caractères supplémentaires)")
print("-" * 80)
except Exception as e:
print(f"\n❌ ERREUR lors du parsing de l'email: {e}")
print("\n📋 CONTENU BRUT:")
print("-" * 80)
print(envelope.content.decode('utf-8', errors='replace')[:2000])
print("-" * 80)
print("\n" + "=" * 80)
print("✅ Email traité avec succès")
print("=" * 80 + "\n")
return '250 Message accepted for delivery'
async def run_server():
"""Lance le serveur SMTP et attend indéfiniment."""
handler = DebugSMTPHandler()
controller = Controller(handler, hostname='localhost', port=1025)
controller.start()
print("✅ Serveur démarré avec succès!")
print("📬 En attente d'emails... (Ctrl+C pour arrêter)\n")
try:
# Attendre indéfiniment
while True:
await asyncio.sleep(3600)
finally:
controller.stop()
def main():
"""Lance le serveur SMTP de débogage."""
print("\n" + "=" * 80)
print("🚀 SERVEUR SMTP DE DÉBOGAGE NOTYTEX")
print("=" * 80)
print("\nConfiguration recommandée dans l'interface web:")
print(" • Serveur SMTP: localhost")
print(" • Port: 1025")
print(" • Utilisateur: (laisser vide)")
print(" • Mot de passe: (laisser vide)")
print(" • TLS: Désactivé")
print("\n" + "-" * 80)
print("📡 Démarrage du serveur sur localhost:1025...")
print("-" * 80 + "\n")
try:
asyncio.run(run_server())
except KeyboardInterrupt:
print("\n\n" + "=" * 80)
print("🛑 Arrêt du serveur...")
print("=" * 80)
print("✅ Serveur arrêté proprement\n")
except Exception as e:
print(f"\n❌ ERREUR: {e}")
raise
if __name__ == "__main__":
main()

View File

@@ -416,9 +416,10 @@ class StudentReportService:
} }
def generate_report_html(report_data: Dict[str, Any], message: str = "") -> str: def generate_report_html(report_data: Dict[str, Any], message: str = "") -> str:
""" """
Génère le HTML du rapport d'élève. Génère le HTML du rapport d'élève en utilisant le template Jinja2.
Args: Args:
report_data: Données du rapport report_data: Données du rapport
@@ -427,169 +428,44 @@ def generate_report_html(report_data: Dict[str, Any], message: str = "") -> str:
Returns: Returns:
HTML du rapport HTML du rapport
""" """
student = report_data['student'] from jinja2 import Environment, FileSystemLoader, select_autoescape
assessment = report_data['assessment'] from pathlib import Path
# Déterminer le chemin du template
template_dir = Path(__file__).parent.parent.parent / "templates" / "email"
# Initialiser Jinja2
env = Environment(
loader=FileSystemLoader(str(template_dir)),
autoescape=select_autoescape(['html', 'xml'])
)
template = env.get_template('student_report.html')
# Déterminer la couleur du score basée sur le pourcentage
results = report_data['results'] results = report_data['results']
stats = report_data['class_statistics']
# Couleur basée sur le pourcentage
percentage = results['percentage'] percentage = results['percentage']
if percentage >= 80: if percentage >= 80:
color = "#22c55e" # vert score_color = "#22c55e" # vert
elif percentage >= 60: elif percentage >= 60:
color = "#f6d32d" # jaune score_color = "#f6d32d" # jaune
elif percentage >= 40: elif percentage >= 40:
color = "#f97316" # orange score_color = "#f97316" # orange
else: else:
color = "#ef4444" # rouge score_color = "#ef4444" # rouge
html = f""" # Rendre le template avec les données
<html> html = template.render(
<head> assessment=report_data['assessment'],
<meta charset="utf-8"> student=report_data['student'],
<style> results=report_data['results'],
body {{ class_statistics=report_data['class_statistics'],
font-family: Arial, sans-serif; exercises=report_data['exercises'],
padding: 20px; competences=report_data.get('competences', []),
color: #333; domains=report_data.get('domains', []),
max-width: 800px; custom_message=message,
margin: 0 auto; score_color=score_color
}} )
.header {{
background: linear-gradient(135deg, #3b82f6, #8b5cf6);
color: white;
padding: 20px;
border-radius: 8px;
margin-bottom: 20px;
}}
.score-box {{
background: {color};
color: white;
padding: 15px;
border-radius: 8px;
text-align: center;
font-size: 24px;
font-weight: bold;
margin: 20px 0;
}}
.stats-grid {{
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 10px;
margin: 20px 0;
}}
.stat-item {{
background: #f3f4f6;
padding: 10px;
border-radius: 4px;
text-align: center;
}}
.stat-label {{
font-size: 12px;
color: #6b7280;
}}
.stat-value {{
font-size: 18px;
font-weight: bold;
}}
.exercise {{
border: 1px solid #e5e7eb;
border-radius: 8px;
padding: 15px;
margin: 10px 0;
}}
.exercise-header {{
font-weight: bold;
margin-bottom: 10px;
}}
.element {{
padding: 5px 0;
border-bottom: 1px solid #f3f4f6;
}}
.footer {{
margin-top: 30px;
padding-top: 20px;
border-top: 1px solid #e5e7eb;
font-size: 12px;
color: #6b7280;
}}
</style>
</head>
<body>
<div class="header">
<h1>{assessment['title']}</h1>
<p>{assessment['class_name']} - Trimestre {assessment['trimester']}</p>
<p>Date: {assessment['date']}</p>
</div>
<h2>Bonjour {student['first_name']},</h2>
<p>Voici votre bilan pour l'évaluation <strong>{assessment['title']}</strong>.</p>
<div class="score-box">
{results['total_score']}/{results['total_max_points']} ({results['percentage']}%)
</div>
<p style="text-align: center; color: #6b7280;">
Position: {results['position']}/{results['total_students']} élèves
</p>
<h3>Statistiques de la classe</h3>
<div class="stats-grid">
<div class="stat-item">
<div class="stat-label">Moyenne</div>
<div class="stat-value">{stats['mean']}</div>
</div>
<div class="stat-item">
<div class="stat-label">Médiane</div>
<div class="stat-value">{stats['median']}</div>
</div>
<div class="stat-item">
<div class="stat-label">Écart-type</div>
<div class="stat-value">{stats['std_dev']}</div>
</div>
</div>
<h3>Détail par exercice</h3>
"""
# Ajouter les exercices
for exercise in report_data['exercises']:
html += f"""
<div class="exercise">
<div class="exercise-header">
{exercise['title']} - {exercise['score']}/{exercise['max_points']} ({exercise['percentage']}%)
</div>
"""
for element in exercise['elements']:
value_display = element.get('raw_value', '-') or '-'
if element.get('score_label'):
value_display = f"{value_display} ({element['score_label']})"
html += f"""
<div class="element">
<strong>{element['label']}</strong>: {value_display}
</div>
"""
html += "</div>"
# Message personnalisé
if message:
html += f"""
<div style="background: #dbeafe; padding: 15px; border-radius: 8px; margin: 20px 0;">
<strong>Message du professeur:</strong><br>
{message}
</div>
"""
html += """
<div class="footer">
<p>Email généré automatiquement par Notytex - Système de gestion scolaire</p>
</div>
</body>
</html>
"""
return html return html

View File

@@ -20,6 +20,8 @@ dependencies = [
"python-dotenv>=1.0.1", "python-dotenv>=1.0.1",
"httpx>=0.28.0", "httpx>=0.28.0",
"email-validator>=2.2.0", "email-validator>=2.2.0",
"jinja2>=3.1.0",
"aiosmtpd>=1.4.4",
] ]
[project.optional-dependencies] [project.optional-dependencies]

View File

@@ -97,6 +97,7 @@ class StudentScore(BaseSchema):
student_id: int student_id: int
student_name: str student_name: str
email: Optional[str] = None # Email de l'élève pour l'envoi des bilans
total_score: float total_score: float
total_max_points: float total_max_points: float
percentage: float percentage: float
@@ -213,7 +214,6 @@ class SendReportsRequest(BaseSchema):
student_ids: List[int] = Field(..., min_length=1, description="Liste des IDs d'élèves") student_ids: List[int] = Field(..., min_length=1, description="Liste des IDs d'élèves")
custom_message: Optional[str] = Field(None, description="Message personnalisé du professeur") custom_message: Optional[str] = Field(None, description="Message personnalisé du professeur")
include_class_stats: bool = Field(True, description="Inclure les statistiques de classe")
class SendReportResult(BaseSchema): class SendReportResult(BaseSchema):

View File

@@ -0,0 +1,251 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<title>Bilan d'évaluation - {{ assessment.title }}</title>
</head>
<body style="margin: 0; padding: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; line-height: 1.6; color: #333333; background-color: #f5f5f5;">
<table width="100%" cellpadding="0" cellspacing="0" border="0" style="background-color: #f5f5f5; padding: 20px 0;">
<tr>
<td align="center">
<!-- Container principal -->
<table width="600" cellpadding="0" cellspacing="0" border="0" style="max-width: 600px; background-color: #ffffff; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1);">
<!-- Header avec gradient -->
<tr>
<td style="background: linear-gradient(135deg, #3b82f6 0%, #8b5cf6 100%); color: #ffffff; padding: 30px 20px; border-radius: 8px 8px 0 0;">
<h1 style="margin: 0 0 10px 0; font-size: 24px; font-weight: bold;">{{ assessment.title }}</h1>
<p style="margin: 0; font-size: 14px; opacity: 0.9;">
{{ assessment.class_name }} - Trimestre {{ assessment.trimester }}
</p>
<p style="margin: 5px 0 0 0; font-size: 13px; opacity: 0.8;">
📅 Date : {{ assessment.date }}
</p>
</td>
</tr>
<!-- Content -->
<tr>
<td style="padding: 30px 20px;">
<!-- Greeting -->
<p style="margin: 0 0 15px 0; font-size: 16px;">
Bonjour <strong>{{ student.first_name }}</strong>,
</p>
<p style="margin: 0 0 20px 0; font-size: 15px; color: #6b7280;">
Voici votre bilan pour l'évaluation <strong>{{ assessment.title }}</strong>.
</p>
<!-- Score global -->
<table width="100%" cellpadding="0" cellspacing="0" border="0" style="margin: 20px 0 30px 0;">
<tr>
<td align="center" style="background-color: #f3f4f6; color: #1f2937; padding: 25px; border-radius: 8px; border: 2px solid #e5e7eb;">
<div style="font-size: 32px; font-weight: bold; margin-bottom: 10px; color: #1f2937;">
{{ results.total_score }}/{{ results.total_max_points }}
</div>
<!-- Barre de progression -->
<table width="80%" cellpadding="0" cellspacing="0" border="0" style="margin: 0 auto;">
<tr>
<td style="background-color: #d1d5db; border-radius: 10px; height: 12px; overflow: hidden; padding: 0;">
<table width="{{ results.percentage }}%" cellpadding="0" cellspacing="0" border="0" style="height: 12px;">
<tr>
<td style="background-color: #3b82f6; height: 12px; padding: 0;"></td>
</tr>
</table>
</td>
</tr>
</table>
<div style="font-size: 16px; color: #6b7280; margin-top: 8px;">
{{ results.percentage }}%
</div>
</td>
</tr>
</table>
<!-- Détail par exercice -->
<h2 style="font-size: 18px; font-weight: bold; margin: 25px 0 15px 0; color: #1f2937;">
📝 Détail par exercice
</h2>
{% for exercise in exercises %}
<table width="100%" cellpadding="0" cellspacing="0" border="0" style="margin-bottom: 15px; border: 1px solid #e5e7eb; border-radius: 8px; overflow: hidden;">
<tr>
<td style="background-color: #f9fafb; padding: 12px 15px; font-weight: bold; font-size: 15px; color: #1f2937;">
{{ exercise.title }}
<span style="float: right; color: #3b82f6;">
{{ "%.1f"|format(exercise.score) }}/{{ exercise.max_points }} ({{ "%.0f"|format(exercise.percentage) }}%)
</span>
</td>
</tr>
{% for element in exercise.elements %}
<tr>
<td style="padding: 10px 15px; border-top: 1px solid #f3f4f6;">
<table width="100%" cellpadding="0" cellspacing="0" border="0">
<tr>
<td style="vertical-align: middle;">
<strong style="font-size: 14px; color: #374151;">{{ element.label }}</strong>
{% if element.skill %}
<span style="font-size: 12px; color: #6b7280;"> - {{ element.skill }}</span>
{% endif %}
{% if element.description %}
<div style="font-size: 12px; color: #9ca3af; margin-top: 3px;">{{ element.description }}</div>
{% endif %}
</td>
<td align="right" style="vertical-align: middle; white-space: nowrap;">
{% if element.grading_type == 'score' %}
<!-- Affichage étoiles pour compétences -->
<span style="font-size: 18px; color: #fbbf24;">
{% if element.raw_value %}
{% for i in range(3) %}
{% if i < element.raw_value|int %}{% else %}{% endif %}
{% endfor %}
{% else %}
{% endif %}
</span>
{% if element.score_label %}
<div style="font-size: 11px; color: #6b7280; margin-top: 3px;">{{ element.score_label }}</div>
{% endif %}
{% else %}
<!-- Affichage points pour notes -->
<strong style="font-size: 15px; color: #1f2937;">
{% if element.calculated_score is not none %}
{{ "%.1f"|format(element.calculated_score) }}/{{ element.max_points }}
{% else %}
-/{{ element.max_points }}
{% endif %}
</strong>
{% endif %}
</td>
</tr>
</table>
</td>
</tr>
{% endfor %}
</table>
{% endfor %}
<!-- Résultats par compétences -->
{% if competences and competences|length > 0 %}
<h2 style="font-size: 18px; font-weight: bold; margin: 25px 0 15px 0; color: #1f2937;">
⭐ Compétences travaillées
</h2>
<table width="100%" cellpadding="0" cellspacing="0" border="0" style="margin-bottom: 20px;">
{% for comp in competences %}
<tr>
<td style="padding: 10px 0; border-bottom: 1px solid #e5e7eb;">
<table width="100%" cellpadding="0" cellspacing="0" border="0">
<tr>
<td colspan="2" style="font-weight: 500; font-size: 14px; color: #374151; padding-bottom: 8px;">
{{ comp.name }}
</td>
</tr>
<tr>
<td style="width: 70%; vertical-align: middle;">
<!-- Barre de progression -->
<table width="100%" cellpadding="0" cellspacing="0" border="0">
<tr>
<td style="background-color: #e5e7eb; border-radius: 8px; height: 10px; overflow: hidden; padding: 0;">
<table width="{{ comp.percentage }}%" cellpadding="0" cellspacing="0" border="0" style="height: 10px;">
<tr>
<td style="background-color: #3b82f6; height: 10px; padding: 0;"></td>
</tr>
</table>
</td>
</tr>
</table>
</td>
<td align="right" style="font-size: 14px; color: #6b7280; padding-left: 12px; white-space: nowrap;">
{{ "%.1f"|format(comp.score) }}/{{ comp.max_points }}
<span style="color: #3b82f6; font-weight: 500;">({{ "%.0f"|format(comp.percentage) }}%)</span>
</td>
</tr>
</table>
</td>
</tr>
{% endfor %}
</table>
{% endif %}
<!-- Résultats par domaines -->
{% if domains and domains|length > 0 %}
<h2 style="font-size: 18px; font-weight: bold; margin: 25px 0 15px 0; color: #1f2937;">
🏷️ Domaines travaillés
</h2>
<table width="100%" cellpadding="0" cellspacing="0" border="0" style="margin-bottom: 20px;">
{% for domain in domains %}
<tr>
<td style="padding: 10px 0; border-bottom: 1px solid #e5e7eb;">
<table width="100%" cellpadding="0" cellspacing="0" border="0">
<tr>
<td colspan="2" style="padding-bottom: 8px; vertical-align: middle;">
<span style="display: inline-block; width: 12px; height: 12px; border-radius: 3px; background-color: {{ domain.color }}; margin-right: 8px; vertical-align: middle;"></span>
<span style="font-weight: 500; font-size: 14px; color: #374151;">{{ domain.name }}</span>
</td>
</tr>
<tr>
<td style="width: 70%; vertical-align: middle;">
<!-- Barre de progression -->
<table width="100%" cellpadding="0" cellspacing="0" border="0">
<tr>
<td style="background-color: #e5e7eb; border-radius: 8px; height: 10px; overflow: hidden; padding: 0;">
<table width="{{ domain.percentage }}%" cellpadding="0" cellspacing="0" border="0" style="height: 10px;">
<tr>
<td style="background-color: #3b82f6; height: 10px; padding: 0;"></td>
</tr>
</table>
</td>
</tr>
</table>
</td>
<td align="right" style="font-size: 14px; color: #6b7280; padding-left: 12px; white-space: nowrap;">
{{ "%.1f"|format(domain.score) }}/{{ domain.max_points }}
<span style="color: #3b82f6; font-weight: 500;">({{ "%.0f"|format(domain.percentage) }}%)</span>
</td>
</tr>
</table>
</td>
</tr>
{% endfor %}
</table>
{% endif %}
<!-- Message personnalisé du professeur -->
{% if custom_message %}
<table width="100%" cellpadding="0" cellspacing="0" border="0" style="margin: 25px 0;">
<tr>
<td style="background-color: #dbeafe; padding: 15px; border-radius: 8px; border-left: 4px solid #3b82f6;">
<p style="margin: 0 0 8px 0; font-weight: bold; font-size: 14px; color: #1e40af;">
💬 Message du professeur
</p>
<p style="margin: 0; font-size: 14px; color: #1f2937; white-space: pre-wrap;">{{ custom_message }}</p>
</td>
</tr>
</table>
{% endif %}
</td>
</tr>
<!-- Footer -->
<tr>
<td style="background-color: #f9fafb; padding: 20px; border-top: 2px solid #e5e7eb; border-radius: 0 0 8px 8px;">
<p style="margin: 0; font-size: 12px; color: #6b7280; text-align: center;">
Email généré automatiquement par <strong>Notytex</strong>
</p>
<p style="margin: 5px 0 0 0; font-size: 11px; color: #9ca3af; text-align: center;">
Système de gestion scolaire
</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>

View File

@@ -292,9 +292,15 @@ class TestGenerateReportHTML:
'elements': [ 'elements': [
{ {
'label': 'Q1', 'label': 'Q1',
'description': '',
'skill': '',
'domain': '',
'raw_value': '4', 'raw_value': '4',
'calculated_score': 4.0,
'max_points': 5.0,
'grading_type': 'notes',
'score_label': '', 'score_label': '',
'max_points': 5.0 'comment': ''
} }
] ]
} }
@@ -329,10 +335,11 @@ class TestGenerateReportHTML:
assert '75' in html # percentage assert '75' in html # percentage
def test_generate_html_contains_statistics(self, sample_report_data): def test_generate_html_contains_statistics(self, sample_report_data):
"""Le HTML contient les statistiques.""" """Le HTML ne contient plus les statistiques de classe (supprimées)."""
html = generate_report_html(sample_report_data) html = generate_report_html(sample_report_data)
assert '12.5' in html # mean # Les statistiques de classe ont été supprimées du template
assert '13.0' in html or '13' in html # median assert 'Statistiques de la classe' not in html
assert 'Position :' not in html
def test_generate_html_with_message(self, sample_report_data): def test_generate_html_with_message(self, sample_report_data):
"""Le HTML inclut le message personnalisé.""" """Le HTML inclut le message personnalisé."""
@@ -340,8 +347,8 @@ class TestGenerateReportHTML:
html = generate_report_html(sample_report_data, message) html = generate_report_html(sample_report_data, message)
assert message in html assert message in html
def test_generate_html_color_code_green(self): def test_generate_html_no_dynamic_color(self):
"""Couleur verte pour les bons scores (>= 80%).""" """Le HTML n'a plus de couleur dynamique basée sur le score."""
report_data = { report_data = {
'assessment': {'title': 'Test', 'date': '2025-01-01', 'trimester': 1, 'class_name': 'Test', 'coefficient': 1}, 'assessment': {'title': 'Test', 'date': '2025-01-01', 'trimester': 1, 'class_name': 'Test', 'coefficient': 1},
'student': {'full_name': 'Test', 'first_name': 'Test', 'last_name': 'Test', 'email': 'test@test.com'}, 'student': {'full_name': 'Test', 'first_name': 'Test', 'last_name': 'Test', 'email': 'test@test.com'},
@@ -352,18 +359,9 @@ class TestGenerateReportHTML:
'class_statistics': {'count': 10, 'mean': 12, 'median': 12, 'min': 5, 'max': 18, 'std_dev': 2} 'class_statistics': {'count': 10, 'mean': 12, 'median': 12, 'min': 5, 'max': 18, 'std_dev': 2}
} }
html = generate_report_html(report_data) html = generate_report_html(report_data)
assert '#22c55e' in html # green # Les couleurs dynamiques (vert/jaune/rouge) ont été supprimées
assert '#22c55e' not in html # pas de vert
def test_generate_html_color_code_red(self): assert '#f6d32d' not in html # pas de jaune
"""Couleur rouge pour les mauvais scores (< 40%).""" assert '#ef4444' not in html # pas de rouge
report_data = { # Le score utilise maintenant une couleur neutre
'assessment': {'title': 'Test', 'date': '2025-01-01', 'trimester': 1, 'class_name': 'Test', 'coefficient': 1}, assert 'background-color: #f3f4f6' in html # gris neutre
'student': {'full_name': 'Test', 'first_name': 'Test', 'last_name': 'Test', 'email': 'test@test.com'},
'results': {'total_score': 5, 'total_max_points': 20, 'percentage': 25.0, 'position': 10, 'total_students': 10},
'exercises': [],
'competences': [],
'domains': [],
'class_statistics': {'count': 10, 'mean': 12, 'median': 12, 'min': 5, 'max': 18, 'std_dev': 2}
}
html = generate_report_html(report_data)
assert '#ef4444' in html # red

99
backend/uv.lock generated
View File

@@ -2,6 +2,19 @@ version = 1
revision = 3 revision = 3
requires-python = ">=3.11, <3.14" requires-python = ">=3.11, <3.14"
[[package]]
name = "aiosmtpd"
version = "1.4.6"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "atpublic" },
{ name = "attrs" },
]
sdist = { url = "https://files.pythonhosted.org/packages/c4/ca/b2b7cc880403ef24be77383edaadfcf0098f5d7b9ddbf3e2c17ef0a6af0d/aiosmtpd-1.4.6.tar.gz", hash = "sha256:5a811826e1a5a06c25ebc3e6c4a704613eb9a1bcf6b78428fbe865f4f6c9a4b8", size = 152775, upload-time = "2024-05-18T11:37:50.029Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ec/39/d401756df60a8344848477d54fdf4ce0f50531f6149f3b8eaae9c06ae3dc/aiosmtpd-1.4.6-py3-none-any.whl", hash = "sha256:72c99179ba5aa9ae0abbda6994668239b64a5ce054471955fe75f581d2592475", size = 154263, upload-time = "2024-05-18T11:37:47.877Z" },
]
[[package]] [[package]]
name = "aiosqlite" name = "aiosqlite"
version = "0.21.0" version = "0.21.0"
@@ -46,6 +59,24 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/15/b3/9b1a8074496371342ec1e796a96f99c82c945a339cd81a8e73de28b4cf9e/anyio-4.11.0-py3-none-any.whl", hash = "sha256:0287e96f4d26d4149305414d4e3bc32f0dcd0862365a4bddea19d7a1ec38c4fc", size = 109097, upload-time = "2025-09-23T09:19:10.601Z" }, { url = "https://files.pythonhosted.org/packages/15/b3/9b1a8074496371342ec1e796a96f99c82c945a339cd81a8e73de28b4cf9e/anyio-4.11.0-py3-none-any.whl", hash = "sha256:0287e96f4d26d4149305414d4e3bc32f0dcd0862365a4bddea19d7a1ec38c4fc", size = 109097, upload-time = "2025-09-23T09:19:10.601Z" },
] ]
[[package]]
name = "atpublic"
version = "7.0.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/a9/05/e2e131a0debaf0f01b8a1b586f5f11713f6affc3e711b406f15f11eafc92/atpublic-7.0.0.tar.gz", hash = "sha256:466ef10d0c8bbd14fd02a5fbd5a8b6af6a846373d91106d3a07c16d72d96b63e", size = 17801, upload-time = "2025-11-29T05:56:45.45Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/96/c0/271f3e1e3502a8decb8ee5c680dbed2d8dc2cd504f5e20f7ed491d5f37e1/atpublic-7.0.0-py3-none-any.whl", hash = "sha256:6702bd9e7245eb4e8220a3e222afcef7f87412154732271ee7deee4433b72b4b", size = 6421, upload-time = "2025-11-29T05:56:44.604Z" },
]
[[package]]
name = "attrs"
version = "25.4.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/6b/5c/685e6633917e101e5dcb62b9dd76946cbb57c26e133bae9e0cd36033c0a9/attrs-25.4.0.tar.gz", hash = "sha256:16d5969b87f0859ef33a48b35d55ac1be6e42ae49d5e853b597db70c35c57e11", size = 934251, upload-time = "2025-10-06T13:54:44.725Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/3a/2a/7cc015f5b9f5db42b7d48157e23356022889fc354a2813c15934b7cb5c0e/attrs-25.4.0-py3-none-any.whl", hash = "sha256:adcf7e2a1fb3b36ac48d97835bb6d8ade15b8dcce26aba8bf1d14847b57a3373", size = 67615, upload-time = "2025-10-06T13:54:43.17Z" },
]
[[package]] [[package]]
name = "black" name = "black"
version = "25.11.0" version = "25.11.0"
@@ -333,6 +364,70 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
] ]
[[package]]
name = "jinja2"
version = "3.1.6"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "markupsafe" },
]
sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" },
]
[[package]]
name = "markupsafe"
version = "3.0.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/08/db/fefacb2136439fc8dd20e797950e749aa1f4997ed584c62cfb8ef7c2be0e/markupsafe-3.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cc7ea17a6824959616c525620e387f6dd30fec8cb44f649e31712db02123dad", size = 11631, upload-time = "2025-09-27T18:36:18.185Z" },
{ url = "https://files.pythonhosted.org/packages/e1/2e/5898933336b61975ce9dc04decbc0a7f2fee78c30353c5efba7f2d6ff27a/markupsafe-3.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4bd4cd07944443f5a265608cc6aab442e4f74dff8088b0dfc8238647b8f6ae9a", size = 12058, upload-time = "2025-09-27T18:36:19.444Z" },
{ url = "https://files.pythonhosted.org/packages/1d/09/adf2df3699d87d1d8184038df46a9c80d78c0148492323f4693df54e17bb/markupsafe-3.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b5420a1d9450023228968e7e6a9ce57f65d148ab56d2313fcd589eee96a7a50", size = 24287, upload-time = "2025-09-27T18:36:20.768Z" },
{ url = "https://files.pythonhosted.org/packages/30/ac/0273f6fcb5f42e314c6d8cd99effae6a5354604d461b8d392b5ec9530a54/markupsafe-3.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0bf2a864d67e76e5c9a34dc26ec616a66b9888e25e7b9460e1c76d3293bd9dbf", size = 22940, upload-time = "2025-09-27T18:36:22.249Z" },
{ url = "https://files.pythonhosted.org/packages/19/ae/31c1be199ef767124c042c6c3e904da327a2f7f0cd63a0337e1eca2967a8/markupsafe-3.0.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc51efed119bc9cfdf792cdeaa4d67e8f6fcccab66ed4bfdd6bde3e59bfcbb2f", size = 21887, upload-time = "2025-09-27T18:36:23.535Z" },
{ url = "https://files.pythonhosted.org/packages/b2/76/7edcab99d5349a4532a459e1fe64f0b0467a3365056ae550d3bcf3f79e1e/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:068f375c472b3e7acbe2d5318dea141359e6900156b5b2ba06a30b169086b91a", size = 23692, upload-time = "2025-09-27T18:36:24.823Z" },
{ url = "https://files.pythonhosted.org/packages/a4/28/6e74cdd26d7514849143d69f0bf2399f929c37dc2b31e6829fd2045b2765/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7be7b61bb172e1ed687f1754f8e7484f1c8019780f6f6b0786e76bb01c2ae115", size = 21471, upload-time = "2025-09-27T18:36:25.95Z" },
{ url = "https://files.pythonhosted.org/packages/62/7e/a145f36a5c2945673e590850a6f8014318d5577ed7e5920a4b3448e0865d/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f9e130248f4462aaa8e2552d547f36ddadbeaa573879158d721bbd33dfe4743a", size = 22923, upload-time = "2025-09-27T18:36:27.109Z" },
{ url = "https://files.pythonhosted.org/packages/0f/62/d9c46a7f5c9adbeeeda52f5b8d802e1094e9717705a645efc71b0913a0a8/markupsafe-3.0.3-cp311-cp311-win32.whl", hash = "sha256:0db14f5dafddbb6d9208827849fad01f1a2609380add406671a26386cdf15a19", size = 14572, upload-time = "2025-09-27T18:36:28.045Z" },
{ url = "https://files.pythonhosted.org/packages/83/8a/4414c03d3f891739326e1783338e48fb49781cc915b2e0ee052aa490d586/markupsafe-3.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:de8a88e63464af587c950061a5e6a67d3632e36df62b986892331d4620a35c01", size = 15077, upload-time = "2025-09-27T18:36:29.025Z" },
{ url = "https://files.pythonhosted.org/packages/35/73/893072b42e6862f319b5207adc9ae06070f095b358655f077f69a35601f0/markupsafe-3.0.3-cp311-cp311-win_arm64.whl", hash = "sha256:3b562dd9e9ea93f13d53989d23a7e775fdfd1066c33494ff43f5418bc8c58a5c", size = 13876, upload-time = "2025-09-27T18:36:29.954Z" },
{ url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" },
{ url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" },
{ url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" },
{ url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947, upload-time = "2025-09-27T18:36:33.86Z" },
{ url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962, upload-time = "2025-09-27T18:36:35.099Z" },
{ url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760, upload-time = "2025-09-27T18:36:36.001Z" },
{ url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529, upload-time = "2025-09-27T18:36:36.906Z" },
{ url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015, upload-time = "2025-09-27T18:36:37.868Z" },
{ url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload-time = "2025-09-27T18:36:38.761Z" },
{ url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload-time = "2025-09-27T18:36:39.701Z" },
{ url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" },
{ url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" },
{ url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" },
{ url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" },
{ url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" },
{ url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" },
{ url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" },
{ url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" },
{ url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" },
{ url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" },
{ url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" },
{ url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" },
{ url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" },
{ url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" },
{ url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" },
{ url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" },
{ url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" },
{ url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" },
{ url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" },
{ url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" },
{ url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" },
{ url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" },
{ url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" },
]
[[package]] [[package]]
name = "mypy" name = "mypy"
version = "1.18.2" version = "1.18.2"
@@ -379,10 +474,12 @@ name = "notytex-backend"
version = "2.0.0" version = "2.0.0"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "aiosmtpd" },
{ name = "aiosqlite" }, { name = "aiosqlite" },
{ name = "email-validator" }, { name = "email-validator" },
{ name = "fastapi" }, { name = "fastapi" },
{ name = "httpx" }, { name = "httpx" },
{ name = "jinja2" },
{ name = "pydantic" }, { name = "pydantic" },
{ name = "pydantic-settings" }, { name = "pydantic-settings" },
{ name = "python-dotenv" }, { name = "python-dotenv" },
@@ -404,12 +501,14 @@ dev = [
[package.metadata] [package.metadata]
requires-dist = [ requires-dist = [
{ name = "aiosmtpd", specifier = ">=1.4.4" },
{ name = "aiosqlite", specifier = ">=0.20.0" }, { name = "aiosqlite", specifier = ">=0.20.0" },
{ name = "black", marker = "extra == 'dev'", specifier = ">=24.11.0" }, { name = "black", marker = "extra == 'dev'", specifier = ">=24.11.0" },
{ name = "email-validator", specifier = ">=2.2.0" }, { name = "email-validator", specifier = ">=2.2.0" },
{ name = "fastapi", specifier = ">=0.115.0" }, { name = "fastapi", specifier = ">=0.115.0" },
{ name = "httpx", specifier = ">=0.28.0" }, { name = "httpx", specifier = ">=0.28.0" },
{ name = "httpx", marker = "extra == 'dev'", specifier = ">=0.28.0" }, { name = "httpx", marker = "extra == 'dev'", specifier = ">=0.28.0" },
{ name = "jinja2", specifier = ">=3.1.0" },
{ name = "mypy", marker = "extra == 'dev'", specifier = ">=1.13.0" }, { name = "mypy", marker = "extra == 'dev'", specifier = ">=1.13.0" },
{ name = "pydantic", specifier = ">=2.10.0" }, { name = "pydantic", specifier = ">=2.10.0" },
{ name = "pydantic-settings", specifier = ">=2.6.0" }, { name = "pydantic-settings", specifier = ">=2.6.0" },

View File

@@ -0,0 +1,267 @@
<template>
<Modal :model-value="true" @close="handleClose" size="xl">
<template #header>
<h3 class="text-xl font-semibold text-gray-900">
📧 Envoyer les bilans par email
</h3>
</template>
<!-- Phase 1: Configuration de l'envoi -->
<div v-if="!sending && !results" class="space-y-6">
<!-- Liste des élèves sélectionnés -->
<div>
<h4 class="font-medium text-gray-900 mb-3">
{{ selectedStudents.length }} élève(s) sélectionné(s)
</h4>
<div class="max-h-40 overflow-y-auto bg-gray-50 rounded-lg p-4 space-y-2">
<div
v-for="student in selectedStudents"
:key="student.id"
class="flex items-center justify-between text-sm"
>
<span class="font-medium text-gray-700">{{ student.name }}</span>
<span class="text-gray-500">{{ student.email }}</span>
</div>
</div>
</div>
<!-- Message personnalisé -->
<div>
<label for="custom-message" class="label">
Message personnalisé (optionnel)
</label>
<textarea
id="custom-message"
v-model="customMessage"
rows="4"
placeholder="Ajoutez un commentaire pour vos élèves...&#10;Exemple: Bon travail cette semaine ! Continuez vos efforts pour le prochain contrôle."
class="input"
/>
<p class="text-xs text-gray-500 mt-1">
Ce message apparaîtra dans le bilan envoyé à chaque élève
</p>
</div>
</div>
<!-- Phase 2: Envoi en cours -->
<div v-if="sending" class="py-12">
<div class="text-center space-y-6">
<!-- Spinner -->
<div class="flex justify-center">
<div class="animate-spin rounded-full h-16 w-16 border-b-2 border-primary-600"></div>
</div>
<!-- Message -->
<div>
<h4 class="text-lg font-semibold text-gray-900 mb-2">
Envoi en cours...
</h4>
<p class="text-gray-600">
{{ sentCount }}/{{ selectedStudents.length }} bilan(s) envoyé(s)
</p>
</div>
<!-- Progress bar -->
<div class="max-w-md mx-auto">
<div class="w-full bg-gray-200 rounded-full h-3 overflow-hidden">
<div
class="bg-primary-600 h-full transition-all duration-300 ease-out"
:style="{ width: progressPercentage + '%' }"
></div>
</div>
<p class="text-sm text-gray-500 mt-2">{{ progressPercentage }}%</p>
</div>
</div>
</div>
<!-- Phase 3: Résultats -->
<div v-if="results" class="space-y-6">
<!-- Résumé avec cards colorées -->
<div class="grid grid-cols-2 gap-4">
<div class="bg-green-50 border border-green-200 rounded-lg p-6 text-center">
<div class="text-4xl font-bold text-green-700 mb-2">
{{ results.total_sent }}
</div>
<div class="text-sm font-medium text-green-600">
✅ Envoyé(s) avec succès
</div>
</div>
<div class="bg-red-50 border border-red-200 rounded-lg p-6 text-center">
<div class="text-4xl font-bold text-red-700 mb-2">
{{ results.total_failed }}
</div>
<div class="text-sm font-medium text-red-600">
❌ Échec(s)
</div>
</div>
</div>
<!-- Message global -->
<div
class="p-4 rounded-lg"
:class="results.success ? 'bg-green-50 border border-green-200' : 'bg-amber-50 border border-amber-200'"
>
<p class="text-sm font-medium" :class="results.success ? 'text-green-800' : 'text-amber-800'">
{{ results.message }}
</p>
</div>
<!-- Détails par élève -->
<div>
<h4 class="font-medium text-gray-900 mb-3">Détails par élève</h4>
<div class="max-h-80 overflow-y-auto space-y-2">
<div
v-for="result in results.results"
:key="result.student_id"
class="flex items-start gap-3 p-3 rounded-lg border"
:class="result.success
? 'bg-green-50 border-green-200'
: 'bg-red-50 border-red-200'"
>
<!-- Icône de statut -->
<span class="text-2xl flex-shrink-0">
{{ result.success ? '' : '' }}
</span>
<!-- Informations -->
<div class="flex-1 min-w-0">
<p class="font-medium" :class="result.success ? 'text-green-900' : 'text-red-900'">
{{ result.student_name }}
</p>
<p class="text-sm text-gray-600 truncate">
{{ result.email || 'Pas d\'email' }}
</p>
<p v-if="!result.success" class="text-xs text-red-600 mt-1">
{{ result.error_message }}
</p>
</div>
</div>
</div>
</div>
</div>
<template #footer>
<!-- Boutons selon la phase -->
<div class="flex justify-end gap-3">
<button
v-if="!sending"
@click="handleClose"
class="btn"
:disabled="sending"
>
{{ results ? 'Fermer' : 'Annuler' }}
</button>
<button
v-if="!results && !sending"
@click="sendReports"
class="btn btn-primary"
:disabled="selectedStudents.length === 0"
>
<svg class="w-5 h-5 mr-2 inline" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
</svg>
Envoyer les bilans
</button>
</div>
</template>
</Modal>
</template>
<script setup>
import { ref, computed } from 'vue'
import Modal from '@/components/common/Modal.vue'
import { assessmentsService } from '@/services/assessments'
import { useNotificationsStore } from '@/stores/notifications'
const props = defineProps({
assessmentId: {
type: Number,
required: true
},
selectedStudents: {
type: Array,
required: true,
// Chaque élément: { id, name, email }
}
})
const emit = defineEmits(['close', 'sent'])
const notifications = useNotificationsStore()
// État du composant
const customMessage = ref('')
const sending = ref(false)
const sentCount = ref(0)
const results = ref(null)
// Progress bar
const progressPercentage = computed(() => {
if (!sending.value || props.selectedStudents.length === 0) return 0
return Math.round((sentCount.value / props.selectedStudents.length) * 100)
})
// Méthode d'envoi
async function sendReports() {
if (props.selectedStudents.length === 0) return
sending.value = true
sentCount.value = 0
results.value = null
try {
// Extraire les IDs des élèves
const studentIds = props.selectedStudents.map(s => s.id)
// Appeler le service d'envoi
const response = await assessmentsService.sendReports(
props.assessmentId,
studentIds,
customMessage.value
)
// Afficher les résultats
results.value = response
sentCount.value = response.total_sent
// Notification
if (response.success) {
notifications.success(`${response.total_sent} bilan(s) envoyé(s) avec succès`)
} else {
notifications.warning(response.message)
}
// Émettre l'événement de succès
emit('sent', response)
} catch (error) {
console.error('Erreur lors de l\'envoi:', error)
// Afficher une erreur générale
const errorMessage = error.response?.data?.detail || 'Erreur lors de l\'envoi des bilans'
notifications.error(errorMessage)
results.value = {
success: false,
total_sent: 0,
total_failed: props.selectedStudents.length,
results: props.selectedStudents.map(s => ({
student_id: s.id,
student_name: s.name,
email: s.email,
success: false,
error_message: errorMessage
})),
message: errorMessage
}
} finally {
sending.value = false
}
}
function handleClose() {
// Bloquer la fermeture pendant l'envoi
if (sending.value) return
emit('close')
}
</script>

View File

@@ -144,7 +144,7 @@
</template> </template>
<!-- Add/Enroll Student Modal --> <!-- Add/Enroll Student Modal -->
<Modal v-model="showAddModal" title="Ajouter un élève" size="large"> <Modal v-model="showAddModal" title="Ajouter un élève" size="xl">
<div class="space-y-4"> <div class="space-y-4">
<!-- Tabs --> <!-- Tabs -->
<div class="border-b border-gray-200"> <div class="border-b border-gray-200">

View File

@@ -202,30 +202,126 @@
</div> </div>
</div> </div>
<!-- Toolbar de sélection (mode sélection activé) -->
<div v-if="selectionMode && gradedStudentsWithEmail.length > 0" class="card mb-6 border-2 border-blue-500 bg-blue-50">
<div class="card-body">
<div class="flex items-center justify-between">
<div class="flex items-center gap-4">
<div class="flex items-center gap-2">
<svg class="w-6 h-6 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<div>
<h3 class="font-semibold text-blue-900">Mode Sélection Activé</h3>
<p class="text-sm text-blue-700">
Cliquez sur les cases pour sélectionner les élèves qui recevront leur bilan
<span v-if="selectedStudents.length > 0" class="font-semibold">
({{ selectedStudents.length }} sélectionné{{selectedStudents.length > 1 ? 's' : ''}})
</span>
</p>
</div>
</div>
</div>
<div class="flex items-center gap-3">
<button
@click="toggleSelectAll"
class="btn btn-sm btn-secondary"
>
{{ allSelected ? 'Tout désélectionner' : 'Tout sélectionner' }}
</button>
<button
@click="cancelSelectionMode"
class="btn btn-sm btn-secondary"
>
<svg class="w-4 h-4 mr-1 inline" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
Annuler
</button>
<button
@click="openSendModal"
:disabled="selectedStudents.length === 0"
class="btn btn-primary shadow-lg"
:class="{ 'opacity-50 cursor-not-allowed': selectedStudents.length === 0 }"
>
<svg class="w-5 h-5 mr-2 inline" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
</svg>
Envoyer à {{ selectedStudents.length }} élève{{selectedStudents.length > 1 ? 's' : ''}}
</button>
</div>
</div>
</div>
</div>
<!-- Student scores table --> <!-- Student scores table -->
<div class="card"> <div class="card">
<div class="card-header"> <div class="card-header flex justify-between items-center">
<h2 class="text-lg font-semibold">Détail par élève</h2> <h2 class="text-lg font-semibold">Détail par élève</h2>
<!-- Mode Normal : Bouton pour activer la sélection -->
<button
v-if="!selectionMode && gradedStudentsWithEmail.length > 0"
@click="activateSelectionMode"
class="btn btn-primary"
>
<svg class="w-5 h-5 mr-2 inline" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
</svg>
📧 Envoyer des bilans
</button>
</div> </div>
<div class="overflow-x-auto"> <div class="overflow-x-auto">
<table class="table"> <table class="table">
<thead> <thead>
<tr> <tr>
<th v-if="selectionMode && gradedStudentsWithEmail.length > 0" class="w-16">
<div class="flex items-center gap-1">
<svg class="w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
</th>
<th>Élève</th> <th>Élève</th>
<th>Email</th>
<th class="text-center">Score</th> <th class="text-center">Score</th>
<th class="text-center">%</th> <th class="text-center">%</th>
<th class="text-center">Actions</th>
</tr> </tr>
</thead> </thead>
<tbody class="divide-y divide-gray-200"> <tbody class="divide-y divide-gray-200">
<tr <tr
v-for="student in results.students_scores" v-for="student in results.students_scores"
:key="student.student_id" :key="student.student_id"
:class="{ 'bg-gray-50 opacity-60': !isStudentGraded(student) }" :class="{
'bg-gray-50 opacity-60': !isStudentGraded(student),
'bg-blue-50 border-l-4 border-blue-500': selectionMode && isSelected(student.student_id)
}"
> >
<td v-if="selectionMode && gradedStudentsWithEmail.length > 0" class="w-16">
<input
v-if="isStudentGraded(student) && student.email"
type="checkbox"
:value="student.student_id"
v-model="selectedStudents"
class="w-5 h-5 rounded border-gray-300 text-blue-600 focus:ring-blue-500 cursor-pointer"
/>
<span
v-else-if="isStudentGraded(student) && !student.email"
class="text-amber-500"
title="Pas d'adresse email"
>
</span>
</td>
<td class="font-medium"> <td class="font-medium">
{{ student.student_name }} {{ student.student_name }}
<span v-if="!isStudentGraded(student)" class="ml-2 text-xs text-amber-600">(non évalué)</span> <span v-if="!isStudentGraded(student)" class="ml-2 text-xs text-amber-600">(non évalué)</span>
</td> </td>
<td class="text-sm text-gray-600">
<span v-if="student.email">{{ student.email }}</span>
<span v-else class="text-gray-400 italic">Pas d'email</span>
</td>
<td class="text-center font-bold"> <td class="text-center font-bold">
<template v-if="isStudentGraded(student)"> <template v-if="isStudentGraded(student)">
<span <span
@@ -266,12 +362,31 @@
</template> </template>
<span v-else class="text-gray-400">-</span> <span v-else class="text-gray-400">-</span>
</td> </td>
<td class="text-center">
<button
v-if="isStudentGraded(student)"
@click="openReportInNewTab(student.student_id)"
class="btn btn-sm btn-secondary"
title="Ouvrir le bilan dans un nouvel onglet"
>
👁️ Aperçu
</button>
</td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
</div> </div>
</div> </div>
</template> </template>
<!-- Modal d'envoi de bilans -->
<SendReportsModal
v-if="showSendModal"
:assessment-id="results.assessment_id"
:selected-students="selectedStudentsData"
@close="showSendModal = false"
@sent="handleReportsSent"
/>
</div> </div>
</template> </template>
@@ -283,6 +398,7 @@ import { useConfigStore } from '@/stores/config'
import { Bar } from 'vue-chartjs' import { Bar } from 'vue-chartjs'
import { Chart as ChartJS, CategoryScale, LinearScale, BarElement, Title, Tooltip, Legend } from 'chart.js' import { Chart as ChartJS, CategoryScale, LinearScale, BarElement, Title, Tooltip, Legend } from 'chart.js'
import LoadingSpinner from '@/components/common/LoadingSpinner.vue' import LoadingSpinner from '@/components/common/LoadingSpinner.vue'
import SendReportsModal from '@/components/assessment/SendReportsModal.vue'
ChartJS.register(CategoryScale, LinearScale, BarElement, Title, Tooltip, Legend) ChartJS.register(CategoryScale, LinearScale, BarElement, Title, Tooltip, Legend)
@@ -290,6 +406,11 @@ const route = useRoute()
const assessmentsStore = useAssessmentsStore() const assessmentsStore = useAssessmentsStore()
const configStore = useConfigStore() const configStore = useConfigStore()
// État pour la sélection d'élèves et l'envoi d'emails
const selectedStudents = ref([])
const showSendModal = ref(false)
const selectionMode = ref(false) // Mode sélection activé/désactivé
// Color interpolation functions // Color interpolation functions
function hexToRgb(hex) { function hexToRgb(hex) {
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex) const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex)
@@ -586,6 +707,88 @@ function isStudentGraded(student) {
return student.has_grades === true return student.has_grades === true
} }
// Computed pour les élèves corrigés avec email
const gradedStudentsWithEmail = computed(() => {
return gradedStudents.value.filter(s => s.email)
})
// Vérifie si tous les élèves sont sélectionnés
const allSelected = computed(() => {
if (gradedStudentsWithEmail.value.length === 0) return false
return gradedStudentsWithEmail.value.every(s => selectedStudents.value.includes(s.student_id))
})
// Vérifie s'il y a au moins un élève avec email
const hasStudentsWithEmail = computed(() => {
return gradedStudentsWithEmail.value.length > 0
})
// Données des élèves sélectionnés pour le modal
const selectedStudentsData = computed(() => {
if (!results.value) return []
return results.value.students_scores
.filter(s => selectedStudents.value.includes(s.student_id))
.map(s => ({
id: s.student_id,
name: s.student_name,
email: s.email
}))
})
// Activer le mode sélection
function activateSelectionMode() {
selectionMode.value = true
selectedStudents.value = [] // Réinitialiser la sélection
}
// Annuler le mode sélection
function cancelSelectionMode() {
selectionMode.value = false
selectedStudents.value = []
}
// Vider la sélection
function clearSelection() {
selectedStudents.value = []
}
// Vérifier si un élève est sélectionné
function isSelected(studentId) {
return selectedStudents.value.includes(studentId)
}
// Toggle sélection de tous les élèves
function toggleSelectAll() {
if (allSelected.value) {
selectedStudents.value = []
} else {
selectedStudents.value = gradedStudentsWithEmail.value.map(s => s.student_id)
}
}
// Ouvrir le modal d'envoi
function openSendModal() {
if (selectedStudents.value.length === 0) return
showSendModal.value = true
}
// Gérer le résultat de l'envoi
function handleReportsSent(result) {
showSendModal.value = false
selectedStudents.value = []
selectionMode.value = false // Désactiver le mode sélection après envoi
// Le modal affiche déjà les résultats, pas besoin de notification supplémentaire
}
// Ouvrir l'aperçu d'un bilan dans un nouvel onglet
function openReportInNewTab(studentId) {
const assessmentId = results.value.assessment_id
// Construire l'URL complète avec le préfixe API
const baseUrl = window.location.origin // Ex: http://localhost:5173
const url = `${baseUrl}/api/v2/assessments/${assessmentId}/preview-report/${studentId}`
window.open(url, '_blank')
}
onMounted(async () => { onMounted(async () => {
try { try {
// Charger la config du dégradé si pas déjà chargée // Charger la config du dégradé si pas déjà chargée