feat: add mailing and bilan to send

This commit is contained in:
2025-09-10 09:04:32 +02:00
parent 2c549c7234
commit 844d4d6bba
14 changed files with 2334 additions and 3 deletions

214
services/email_service.py Normal file
View File

@@ -0,0 +1,214 @@
"""
Service d'envoi d'emails pour Notytex.
Gère la configuration SMTP et l'envoi de bilans d'évaluation.
"""
import smtplib
import logging
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
from typing import List, Optional, Dict, Any
from flask import current_app
from premailer import transform
class EmailService:
"""Service d'envoi d'emails avec configuration dynamique."""
def __init__(self, config_manager=None):
"""Initialise le service avec le gestionnaire de configuration."""
if config_manager is None:
from app_config import config_manager as default_manager
self.config_manager = default_manager
else:
self.config_manager = config_manager
self.logger = logging.getLogger(__name__)
def get_smtp_config(self) -> Dict[str, Any]:
"""Récupère la configuration SMTP depuis la base de données."""
try:
return {
'host': self.config_manager.get('email.smtp_host', ''),
'port': int(self.config_manager.get('email.smtp_port', 587)),
'username': self.config_manager.get('email.username', ''),
'password': self.config_manager.get('email.password', ''),
'use_tls': self.config_manager.get('email.use_tls', 'true').lower() == 'true',
'from_name': self.config_manager.get('email.from_name', 'Notytex'),
'from_address': self.config_manager.get('email.from_address', ''),
}
except Exception as e:
self.logger.error(f"Erreur lors de la récupération de la configuration email: {e}")
return {}
def is_configured(self) -> bool:
"""Vérifie si la configuration email est complète."""
config = self.get_smtp_config()
# Vérifier les champs obligatoires de base
if not config.get('host'):
self.logger.warning("Configuration email incomplète: champ 'host' manquant")
return False
if not config.get('from_address'):
self.logger.warning("Configuration email incomplète: champ 'from_address' manquant")
return False
# Pour les serveurs locaux de test (localhost), l'authentification n'est pas requise
is_localhost = config.get('host', '').lower() in ['localhost', '127.0.0.1']
is_test_port = str(config.get('port', '')).strip() in ['1025', '2525', '8025']
if not is_localhost or not is_test_port:
# Pour les vrais serveurs SMTP, username et password sont requis
if not config.get('username'):
self.logger.warning("Configuration email incomplète: champ 'username' manquant")
return False
if not config.get('password'):
self.logger.warning("Configuration email incomplète: champ 'password' manquant")
return False
return True
def send_email(self, to_emails: List[str], subject: str, html_body: str,
text_body: Optional[str] = None) -> Dict[str, Any]:
"""
Envoie un email à une liste de destinataires.
Args:
to_emails: Liste des adresses email destinataires
subject: Sujet de l'email
html_body: Corps de l'email en HTML
text_body: Corps de l'email en texte brut (optionnel)
Returns:
Dict avec le statut de l'envoi et les détails
"""
if not self.is_configured():
return {
'success': False,
'error': 'Configuration email incomplète. Vérifiez les paramètres dans Configuration > Email.'
}
config = self.get_smtp_config()
try:
# Préparer l'email
msg = MIMEMultipart('alternative')
msg['Subject'] = subject
msg['From'] = f"{config['from_name']} <{config['from_address']}>"
msg['To'] = ', '.join(to_emails)
# Ajouter le corps en texte brut si fourni
if text_body:
part1 = MIMEText(text_body, 'plain', 'utf-8')
msg.attach(part1)
# Transformer le HTML pour optimiser l'affichage email
optimized_html = transform(html_body)
part2 = MIMEText(optimized_html, 'html', 'utf-8')
msg.attach(part2)
# Connexion SMTP et envoi
with smtplib.SMTP(config['host'], config['port']) as server:
if config['use_tls']:
server.starttls()
# Ne pas s'authentifier sur les serveurs de test locaux
is_localhost = config.get('host', '').lower() in ['localhost', '127.0.0.1']
is_test_port = str(config.get('port', '')).strip() in ['1025', '2525', '8025']
if not (is_localhost and is_test_port) and config.get('username') and config.get('password'):
server.login(config['username'], config['password'])
server.send_message(msg)
self.logger.info(f"Email envoyé avec succès à {len(to_emails)} destinataires: {subject}")
return {
'success': True,
'message': f'Email envoyé avec succès à {len(to_emails)} destinataire(s)',
'recipients_count': len(to_emails)
}
except smtplib.SMTPAuthenticationError as e:
error_msg = "Erreur d'authentification SMTP. Vérifiez les identifiants."
self.logger.error(f"Erreur SMTP Auth: {e}")
return {'success': False, 'error': error_msg}
except smtplib.SMTPException as e:
error_msg = f"Erreur SMTP lors de l'envoi: {str(e)}"
self.logger.error(f"Erreur SMTP: {e}")
return {'success': False, 'error': error_msg}
except Exception as e:
error_msg = f"Erreur inattendue lors de l'envoi: {str(e)}"
self.logger.error(f"Erreur envoi email: {e}")
return {'success': False, 'error': error_msg}
def send_test_email(self, to_email: str) -> Dict[str, Any]:
"""
Envoie un email de test pour vérifier la configuration.
Args:
to_email: Adresse email de test
Returns:
Dict avec le statut du test
"""
subject = "Test de configuration email - Notytex"
html_body = """
<html>
<body style="font-family: Arial, sans-serif; padding: 20px;">
<h2 style="color: #3b82f6;">Test de configuration email</h2>
<p>Félicitations ! Votre configuration email fonctionne correctement.</p>
<p>Vous pouvez maintenant envoyer des bilans d'évaluation par email.</p>
<hr style="margin: 20px 0;">
<p style="color: #6b7280; font-size: 12px;">
Email envoyé depuis Notytex - Système de gestion scolaire
</p>
</body>
</html>
"""
text_body = """
Test de configuration email - Notytex
Félicitations ! Votre configuration email fonctionne correctement.
Vous pouvez maintenant envoyer des bilans d'évaluation par email.
---
Email envoyé depuis Notytex - Système de gestion scolaire
"""
return self.send_email([to_email], subject, html_body, text_body)
def validate_email_addresses(self, emails: List[str]) -> Dict[str, Any]:
"""
Valide une liste d'adresses email.
Args:
emails: Liste des adresses à valider
Returns:
Dict avec les emails valides et invalides
"""
import re
email_regex = re.compile(r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$')
valid_emails = []
invalid_emails = []
for email in emails:
email = email.strip()
if email and email_regex.match(email):
valid_emails.append(email)
elif email: # Email non vide mais invalide
invalid_emails.append(email)
return {
'valid': valid_emails,
'invalid': invalid_emails,
'valid_count': len(valid_emails),
'invalid_count': len(invalid_emails)
}

View File

@@ -0,0 +1,325 @@
"""
Service de génération de bilans d'évaluation pour les élèves.
Génère les rapports HTML individualisés à envoyer par email.
"""
from typing import Dict, List, Any, Optional
from models import Assessment, Student, GradingCalculator
class StudentReportService:
"""Service de génération des bilans d'évaluation individuels."""
def __init__(self, config_manager=None):
"""Initialise le service avec le gestionnaire de configuration."""
if config_manager is None:
from app_config import config_manager as default_manager
self.config_manager = default_manager
else:
self.config_manager = config_manager
def generate_student_report(self, assessment: Assessment, student: Student) -> Dict[str, Any]:
"""
Génère le rapport individuel d'un élève pour une évaluation.
Args:
assessment: L'évaluation concernée
student: L'élève pour qui générer le rapport
Returns:
Dict contenant toutes les données du rapport
"""
# Récupérer les labels des compétences depuis la configuration
score_meanings = self.config_manager.get_score_meanings()
# Calculer les scores de tous les élèves pour avoir les statistiques
students_scores, exercise_scores = assessment.calculate_student_scores()
# Vérifier que l'élève est dans les résultats
if student.id not in students_scores:
raise ValueError(f"L'élève {student.full_name} n'est pas éligible pour cette évaluation")
student_data = students_scores[student.id]
# Calculer les statistiques de classe
statistics = assessment.get_assessment_statistics()
total_max_points = assessment.get_total_max_points()
# Calculer la position de l'élève dans la classe
all_scores = [data['total_score'] for data in students_scores.values()]
all_scores_sorted = sorted(all_scores, reverse=True)
student_position = all_scores_sorted.index(student_data['total_score']) + 1
total_students = len(all_scores)
# Préparer les détails par exercice
exercises_details = []
for exercise in sorted(assessment.exercises, key=lambda x: x.order):
exercise_score = student_data['exercises'].get(exercise.id, {'score': 0, 'max_points': 0})
# Détails des éléments de notation de l'exercice
elements_details = []
for element in exercise.grading_elements:
# Trouver la note de l'élève pour cet élément
grade = None
for g in element.grades:
if g.student_id == student.id:
grade = g
break
if grade and grade.value:
calculated_score = GradingCalculator.calculate_score(
grade.value, element.grading_type, element.max_points
)
# Récupérer le label de compétence si c'est un score
score_label = ''
if element.grading_type == 'score' and grade.value.isdigit():
score_val = int(grade.value)
if score_val in score_meanings:
score_label = score_meanings[score_val]['label']
elements_details.append({
'label': element.label,
'description': element.description or '',
'skill': element.skill or '',
'domain': element.domain.name if element.domain else '',
'raw_value': grade.value,
'calculated_score': calculated_score,
'max_points': element.max_points,
'grading_type': element.grading_type,
'score_label': score_label,
'comment': grade.comment or ''
})
else:
elements_details.append({
'label': element.label,
'description': element.description or '',
'skill': element.skill or '',
'domain': element.domain.name if element.domain else '',
'raw_value': None,
'calculated_score': None,
'max_points': element.max_points,
'grading_type': element.grading_type,
'score_label': '',
'comment': ''
})
exercises_details.append({
'title': exercise.title,
'description': exercise.description or '',
'score': exercise_score['score'],
'max_points': exercise_score['max_points'],
'percentage': round((exercise_score['score'] / exercise_score['max_points']) * 100, 1) if exercise_score['max_points'] > 0 else 0,
'elements': elements_details
})
# Calculer les performances par compétence
competences_performance = self._calculate_competences_performance(assessment, student)
# Calculer les performances par domaine
domains_performance = self._calculate_domains_performance(assessment, student)
return {
'assessment': {
'title': assessment.title,
'description': assessment.description or '',
'date': assessment.date,
'trimester': assessment.trimester,
'class_name': assessment.class_group.name,
'coefficient': assessment.coefficient
},
'student': {
'full_name': student.full_name,
'first_name': student.first_name,
'last_name': student.last_name,
'email': student.email
},
'results': {
'total_score': student_data['total_score'],
'total_max_points': student_data['total_max_points'],
'percentage': round((student_data['total_score'] / student_data['total_max_points']) * 100, 1) if student_data['total_max_points'] > 0 else 0,
'position': student_position,
'total_students': total_students
},
'exercises': exercises_details,
'competences': competences_performance,
'domains': domains_performance,
'class_statistics': {
'count': statistics['count'],
'mean': statistics['mean'],
'median': statistics['median'],
'min': statistics['min'],
'max': statistics['max'],
'std_dev': statistics['std_dev']
}
}
def _calculate_competences_performance(self, assessment: Assessment, student: Student) -> List[Dict[str, Any]]:
"""Calcule les performances de l'élève par compétence."""
competences_data = {}
for exercise in assessment.exercises:
for element in exercise.grading_elements:
if element.skill:
# Trouver la note de l'élève
grade = None
for g in element.grades:
if g.student_id == student.id:
grade = g
break
if grade and grade.value:
score = GradingCalculator.calculate_score(
grade.value, element.grading_type, element.max_points
)
if score is not None: # Exclure les dispensés
if element.skill not in competences_data:
competences_data[element.skill] = {
'total_score': 0,
'total_max_points': 0,
'elements_count': 0
}
competences_data[element.skill]['total_score'] += score
competences_data[element.skill]['total_max_points'] += element.max_points
competences_data[element.skill]['elements_count'] += 1
# Convertir en liste avec pourcentages
competences_performance = []
for competence, data in competences_data.items():
percentage = round((data['total_score'] / data['total_max_points']) * 100, 1) if data['total_max_points'] > 0 else 0
competences_performance.append({
'name': competence,
'score': data['total_score'],
'max_points': data['total_max_points'],
'percentage': percentage,
'elements_count': data['elements_count']
})
return sorted(competences_performance, key=lambda x: x['name'])
def _calculate_domains_performance(self, assessment: Assessment, student: Student) -> List[Dict[str, Any]]:
"""Calcule les performances de l'élève par domaine."""
domains_data = {}
for exercise in assessment.exercises:
for element in exercise.grading_elements:
if element.domain:
# Trouver la note de l'élève
grade = None
for g in element.grades:
if g.student_id == student.id:
grade = g
break
if grade and grade.value:
score = GradingCalculator.calculate_score(
grade.value, element.grading_type, element.max_points
)
if score is not None: # Exclure les dispensés
domain_name = element.domain.name
if domain_name not in domains_data:
domains_data[domain_name] = {
'total_score': 0,
'total_max_points': 0,
'elements_count': 0,
'color': element.domain.color
}
domains_data[domain_name]['total_score'] += score
domains_data[domain_name]['total_max_points'] += element.max_points
domains_data[domain_name]['elements_count'] += 1
# Convertir en liste avec pourcentages
domains_performance = []
for domain, data in domains_data.items():
percentage = round((data['total_score'] / data['total_max_points']) * 100, 1) if data['total_max_points'] > 0 else 0
domains_performance.append({
'name': domain,
'score': data['total_score'],
'max_points': data['total_max_points'],
'percentage': percentage,
'elements_count': data['elements_count'],
'color': data['color']
})
return sorted(domains_performance, key=lambda x: x['name'])
def generate_multiple_reports(self, assessment: Assessment, student_ids: List[int]) -> Dict[int, Dict[str, Any]]:
"""
Génère les rapports pour plusieurs élèves.
Args:
assessment: L'évaluation concernée
student_ids: Liste des IDs d'élèves
Returns:
Dict avec les rapports par ID d'élève
"""
from models import Student
reports = {}
errors = {}
for student_id in student_ids:
try:
student = Student.query.get(student_id)
if not student:
errors[student_id] = "Élève introuvable"
continue
report = self.generate_student_report(assessment, student)
reports[student_id] = report
except Exception as e:
errors[student_id] = str(e)
return {
'reports': reports,
'errors': errors,
'success_count': len(reports),
'error_count': len(errors)
}
def get_assessment_summary(self, assessment: Assessment) -> Dict[str, Any]:
"""
Génère un résumé de l'évaluation pour les emails.
Args:
assessment: L'évaluation concernée
Returns:
Dict avec le résumé de l'évaluation
"""
statistics = assessment.get_assessment_statistics()
total_max_points = assessment.get_total_max_points()
# Compter les exercices et éléments
exercises_count = len(assessment.exercises)
elements_count = sum(len(ex.grading_elements) for ex in assessment.exercises)
# Récupérer les compétences et domaines évalués
competences = set()
domains = set()
for exercise in assessment.exercises:
for element in exercise.grading_elements:
if element.skill:
competences.add(element.skill)
if element.domain:
domains.add(element.domain.name)
return {
'assessment': {
'title': assessment.title,
'description': assessment.description or '',
'date': assessment.date,
'trimester': assessment.trimester,
'class_name': assessment.class_group.name,
'coefficient': assessment.coefficient,
'total_max_points': total_max_points,
'exercises_count': exercises_count,
'elements_count': elements_count
},
'statistics': statistics,
'competences_evaluated': sorted(list(competences)),
'domains_evaluated': sorted(list(domains))
}