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 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
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:
"""
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']
from jinja2 import Environment, FileSystemLoader, select_autoescape
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']
stats = report_data['class_statistics']
# Couleur basée sur le pourcentage
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
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>
"""
score_color = "#ef4444" # rouge
# 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

View File

@@ -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]

View File

@@ -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):

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': [
{
'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
View File

@@ -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" },