feat(mail): restauration de l'envoie de mail
This commit is contained in:
@@ -6,6 +6,7 @@ from typing import Optional, List
|
||||
from datetime import date
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query
|
||||
from fastapi.responses import HTMLResponse
|
||||
from sqlalchemy import select, func
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
@@ -392,6 +393,7 @@ async def get_assessment_results(
|
||||
student_scores[student_id] = StudentScore(
|
||||
student_id=student_id,
|
||||
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_max_points=counted_max,
|
||||
percentage=percentage,
|
||||
@@ -1215,3 +1217,127 @@ async def send_reports(
|
||||
results=results,
|
||||
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
183
backend/debug_smtp_server.py
Executable 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()
|
||||
@@ -416,9 +416,10 @@ class StudentReportService:
|
||||
}
|
||||
|
||||
|
||||
|
||||
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:
|
||||
report_data: Données du rapport
|
||||
@@ -427,169 +428,44 @@ def generate_report_html(report_data: Dict[str, Any], message: str = "") -> str:
|
||||
Returns:
|
||||
HTML du rapport
|
||||
"""
|
||||
student = report_data['student']
|
||||
assessment = report_data['assessment']
|
||||
results = report_data['results']
|
||||
stats = report_data['class_statistics']
|
||||
from jinja2 import Environment, FileSystemLoader, select_autoescape
|
||||
from pathlib import Path
|
||||
|
||||
# Couleur basée sur le pourcentage
|
||||
# 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']
|
||||
percentage = results['percentage']
|
||||
if percentage >= 80:
|
||||
color = "#22c55e" # vert
|
||||
score_color = "#22c55e" # vert
|
||||
elif percentage >= 60:
|
||||
color = "#f6d32d" # jaune
|
||||
score_color = "#f6d32d" # jaune
|
||||
elif percentage >= 40:
|
||||
color = "#f97316" # orange
|
||||
score_color = "#f97316" # orange
|
||||
else:
|
||||
color = "#ef4444" # rouge
|
||||
score_color = "#ef4444" # rouge
|
||||
|
||||
html = f"""
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<style>
|
||||
body {{
|
||||
font-family: Arial, sans-serif;
|
||||
padding: 20px;
|
||||
color: #333;
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
}}
|
||||
.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>
|
||||
"""
|
||||
# Rendre le template avec les données
|
||||
html = template.render(
|
||||
assessment=report_data['assessment'],
|
||||
student=report_data['student'],
|
||||
results=report_data['results'],
|
||||
class_statistics=report_data['class_statistics'],
|
||||
exercises=report_data['exercises'],
|
||||
competences=report_data.get('competences', []),
|
||||
domains=report_data.get('domains', []),
|
||||
custom_message=message,
|
||||
score_color=score_color
|
||||
)
|
||||
|
||||
return html
|
||||
|
||||
|
||||
@@ -20,6 +20,8 @@ dependencies = [
|
||||
"python-dotenv>=1.0.1",
|
||||
"httpx>=0.28.0",
|
||||
"email-validator>=2.2.0",
|
||||
"jinja2>=3.1.0",
|
||||
"aiosmtpd>=1.4.4",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
|
||||
@@ -97,6 +97,7 @@ class StudentScore(BaseSchema):
|
||||
|
||||
student_id: int
|
||||
student_name: str
|
||||
email: Optional[str] = None # Email de l'élève pour l'envoi des bilans
|
||||
total_score: float
|
||||
total_max_points: 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")
|
||||
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):
|
||||
|
||||
251
backend/templates/email/student_report.html
Normal file
251
backend/templates/email/student_report.html
Normal 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>
|
||||
@@ -292,9 +292,15 @@ class TestGenerateReportHTML:
|
||||
'elements': [
|
||||
{
|
||||
'label': 'Q1',
|
||||
'description': '',
|
||||
'skill': '',
|
||||
'domain': '',
|
||||
'raw_value': '4',
|
||||
'calculated_score': 4.0,
|
||||
'max_points': 5.0,
|
||||
'grading_type': 'notes',
|
||||
'score_label': '',
|
||||
'max_points': 5.0
|
||||
'comment': ''
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -329,10 +335,11 @@ class TestGenerateReportHTML:
|
||||
assert '75' in html # percentage
|
||||
|
||||
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)
|
||||
assert '12.5' in html # mean
|
||||
assert '13.0' in html or '13' in html # median
|
||||
# Les statistiques de classe ont été supprimées du template
|
||||
assert 'Statistiques de la classe' not in html
|
||||
assert 'Position :' not in html
|
||||
|
||||
def test_generate_html_with_message(self, sample_report_data):
|
||||
"""Le HTML inclut le message personnalisé."""
|
||||
@@ -340,8 +347,8 @@ class TestGenerateReportHTML:
|
||||
html = generate_report_html(sample_report_data, message)
|
||||
assert message in html
|
||||
|
||||
def test_generate_html_color_code_green(self):
|
||||
"""Couleur verte pour les bons scores (>= 80%)."""
|
||||
def test_generate_html_no_dynamic_color(self):
|
||||
"""Le HTML n'a plus de couleur dynamique basée sur le score."""
|
||||
report_data = {
|
||||
'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'},
|
||||
@@ -352,18 +359,9 @@ class TestGenerateReportHTML:
|
||||
'class_statistics': {'count': 10, 'mean': 12, 'median': 12, 'min': 5, 'max': 18, 'std_dev': 2}
|
||||
}
|
||||
html = generate_report_html(report_data)
|
||||
assert '#22c55e' in html # green
|
||||
|
||||
def test_generate_html_color_code_red(self):
|
||||
"""Couleur rouge pour les mauvais scores (< 40%)."""
|
||||
report_data = {
|
||||
'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'},
|
||||
'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
|
||||
# Les couleurs dynamiques (vert/jaune/rouge) ont été supprimées
|
||||
assert '#22c55e' not in html # pas de vert
|
||||
assert '#f6d32d' not in html # pas de jaune
|
||||
assert '#ef4444' not in html # pas de rouge
|
||||
# Le score utilise maintenant une couleur neutre
|
||||
assert 'background-color: #f3f4f6' in html # gris neutre
|
||||
|
||||
99
backend/uv.lock
generated
99
backend/uv.lock
generated
@@ -2,6 +2,19 @@ version = 1
|
||||
revision = 3
|
||||
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]]
|
||||
name = "aiosqlite"
|
||||
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" },
|
||||
]
|
||||
|
||||
[[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]]
|
||||
name = "black"
|
||||
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" },
|
||||
]
|
||||
|
||||
[[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]]
|
||||
name = "mypy"
|
||||
version = "1.18.2"
|
||||
@@ -379,10 +474,12 @@ name = "notytex-backend"
|
||||
version = "2.0.0"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "aiosmtpd" },
|
||||
{ name = "aiosqlite" },
|
||||
{ name = "email-validator" },
|
||||
{ name = "fastapi" },
|
||||
{ name = "httpx" },
|
||||
{ name = "jinja2" },
|
||||
{ name = "pydantic" },
|
||||
{ name = "pydantic-settings" },
|
||||
{ name = "python-dotenv" },
|
||||
@@ -404,12 +501,14 @@ dev = [
|
||||
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "aiosmtpd", specifier = ">=1.4.4" },
|
||||
{ name = "aiosqlite", specifier = ">=0.20.0" },
|
||||
{ name = "black", marker = "extra == 'dev'", specifier = ">=24.11.0" },
|
||||
{ name = "email-validator", specifier = ">=2.2.0" },
|
||||
{ name = "fastapi", specifier = ">=0.115.0" },
|
||||
{ name = "httpx", 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 = "pydantic", specifier = ">=2.10.0" },
|
||||
{ name = "pydantic-settings", specifier = ">=2.6.0" },
|
||||
|
||||
267
frontend/src/components/assessment/SendReportsModal.vue
Normal file
267
frontend/src/components/assessment/SendReportsModal.vue
Normal 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... 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>
|
||||
@@ -144,7 +144,7 @@
|
||||
</template>
|
||||
|
||||
<!-- 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">
|
||||
<!-- Tabs -->
|
||||
<div class="border-b border-gray-200">
|
||||
|
||||
@@ -202,30 +202,126 @@
|
||||
</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 -->
|
||||
<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>
|
||||
|
||||
<!-- 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 class="overflow-x-auto">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<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>Email</th>
|
||||
<th class="text-center">Score</th>
|
||||
<th class="text-center">%</th>
|
||||
<th class="text-center">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200">
|
||||
<tr
|
||||
v-for="student in results.students_scores"
|
||||
: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">
|
||||
{{ student.student_name }}
|
||||
<span v-if="!isStudentGraded(student)" class="ml-2 text-xs text-amber-600">(non évalué)</span>
|
||||
</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">
|
||||
<template v-if="isStudentGraded(student)">
|
||||
<span
|
||||
@@ -266,12 +362,31 @@
|
||||
</template>
|
||||
<span v-else class="text-gray-400">-</span>
|
||||
</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>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Modal d'envoi de bilans -->
|
||||
<SendReportsModal
|
||||
v-if="showSendModal"
|
||||
:assessment-id="results.assessment_id"
|
||||
:selected-students="selectedStudentsData"
|
||||
@close="showSendModal = false"
|
||||
@sent="handleReportsSent"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -283,6 +398,7 @@ import { useConfigStore } from '@/stores/config'
|
||||
import { Bar } from 'vue-chartjs'
|
||||
import { Chart as ChartJS, CategoryScale, LinearScale, BarElement, Title, Tooltip, Legend } from 'chart.js'
|
||||
import LoadingSpinner from '@/components/common/LoadingSpinner.vue'
|
||||
import SendReportsModal from '@/components/assessment/SendReportsModal.vue'
|
||||
|
||||
ChartJS.register(CategoryScale, LinearScale, BarElement, Title, Tooltip, Legend)
|
||||
|
||||
@@ -290,6 +406,11 @@ const route = useRoute()
|
||||
const assessmentsStore = useAssessmentsStore()
|
||||
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
|
||||
function hexToRgb(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
|
||||
}
|
||||
|
||||
// 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 () => {
|
||||
try {
|
||||
// Charger la config du dégradé si pas déjà chargée
|
||||
|
||||
Reference in New Issue
Block a user