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

View File

@@ -370,8 +370,31 @@ class ConfigManager:
return self.default_config['grading_system']['special_values']
def get_score_meanings(self) -> Dict[int, Dict[str, str]]:
"""Récupère les significations des scores (0-3)."""
return self.default_config['grading_system']['score_meanings']
"""Récupère les significations des scores (0-3) depuis la base de données."""
# Récupérer les valeurs depuis la base de données
scale_values = CompetenceScaleValue.query.filter(
CompetenceScaleValue.value.in_(['0', '1', '2', '3'])
).all()
# Convertir en dict avec clés entières
meanings = {}
for scale_value in scale_values:
try:
score_int = int(scale_value.value)
meanings[score_int] = {
'label': scale_value.label,
'color': scale_value.color
}
except ValueError:
continue # Ignorer les valeurs non numériques
# Fallback vers la config par défaut si pas de données en base
if not meanings:
default_meanings = self.default_config['grading_system']['score_meanings']
for key, value in default_meanings.items():
meanings[int(key)] = value
return meanings
def validate_grade_value(self, value: str, grading_type: str, max_points: float = None) -> bool:
"""

57
debug_smtp_server.py Normal file
View File

@@ -0,0 +1,57 @@
#!/usr/bin/env python3
"""
Serveur SMTP de débogage simple pour tester l'envoi d'emails en local.
Usage: python debug_smtp_server.py
"""
import asyncore
import smtpd
import sys
from datetime import datetime
class DebuggingServer(smtpd.SMTPServer):
"""Serveur SMTP qui affiche tous les emails reçus dans le terminal."""
def process_message(self, peer, mailfrom, rcpttos, data, **kwargs):
"""Traite et affiche les messages reçus."""
print('=' * 80)
print(f'📧 EMAIL REÇU le {datetime.now().strftime("%Y-%m-%d %H:%M:%S")}')
print('=' * 80)
print(f'🔗 Connexion depuis: {peer}')
print(f'📨 De: {mailfrom}')
print(f'📬 Vers: {", ".join(rcpttos)}')
print('-' * 80)
print('📄 CONTENU:')
print(data.decode('utf-8', errors='replace'))
print('=' * 80)
print()
def main():
"""Lance le serveur SMTP de débogage."""
host = 'localhost'
port = 1025
print(f'🚀 Démarrage du serveur SMTP de débogage sur {host}:{port}')
print('📧 Tous les emails envoyés à ce serveur seront affichés ici')
print('❌ Aucun email ne sera réellement envoyé')
print('🛑 Appuyez sur Ctrl+C pour arrêter')
print('=' * 80)
try:
server = DebuggingServer((host, port), None)
print(f'✅ Serveur prêt ! Configurez Notytex avec:')
print(f' - Serveur SMTP: {host}')
print(f' - Port: {port}')
print(f' - TLS: Désactivé')
print(f' - Authentification: Aucune')
print('=' * 80)
asyncore.loop()
except KeyboardInterrupt:
print('\n🛑 Serveur arrêté par l\'utilisateur')
sys.exit(0)
except Exception as e:
print(f'❌ Erreur: {e}')
sys.exit(1)
if __name__ == '__main__':
main()

44
decode_email.py Normal file
View File

@@ -0,0 +1,44 @@
#!/usr/bin/env python3
"""
Script pour décoder le contenu Base64 des emails du serveur de débogage.
Usage: python decode_email.py
Copiez le contenu Base64 et appuyez sur Ctrl+D pour décoder.
"""
import base64
import sys
def decode_email_content():
"""Lit le contenu Base64 depuis stdin et l'affiche décodé."""
print("📧 Décodeur d'email Base64")
print("Collez le contenu Base64 de l'email et appuyez sur Ctrl+D :")
print("-" * 60)
try:
# Lire tout le contenu depuis stdin
base64_content = sys.stdin.read().strip()
if not base64_content:
print("❌ Aucun contenu fourni")
return
# Décoder le Base64
decoded_bytes = base64.b64decode(base64_content)
decoded_html = decoded_bytes.decode('utf-8')
print("=" * 80)
print("📄 CONTENU HTML DÉCODÉ :")
print("=" * 80)
print(decoded_html)
print("=" * 80)
# Optionnel : sauvegarder dans un fichier
with open('email_decoded.html', 'w', encoding='utf-8') as f:
f.write(decoded_html)
print("✅ Email sauvegardé dans 'email_decoded.html'")
except Exception as e:
print(f"❌ Erreur lors du décodage : {e}")
if __name__ == '__main__':
decode_email_content()

View File

@@ -10,6 +10,8 @@ dependencies = [
"WTForms>=3.0.1",
"python-dotenv>=1.0.0",
"pydantic>=2.0.0",
"Flask-Mail>=0.9.1",
"premailer>=3.10.0",
]
[build-system]
@@ -26,6 +28,9 @@ dev-dependencies = [
"pytest-cov>=4.1.0",
]
[tool.poetry.group.dev.dependencies]
aiosmtpd = "^1.4.6"
[dependency-groups]
dev = [
"psutil>=7.0.0",

View File

@@ -439,3 +439,169 @@ def delete(id):
current_app.logger.info(f'Évaluation supprimée: {title} (ID: {id})')
flash('Évaluation supprimée avec succès !', 'success')
return redirect(url_for('assessments.list'))
@bp.route('/<int:id>/preview-report/<int:student_id>')
@handle_db_errors
def preview_report(id, student_id):
"""Prévisualise le bilan d'un élève dans le navigateur."""
from services.student_report_service import StudentReportService
from models import Student
# Récupérer l'évaluation
assessment_repo = AssessmentRepository()
assessment = assessment_repo.get_with_full_details_or_404(id)
# Récupérer l'élève
student = Student.query.get_or_404(student_id)
# Générer le rapport
report_service = StudentReportService()
report_data = report_service.generate_student_report(assessment, student)
# Afficher le template email directement
return render_template('email/student_report.html', report=report_data)
@bp.route('/<int:id>/send-reports', methods=['POST'])
@handle_db_errors
def send_reports(id):
"""Envoie les bilans d'évaluation par email."""
try:
# Récupération des données du formulaire
data = request.get_json()
if not data:
return jsonify({'success': False, 'error': 'Aucune donnée fournie'}), 400
student_ids = data.get('student_ids', [])
custom_message = data.get('custom_message', '').strip()
if not student_ids:
return jsonify({'success': False, 'error': 'Aucun élève sélectionné'}), 400
# Récupération de l'évaluation
assessment_repo = AssessmentRepository()
assessment = assessment_repo.get_with_full_details_or_404(id)
# Vérification de la configuration email
from services.email_service import EmailService
from services.student_report_service import StudentReportService
from flask import render_template
email_service = EmailService()
if not email_service.is_configured():
return jsonify({
'success': False,
'error': 'Configuration email incomplète. Rendez-vous dans Configuration > Email.'
}), 400
report_service = StudentReportService()
# Génération des rapports
reports_data = report_service.generate_multiple_reports(assessment, student_ids)
if reports_data['error_count'] > 0:
current_app.logger.warning(f"Erreurs lors de la génération de rapports: {reports_data['errors']}")
# Envoi des emails
sent_count = 0
errors = []
for student_id, report_data in reports_data['reports'].items():
try:
student = report_data['student']
# Vérification de l'email de l'élève
if not student['email']:
errors.append(f"{student['full_name']}: Aucune adresse email")
continue
# Validation de l'email
validation = email_service.validate_email_addresses([student['email']])
if validation['invalid_count'] > 0:
errors.append(f"{student['full_name']}: Adresse email invalide ({student['email']})")
continue
# Génération du HTML de l'email
html_content = render_template('email/student_report.html',
report=report_data,
custom_message=custom_message)
# Sujet de l'email
subject = f"Bilan d'évaluation - {assessment.title} - {student['full_name']}"
# Envoi de l'email
result = email_service.send_email([student['email']], subject, html_content)
if result['success']:
sent_count += 1
current_app.logger.info(f"Bilan envoyé à {student['full_name']} ({student['email']})")
else:
errors.append(f"{student['full_name']}: {result['error']}")
current_app.logger.error(f"Erreur envoi bilan à {student['full_name']}: {result['error']}")
except Exception as e:
error_msg = f"{report_data.get('student', {}).get('full_name', 'Élève inconnu')}: Erreur inattendue - {str(e)}"
errors.append(error_msg)
current_app.logger.error(f"Erreur envoi bilan: {e}")
# Préparer la réponse
response_data = {
'success': sent_count > 0,
'sent_count': sent_count,
'total_requested': len(student_ids),
'error_count': len(errors),
'errors': errors
}
if sent_count > 0:
if len(errors) == 0:
response_data['message'] = f"{sent_count} bilan(s) envoyé(s) avec succès !"
else:
response_data['message'] = f"{sent_count} bilan(s) envoyé(s), {len(errors)} erreur(s)"
else:
response_data['message'] = f"❌ Aucun bilan envoyé - {len(errors)} erreur(s)"
return jsonify(response_data)
except Exception as e:
current_app.logger.error(f"Erreur lors de l'envoi de bilans: {e}")
return jsonify({
'success': False,
'error': f'Erreur inattendue: {str(e)}'
}), 500
@bp.route('/<int:id>/eligible-students')
@handle_db_errors
def get_eligible_students(id):
"""Récupère la liste des élèves éligibles avec leurs emails pour l'envoi de bilans."""
try:
assessment_repo = AssessmentRepository()
assessment = assessment_repo.get_with_full_details_or_404(id)
# Récupérer les élèves éligibles (ceux qui étaient dans la classe à la date de l'évaluation)
eligible_students = []
for student in assessment.class_group.get_students_at_date(assessment.date):
eligible_students.append({
'id': student.id,
'first_name': student.first_name,
'last_name': student.last_name,
'full_name': student.full_name,
'email': student.email or '',
'has_email': bool(student.email)
})
# Trier par nom de famille puis prénom
eligible_students.sort(key=lambda x: (x['last_name'].lower(), x['first_name'].lower()))
return jsonify({
'success': True,
'students': eligible_students,
'total_count': len(eligible_students),
'with_email_count': len([s for s in eligible_students if s['has_email']])
})
except Exception as e:
current_app.logger.error(f"Erreur récupération élèves éligibles: {e}")
return jsonify({
'success': False,
'error': f'Erreur: {str(e)}'
}), 500

View File

@@ -446,6 +446,105 @@ def update_general():
return redirect(url_for('config.general'))
@bp.route('/email')
def email():
"""Page de configuration email."""
try:
# Récupérer la configuration email actuelle
email_config = {
'smtp_host': config_manager.get('email.smtp_host', ''),
'smtp_port': config_manager.get('email.smtp_port', '587'),
'username': config_manager.get('email.username', ''),
'password': config_manager.get('email.password', ''),
'use_tls': config_manager.get('email.use_tls', 'true') == 'true',
'from_name': config_manager.get('email.from_name', 'Notytex'),
'from_address': config_manager.get('email.from_address', ''),
}
return render_template('config/email.html', email_config=email_config)
except Exception as e:
return handle_error(e, "Erreur lors du chargement de la configuration email")
@bp.route('/email/update', methods=['POST'])
@handle_db_errors
def update_email():
"""Mettre à jour la configuration email."""
try:
# Récupérer les données du formulaire
smtp_host = request.form.get('smtp_host', '').strip()
smtp_port = request.form.get('smtp_port', '587').strip()
username = request.form.get('username', '').strip()
password = request.form.get('password', '').strip()
use_tls = request.form.get('use_tls') == 'on'
from_name = request.form.get('from_name', 'Notytex').strip()
from_address = request.form.get('from_address', '').strip()
# Validation des données
if smtp_host and not smtp_port.isdigit():
flash('Le port SMTP doit être un nombre', 'error')
return redirect(url_for('config.email'))
if from_address:
import re
email_regex = re.compile(r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$')
if not email_regex.match(from_address):
flash('Format d\'adresse email invalide', 'error')
return redirect(url_for('config.email'))
# Sauvegarder la configuration
config_manager.set('email.smtp_host', smtp_host)
config_manager.set('email.smtp_port', smtp_port)
config_manager.set('email.username', username)
config_manager.set('email.password', password)
config_manager.set('email.use_tls', 'true' if use_tls else 'false')
config_manager.set('email.from_name', from_name)
config_manager.set('email.from_address', from_address)
if config_manager.save():
flash('Configuration email mise à jour avec succès', 'success')
else:
flash('Erreur lors de la sauvegarde', 'error')
except Exception as e:
logging.error(f"Erreur mise à jour config email: {e}")
flash('Erreur lors de la mise à jour', 'error')
return redirect(url_for('config.email'))
@bp.route('/email/test', methods=['POST'])
@handle_db_errors
def test_email():
"""Tester la configuration email."""
try:
test_email_address = request.form.get('test_email', '').strip()
if not test_email_address:
flash('Adresse email de test requise', 'error')
return redirect(url_for('config.email'))
# Validation de l'adresse email
import re
email_regex = re.compile(r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$')
if not email_regex.match(test_email_address):
flash('Format d\'adresse email invalide', 'error')
return redirect(url_for('config.email'))
# Tenter l'envoi d'un email de test
from services.email_service import EmailService
email_service = EmailService(config_manager)
result = email_service.send_test_email(test_email_address)
if result['success']:
flash(f'Email de test envoyé avec succès à {test_email_address}', 'success')
else:
flash(f'Erreur lors de l\'envoi du test: {result["error"]}', 'error')
except Exception as e:
logging.error(f"Erreur test email: {e}")
flash('Erreur lors du test d\'envoi', 'error')
return redirect(url_for('config.email'))
@bp.route('/reset', methods=['POST'])
def reset_config():
"""Réinitialise la configuration aux valeurs par défaut."""

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))
}

View File

@@ -52,6 +52,17 @@
</div>
</div>
<!-- Bouton d'envoi des bilans -->
<div class="flex justify-end mb-6">
<button id="sendReportsBtn" onclick="openSendReportsModal()"
class="inline-flex items-center px-6 py-3 bg-blue-600 text-white font-semibold rounded-lg hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 transition-colors">
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 8l7.89 4.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 par email
</button>
</div>
<!-- Histogramme des notes -->
<div class="bg-white shadow rounded-lg">
<div class="px-6 py-4 border-b border-gray-200">
@@ -764,5 +775,284 @@ document.addEventListener('DOMContentLoaded', function() {
createGradingElementsHeatmap('gradingElementsHeatmap', gradingElementsData);
{% endif %}
});
// === FONCTIONNALITÉ D'ENVOI DE BILANS ===
let eligibleStudents = [];
let selectedStudents = [];
// Charger les élèves éligibles au chargement de la page
document.addEventListener('DOMContentLoaded', function() {
loadEligibleStudents();
});
function loadEligibleStudents() {
fetch(`/assessments/{{ assessment.id }}/eligible-students`)
.then(response => response.json())
.then(data => {
if (data.success) {
eligibleStudents = data.students;
updateSendReportsButton(data.with_email_count, data.total_count);
} else {
console.error('Erreur lors du chargement des élèves:', data.error);
}
})
.catch(error => {
console.error('Erreur réseau:', error);
});
}
function updateSendReportsButton(withEmailCount, totalCount) {
const btn = document.getElementById('sendReportsBtn');
if (withEmailCount === 0) {
btn.disabled = true;
btn.classList.add('opacity-50', 'cursor-not-allowed');
btn.classList.remove('hover:bg-blue-700');
btn.innerHTML = `
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
Aucune adresse email configurée
`;
} else {
btn.innerHTML = `
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 8l7.89 4.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 (${withEmailCount}/${totalCount} avec email)
`;
}
}
function openSendReportsModal() {
if (eligibleStudents.length === 0) {
alert('Aucun élève éligible trouvé');
return;
}
document.getElementById('sendReportsModal').classList.remove('hidden');
populateStudentsList();
}
function closeSendReportsModal() {
document.getElementById('sendReportsModal').classList.add('hidden');
selectedStudents = [];
}
function populateStudentsList() {
const container = document.getElementById('studentsList');
const studentsWithEmail = eligibleStudents.filter(s => s.has_email);
if (studentsWithEmail.length === 0) {
container.innerHTML = '<p class="text-gray-500 text-center py-4">Aucun élève avec adresse email configurée</p>';
return;
}
container.innerHTML = studentsWithEmail.map(student => `
<label class="flex items-center p-3 hover:bg-gray-50 rounded-lg cursor-pointer">
<input type="checkbox"
value="${student.id}"
onchange="updateSelectedStudents()"
class="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded mr-3">
<div class="flex-1">
<div class="font-medium text-gray-900">${student.full_name}</div>
<div class="text-sm text-gray-500">${student.email}</div>
</div>
</label>
`).join('');
// Sélectionner tous par défaut
selectAllStudents();
}
function selectAllStudents() {
const checkboxes = document.querySelectorAll('#studentsList input[type="checkbox"]');
checkboxes.forEach(cb => cb.checked = true);
updateSelectedStudents();
}
function unselectAllStudents() {
const checkboxes = document.querySelectorAll('#studentsList input[type="checkbox"]');
checkboxes.forEach(cb => cb.checked = false);
updateSelectedStudents();
}
function updateSelectedStudents() {
const checkboxes = document.querySelectorAll('#studentsList input[type="checkbox"]:checked');
selectedStudents = Array.from(checkboxes).map(cb => parseInt(cb.value));
const count = selectedStudents.length;
document.getElementById('selectedCount').textContent = count;
document.getElementById('sendReportsSubmitBtn').disabled = count === 0;
}
function sendReports() {
if (selectedStudents.length === 0) {
alert('Veuillez sélectionner au moins un élève');
return;
}
const customMessage = document.getElementById('customMessage').value.trim();
const submitBtn = document.getElementById('sendReportsSubmitBtn');
const originalText = submitBtn.innerHTML;
// Désactiver le bouton et afficher le loading
submitBtn.disabled = true;
submitBtn.innerHTML = `
<svg class="animate-spin -ml-1 mr-3 h-5 w-5 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
Envoi en cours...
`;
// Envoyer la requête
fetch(`/assessments/{{ assessment.id }}/send-reports`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
student_ids: selectedStudents,
custom_message: customMessage
})
})
.then(response => response.json())
.then(data => {
// Restaurer le bouton
submitBtn.disabled = false;
submitBtn.innerHTML = originalText;
if (data.success) {
// Afficher le résultat
showSendResult(data);
// Fermer la modal après un délai
setTimeout(() => {
closeSendReportsModal();
}, 3000);
} else {
alert(`Erreur lors de l'envoi: ${data.error}`);
}
})
.catch(error => {
// Restaurer le bouton en cas d'erreur
submitBtn.disabled = false;
submitBtn.innerHTML = originalText;
console.error('Erreur:', error);
alert('Erreur réseau lors de l\'envoi des bilans');
});
}
function showSendResult(result) {
const resultDiv = document.getElementById('sendResult');
let content = `<div class="mb-4">
<h3 class="text-lg font-medium ${result.success ? 'text-green-600' : 'text-red-600'} mb-2">
${result.message}
</h3>
</div>`;
if (result.sent_count > 0) {
content += `<div class="mb-3">
<p class="text-green-600">✅ ${result.sent_count} bilan(s) envoyé(s) avec succès</p>
</div>`;
}
if (result.error_count > 0) {
content += `<div class="mb-3">
<p class="text-red-600 font-medium">❌ ${result.error_count} erreur(s):</p>
<ul class="text-sm text-red-600 mt-2 max-h-32 overflow-y-auto">`;
result.errors.forEach(error => {
content += `<li class="py-1">• ${error}</li>`;
});
content += `</ul></div>`;
}
resultDiv.innerHTML = content;
resultDiv.classList.remove('hidden');
// Cacher les autres sections
document.getElementById('studentsSelection').classList.add('hidden');
document.getElementById('modalActions').classList.add('hidden');
}
// Fermer la modal en cliquant à côté
document.addEventListener('click', function(e) {
const modal = document.getElementById('sendReportsModal');
if (e.target === modal) {
closeSendReportsModal();
}
});
</script>
<!-- Modal d'envoi des bilans -->
<div id="sendReportsModal" class="hidden fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full z-50">
<div class="relative top-20 mx-auto p-5 border w-11/12 max-w-2xl shadow-lg rounded-md bg-white">
<div class="mt-3">
<!-- En-tête -->
<div class="flex justify-between items-center pb-4 border-b">
<h3 class="text-lg font-medium text-gray-900">📧 Envoyer les bilans par email</h3>
<button onclick="closeSendReportsModal()" class="text-gray-400 hover:text-gray-600">
<svg class="w-6 h-6" 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>
</button>
</div>
<!-- Sélection des élèves -->
<div id="studentsSelection" class="py-4">
<div class="flex justify-between items-center mb-4">
<h4 class="font-medium text-gray-900">Sélectionner les élèves</h4>
<div class="flex space-x-2">
<button onclick="selectAllStudents()"
class="text-sm text-blue-600 hover:text-blue-800">Tout sélectionner</button>
<span class="text-gray-300">|</span>
<button onclick="unselectAllStudents()"
class="text-sm text-blue-600 hover:text-blue-800">Tout désélectionner</button>
</div>
</div>
<div id="studentsList" class="max-h-64 overflow-y-auto border border-gray-200 rounded-lg p-2 space-y-1">
<!-- Les élèves seront ajoutés ici par JavaScript -->
</div>
<p class="text-sm text-gray-600 mt-2">
<span id="selectedCount">0</span> élève(s) sélectionné(s)
</p>
</div>
<!-- Message personnalisé -->
<div class="py-4 border-t">
<label for="customMessage" class="block text-sm font-medium text-gray-700 mb-2">
Message personnalisé (optionnel)
</label>
<textarea id="customMessage"
rows="3"
placeholder="Message du professeur à ajouter dans les bilans..."
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"></textarea>
</div>
<!-- Résultat de l'envoi -->
<div id="sendResult" class="hidden py-4 border-t">
<!-- Le résultat sera affiché ici -->
</div>
<!-- Actions -->
<div id="modalActions" class="flex justify-end space-x-3 pt-4 border-t">
<button onclick="closeSendReportsModal()"
class="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50">
Annuler
</button>
<button id="sendReportsSubmitBtn" onclick="sendReports()"
class="px-4 py-2 text-sm font-medium text-white bg-blue-600 border border-transparent rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
disabled>
📧 Envoyer les bilans
</button>
</div>
</div>
</div>
</div>
{% endblock %}

258
templates/config/email.html Normal file
View File

@@ -0,0 +1,258 @@
{% extends "base.html" %}
{% block title %}Configuration Email - Gestion Scolaire{% endblock %}
{% block content %}
<div class="space-y-6">
<div class="flex justify-between items-center">
<div>
<a href="{{ url_for('config.index') }}" class="text-blue-600 hover:text-blue-800 text-sm font-medium mb-2 inline-block">
← Retour à la configuration
</a>
<h1 class="text-2xl font-bold text-gray-900">Configuration Email</h1>
<p class="text-gray-600">Configurez les paramètres SMTP pour l'envoi des bilans d'évaluation</p>
</div>
</div>
<!-- Formulaire de configuration email -->
<div class="bg-white shadow rounded-lg">
<div class="px-6 py-4 border-b border-gray-200">
<h2 class="text-lg font-medium text-gray-900">Paramètres SMTP</h2>
<p class="text-sm text-gray-600 mt-1">
Configurez votre serveur SMTP pour envoyer les bilans d'évaluation par email
</p>
</div>
<form method="POST" action="{{ url_for('config.update_email') }}" class="px-6 py-4 space-y-4">
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<!-- Serveur SMTP -->
<div>
<label for="smtp_host" class="block text-sm font-medium text-gray-700 mb-1">
Serveur SMTP
</label>
<input type="text"
id="smtp_host"
name="smtp_host"
value="{{ email_config.smtp_host }}"
placeholder="smtp.gmail.com"
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500">
<p class="text-xs text-gray-500 mt-1">Ex: smtp.gmail.com, smtp.outlook.com</p>
</div>
<!-- Port SMTP -->
<div>
<label for="smtp_port" class="block text-sm font-medium text-gray-700 mb-1">
Port SMTP
</label>
<input type="text"
id="smtp_port"
name="smtp_port"
value="{{ email_config.smtp_port }}"
placeholder="587"
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500">
<p class="text-xs text-gray-500 mt-1">587 (TLS) ou 465 (SSL) généralement</p>
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<!-- Nom d'utilisateur -->
<div>
<label for="username" class="block text-sm font-medium text-gray-700 mb-1">
Nom d'utilisateur
</label>
<input type="text"
id="username"
name="username"
value="{{ email_config.username }}"
placeholder="votre.email@domain.com"
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500">
<p class="text-xs text-gray-500 mt-1">Votre adresse email de connexion SMTP</p>
</div>
<!-- Mot de passe -->
<div>
<label for="password" class="block text-sm font-medium text-gray-700 mb-1">
Mot de passe
</label>
<input type="password"
id="password"
name="password"
value="{{ email_config.password }}"
placeholder="••••••••"
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500">
<p class="text-xs text-gray-500 mt-1">Mot de passe ou mot de passe d'application</p>
</div>
</div>
<!-- Options de sécurité -->
<div class="flex items-center">
<input type="checkbox"
id="use_tls"
name="use_tls"
{% if email_config.use_tls %}checked{% endif %}
class="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded">
<label for="use_tls" class="ml-2 text-sm text-gray-700">
Utiliser TLS (recommandé)
</label>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<!-- Nom d'expéditeur -->
<div>
<label for="from_name" class="block text-sm font-medium text-gray-700 mb-1">
Nom d'expéditeur
</label>
<input type="text"
id="from_name"
name="from_name"
value="{{ email_config.from_name }}"
placeholder="Notytex"
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500">
<p class="text-xs text-gray-500 mt-1">Nom qui apparaîtra comme expéditeur</p>
</div>
<!-- Adresse d'expéditeur -->
<div>
<label for="from_address" class="block text-sm font-medium text-gray-700 mb-1">
Adresse d'expéditeur
</label>
<input type="email"
id="from_address"
name="from_address"
value="{{ email_config.from_address }}"
placeholder="professeur@etablissement.fr"
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500">
<p class="text-xs text-gray-500 mt-1">Adresse qui apparaîtra comme expéditeur</p>
</div>
</div>
<div class="flex justify-end space-x-3 pt-4 border-t border-gray-200">
<a href="{{ url_for('config.index') }}"
class="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500">
Annuler
</a>
<button type="submit"
class="px-4 py-2 text-sm font-medium text-white bg-blue-600 border border-transparent rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500">
Sauvegarder
</button>
</div>
</form>
</div>
<!-- Test de configuration -->
<div class="bg-white shadow rounded-lg">
<div class="px-6 py-4 border-b border-gray-200">
<h2 class="text-lg font-medium text-gray-900">Test de configuration</h2>
<p class="text-sm text-gray-600 mt-1">
Testez votre configuration en envoyant un email de test
</p>
</div>
<form method="POST" action="{{ url_for('config.test_email') }}" class="px-6 py-4">
<div class="space-y-4">
<div>
<label for="test_email" class="block text-sm font-medium text-gray-700 mb-1">
Adresse email de test
</label>
<input type="email"
id="test_email"
name="test_email"
placeholder="test@example.com"
required
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500">
<p class="text-xs text-gray-500 mt-1">Un email de test sera envoyé à cette adresse</p>
</div>
<div class="flex justify-end">
<button type="submit"
class="px-4 py-2 text-sm font-medium text-white bg-green-600 border border-transparent rounded-md hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500">
📧 Envoyer un test
</button>
</div>
</div>
</form>
</div>
<!-- Aide configuration -->
<div class="bg-blue-50 border border-blue-200 rounded-lg p-6">
<h3 class="text-lg font-medium text-blue-900 mb-3">💡 Aide à la configuration</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<h4 class="font-medium text-blue-800 mb-2">Gmail</h4>
<ul class="text-sm text-blue-700 space-y-1">
<li><strong>Serveur:</strong> smtp.gmail.com</li>
<li><strong>Port:</strong> 587 (TLS)</li>
<li><strong>Sécurité:</strong> Utiliser un mot de passe d'application</li>
</ul>
</div>
<div>
<h4 class="font-medium text-blue-800 mb-2">Outlook/Hotmail</h4>
<ul class="text-sm text-blue-700 space-y-1">
<li><strong>Serveur:</strong> smtp-mail.outlook.com</li>
<li><strong>Port:</strong> 587 (TLS)</li>
<li><strong>Sécurité:</strong> Authentification moderne requise</li>
</ul>
</div>
</div>
<div class="mt-4 p-4 bg-yellow-50 border border-yellow-200 rounded-md">
<p class="text-sm text-yellow-800">
<strong>⚠️ Sécurité:</strong> Pour Gmail, créez un mot de passe d'application dans votre compte Google.
N'utilisez pas votre mot de passe principal.
</p>
</div>
</div>
<!-- Serveur SMTP de test local -->
<div class="bg-green-50 border border-green-200 rounded-lg p-6">
<h3 class="text-lg font-medium text-green-900 mb-3">🧪 Tests en local - Serveur SMTP factice</h3>
<p class="text-sm text-green-700 mb-3">
Pour tester l'envoi d'emails sans configuration réelle, utilisez le serveur SMTP de débogage Python :
</p>
<div class="bg-green-100 p-4 rounded-md mb-4">
<h4 class="font-medium text-green-800 mb-2">1. Lancer le serveur de débogage</h4>
<p class="text-sm text-green-700 mb-2"><strong>Option A:</strong> Script inclus dans Notytex (recommandé)</p>
<code class="block text-sm bg-gray-900 text-green-400 p-2 rounded mb-3">
python debug_smtp_server.py
</code>
<p class="text-sm text-green-700 mb-2"><strong>Option B:</strong> Avec aiosmtpd</p>
<code class="block text-sm bg-gray-900 text-blue-400 p-2 rounded mb-1">
pip install aiosmtpd
</code>
<code class="block text-sm bg-gray-900 text-blue-400 p-2 rounded mb-3">
python -m aiosmtpd -n -l localhost:1025
</code>
<p class="text-xs text-green-600">💡 Le script inclus affiche les emails avec un formatage amélioré</p>
<p class="text-xs text-green-600">⚠️ Laissez ce terminal ouvert pendant les tests</p>
</div>
<div class="bg-green-100 p-4 rounded-md mb-4">
<h4 class="font-medium text-green-800 mb-2">2. Configuration pour les tests</h4>
<ul class="text-sm text-green-700 space-y-1">
<li><strong>Serveur SMTP:</strong> localhost</li>
<li><strong>Port:</strong> 1025</li>
<li><strong>Utilisateur/Mot de passe:</strong> Laisser vides</li>
<li><strong>TLS:</strong> ❌ Décocher</li>
<li><strong>Adresse expéditeur:</strong> test@notytex.local</li>
</ul>
</div>
<div class="bg-green-100 p-4 rounded-md">
<h4 class="font-medium text-green-800 mb-2">3. Résultat</h4>
<p class="text-sm text-green-700">
✅ Tous les emails s'afficheront dans le terminal (contenu HTML complet)<br>
✅ Aucun email réellement envoyé<br>
✅ Parfait pour tester les bilans d'évaluation
</p>
</div>
</div>
</div>
{% endblock %}

View File

@@ -103,6 +103,43 @@
Configurer l'échelle
</a>
</div>
<!-- Configuration email -->
<div class="bg-white rounded-lg shadow-md p-6 hover:shadow-lg transition-shadow">
<div class="flex items-center mb-4">
<div class="w-12 h-12 bg-orange-100 rounded-lg flex items-center justify-center mr-4">
<svg class="w-6 h-6 text-orange-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 8l7.89 4.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>
</div>
<h3 class="text-lg font-semibold text-gray-900">Configuration Email</h3>
</div>
<p class="text-gray-600 mb-4">Paramètres SMTP pour l'envoi des bilans d'évaluation</p>
<div class="space-y-3">
<div class="flex justify-between">
<span class="text-sm text-gray-500">Serveur SMTP :</span>
<span class="text-sm font-medium text-gray-900">
{% set smtp_host = app_config.get('email.smtp_host', '') %}
{{ smtp_host if smtp_host else 'Non configuré' }}
</span>
</div>
<div class="flex justify-between">
<span class="text-sm text-gray-500">Statut :</span>
<span class="text-sm font-medium">
{% set smtp_host = app_config.get('email.smtp_host', '') %}
{% set from_address = app_config.get('email.from_address', '') %}
{% if smtp_host and from_address %}
<span class="text-green-600">✅ Configuré</span>
{% else %}
<span class="text-red-600">⚠️ À configurer</span>
{% endif %}
</span>
</div>
</div>
<a href="{{ url_for('config.email') }}" class="mt-4 block w-full bg-orange-600 text-white py-2 px-4 rounded-md hover:bg-orange-700 transition-colors text-center">
Configurer Email
</a>
</div>
</div>
<!-- Actions globales -->

View File

@@ -0,0 +1,269 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}Notytex - Bilan d'évaluation{% endblock %}</title>
<style>
/* Reset styles pour compatibilité email */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
line-height: 1.6;
color: #374151;
background-color: #f9fafb;
padding: 20px;
}
.email-container {
max-width: 800px;
margin: 0 auto;
background-color: #ffffff;
border-radius: 12px;
overflow: hidden;
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.1);
}
.header {
background: linear-gradient(135deg, #3b82f6, #8b5cf6);
color: white;
padding: 30px;
text-align: center;
}
.header h1 {
font-size: 28px;
margin-bottom: 8px;
font-weight: 700;
}
.header p {
font-size: 16px;
opacity: 0.9;
}
.content {
padding: 30px;
}
.section {
margin-bottom: 30px;
padding: 20px;
background-color: #f8fafc;
border-radius: 8px;
border-left: 4px solid #3b82f6;
}
.section h2 {
color: #1f2937;
font-size: 20px;
margin-bottom: 15px;
font-weight: 600;
}
.section h3 {
color: #374151;
font-size: 16px;
margin-bottom: 10px;
font-weight: 600;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
gap: 15px;
margin: 20px 0;
}
.stat-item {
text-align: center;
padding: 15px;
background-color: white;
border-radius: 8px;
border: 2px solid #e5e7eb;
}
.stat-value {
font-size: 24px;
font-weight: 700;
color: #3b82f6;
}
.stat-label {
font-size: 12px;
color: #6b7280;
text-transform: uppercase;
letter-spacing: 0.5px;
margin-top: 5px;
}
.progress-bar {
width: 100%;
height: 8px;
background-color: #e5e7eb;
border-radius: 4px;
overflow: hidden;
margin: 8px 0;
}
.progress-fill {
height: 100%;
border-radius: 4px;
transition: width 0.3s ease;
}
.table {
width: 100%;
border-collapse: collapse;
margin: 15px 0;
background-color: white;
border-radius: 8px;
overflow: hidden;
}
.table th,
.table td {
padding: 12px 15px;
text-align: left;
border-bottom: 1px solid #e5e7eb;
}
.table th {
background-color: #f3f4f6;
font-weight: 600;
color: #374151;
text-transform: uppercase;
font-size: 12px;
letter-spacing: 0.5px;
}
.table tr:hover {
background-color: #f9fafb;
}
.badge {
display: inline-block;
padding: 4px 12px;
border-radius: 12px;
font-size: 12px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.badge-success {
background-color: #dcfce7;
color: #166534;
}
.badge-warning {
background-color: #fef3c7;
color: #92400e;
}
.badge-info {
background-color: #dbeafe;
color: #1e40af;
}
.competence-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px 15px;
margin: 8px 0;
background-color: white;
border-radius: 6px;
border: 1px solid #e5e7eb;
}
.competence-name {
font-weight: 500;
}
.competence-score {
font-weight: 600;
color: #3b82f6;
}
.footer {
background-color: #f3f4f6;
padding: 20px 30px;
text-align: center;
border-top: 1px solid #e5e7eb;
}
.footer p {
color: #6b7280;
font-size: 12px;
margin: 5px 0;
}
.highlight {
background-color: #fef3c7;
padding: 2px 6px;
border-radius: 4px;
font-weight: 600;
}
/* Responsive pour mobile */
@media only screen and (max-width: 600px) {
body {
padding: 10px;
}
.header {
padding: 20px;
}
.header h1 {
font-size: 24px;
}
.content {
padding: 20px;
}
.section {
padding: 15px;
}
.stats-grid {
grid-template-columns: repeat(2, 1fr);
gap: 10px;
}
.table th,
.table td {
padding: 8px 10px;
font-size: 14px;
}
}
</style>
</head>
<body>
<div class="email-container">
<div class="header">
<h1>{% block header_title %}🎓 Notytex{% endblock %}</h1>
<p>{% block header_subtitle %}Système de gestion scolaire{% endblock %}</p>
</div>
<div class="content">
{% block content %}
<!-- Contenu principal de l'email -->
{% endblock %}
</div>
<div class="footer">
<p><strong>{% block school_name %}Établissement scolaire{% endblock %}</strong></p>
<p>Email généré automatiquement par Notytex</p>
<p>Pour toute question, contactez votre professeur.</p>
</div>
</div>
</body>
</html>

View File

@@ -0,0 +1,239 @@
{% extends "email/base_email.html" %}
{% block title %}{{ report.assessment.title }} - {{ report.student.full_name }}{% endblock %}
{% block header_title %}📊 Bilan d'Évaluation{% endblock %}
{% block header_subtitle %}{{ report.assessment.title }} - {{ report.student.full_name }}{% endblock %}
{% block content %}
<!-- Informations sur l'évaluation -->
<div class="section">
<h2>📋 Informations sur l'évaluation</h2>
<div class="stats-grid">
<div class="stat-item">
<div class="stat-value">{{ report.assessment.class_name }}</div>
<div class="stat-label">Classe</div>
</div>
<div class="stat-item">
<div class="stat-value">{{ report.assessment.date.strftime('%d/%m/%Y') }}</div>
<div class="stat-label">Date</div>
</div>
<div class="stat-item">
<div class="stat-value">T{{ report.assessment.trimester }}</div>
<div class="stat-label">Trimestre</div>
</div>
<div class="stat-item">
<div class="stat-value">×{{ report.assessment.coefficient }}</div>
<div class="stat-label">Coefficient</div>
</div>
</div>
{% if report.assessment.description %}
<p style="margin-top: 15px; padding: 15px; background-color: #e0f2fe; border-radius: 6px; border-left: 3px solid #0288d1;">
<strong>Description :</strong> {{ report.assessment.description }}
</p>
{% endif %}
</div>
<!-- Note globale -->
<div class="section" style="border-left-color: #10b981;">
<h2>🎯 Note globale</h2>
<div style="text-align: center; padding: 30px;">
<div style="font-size: 48px; font-weight: bold; color: #10b981; margin-bottom: 10px;">
{{ "%.1f"|format(report.results.total_score) }}/{{ "%.1f"|format(report.results.total_max_points) }}
</div>
<div style="font-size: 16px; color: #6b7280; margin-bottom: 20px;">
Note obtenue sur cette évaluation
</div>
<div class="progress-bar">
<div class="progress-fill" style="width: {{ report.results.percentage }}%;
background: linear-gradient(90deg,
{% if report.results.percentage < 50 %}#ef4444{% elif report.results.percentage < 75 %}#f59e0b{% else %}#10b981{% endif %},
{% if report.results.percentage < 50 %}#dc2626{% elif report.results.percentage < 75 %}#d97706{% else %}#059669{% endif %});"></div>
</div>
</div>
</div>
<!-- Résultats par exercice -->
{% if report.exercises %}
<div class="section" style="border-left-color: #8b5cf6;">
<h2>📝 Résultats par exercice</h2>
{% for exercise in report.exercises %}
<div style="margin: 20px 0; padding: 15px; background-color: white; border-radius: 8px; border: 1px solid #e5e7eb;">
<h3 style="color: #8b5cf6;">{{ exercise.title }}</h3>
{% if exercise.description %}
<p style="font-size: 14px; color: #6b7280; margin-bottom: 10px;">{{ exercise.description }}</p>
{% endif %}
<div style="display: flex; justify-content: space-between; align-items: center; margin: 10px 0;">
<span style="font-weight: 600;">Score :</span>
<span style="font-size: 18px; font-weight: 700; color: #8b5cf6;">
{{ "%.1f"|format(exercise.score) }}/{{ "%.1f"|format(exercise.max_points) }}
</span>
</div>
<div class="progress-bar">
<div class="progress-fill" style="width: {{ exercise.percentage }}%; background-color: #8b5cf6;"></div>
</div>
{% if exercise.elements %}
<div style="margin-top: 15px;">
<h4 style="font-size: 14px; color: #374151; margin-bottom: 8px;">Détail des questions :</h4>
<table class="table" style="font-size: 14px;">
<thead>
<tr>
<th>Question</th>
<th>Compétence</th>
<th>Domaine</th>
<th>Résultat</th>
</tr>
</thead>
<tbody>
{% for element in exercise.elements %}
<tr>
<td>
<strong>{{ element.label }}</strong>
{% if element.description %}
<br><small style="color: #6b7280;">{{ element.description }}</small>
{% endif %}
</td>
<td>
{% if element.skill %}
<span class="badge badge-info">{{ element.skill }}</span>
{% else %}
<span style="color: #9ca3af;">-</span>
{% endif %}
</td>
<td>
{% if element.domain %}
<small style="color: #6b7280;">{{ element.domain }}</small>
{% else %}
<span style="color: #9ca3af;">-</span>
{% endif %}
</td>
<td style="text-align: center;">
{% if element.raw_value == '.' %}
<span style="color: #9ca3af; font-size: 20px;"></span>
<br><small style="color: #9ca3af;">Pas de réponse</small>
{% elif element.grading_type == 'score' and element.raw_value %}
{% set score_value = element.raw_value|int %}
<div style="font-size: 18px;">
{% for i in range(3) %}
{% if i < score_value %}{% else %}{% endif %}
{% endfor %}
</div>
<small style="color: #6b7280;">
{{ element.score_label if element.score_label else 'Score ' + score_value|string }}
</small>
{% elif element.raw_value %}
<strong style="font-size: 16px;">{{ element.raw_value }}/{{ element.max_points }}</strong>
{% else %}
<span style="color: #9ca3af; font-size: 20px;"></span>
<br><small style="color: #9ca3af;">Non noté</small>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endif %}
</div>
{% endfor %}
</div>
{% endif %}
<!-- Performances par compétence -->
{% if report.competences %}
<div class="section" style="border-left-color: #f59e0b;">
<h2>⭐ Performances par compétence</h2>
<p style="font-size: 14px; color: #6b7280; margin-bottom: 15px;">
Analyse de vos performances sur chaque compétence évaluée
</p>
{% for competence in report.competences %}
<div class="competence-item">
<div>
<div class="competence-name">{{ competence.name }}</div>
<div style="font-size: 12px; color: #6b7280;">
{{ competence.elements_count }} élément{{ 's' if competence.elements_count > 1 else '' }} évalué{{ 's' if competence.elements_count > 1 else '' }}
</div>
</div>
<div style="text-align: right;">
{% set competence_percentage = (competence.score / competence.max_points * 100) if competence.max_points > 0 else 0 %}
{% if competence_percentage < 20 %}
{% set star_count = 0 %}
{% elif competence_percentage < 50 %}
{% set star_count = 1 %}
{% elif competence_percentage < 80 %}
{% set star_count = 2 %}
{% else %}
{% set star_count = 3 %}
{% endif %}
<div style="font-size: 18px; margin-bottom: 4px;">
{% for i in range(3) %}
{% if i < star_count %}{% else %}{% endif %}
{% endfor %}
</div>
<div style="font-size: 12px; color: #6b7280;">
{{ "%.1f"|format(competence.score) }}/{{ "%.1f"|format(competence.max_points) }}
</div>
</div>
</div>
{% endfor %}
</div>
{% endif %}
<!-- Performances par domaine -->
{% if report.domains %}
<div class="section" style="border-left-color: #06b6d4;">
<h2>🏷️ Performances par domaine</h2>
<p style="font-size: 14px; color: #6b7280; margin-bottom: 15px;">
Analyse de vos performances par thème/domaine
</p>
{% for domain in report.domains %}
<div class="competence-item">
<div>
<div class="competence-name">{{ domain.name }}</div>
<div style="font-size: 12px; color: #6b7280;">
{{ domain.elements_count }} élément{{ 's' if domain.elements_count > 1 else '' }} évalué{{ 's' if domain.elements_count > 1 else '' }}
</div>
</div>
<div style="text-align: right;">
{% set domain_percentage = (domain.score / domain.max_points * 100) if domain.max_points > 0 else 0 %}
{% if domain_percentage < 20 %}
{% set star_count = 0 %}
{% elif domain_percentage < 50 %}
{% set star_count = 1 %}
{% elif domain_percentage < 80 %}
{% set star_count = 2 %}
{% else %}
{% set star_count = 3 %}
{% endif %}
<div style="font-size: 18px; margin-bottom: 4px;">
{% for i in range(3) %}
{% if i < star_count %}{% else %}{% endif %}
{% endfor %}
</div>
<div style="font-size: 12px; color: #6b7280;">
{{ "%.1f"|format(domain.score) }}/{{ "%.1f"|format(domain.max_points) }}
</div>
</div>
</div>
{% endfor %}
</div>
{% endif %}
<!-- Message personnalisé si fourni -->
{% if custom_message %}
<div class="section" style="border-left-color: #10b981; background-color: #f0fdf4;">
<h2>💬 Message du professeur</h2>
<p style="font-style: italic; color: #166534;">{{ custom_message }}</p>
</div>
{% endif %}
{% endblock %}

305
uv.lock generated
View File

@@ -24,6 +24,99 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/10/cb/f2ad4230dc2eb1a74edf38f1a38b9b52277f75bef262d8908e60d957e13c/blinker-1.9.0-py3-none-any.whl", hash = "sha256:ba0efaa9080b619ff2f3459d1d500c57bddea4a6b424b60a91141db6fd2f08bc", size = 8458, upload-time = "2024-11-08T17:25:46.184Z" },
]
[[package]]
name = "cachetools"
version = "6.2.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/9d/61/e4fad8155db4a04bfb4734c7c8ff0882f078f24294d42798b3568eb63bff/cachetools-6.2.0.tar.gz", hash = "sha256:38b328c0889450f05f5e120f56ab68c8abaf424e1275522b138ffc93253f7e32", size = 30988, upload-time = "2025-08-25T18:57:30.924Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/6c/56/3124f61d37a7a4e7cc96afc5492c78ba0cb551151e530b54669ddd1436ef/cachetools-6.2.0-py3-none-any.whl", hash = "sha256:1c76a8960c0041fcc21097e357f882197c79da0dbff766e7317890a65d7d8ba6", size = 11276, upload-time = "2025-08-25T18:57:29.684Z" },
]
[[package]]
name = "certifi"
version = "2025.8.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/dc/67/960ebe6bf230a96cda2e0abcf73af550ec4f090005363542f0765df162e0/certifi-2025.8.3.tar.gz", hash = "sha256:e564105f78ded564e3ae7c923924435e1daa7463faeab5bb932bc53ffae63407", size = 162386, upload-time = "2025-08-03T03:07:47.08Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e5/48/1549795ba7742c948d2ad169c1c8cdbae65bc450d6cd753d124b17c8cd32/certifi-2025.8.3-py3-none-any.whl", hash = "sha256:f6c12493cfb1b06ba2ff328595af9350c65d6644968e5d3a2ffd78699af217a5", size = 161216, upload-time = "2025-08-03T03:07:45.777Z" },
]
[[package]]
name = "charset-normalizer"
version = "3.4.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/83/2d/5fd176ceb9b2fc619e63405525573493ca23441330fcdaee6bef9460e924/charset_normalizer-3.4.3.tar.gz", hash = "sha256:6fce4b8500244f6fcb71465d4a4930d132ba9ab8e71a7859e6a5d59851068d14", size = 122371, upload-time = "2025-08-09T07:57:28.46Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d6/98/f3b8013223728a99b908c9344da3aa04ee6e3fa235f19409033eda92fb78/charset_normalizer-3.4.3-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:fb7f67a1bfa6e40b438170ebdc8158b78dc465a5a67b6dde178a46987b244a72", size = 207695, upload-time = "2025-08-09T07:55:36.452Z" },
{ url = "https://files.pythonhosted.org/packages/21/40/5188be1e3118c82dcb7c2a5ba101b783822cfb413a0268ed3be0468532de/charset_normalizer-3.4.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cc9370a2da1ac13f0153780040f465839e6cccb4a1e44810124b4e22483c93fe", size = 147153, upload-time = "2025-08-09T07:55:38.467Z" },
{ url = "https://files.pythonhosted.org/packages/37/60/5d0d74bc1e1380f0b72c327948d9c2aca14b46a9efd87604e724260f384c/charset_normalizer-3.4.3-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:07a0eae9e2787b586e129fdcbe1af6997f8d0e5abaa0bc98c0e20e124d67e601", size = 160428, upload-time = "2025-08-09T07:55:40.072Z" },
{ url = "https://files.pythonhosted.org/packages/85/9a/d891f63722d9158688de58d050c59dc3da560ea7f04f4c53e769de5140f5/charset_normalizer-3.4.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:74d77e25adda8581ffc1c720f1c81ca082921329452eba58b16233ab1842141c", size = 157627, upload-time = "2025-08-09T07:55:41.706Z" },
{ url = "https://files.pythonhosted.org/packages/65/1a/7425c952944a6521a9cfa7e675343f83fd82085b8af2b1373a2409c683dc/charset_normalizer-3.4.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d0e909868420b7049dafd3a31d45125b31143eec59235311fc4c57ea26a4acd2", size = 152388, upload-time = "2025-08-09T07:55:43.262Z" },
{ url = "https://files.pythonhosted.org/packages/f0/c9/a2c9c2a355a8594ce2446085e2ec97fd44d323c684ff32042e2a6b718e1d/charset_normalizer-3.4.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:c6f162aabe9a91a309510d74eeb6507fab5fff92337a15acbe77753d88d9dcf0", size = 150077, upload-time = "2025-08-09T07:55:44.903Z" },
{ url = "https://files.pythonhosted.org/packages/3b/38/20a1f44e4851aa1c9105d6e7110c9d020e093dfa5836d712a5f074a12bf7/charset_normalizer-3.4.3-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:4ca4c094de7771a98d7fbd67d9e5dbf1eb73efa4f744a730437d8a3a5cf994f0", size = 161631, upload-time = "2025-08-09T07:55:46.346Z" },
{ url = "https://files.pythonhosted.org/packages/a4/fa/384d2c0f57edad03d7bec3ebefb462090d8905b4ff5a2d2525f3bb711fac/charset_normalizer-3.4.3-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:02425242e96bcf29a49711b0ca9f37e451da7c70562bc10e8ed992a5a7a25cc0", size = 159210, upload-time = "2025-08-09T07:55:47.539Z" },
{ url = "https://files.pythonhosted.org/packages/33/9e/eca49d35867ca2db336b6ca27617deed4653b97ebf45dfc21311ce473c37/charset_normalizer-3.4.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:78deba4d8f9590fe4dae384aeff04082510a709957e968753ff3c48399f6f92a", size = 153739, upload-time = "2025-08-09T07:55:48.744Z" },
{ url = "https://files.pythonhosted.org/packages/2a/91/26c3036e62dfe8de8061182d33be5025e2424002125c9500faff74a6735e/charset_normalizer-3.4.3-cp310-cp310-win32.whl", hash = "sha256:d79c198e27580c8e958906f803e63cddb77653731be08851c7df0b1a14a8fc0f", size = 99825, upload-time = "2025-08-09T07:55:50.305Z" },
{ url = "https://files.pythonhosted.org/packages/e2/c6/f05db471f81af1fa01839d44ae2a8bfeec8d2a8b4590f16c4e7393afd323/charset_normalizer-3.4.3-cp310-cp310-win_amd64.whl", hash = "sha256:c6e490913a46fa054e03699c70019ab869e990270597018cef1d8562132c2669", size = 107452, upload-time = "2025-08-09T07:55:51.461Z" },
{ url = "https://files.pythonhosted.org/packages/7f/b5/991245018615474a60965a7c9cd2b4efbaabd16d582a5547c47ee1c7730b/charset_normalizer-3.4.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:b256ee2e749283ef3ddcff51a675ff43798d92d746d1a6e4631bf8c707d22d0b", size = 204483, upload-time = "2025-08-09T07:55:53.12Z" },
{ url = "https://files.pythonhosted.org/packages/c7/2a/ae245c41c06299ec18262825c1569c5d3298fc920e4ddf56ab011b417efd/charset_normalizer-3.4.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:13faeacfe61784e2559e690fc53fa4c5ae97c6fcedb8eb6fb8d0a15b475d2c64", size = 145520, upload-time = "2025-08-09T07:55:54.712Z" },
{ url = "https://files.pythonhosted.org/packages/3a/a4/b3b6c76e7a635748c4421d2b92c7b8f90a432f98bda5082049af37ffc8e3/charset_normalizer-3.4.3-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:00237675befef519d9af72169d8604a067d92755e84fe76492fef5441db05b91", size = 158876, upload-time = "2025-08-09T07:55:56.024Z" },
{ url = "https://files.pythonhosted.org/packages/e2/e6/63bb0e10f90a8243c5def74b5b105b3bbbfb3e7bb753915fe333fb0c11ea/charset_normalizer-3.4.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:585f3b2a80fbd26b048a0be90c5aae8f06605d3c92615911c3a2b03a8a3b796f", size = 156083, upload-time = "2025-08-09T07:55:57.582Z" },
{ url = "https://files.pythonhosted.org/packages/87/df/b7737ff046c974b183ea9aa111b74185ac8c3a326c6262d413bd5a1b8c69/charset_normalizer-3.4.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e78314bdc32fa80696f72fa16dc61168fda4d6a0c014e0380f9d02f0e5d8a07", size = 150295, upload-time = "2025-08-09T07:55:59.147Z" },
{ url = "https://files.pythonhosted.org/packages/61/f1/190d9977e0084d3f1dc169acd060d479bbbc71b90bf3e7bf7b9927dec3eb/charset_normalizer-3.4.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:96b2b3d1a83ad55310de8c7b4a2d04d9277d5591f40761274856635acc5fcb30", size = 148379, upload-time = "2025-08-09T07:56:00.364Z" },
{ url = "https://files.pythonhosted.org/packages/4c/92/27dbe365d34c68cfe0ca76f1edd70e8705d82b378cb54ebbaeabc2e3029d/charset_normalizer-3.4.3-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:939578d9d8fd4299220161fdd76e86c6a251987476f5243e8864a7844476ba14", size = 160018, upload-time = "2025-08-09T07:56:01.678Z" },
{ url = "https://files.pythonhosted.org/packages/99/04/baae2a1ea1893a01635d475b9261c889a18fd48393634b6270827869fa34/charset_normalizer-3.4.3-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:fd10de089bcdcd1be95a2f73dbe6254798ec1bda9f450d5828c96f93e2536b9c", size = 157430, upload-time = "2025-08-09T07:56:02.87Z" },
{ url = "https://files.pythonhosted.org/packages/2f/36/77da9c6a328c54d17b960c89eccacfab8271fdaaa228305330915b88afa9/charset_normalizer-3.4.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1e8ac75d72fa3775e0b7cb7e4629cec13b7514d928d15ef8ea06bca03ef01cae", size = 151600, upload-time = "2025-08-09T07:56:04.089Z" },
{ url = "https://files.pythonhosted.org/packages/64/d4/9eb4ff2c167edbbf08cdd28e19078bf195762e9bd63371689cab5ecd3d0d/charset_normalizer-3.4.3-cp311-cp311-win32.whl", hash = "sha256:6cf8fd4c04756b6b60146d98cd8a77d0cdae0e1ca20329da2ac85eed779b6849", size = 99616, upload-time = "2025-08-09T07:56:05.658Z" },
{ url = "https://files.pythonhosted.org/packages/f4/9c/996a4a028222e7761a96634d1820de8a744ff4327a00ada9c8942033089b/charset_normalizer-3.4.3-cp311-cp311-win_amd64.whl", hash = "sha256:31a9a6f775f9bcd865d88ee350f0ffb0e25936a7f930ca98995c05abf1faf21c", size = 107108, upload-time = "2025-08-09T07:56:07.176Z" },
{ url = "https://files.pythonhosted.org/packages/e9/5e/14c94999e418d9b87682734589404a25854d5f5d0408df68bc15b6ff54bb/charset_normalizer-3.4.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:e28e334d3ff134e88989d90ba04b47d84382a828c061d0d1027b1b12a62b39b1", size = 205655, upload-time = "2025-08-09T07:56:08.475Z" },
{ url = "https://files.pythonhosted.org/packages/7d/a8/c6ec5d389672521f644505a257f50544c074cf5fc292d5390331cd6fc9c3/charset_normalizer-3.4.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0cacf8f7297b0c4fcb74227692ca46b4a5852f8f4f24b3c766dd94a1075c4884", size = 146223, upload-time = "2025-08-09T07:56:09.708Z" },
{ url = "https://files.pythonhosted.org/packages/fc/eb/a2ffb08547f4e1e5415fb69eb7db25932c52a52bed371429648db4d84fb1/charset_normalizer-3.4.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c6fd51128a41297f5409deab284fecbe5305ebd7e5a1f959bee1c054622b7018", size = 159366, upload-time = "2025-08-09T07:56:11.326Z" },
{ url = "https://files.pythonhosted.org/packages/82/10/0fd19f20c624b278dddaf83b8464dcddc2456cb4b02bb902a6da126b87a1/charset_normalizer-3.4.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3cfb2aad70f2c6debfbcb717f23b7eb55febc0bb23dcffc0f076009da10c6392", size = 157104, upload-time = "2025-08-09T07:56:13.014Z" },
{ url = "https://files.pythonhosted.org/packages/16/ab/0233c3231af734f5dfcf0844aa9582d5a1466c985bbed6cedab85af9bfe3/charset_normalizer-3.4.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1606f4a55c0fd363d754049cdf400175ee96c992b1f8018b993941f221221c5f", size = 151830, upload-time = "2025-08-09T07:56:14.428Z" },
{ url = "https://files.pythonhosted.org/packages/ae/02/e29e22b4e02839a0e4a06557b1999d0a47db3567e82989b5bb21f3fbbd9f/charset_normalizer-3.4.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:027b776c26d38b7f15b26a5da1044f376455fb3766df8fc38563b4efbc515154", size = 148854, upload-time = "2025-08-09T07:56:16.051Z" },
{ url = "https://files.pythonhosted.org/packages/05/6b/e2539a0a4be302b481e8cafb5af8792da8093b486885a1ae4d15d452bcec/charset_normalizer-3.4.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:42e5088973e56e31e4fa58eb6bd709e42fc03799c11c42929592889a2e54c491", size = 160670, upload-time = "2025-08-09T07:56:17.314Z" },
{ url = "https://files.pythonhosted.org/packages/31/e7/883ee5676a2ef217a40ce0bffcc3d0dfbf9e64cbcfbdf822c52981c3304b/charset_normalizer-3.4.3-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:cc34f233c9e71701040d772aa7490318673aa7164a0efe3172b2981218c26d93", size = 158501, upload-time = "2025-08-09T07:56:18.641Z" },
{ url = "https://files.pythonhosted.org/packages/c1/35/6525b21aa0db614cf8b5792d232021dca3df7f90a1944db934efa5d20bb1/charset_normalizer-3.4.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:320e8e66157cc4e247d9ddca8e21f427efc7a04bbd0ac8a9faf56583fa543f9f", size = 153173, upload-time = "2025-08-09T07:56:20.289Z" },
{ url = "https://files.pythonhosted.org/packages/50/ee/f4704bad8201de513fdc8aac1cabc87e38c5818c93857140e06e772b5892/charset_normalizer-3.4.3-cp312-cp312-win32.whl", hash = "sha256:fb6fecfd65564f208cbf0fba07f107fb661bcd1a7c389edbced3f7a493f70e37", size = 99822, upload-time = "2025-08-09T07:56:21.551Z" },
{ url = "https://files.pythonhosted.org/packages/39/f5/3b3836ca6064d0992c58c7561c6b6eee1b3892e9665d650c803bd5614522/charset_normalizer-3.4.3-cp312-cp312-win_amd64.whl", hash = "sha256:86df271bf921c2ee3818f0522e9a5b8092ca2ad8b065ece5d7d9d0e9f4849bcc", size = 107543, upload-time = "2025-08-09T07:56:23.115Z" },
{ url = "https://files.pythonhosted.org/packages/65/ca/2135ac97709b400c7654b4b764daf5c5567c2da45a30cdd20f9eefe2d658/charset_normalizer-3.4.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:14c2a87c65b351109f6abfc424cab3927b3bdece6f706e4d12faaf3d52ee5efe", size = 205326, upload-time = "2025-08-09T07:56:24.721Z" },
{ url = "https://files.pythonhosted.org/packages/71/11/98a04c3c97dd34e49c7d247083af03645ca3730809a5509443f3c37f7c99/charset_normalizer-3.4.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:41d1fc408ff5fdfb910200ec0e74abc40387bccb3252f3f27c0676731df2b2c8", size = 146008, upload-time = "2025-08-09T07:56:26.004Z" },
{ url = "https://files.pythonhosted.org/packages/60/f5/4659a4cb3c4ec146bec80c32d8bb16033752574c20b1252ee842a95d1a1e/charset_normalizer-3.4.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1bb60174149316da1c35fa5233681f7c0f9f514509b8e399ab70fea5f17e45c9", size = 159196, upload-time = "2025-08-09T07:56:27.25Z" },
{ url = "https://files.pythonhosted.org/packages/86/9e/f552f7a00611f168b9a5865a1414179b2c6de8235a4fa40189f6f79a1753/charset_normalizer-3.4.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:30d006f98569de3459c2fc1f2acde170b7b2bd265dc1943e87e1a4efe1b67c31", size = 156819, upload-time = "2025-08-09T07:56:28.515Z" },
{ url = "https://files.pythonhosted.org/packages/7e/95/42aa2156235cbc8fa61208aded06ef46111c4d3f0de233107b3f38631803/charset_normalizer-3.4.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:416175faf02e4b0810f1f38bcb54682878a4af94059a1cd63b8747244420801f", size = 151350, upload-time = "2025-08-09T07:56:29.716Z" },
{ url = "https://files.pythonhosted.org/packages/c2/a9/3865b02c56f300a6f94fc631ef54f0a8a29da74fb45a773dfd3dcd380af7/charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6aab0f181c486f973bc7262a97f5aca3ee7e1437011ef0c2ec04b5a11d16c927", size = 148644, upload-time = "2025-08-09T07:56:30.984Z" },
{ url = "https://files.pythonhosted.org/packages/77/d9/cbcf1a2a5c7d7856f11e7ac2d782aec12bdfea60d104e60e0aa1c97849dc/charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:fdabf8315679312cfa71302f9bd509ded4f2f263fb5b765cf1433b39106c3cc9", size = 160468, upload-time = "2025-08-09T07:56:32.252Z" },
{ url = "https://files.pythonhosted.org/packages/f6/42/6f45efee8697b89fda4d50580f292b8f7f9306cb2971d4b53f8914e4d890/charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:bd28b817ea8c70215401f657edef3a8aa83c29d447fb0b622c35403780ba11d5", size = 158187, upload-time = "2025-08-09T07:56:33.481Z" },
{ url = "https://files.pythonhosted.org/packages/70/99/f1c3bdcfaa9c45b3ce96f70b14f070411366fa19549c1d4832c935d8e2c3/charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:18343b2d246dc6761a249ba1fb13f9ee9a2bcd95decc767319506056ea4ad4dc", size = 152699, upload-time = "2025-08-09T07:56:34.739Z" },
{ url = "https://files.pythonhosted.org/packages/a3/ad/b0081f2f99a4b194bcbb1934ef3b12aa4d9702ced80a37026b7607c72e58/charset_normalizer-3.4.3-cp313-cp313-win32.whl", hash = "sha256:6fb70de56f1859a3f71261cbe41005f56a7842cc348d3aeb26237560bfa5e0ce", size = 99580, upload-time = "2025-08-09T07:56:35.981Z" },
{ url = "https://files.pythonhosted.org/packages/9a/8f/ae790790c7b64f925e5c953b924aaa42a243fb778fed9e41f147b2a5715a/charset_normalizer-3.4.3-cp313-cp313-win_amd64.whl", hash = "sha256:cf1ebb7d78e1ad8ec2a8c4732c7be2e736f6e5123a4146c5b89c9d1f585f8cef", size = 107366, upload-time = "2025-08-09T07:56:37.339Z" },
{ url = "https://files.pythonhosted.org/packages/8e/91/b5a06ad970ddc7a0e513112d40113e834638f4ca1120eb727a249fb2715e/charset_normalizer-3.4.3-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3cd35b7e8aedeb9e34c41385fda4f73ba609e561faedfae0a9e75e44ac558a15", size = 204342, upload-time = "2025-08-09T07:56:38.687Z" },
{ url = "https://files.pythonhosted.org/packages/ce/ec/1edc30a377f0a02689342f214455c3f6c2fbedd896a1d2f856c002fc3062/charset_normalizer-3.4.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b89bc04de1d83006373429975f8ef9e7932534b8cc9ca582e4db7d20d91816db", size = 145995, upload-time = "2025-08-09T07:56:40.048Z" },
{ url = "https://files.pythonhosted.org/packages/17/e5/5e67ab85e6d22b04641acb5399c8684f4d37caf7558a53859f0283a650e9/charset_normalizer-3.4.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2001a39612b241dae17b4687898843f254f8748b796a2e16f1051a17078d991d", size = 158640, upload-time = "2025-08-09T07:56:41.311Z" },
{ url = "https://files.pythonhosted.org/packages/f1/e5/38421987f6c697ee3722981289d554957c4be652f963d71c5e46a262e135/charset_normalizer-3.4.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8dcfc373f888e4fb39a7bc57e93e3b845e7f462dacc008d9749568b1c4ece096", size = 156636, upload-time = "2025-08-09T07:56:43.195Z" },
{ url = "https://files.pythonhosted.org/packages/a0/e4/5a075de8daa3ec0745a9a3b54467e0c2967daaaf2cec04c845f73493e9a1/charset_normalizer-3.4.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:18b97b8404387b96cdbd30ad660f6407799126d26a39ca65729162fd810a99aa", size = 150939, upload-time = "2025-08-09T07:56:44.819Z" },
{ url = "https://files.pythonhosted.org/packages/02/f7/3611b32318b30974131db62b4043f335861d4d9b49adc6d57c1149cc49d4/charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ccf600859c183d70eb47e05a44cd80a4ce77394d1ac0f79dbd2dd90a69a3a049", size = 148580, upload-time = "2025-08-09T07:56:46.684Z" },
{ url = "https://files.pythonhosted.org/packages/7e/61/19b36f4bd67f2793ab6a99b979b4e4f3d8fc754cbdffb805335df4337126/charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:53cd68b185d98dde4ad8990e56a58dea83a4162161b1ea9272e5c9182ce415e0", size = 159870, upload-time = "2025-08-09T07:56:47.941Z" },
{ url = "https://files.pythonhosted.org/packages/06/57/84722eefdd338c04cf3030ada66889298eaedf3e7a30a624201e0cbe424a/charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:30a96e1e1f865f78b030d65241c1ee850cdf422d869e9028e2fc1d5e4db73b92", size = 157797, upload-time = "2025-08-09T07:56:49.756Z" },
{ url = "https://files.pythonhosted.org/packages/72/2a/aff5dd112b2f14bcc3462c312dce5445806bfc8ab3a7328555da95330e4b/charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d716a916938e03231e86e43782ca7878fb602a125a91e7acb8b5112e2e96ac16", size = 152224, upload-time = "2025-08-09T07:56:51.369Z" },
{ url = "https://files.pythonhosted.org/packages/b7/8c/9839225320046ed279c6e839d51f028342eb77c91c89b8ef2549f951f3ec/charset_normalizer-3.4.3-cp314-cp314-win32.whl", hash = "sha256:c6dbd0ccdda3a2ba7c2ecd9d77b37f3b5831687d8dc1b6ca5f56a4880cc7b7ce", size = 100086, upload-time = "2025-08-09T07:56:52.722Z" },
{ url = "https://files.pythonhosted.org/packages/ee/7a/36fbcf646e41f710ce0a563c1c9a343c6edf9be80786edeb15b6f62e17db/charset_normalizer-3.4.3-cp314-cp314-win_amd64.whl", hash = "sha256:73dc19b562516fc9bcf6e5d6e596df0b4eb98d87e4f79f3ae71840e6ed21361c", size = 107400, upload-time = "2025-08-09T07:56:55.172Z" },
{ url = "https://files.pythonhosted.org/packages/c2/ca/9a0983dd5c8e9733565cf3db4df2b0a2e9a82659fd8aa2a868ac6e4a991f/charset_normalizer-3.4.3-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:70bfc5f2c318afece2f5838ea5e4c3febada0be750fcf4775641052bbba14d05", size = 207520, upload-time = "2025-08-09T07:57:11.026Z" },
{ url = "https://files.pythonhosted.org/packages/39/c6/99271dc37243a4f925b09090493fb96c9333d7992c6187f5cfe5312008d2/charset_normalizer-3.4.3-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:23b6b24d74478dc833444cbd927c338349d6ae852ba53a0d02a2de1fce45b96e", size = 147307, upload-time = "2025-08-09T07:57:12.4Z" },
{ url = "https://files.pythonhosted.org/packages/e4/69/132eab043356bba06eb333cc2cc60c6340857d0a2e4ca6dc2b51312886b3/charset_normalizer-3.4.3-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:34a7f768e3f985abdb42841e20e17b330ad3aaf4bb7e7aeeb73db2e70f077b99", size = 160448, upload-time = "2025-08-09T07:57:13.712Z" },
{ url = "https://files.pythonhosted.org/packages/04/9a/914d294daa4809c57667b77470533e65def9c0be1ef8b4c1183a99170e9d/charset_normalizer-3.4.3-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fb731e5deb0c7ef82d698b0f4c5bb724633ee2a489401594c5c88b02e6cb15f7", size = 157758, upload-time = "2025-08-09T07:57:14.979Z" },
{ url = "https://files.pythonhosted.org/packages/b0/a8/6f5bcf1bcf63cb45625f7c5cadca026121ff8a6c8a3256d8d8cd59302663/charset_normalizer-3.4.3-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:257f26fed7d7ff59921b78244f3cd93ed2af1800ff048c33f624c87475819dd7", size = 152487, upload-time = "2025-08-09T07:57:16.332Z" },
{ url = "https://files.pythonhosted.org/packages/c4/72/d3d0e9592f4e504f9dea08b8db270821c909558c353dc3b457ed2509f2fb/charset_normalizer-3.4.3-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:1ef99f0456d3d46a50945c98de1774da86f8e992ab5c77865ea8b8195341fc19", size = 150054, upload-time = "2025-08-09T07:57:17.576Z" },
{ url = "https://files.pythonhosted.org/packages/20/30/5f64fe3981677fe63fa987b80e6c01042eb5ff653ff7cec1b7bd9268e54e/charset_normalizer-3.4.3-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:2c322db9c8c89009a990ef07c3bcc9f011a3269bc06782f916cd3d9eed7c9312", size = 161703, upload-time = "2025-08-09T07:57:20.012Z" },
{ url = "https://files.pythonhosted.org/packages/e1/ef/dd08b2cac9284fd59e70f7d97382c33a3d0a926e45b15fc21b3308324ffd/charset_normalizer-3.4.3-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:511729f456829ef86ac41ca78c63a5cb55240ed23b4b737faca0eb1abb1c41bc", size = 159096, upload-time = "2025-08-09T07:57:21.329Z" },
{ url = "https://files.pythonhosted.org/packages/45/8c/dcef87cfc2b3f002a6478f38906f9040302c68aebe21468090e39cde1445/charset_normalizer-3.4.3-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:88ab34806dea0671532d3f82d82b85e8fc23d7b2dd12fa837978dad9bb392a34", size = 153852, upload-time = "2025-08-09T07:57:22.608Z" },
{ url = "https://files.pythonhosted.org/packages/63/86/9cbd533bd37883d467fcd1bd491b3547a3532d0fbb46de2b99feeebf185e/charset_normalizer-3.4.3-cp39-cp39-win32.whl", hash = "sha256:16a8770207946ac75703458e2c743631c79c59c5890c80011d536248f8eaa432", size = 99840, upload-time = "2025-08-09T07:57:23.883Z" },
{ url = "https://files.pythonhosted.org/packages/ce/d6/7e805c8e5c46ff9729c49950acc4ee0aeb55efb8b3a56687658ad10c3216/charset_normalizer-3.4.3-cp39-cp39-win_amd64.whl", hash = "sha256:d22dbedd33326a4a5190dd4fe9e9e693ef12160c77382d9e87919bce54f3d4ca", size = 107438, upload-time = "2025-08-09T07:57:25.287Z" },
{ url = "https://files.pythonhosted.org/packages/8a/1f/f041989e93b001bc4e44bb1669ccdcf54d3f00e628229a85b08d330615c5/charset_normalizer-3.4.3-py3-none-any.whl", hash = "sha256:ce571ab16d890d23b5c278547ba694193a45011ff86a9162a71307ed9f86759a", size = 53175, upload-time = "2025-08-09T07:57:26.864Z" },
]
[[package]]
name = "click"
version = "8.1.8"
@@ -163,6 +256,27 @@ toml = [
{ name = "tomli", marker = "python_full_version <= '3.11'" },
]
[[package]]
name = "cssselect"
version = "1.3.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/72/0a/c3ea9573b1dc2e151abfe88c7fe0c26d1892fe6ed02d0cdb30f0d57029d5/cssselect-1.3.0.tar.gz", hash = "sha256:57f8a99424cfab289a1b6a816a43075a4b00948c86b4dcf3ef4ee7e15f7ab0c7", size = 42870, upload-time = "2025-03-10T09:30:29.638Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ee/58/257350f7db99b4ae12b614a36256d9cc870d71d9e451e79c2dc3b23d7c3c/cssselect-1.3.0-py3-none-any.whl", hash = "sha256:56d1bf3e198080cc1667e137bc51de9cadfca259f03c2d4e09037b3e01e30f0d", size = 18786, upload-time = "2025-03-10T09:30:28.048Z" },
]
[[package]]
name = "cssutils"
version = "2.11.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "more-itertools" },
]
sdist = { url = "https://files.pythonhosted.org/packages/33/9f/329d26121fe165be44b1dfff21aa0dc348f04633931f1d20ed6cf448a236/cssutils-2.11.1.tar.gz", hash = "sha256:0563a76513b6af6eebbe788c3bf3d01c920e46b3f90c8416738c5cfc773ff8e2", size = 711657, upload-time = "2024-06-04T15:51:39.373Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a7/ec/bb273b7208c606890dc36540fe667d06ce840a6f62f9fae7e658fcdc90fb/cssutils-2.11.1-py3-none-any.whl", hash = "sha256:a67bfdfdff4f3867fab43698ec4897c1a828eca5973f4073321b3bccaf1199b1", size = 385747, upload-time = "2024-06-04T15:51:37.499Z" },
]
[[package]]
name = "exceptiongroup"
version = "1.3.0"
@@ -194,6 +308,19 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/3d/68/9d4508e893976286d2ead7f8f571314af6c2037af34853a30fd769c02e9d/flask-3.1.1-py3-none-any.whl", hash = "sha256:07aae2bb5eaf77993ef57e357491839f5fd9f4dc281593a81a9e4d79a24f295c", size = 103305, upload-time = "2025-05-13T15:01:15.591Z" },
]
[[package]]
name = "flask-mail"
version = "0.10.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "blinker" },
{ name = "flask" },
]
sdist = { url = "https://files.pythonhosted.org/packages/ba/29/e92dc84c675d1e8d260d5768eb3fb65c70cbd33addecf424187587bee862/flask_mail-0.10.0.tar.gz", hash = "sha256:44083e7b02bbcce792209c06252f8569dd5a325a7aaa76afe7330422bd97881d", size = 8152, upload-time = "2024-05-23T22:30:12.612Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e4/c0/a81083da779f482494d49195d8b6c9fde21072558253e4a9fb2ec969c3c1/flask_mail-0.10.0-py3-none-any.whl", hash = "sha256:a451e490931bb3441d9b11ebab6812a16bfa81855792ae1bf9c1e1e22c4e51e7", size = 8529, upload-time = "2024-05-23T22:30:10.962Z" },
]
[[package]]
name = "flask-sqlalchemy"
version = "3.1.1"
@@ -282,6 +409,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/6c/4c/bf2100cbc1bd07f39bee3b09e7eef39beffe29f5453dc2477a2693737913/greenlet-3.2.3-cp39-cp39-win_amd64.whl", hash = "sha256:aaa7aae1e7f75eaa3ae400ad98f8644bb81e1dc6ba47ce8a93d3f17274e08322", size = 296444, upload-time = "2025-06-05T16:39:22.664Z" },
]
[[package]]
name = "idna"
version = "3.10"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f1/70/7703c29685631f5a7590aa73f1f1d3fa9a380e654b86af429e0934a32f7d/idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9", size = 190490, upload-time = "2024-09-15T18:07:39.745Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442, upload-time = "2024-09-15T18:07:37.964Z" },
]
[[package]]
name = "importlib-metadata"
version = "8.7.0"
@@ -324,6 +460,122 @@ 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 = "lxml"
version = "6.0.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/8f/bd/f9d01fd4132d81c6f43ab01983caea69ec9614b913c290a26738431a015d/lxml-6.0.1.tar.gz", hash = "sha256:2b3a882ebf27dd026df3801a87cf49ff791336e0f94b0fad195db77e01240690", size = 4070214, upload-time = "2025-08-22T10:37:53.525Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b2/06/29693634ad5fc8ae0bab6723ba913c821c780614eea9ab9ebb5b2105d0e4/lxml-6.0.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:3b38e20c578149fdbba1fd3f36cb1928a3aaca4b011dfd41ba09d11fb396e1b9", size = 8381164, upload-time = "2025-08-22T10:31:55.164Z" },
{ url = "https://files.pythonhosted.org/packages/97/e0/69d4113afbda9441f0e4d5574d9336535ead6a0608ee6751b3db0832ade0/lxml-6.0.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:11a052cbd013b7140bbbb38a14e2329b6192478344c99097e378c691b7119551", size = 4553444, upload-time = "2025-08-22T10:31:57.86Z" },
{ url = "https://files.pythonhosted.org/packages/eb/3d/8fa1dbf48a3ea0d6c646f0129bef89a5ecf9a1cfe935e26e07554261d728/lxml-6.0.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:21344d29c82ca8547ea23023bb8e7538fa5d4615a1773b991edf8176a870c1ea", size = 4997433, upload-time = "2025-08-22T10:32:00.058Z" },
{ url = "https://files.pythonhosted.org/packages/2c/52/a48331a269900488b886d527611ab66238cddc6373054a60b3c15d4cefb2/lxml-6.0.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aa8f130f4b2dc94baa909c17bb7994f0268a2a72b9941c872e8e558fd6709050", size = 5155765, upload-time = "2025-08-22T10:32:01.951Z" },
{ url = "https://files.pythonhosted.org/packages/33/3b/8f6778a6fb9d30a692db2b1f5a9547dfcb674b27b397e1d864ca797486b1/lxml-6.0.1-cp310-cp310-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4588806a721552692310ebe9f90c17ac6c7c5dac438cd93e3d74dd60531c3211", size = 5066508, upload-time = "2025-08-22T10:32:04.358Z" },
{ url = "https://files.pythonhosted.org/packages/42/15/c9364f23fa89ef2d3dbb896912aa313108820286223cfa833a0a9e183c9e/lxml-6.0.1-cp310-cp310-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:8466faa66b0353802fb7c054a400ac17ce2cf416e3ad8516eadeff9cba85b741", size = 5405401, upload-time = "2025-08-22T10:32:06.741Z" },
{ url = "https://files.pythonhosted.org/packages/04/af/11985b0d47786161ddcdc53dc06142dc863b81a38da7f221c7b997dd5d4b/lxml-6.0.1-cp310-cp310-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:50b5e54f6a9461b1e9c08b4a3420415b538d4773bd9df996b9abcbfe95f4f1fd", size = 5287651, upload-time = "2025-08-22T10:32:08.697Z" },
{ url = "https://files.pythonhosted.org/packages/6a/42/74b35ccc9ef1bb53f0487a4dace5ff612f1652d27faafe91ada7f7b9ee60/lxml-6.0.1-cp310-cp310-manylinux_2_31_armv7l.whl", hash = "sha256:6f393e10685b37f15b1daef8aa0d734ec61860bb679ec447afa0001a31e7253f", size = 4771036, upload-time = "2025-08-22T10:32:10.579Z" },
{ url = "https://files.pythonhosted.org/packages/b0/5a/b934534f83561ad71fb64ba1753992e836ea73776cfb56fc0758dbb46bdf/lxml-6.0.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:07038c62fd0fe2743e2f5326f54d464715373c791035d7dda377b3c9a5d0ad77", size = 5109855, upload-time = "2025-08-22T10:32:13.012Z" },
{ url = "https://files.pythonhosted.org/packages/6c/26/d833a56ec8ca943b696f3a7a1e54f97cfb63754c951037de5e222c011f3b/lxml-6.0.1-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:7a44a5fb1edd11b3a65c12c23e1049c8ae49d90a24253ff18efbcb6aa042d012", size = 4798088, upload-time = "2025-08-22T10:32:15.128Z" },
{ url = "https://files.pythonhosted.org/packages/3f/cb/601aa274c7cda51d0cc84a13d9639096c1191de9d9adf58f6c195d4822a2/lxml-6.0.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:a57d9eb9aadf311c9e8785230eec83c6abb9aef2adac4c0587912caf8f3010b8", size = 5313252, upload-time = "2025-08-22T10:32:17.44Z" },
{ url = "https://files.pythonhosted.org/packages/76/4e/e079f7b324e6d5f83007f30855448646e1cba74b5c30da1a081df75eba89/lxml-6.0.1-cp310-cp310-win32.whl", hash = "sha256:d877874a31590b72d1fa40054b50dc33084021bfc15d01b3a661d85a302af821", size = 3611251, upload-time = "2025-08-22T10:32:19.223Z" },
{ url = "https://files.pythonhosted.org/packages/65/0a/da298d7a96316c75ae096686de8d036d814ec3b72c7d643a2c226c364168/lxml-6.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:c43460f4aac016ee0e156bfa14a9de9b3e06249b12c228e27654ac3996a46d5b", size = 4031884, upload-time = "2025-08-22T10:32:21.054Z" },
{ url = "https://files.pythonhosted.org/packages/0f/65/d7f61082fecf4543ab084e8bd3d4b9be0c1a0c83979f1fa2258e2a7987fb/lxml-6.0.1-cp310-cp310-win_arm64.whl", hash = "sha256:615bb6c73fed7929e3a477a3297a797892846b253d59c84a62c98bdce3849a0a", size = 3679487, upload-time = "2025-08-22T10:32:22.781Z" },
{ url = "https://files.pythonhosted.org/packages/29/c8/262c1d19339ef644cdc9eb5aad2e85bd2d1fa2d7c71cdef3ede1a3eed84d/lxml-6.0.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:c6acde83f7a3d6399e6d83c1892a06ac9b14ea48332a5fbd55d60b9897b9570a", size = 8422719, upload-time = "2025-08-22T10:32:24.848Z" },
{ url = "https://files.pythonhosted.org/packages/e5/d4/1b0afbeb801468a310642c3a6f6704e53c38a4a6eb1ca6faea013333e02f/lxml-6.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:0d21c9cacb6a889cbb8eeb46c77ef2c1dd529cde10443fdeb1de847b3193c541", size = 4575763, upload-time = "2025-08-22T10:32:27.057Z" },
{ url = "https://files.pythonhosted.org/packages/5b/c1/8db9b5402bf52ceb758618313f7423cd54aea85679fcf607013707d854a8/lxml-6.0.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:847458b7cd0d04004895f1fb2cca8e7c0f8ec923c49c06b7a72ec2d48ea6aca2", size = 4943244, upload-time = "2025-08-22T10:32:28.847Z" },
{ url = "https://files.pythonhosted.org/packages/e7/78/838e115358dd2369c1c5186080dd874a50a691fb5cd80db6afe5e816e2c6/lxml-6.0.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1dc13405bf315d008fe02b1472d2a9d65ee1c73c0a06de5f5a45e6e404d9a1c0", size = 5081725, upload-time = "2025-08-22T10:32:30.666Z" },
{ url = "https://files.pythonhosted.org/packages/c7/b6/bdcb3a3ddd2438c5b1a1915161f34e8c85c96dc574b0ef3be3924f36315c/lxml-6.0.1-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:70f540c229a8c0a770dcaf6d5af56a5295e0fc314fc7ef4399d543328054bcea", size = 5021238, upload-time = "2025-08-22T10:32:32.49Z" },
{ url = "https://files.pythonhosted.org/packages/73/e5/1bfb96185dc1a64c7c6fbb7369192bda4461952daa2025207715f9968205/lxml-6.0.1-cp311-cp311-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:d2f73aef768c70e8deb8c4742fca4fd729b132fda68458518851c7735b55297e", size = 5343744, upload-time = "2025-08-22T10:32:34.385Z" },
{ url = "https://files.pythonhosted.org/packages/a2/ae/df3ea9ebc3c493b9c6bdc6bd8c554ac4e147f8d7839993388aab57ec606d/lxml-6.0.1-cp311-cp311-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e7f4066b85a4fa25ad31b75444bd578c3ebe6b8ed47237896341308e2ce923c3", size = 5223477, upload-time = "2025-08-22T10:32:36.256Z" },
{ url = "https://files.pythonhosted.org/packages/37/b3/65e1e33600542c08bc03a4c5c9c306c34696b0966a424a3be6ffec8038ed/lxml-6.0.1-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:0cce65db0cd8c750a378639900d56f89f7d6af11cd5eda72fde054d27c54b8ce", size = 4676626, upload-time = "2025-08-22T10:32:38.793Z" },
{ url = "https://files.pythonhosted.org/packages/7a/46/ee3ed8f3a60e9457d7aea46542d419917d81dbfd5700fe64b2a36fb5ef61/lxml-6.0.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c372d42f3eee5844b69dcab7b8d18b2f449efd54b46ac76970d6e06b8e8d9a66", size = 5066042, upload-time = "2025-08-22T10:32:41.134Z" },
{ url = "https://files.pythonhosted.org/packages/9c/b9/8394538e7cdbeb3bfa36bc74924be1a4383e0bb5af75f32713c2c4aa0479/lxml-6.0.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:2e2b0e042e1408bbb1c5f3cfcb0f571ff4ac98d8e73f4bf37c5dd179276beedd", size = 4724714, upload-time = "2025-08-22T10:32:43.94Z" },
{ url = "https://files.pythonhosted.org/packages/b3/21/3ef7da1ea2a73976c1a5a311d7cde5d379234eec0968ee609517714940b4/lxml-6.0.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:cc73bb8640eadd66d25c5a03175de6801f63c535f0f3cf50cac2f06a8211f420", size = 5247376, upload-time = "2025-08-22T10:32:46.263Z" },
{ url = "https://files.pythonhosted.org/packages/26/7d/0980016f124f00c572cba6f4243e13a8e80650843c66271ee692cddf25f3/lxml-6.0.1-cp311-cp311-win32.whl", hash = "sha256:7c23fd8c839708d368e406282d7953cee5134f4592ef4900026d84566d2b4c88", size = 3609499, upload-time = "2025-08-22T10:32:48.156Z" },
{ url = "https://files.pythonhosted.org/packages/b1/08/28440437521f265eff4413eb2a65efac269c4c7db5fd8449b586e75d8de2/lxml-6.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:2516acc6947ecd3c41a4a4564242a87c6786376989307284ddb115f6a99d927f", size = 4036003, upload-time = "2025-08-22T10:32:50.662Z" },
{ url = "https://files.pythonhosted.org/packages/7b/dc/617e67296d98099213a505d781f04804e7b12923ecd15a781a4ab9181992/lxml-6.0.1-cp311-cp311-win_arm64.whl", hash = "sha256:cb46f8cfa1b0334b074f40c0ff94ce4d9a6755d492e6c116adb5f4a57fb6ad96", size = 3679662, upload-time = "2025-08-22T10:32:52.739Z" },
{ url = "https://files.pythonhosted.org/packages/b0/a9/82b244c8198fcdf709532e39a1751943a36b3e800b420adc739d751e0299/lxml-6.0.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:c03ac546adaabbe0b8e4a15d9ad815a281afc8d36249c246aecf1aaad7d6f200", size = 8422788, upload-time = "2025-08-22T10:32:56.612Z" },
{ url = "https://files.pythonhosted.org/packages/c9/8d/1ed2bc20281b0e7ed3e6c12b0a16e64ae2065d99be075be119ba88486e6d/lxml-6.0.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:33b862c7e3bbeb4ba2c96f3a039f925c640eeba9087a4dc7a572ec0f19d89392", size = 4593547, upload-time = "2025-08-22T10:32:59.016Z" },
{ url = "https://files.pythonhosted.org/packages/76/53/d7fd3af95b72a3493bf7fbe842a01e339d8f41567805cecfecd5c71aa5ee/lxml-6.0.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7a3ec1373f7d3f519de595032d4dcafae396c29407cfd5073f42d267ba32440d", size = 4948101, upload-time = "2025-08-22T10:33:00.765Z" },
{ url = "https://files.pythonhosted.org/packages/9d/51/4e57cba4d55273c400fb63aefa2f0d08d15eac021432571a7eeefee67bed/lxml-6.0.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:03b12214fb1608f4cffa181ec3d046c72f7e77c345d06222144744c122ded870", size = 5108090, upload-time = "2025-08-22T10:33:03.108Z" },
{ url = "https://files.pythonhosted.org/packages/f6/6e/5f290bc26fcc642bc32942e903e833472271614e24d64ad28aaec09d5dae/lxml-6.0.1-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:207ae0d5f0f03b30f95e649a6fa22aa73f5825667fee9c7ec6854d30e19f2ed8", size = 5021791, upload-time = "2025-08-22T10:33:06.972Z" },
{ url = "https://files.pythonhosted.org/packages/13/d4/2e7551a86992ece4f9a0f6eebd4fb7e312d30f1e372760e2109e721d4ce6/lxml-6.0.1-cp312-cp312-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:32297b09ed4b17f7b3f448de87a92fb31bb8747496623483788e9f27c98c0f00", size = 5358861, upload-time = "2025-08-22T10:33:08.967Z" },
{ url = "https://files.pythonhosted.org/packages/8a/5f/cb49d727fc388bf5fd37247209bab0da11697ddc5e976ccac4826599939e/lxml-6.0.1-cp312-cp312-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7e18224ea241b657a157c85e9cac82c2b113ec90876e01e1f127312006233756", size = 5652569, upload-time = "2025-08-22T10:33:10.815Z" },
{ url = "https://files.pythonhosted.org/packages/ca/b8/66c1ef8c87ad0f958b0a23998851e610607c74849e75e83955d5641272e6/lxml-6.0.1-cp312-cp312-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a07a994d3c46cd4020c1ea566345cf6815af205b1e948213a4f0f1d392182072", size = 5252262, upload-time = "2025-08-22T10:33:12.673Z" },
{ url = "https://files.pythonhosted.org/packages/1a/ef/131d3d6b9590e64fdbb932fbc576b81fcc686289da19c7cb796257310e82/lxml-6.0.1-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:2287fadaa12418a813b05095485c286c47ea58155930cfbd98c590d25770e225", size = 4710309, upload-time = "2025-08-22T10:33:14.952Z" },
{ url = "https://files.pythonhosted.org/packages/bc/3f/07f48ae422dce44902309aa7ed386c35310929dc592439c403ec16ef9137/lxml-6.0.1-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b4e597efca032ed99f418bd21314745522ab9fa95af33370dcee5533f7f70136", size = 5265786, upload-time = "2025-08-22T10:33:16.721Z" },
{ url = "https://files.pythonhosted.org/packages/11/c7/125315d7b14ab20d9155e8316f7d287a4956098f787c22d47560b74886c4/lxml-6.0.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:9696d491f156226decdd95d9651c6786d43701e49f32bf23715c975539aa2b3b", size = 5062272, upload-time = "2025-08-22T10:33:18.478Z" },
{ url = "https://files.pythonhosted.org/packages/8b/c3/51143c3a5fc5168a7c3ee626418468ff20d30f5a59597e7b156c1e61fba8/lxml-6.0.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:e4e3cd3585f3c6f87cdea44cda68e692cc42a012f0131d25957ba4ce755241a7", size = 4786955, upload-time = "2025-08-22T10:33:20.34Z" },
{ url = "https://files.pythonhosted.org/packages/11/86/73102370a420ec4529647b31c4a8ce8c740c77af3a5fae7a7643212d6f6e/lxml-6.0.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:45cbc92f9d22c28cd3b97f8d07fcefa42e569fbd587dfdac76852b16a4924277", size = 5673557, upload-time = "2025-08-22T10:33:22.282Z" },
{ url = "https://files.pythonhosted.org/packages/d7/2d/aad90afaec51029aef26ef773b8fd74a9e8706e5e2f46a57acd11a421c02/lxml-6.0.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:f8c9bcfd2e12299a442fba94459adf0b0d001dbc68f1594439bfa10ad1ecb74b", size = 5254211, upload-time = "2025-08-22T10:33:24.15Z" },
{ url = "https://files.pythonhosted.org/packages/63/01/c9e42c8c2d8b41f4bdefa42ab05448852e439045f112903dd901b8fbea4d/lxml-6.0.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1e9dc2b9f1586e7cd77753eae81f8d76220eed9b768f337dc83a3f675f2f0cf9", size = 5275817, upload-time = "2025-08-22T10:33:26.007Z" },
{ url = "https://files.pythonhosted.org/packages/bc/1f/962ea2696759abe331c3b0e838bb17e92224f39c638c2068bf0d8345e913/lxml-6.0.1-cp312-cp312-win32.whl", hash = "sha256:987ad5c3941c64031f59c226167f55a04d1272e76b241bfafc968bdb778e07fb", size = 3610889, upload-time = "2025-08-22T10:33:28.169Z" },
{ url = "https://files.pythonhosted.org/packages/41/e2/22c86a990b51b44442b75c43ecb2f77b8daba8c4ba63696921966eac7022/lxml-6.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:abb05a45394fd76bf4a60c1b7bec0e6d4e8dfc569fc0e0b1f634cd983a006ddc", size = 4010925, upload-time = "2025-08-22T10:33:29.874Z" },
{ url = "https://files.pythonhosted.org/packages/b2/21/dc0c73325e5eb94ef9c9d60dbb5dcdcb2e7114901ea9509735614a74e75a/lxml-6.0.1-cp312-cp312-win_arm64.whl", hash = "sha256:c4be29bce35020d8579d60aa0a4e95effd66fcfce31c46ffddf7e5422f73a299", size = 3671922, upload-time = "2025-08-22T10:33:31.535Z" },
{ url = "https://files.pythonhosted.org/packages/43/c4/cd757eeec4548e6652eff50b944079d18ce5f8182d2b2cf514e125e8fbcb/lxml-6.0.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:485eda5d81bb7358db96a83546949c5fe7474bec6c68ef3fa1fb61a584b00eea", size = 8405139, upload-time = "2025-08-22T10:33:34.09Z" },
{ url = "https://files.pythonhosted.org/packages/ff/99/0290bb86a7403893f5e9658490c705fcea103b9191f2039752b071b4ef07/lxml-6.0.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d12160adea318ce3d118f0b4fbdff7d1225c75fb7749429541b4d217b85c3f76", size = 4585954, upload-time = "2025-08-22T10:33:36.294Z" },
{ url = "https://files.pythonhosted.org/packages/88/a7/4bb54dd1e626342a0f7df6ec6ca44fdd5d0e100ace53acc00e9a689ead04/lxml-6.0.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:48c8d335d8ab72f9265e7ba598ae5105a8272437403f4032107dbcb96d3f0b29", size = 4944052, upload-time = "2025-08-22T10:33:38.19Z" },
{ url = "https://files.pythonhosted.org/packages/71/8d/20f51cd07a7cbef6214675a8a5c62b2559a36d9303fe511645108887c458/lxml-6.0.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:405e7cf9dbdbb52722c231e0f1257214202dfa192327fab3de45fd62e0554082", size = 5098885, upload-time = "2025-08-22T10:33:40.035Z" },
{ url = "https://files.pythonhosted.org/packages/5a/63/efceeee7245d45f97d548e48132258a36244d3c13c6e3ddbd04db95ff496/lxml-6.0.1-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:299a790d403335a6a057ade46f92612ebab87b223e4e8c5308059f2dc36f45ed", size = 5017542, upload-time = "2025-08-22T10:33:41.896Z" },
{ url = "https://files.pythonhosted.org/packages/57/5d/92cb3d3499f5caba17f7933e6be3b6c7de767b715081863337ced42eb5f2/lxml-6.0.1-cp313-cp313-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:48da704672f6f9c461e9a73250440c647638cc6ff9567ead4c3b1f189a604ee8", size = 5347303, upload-time = "2025-08-22T10:33:43.868Z" },
{ url = "https://files.pythonhosted.org/packages/69/f8/606fa16a05d7ef5e916c6481c634f40870db605caffed9d08b1a4fb6b989/lxml-6.0.1-cp313-cp313-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:21e364e1bb731489e3f4d51db416f991a5d5da5d88184728d80ecfb0904b1d68", size = 5641055, upload-time = "2025-08-22T10:33:45.784Z" },
{ url = "https://files.pythonhosted.org/packages/b3/01/15d5fc74ebb49eac4e5df031fbc50713dcc081f4e0068ed963a510b7d457/lxml-6.0.1-cp313-cp313-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1bce45a2c32032afddbd84ed8ab092130649acb935536ef7a9559636ce7ffd4a", size = 5242719, upload-time = "2025-08-22T10:33:48.089Z" },
{ url = "https://files.pythonhosted.org/packages/42/a5/1b85e2aaaf8deaa67e04c33bddb41f8e73d07a077bf9db677cec7128bfb4/lxml-6.0.1-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:fa164387ff20ab0e575fa909b11b92ff1481e6876835014e70280769920c4433", size = 4717310, upload-time = "2025-08-22T10:33:49.852Z" },
{ url = "https://files.pythonhosted.org/packages/42/23/f3bb1292f55a725814317172eeb296615db3becac8f1a059b53c51fc1da8/lxml-6.0.1-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7587ac5e000e1594e62278422c5783b34a82b22f27688b1074d71376424b73e8", size = 5254024, upload-time = "2025-08-22T10:33:52.22Z" },
{ url = "https://files.pythonhosted.org/packages/b4/be/4d768f581ccd0386d424bac615d9002d805df7cc8482ae07d529f60a3c1e/lxml-6.0.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:57478424ac4c9170eabf540237125e8d30fad1940648924c058e7bc9fb9cf6dd", size = 5055335, upload-time = "2025-08-22T10:33:54.041Z" },
{ url = "https://files.pythonhosted.org/packages/40/07/ed61d1a3e77d1a9f856c4fab15ee5c09a2853fb7af13b866bb469a3a6d42/lxml-6.0.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:09c74afc7786c10dd6afaa0be2e4805866beadc18f1d843cf517a7851151b499", size = 4784864, upload-time = "2025-08-22T10:33:56.382Z" },
{ url = "https://files.pythonhosted.org/packages/01/37/77e7971212e5c38a55431744f79dff27fd751771775165caea096d055ca4/lxml-6.0.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:7fd70681aeed83b196482d42a9b0dc5b13bab55668d09ad75ed26dff3be5a2f5", size = 5657173, upload-time = "2025-08-22T10:33:58.698Z" },
{ url = "https://files.pythonhosted.org/packages/32/a3/e98806d483941cd9061cc838b1169626acef7b2807261fbe5e382fcef881/lxml-6.0.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:10a72e456319b030b3dd900df6b1f19d89adf06ebb688821636dc406788cf6ac", size = 5245896, upload-time = "2025-08-22T10:34:00.586Z" },
{ url = "https://files.pythonhosted.org/packages/07/de/9bb5a05e42e8623bf06b4638931ea8c8f5eb5a020fe31703abdbd2e83547/lxml-6.0.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b0fa45fb5f55111ce75b56c703843b36baaf65908f8b8d2fbbc0e249dbc127ed", size = 5267417, upload-time = "2025-08-22T10:34:02.719Z" },
{ url = "https://files.pythonhosted.org/packages/f2/43/c1cb2a7c67226266c463ef8a53b82d42607228beb763b5fbf4867e88a21f/lxml-6.0.1-cp313-cp313-win32.whl", hash = "sha256:01dab65641201e00c69338c9c2b8a0f2f484b6b3a22d10779bb417599fae32b5", size = 3610051, upload-time = "2025-08-22T10:34:04.553Z" },
{ url = "https://files.pythonhosted.org/packages/34/96/6a6c3b8aa480639c1a0b9b6faf2a63fb73ab79ffcd2a91cf28745faa22de/lxml-6.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:bdf8f7c8502552d7bff9e4c98971910a0a59f60f88b5048f608d0a1a75e94d1c", size = 4009325, upload-time = "2025-08-22T10:34:06.24Z" },
{ url = "https://files.pythonhosted.org/packages/8c/66/622e8515121e1fd773e3738dae71b8df14b12006d9fb554ce90886689fd0/lxml-6.0.1-cp313-cp313-win_arm64.whl", hash = "sha256:a6aeca75959426b9fd8d4782c28723ba224fe07cfa9f26a141004210528dcbe2", size = 3670443, upload-time = "2025-08-22T10:34:07.974Z" },
{ url = "https://files.pythonhosted.org/packages/38/e3/b7eb612ce07abe766918a7e581ec6a0e5212352194001fd287c3ace945f0/lxml-6.0.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:29b0e849ec7030e3ecb6112564c9f7ad6881e3b2375dd4a0c486c5c1f3a33859", size = 8426160, upload-time = "2025-08-22T10:34:10.154Z" },
{ url = "https://files.pythonhosted.org/packages/35/8f/ab3639a33595cf284fe733c6526da2ca3afbc5fd7f244ae67f3303cec654/lxml-6.0.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:02a0f7e629f73cc0be598c8b0611bf28ec3b948c549578a26111b01307fd4051", size = 4589288, upload-time = "2025-08-22T10:34:12.972Z" },
{ url = "https://files.pythonhosted.org/packages/2c/65/819d54f2e94d5c4458c1db8c1ccac9d05230b27c1038937d3d788eb406f9/lxml-6.0.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:beab5e54de016e730875f612ba51e54c331e2fa6dc78ecf9a5415fc90d619348", size = 4964523, upload-time = "2025-08-22T10:34:15.474Z" },
{ url = "https://files.pythonhosted.org/packages/5b/4a/d4a74ce942e60025cdaa883c5a4478921a99ce8607fc3130f1e349a83b28/lxml-6.0.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:92a08aefecd19ecc4ebf053c27789dd92c87821df2583a4337131cf181a1dffa", size = 5101108, upload-time = "2025-08-22T10:34:17.348Z" },
{ url = "https://files.pythonhosted.org/packages/cb/48/67f15461884074edd58af17b1827b983644d1fae83b3d909e9045a08b61e/lxml-6.0.1-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:36c8fa7e177649470bc3dcf7eae6bee1e4984aaee496b9ccbf30e97ac4127fa2", size = 5053498, upload-time = "2025-08-22T10:34:19.232Z" },
{ url = "https://files.pythonhosted.org/packages/b6/d4/ec1bf1614828a5492f4af0b6a9ee2eb3e92440aea3ac4fa158e5228b772b/lxml-6.0.1-cp314-cp314-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:5d08e0f1af6916267bb7eff21c09fa105620f07712424aaae09e8cb5dd4164d1", size = 5351057, upload-time = "2025-08-22T10:34:21.143Z" },
{ url = "https://files.pythonhosted.org/packages/65/2b/c85929dacac08821f2100cea3eb258ce5c8804a4e32b774f50ebd7592850/lxml-6.0.1-cp314-cp314-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9705cdfc05142f8c38c97a61bd3a29581ceceb973a014e302ee4a73cc6632476", size = 5671579, upload-time = "2025-08-22T10:34:23.528Z" },
{ url = "https://files.pythonhosted.org/packages/d0/36/cf544d75c269b9aad16752fd9f02d8e171c5a493ca225cb46bb7ba72868c/lxml-6.0.1-cp314-cp314-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:74555e2da7c1636e30bff4e6e38d862a634cf020ffa591f1f63da96bf8b34772", size = 5250403, upload-time = "2025-08-22T10:34:25.642Z" },
{ url = "https://files.pythonhosted.org/packages/c2/e8/83dbc946ee598fd75fdeae6151a725ddeaab39bb321354a9468d4c9f44f3/lxml-6.0.1-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:e38b5f94c5a2a5dadaddd50084098dfd005e5a2a56cd200aaf5e0a20e8941782", size = 4696712, upload-time = "2025-08-22T10:34:27.753Z" },
{ url = "https://files.pythonhosted.org/packages/f4/72/889c633b47c06205743ba935f4d1f5aa4eb7f0325d701ed2b0540df1b004/lxml-6.0.1-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a5ec101a92ddacb4791977acfc86c1afd624c032974bfb6a21269d1083c9bc49", size = 5268177, upload-time = "2025-08-22T10:34:29.804Z" },
{ url = "https://files.pythonhosted.org/packages/b0/b6/f42a21a1428479b66ea0da7bd13e370436aecaff0cfe93270c7e165bd2a4/lxml-6.0.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:5c17e70c82fd777df586c12114bbe56e4e6f823a971814fd40dec9c0de518772", size = 5094648, upload-time = "2025-08-22T10:34:31.703Z" },
{ url = "https://files.pythonhosted.org/packages/51/b0/5f8c1e8890e2ee1c2053c2eadd1cb0e4b79e2304e2912385f6ca666f48b1/lxml-6.0.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:45fdd0415a0c3d91640b5d7a650a8f37410966a2e9afebb35979d06166fd010e", size = 4745220, upload-time = "2025-08-22T10:34:33.595Z" },
{ url = "https://files.pythonhosted.org/packages/eb/f9/820b5125660dae489ca3a21a36d9da2e75dd6b5ffe922088f94bbff3b8a0/lxml-6.0.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:d417eba28981e720a14fcb98f95e44e7a772fe25982e584db38e5d3b6ee02e79", size = 5692913, upload-time = "2025-08-22T10:34:35.482Z" },
{ url = "https://files.pythonhosted.org/packages/23/8e/a557fae9eec236618aecf9ff35fec18df41b6556d825f3ad6017d9f6e878/lxml-6.0.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:8e5d116b9e59be7934febb12c41cce2038491ec8fdb743aeacaaf36d6e7597e4", size = 5259816, upload-time = "2025-08-22T10:34:37.482Z" },
{ url = "https://files.pythonhosted.org/packages/fa/fd/b266cfaab81d93a539040be699b5854dd24c84e523a1711ee5f615aa7000/lxml-6.0.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c238f0d0d40fdcb695c439fe5787fa69d40f45789326b3bb6ef0d61c4b588d6e", size = 5276162, upload-time = "2025-08-22T10:34:39.507Z" },
{ url = "https://files.pythonhosted.org/packages/25/6c/6f9610fbf1de002048e80585ea4719591921a0316a8565968737d9f125ca/lxml-6.0.1-cp314-cp314-win32.whl", hash = "sha256:537b6cf1c5ab88cfd159195d412edb3e434fee880f206cbe68dff9c40e17a68a", size = 3669595, upload-time = "2025-08-22T10:34:41.783Z" },
{ url = "https://files.pythonhosted.org/packages/72/a5/506775e3988677db24dc75a7b03e04038e0b3d114ccd4bccea4ce0116c15/lxml-6.0.1-cp314-cp314-win_amd64.whl", hash = "sha256:911d0a2bb3ef3df55b3d97ab325a9ca7e438d5112c102b8495321105d25a441b", size = 4079818, upload-time = "2025-08-22T10:34:44.04Z" },
{ url = "https://files.pythonhosted.org/packages/0a/44/9613f300201b8700215856e5edd056d4e58dd23368699196b58877d4408b/lxml-6.0.1-cp314-cp314-win_arm64.whl", hash = "sha256:2834377b0145a471a654d699bdb3a2155312de492142ef5a1d426af2c60a0a31", size = 3753901, upload-time = "2025-08-22T10:34:45.799Z" },
{ url = "https://files.pythonhosted.org/packages/04/e7/8b1c778d0ea244079a081358f7bef91408f430d67ec8f1128c9714b40a6a/lxml-6.0.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:edb975280633a68d0988b11940834ce2b0fece9f5278297fc50b044cb713f0e1", size = 8387609, upload-time = "2025-08-22T10:36:54.252Z" },
{ url = "https://files.pythonhosted.org/packages/e4/97/af75a865b0314c8f2bd5594662a8580fe7ad46e506bfad203bf632ace69a/lxml-6.0.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:d4c5acb9bc22f2026bbd0ecbfdb890e9b3e5b311b992609d35034706ad111b5d", size = 4557206, upload-time = "2025-08-22T10:36:56.811Z" },
{ url = "https://files.pythonhosted.org/packages/29/40/f3ab2e07b60196100cc00a1559715f10a5d980eba5e568069db0897108cc/lxml-6.0.1-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:47ab1aff82a95a07d96c1eff4eaebec84f823e0dfb4d9501b1fbf9621270c1d3", size = 5001564, upload-time = "2025-08-22T10:36:59.479Z" },
{ url = "https://files.pythonhosted.org/packages/da/66/0d1e19e8ec32bad8fca5145128efd830f180cd0a46f4d3b3197ffadae025/lxml-6.0.1-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:faa7233bdb7a4365e2411a665d034c370ac82798a926e65f76c26fbbf0fd14b7", size = 5159268, upload-time = "2025-08-22T10:37:02.084Z" },
{ url = "https://files.pythonhosted.org/packages/4c/f3/e93e485184a9265b2da964964f8a2f0f22a75504c27241937177b1cbe1ca/lxml-6.0.1-cp39-cp39-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c71a0ce0e08c7e11e64895c720dc7752bf064bfecd3eb2c17adcd7bfa8ffb22c", size = 5069618, upload-time = "2025-08-22T10:37:05.275Z" },
{ url = "https://files.pythonhosted.org/packages/ba/95/83e9ef69fa527495166ea83da46865659968f09f2a27b6ad85eee9459177/lxml-6.0.1-cp39-cp39-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:57744270a512a93416a149f8b6ea1dbbbee127f5edcbcd5adf28e44b6ff02f33", size = 5408879, upload-time = "2025-08-22T10:37:07.52Z" },
{ url = "https://files.pythonhosted.org/packages/bb/84/036366ca92c348f5f582ab24537d9016b5587685bea4986b3625b9c5b4e9/lxml-6.0.1-cp39-cp39-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e89d977220f7b1f0c725ac76f5c65904193bd4c264577a3af9017de17560ea7e", size = 5291262, upload-time = "2025-08-22T10:37:09.768Z" },
{ url = "https://files.pythonhosted.org/packages/e8/6a/edf19356c65597db9d84cc6442f1f83efb6fbc6615d700defc409c213646/lxml-6.0.1-cp39-cp39-manylinux_2_31_armv7l.whl", hash = "sha256:0c8f7905f1971c2c408badf49ae0ef377cc54759552bcf08ae7a0a8ed18999c2", size = 4775119, upload-time = "2025-08-22T10:37:12.078Z" },
{ url = "https://files.pythonhosted.org/packages/06/e5/2461c902f3c6b493945122c72817e202b28d0d57b75afe30d048c330afa7/lxml-6.0.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:ea27626739e82f2be18cbb1aff7ad59301c723dc0922d9a00bc4c27023f16ab7", size = 5115347, upload-time = "2025-08-22T10:37:14.222Z" },
{ url = "https://files.pythonhosted.org/packages/5a/89/77ba6c34fb3117bf8c306faeed969220c80016ecdf4eb4c485224c3c1a31/lxml-6.0.1-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:21300d8c1bbcc38925aabd4b3c2d6a8b09878daf9e8f2035f09b5b002bcddd66", size = 4800640, upload-time = "2025-08-22T10:37:16.886Z" },
{ url = "https://files.pythonhosted.org/packages/d2/f0/a94cf22539276c240f17b92213cef2e0476297d7a489bc08aad57df75b49/lxml-6.0.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:021497a94907c5901cd49d24b5b0fdd18d198a06611f5ce26feeb67c901b92f2", size = 5316865, upload-time = "2025-08-22T10:37:19.385Z" },
{ url = "https://files.pythonhosted.org/packages/83/a5/be1ffae7efa7d2a1a0d9e95cccd5b8bec9b4aa9a8175624ba6cfc5fbcd98/lxml-6.0.1-cp39-cp39-win32.whl", hash = "sha256:620869f2a3ec1475d000b608024f63259af8d200684de380ccb9650fbc14d1bb", size = 3613293, upload-time = "2025-08-22T10:37:21.881Z" },
{ url = "https://files.pythonhosted.org/packages/89/61/150e6ed573db558b8aadd5e23d391e7361730608a29058d0791b171f2cba/lxml-6.0.1-cp39-cp39-win_amd64.whl", hash = "sha256:afae3a15889942426723839a3cf56dab5e466f7d873640a7a3c53abc671e2387", size = 4034539, upload-time = "2025-08-22T10:37:23.784Z" },
{ url = "https://files.pythonhosted.org/packages/9f/fc/f6624e88171b3fd3dfd4c3f4bbd577a5315ce1247a7c0c5fa7238d825dc5/lxml-6.0.1-cp39-cp39-win_arm64.whl", hash = "sha256:2719e42acda8f3444a0d88204fd90665116dda7331934da4d479dd9296c33ce2", size = 3682596, upload-time = "2025-08-22T10:37:25.773Z" },
{ url = "https://files.pythonhosted.org/packages/ae/61/ad51fbecaf741f825d496947b19d8aea0dcd323fdc2be304e93ce59f66f0/lxml-6.0.1-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:0abfbaf4ebbd7fd33356217d317b6e4e2ef1648be6a9476a52b57ffc6d8d1780", size = 3891543, upload-time = "2025-08-22T10:37:27.849Z" },
{ url = "https://files.pythonhosted.org/packages/1b/7f/310bef082cc69d0db46a8b9d8ca5f4a8fb41e1c5d299ef4ca5f391c4f12d/lxml-6.0.1-pp310-pypy310_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:1ebbf2d9775be149235abebdecae88fe3b3dd06b1797cd0f6dffe6948e85309d", size = 4215518, upload-time = "2025-08-22T10:37:30.065Z" },
{ url = "https://files.pythonhosted.org/packages/86/cc/dc5833def5998c783500666468df127d6d919e8b9678866904e5680b0b13/lxml-6.0.1-pp310-pypy310_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a389e9f11c010bd30531325805bbe97bdf7f728a73d0ec475adef57ffec60547", size = 4325058, upload-time = "2025-08-22T10:37:32.125Z" },
{ url = "https://files.pythonhosted.org/packages/1b/dc/bdd4d413844b5348134444d64911f6f34b211f8b778361946d07623fc904/lxml-6.0.1-pp310-pypy310_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8f5cf2addfbbe745251132c955ad62d8519bb4b2c28b0aa060eca4541798d86e", size = 4267739, upload-time = "2025-08-22T10:37:34.03Z" },
{ url = "https://files.pythonhosted.org/packages/d9/14/e60e9d46972603753824eb7bea06fbe4153c627cc0f7110111253b7c9fc5/lxml-6.0.1-pp310-pypy310_pp73-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f1b60a3287bf33a2a54805d76b82055bcc076e445fd539ee9ae1fe85ed373691", size = 4410303, upload-time = "2025-08-22T10:37:36.002Z" },
{ url = "https://files.pythonhosted.org/packages/42/fa/268c9be8c69a418b8106e096687aba2b1a781fb6fc1b3f04955fac2be2b9/lxml-6.0.1-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:f7bbfb0751551a8786915fc6b615ee56344dacc1b1033697625b553aefdd9837", size = 3516013, upload-time = "2025-08-22T10:37:38.739Z" },
{ url = "https://files.pythonhosted.org/packages/41/37/41961f53f83ded57b37e65e4f47d1c6c6ef5fd02cb1d6ffe028ba0efa7d4/lxml-6.0.1-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:b556aaa6ef393e989dac694b9c95761e32e058d5c4c11ddeef33f790518f7a5e", size = 3903412, upload-time = "2025-08-22T10:37:40.758Z" },
{ url = "https://files.pythonhosted.org/packages/3d/47/8631ea73f3dc776fb6517ccde4d5bd5072f35f9eacbba8c657caa4037a69/lxml-6.0.1-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:64fac7a05ebb3737b79fd89fe5a5b6c5546aac35cfcfd9208eb6e5d13215771c", size = 4224810, upload-time = "2025-08-22T10:37:42.839Z" },
{ url = "https://files.pythonhosted.org/packages/3d/b8/39ae30ca3b1516729faeef941ed84bf8f12321625f2644492ed8320cb254/lxml-6.0.1-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:038d3c08babcfce9dc89aaf498e6da205efad5b7106c3b11830a488d4eadf56b", size = 4329221, upload-time = "2025-08-22T10:37:45.223Z" },
{ url = "https://files.pythonhosted.org/packages/9c/ea/048dea6cdfc7a72d40ae8ed7e7d23cf4a6b6a6547b51b492a3be50af0e80/lxml-6.0.1-pp311-pypy311_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:445f2cee71c404ab4259bc21e20339a859f75383ba2d7fb97dfe7c163994287b", size = 4270228, upload-time = "2025-08-22T10:37:47.276Z" },
{ url = "https://files.pythonhosted.org/packages/6b/d4/c2b46e432377c45d611ae2f669aa47971df1586c1a5240675801d0f02bac/lxml-6.0.1-pp311-pypy311_pp73-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e352d8578e83822d70bea88f3d08b9912528e4c338f04ab707207ab12f4b7aac", size = 4416077, upload-time = "2025-08-22T10:37:49.822Z" },
{ url = "https://files.pythonhosted.org/packages/b6/db/8f620f1ac62cf32554821b00b768dd5957ac8e3fd051593532be5b40b438/lxml-6.0.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:51bd5d1a9796ca253db6045ab45ca882c09c071deafffc22e06975b7ace36300", size = 3518127, upload-time = "2025-08-22T10:37:51.66Z" },
]
[[package]]
name = "markupsafe"
version = "3.0.2"
@@ -392,6 +644,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/b3/73/085399401383ce949f727afec55ec3abd76648d04b9f22e1c0e99cb4bec3/MarkupSafe-3.0.2-cp39-cp39-win_amd64.whl", hash = "sha256:6e296a513ca3d94054c2c881cc913116e90fd030ad1c656b3869762b754f5f8a", size = 15506, upload-time = "2024-10-18T15:21:52.974Z" },
]
[[package]]
name = "more-itertools"
version = "10.8.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/ea/5d/38b681d3fce7a266dd9ab73c66959406d565b3e85f21d5e66e1181d93721/more_itertools-10.8.0.tar.gz", hash = "sha256:f638ddf8a1a0d134181275fb5d58b086ead7c6a72429ad725c67503f13ba30bd", size = 137431, upload-time = "2025-09-02T15:23:11.018Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a4/8e/469e5a4a2f5855992e425f3cb33804cc07bf18d48f2db061aec61ce50270/more_itertools-10.8.0-py3-none-any.whl", hash = "sha256:52d4362373dcf7c52546bc4af9a86ee7c4579df9a8dc268be0a2f949d376cc9b", size = 69667, upload-time = "2025-09-02T15:23:09.635Z" },
]
[[package]]
name = "packaging"
version = "25.0"
@@ -410,6 +671,22 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
]
[[package]]
name = "premailer"
version = "3.10.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cachetools" },
{ name = "cssselect" },
{ name = "cssutils" },
{ name = "lxml" },
{ name = "requests" },
]
sdist = { url = "https://files.pythonhosted.org/packages/a3/6f/e49bd31941eff2987076383fa6d811eb785a28f498f5bb131e981bd71e13/premailer-3.10.0.tar.gz", hash = "sha256:d1875a8411f5dc92b53ef9f193db6c0f879dc378d618e0ad292723e388bfe4c2", size = 24342, upload-time = "2021-08-02T20:32:54.328Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b1/07/4e8d94f94c7d41ca5ddf8a9695ad87b888104e2fd41a35546c1dc9ca74ac/premailer-3.10.0-py2.py3-none-any.whl", hash = "sha256:021b8196364d7df96d04f9ade51b794d0b77bcc19e998321c515633a2273be1a", size = 19544, upload-time = "2021-08-02T20:32:52.771Z" },
]
[[package]]
name = "psutil"
version = "7.0.0"
@@ -613,14 +890,31 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/5f/ed/539768cf28c661b5b068d66d96a2f155c4971a5d55684a514c1a0e0dec2f/python_dotenv-1.1.1-py3-none-any.whl", hash = "sha256:31f23644fe2602f88ff55e1f5c79ba497e01224ee7737937930c448e4d0e24dc", size = 20556, upload-time = "2025-06-24T04:21:06.073Z" },
]
[[package]]
name = "requests"
version = "2.32.5"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "certifi" },
{ name = "charset-normalizer" },
{ name = "idna" },
{ name = "urllib3" },
]
sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517, upload-time = "2025-08-18T20:46:02.573Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" },
]
[[package]]
name = "school-management"
version = "0.1.0"
source = { editable = "." }
dependencies = [
{ name = "flask" },
{ name = "flask-mail" },
{ name = "flask-sqlalchemy" },
{ name = "flask-wtf" },
{ name = "premailer" },
{ name = "pydantic" },
{ name = "python-dotenv" },
{ name = "wtforms" },
@@ -637,8 +931,10 @@ dev = [
[package.metadata]
requires-dist = [
{ name = "flask", specifier = ">=2.3.3" },
{ name = "flask-mail", specifier = ">=0.9.1" },
{ name = "flask-sqlalchemy", specifier = ">=3.0.5" },
{ name = "flask-wtf", specifier = ">=1.1.1" },
{ name = "premailer", specifier = ">=3.10.0" },
{ name = "pydantic", specifier = ">=2.0.0" },
{ name = "python-dotenv", specifier = ">=1.0.0" },
{ name = "wtforms", specifier = ">=3.0.1" },
@@ -765,6 +1061,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/17/69/cd203477f944c353c31bade965f880aa1061fd6bf05ded0726ca845b6ff7/typing_inspection-0.4.1-py3-none-any.whl", hash = "sha256:389055682238f53b04f7badcb49b989835495a96700ced5dab2d8feae4b26f51", size = 14552, upload-time = "2025-05-21T18:55:22.152Z" },
]
[[package]]
name = "urllib3"
version = "2.5.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/15/22/9ee70a2574a4f4599c47dd506532914ce044817c7752a79b6a51286319bc/urllib3-2.5.0.tar.gz", hash = "sha256:3fc47733c7e419d4bc3f6b3dc2b4f890bb743906a30d56ba4a5bfa4bbff92760", size = 393185, upload-time = "2025-06-18T14:07:41.644Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a7/c2/fe1e52489ae3122415c51f387e221dd0773709bad6c6cdaa599e8a2c5185/urllib3-2.5.0-py3-none-any.whl", hash = "sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc", size = 129795, upload-time = "2025-06-18T14:07:40.39Z" },
]
[[package]]
name = "werkzeug"
version = "3.1.3"