From 844d4d6bba78f83e528cea904cae1b5c0869cb44 Mon Sep 17 00:00:00 2001 From: Bertrand Benjamin Date: Wed, 10 Sep 2025 09:04:32 +0200 Subject: [PATCH] feat: add mailing and bilan to send --- app_config.py | 27 ++- debug_smtp_server.py | 57 +++++ decode_email.py | 44 ++++ pyproject.toml | 5 + routes/assessments.py | 168 +++++++++++++- routes/config.py | 99 +++++++++ services/email_service.py | 214 ++++++++++++++++++ services/student_report_service.py | 325 ++++++++++++++++++++++++++++ templates/assessment_results.html | 290 +++++++++++++++++++++++++ templates/config/email.html | 258 ++++++++++++++++++++++ templates/config/index.html | 37 ++++ templates/email/base_email.html | 269 +++++++++++++++++++++++ templates/email/student_report.html | 239 ++++++++++++++++++++ uv.lock | 305 ++++++++++++++++++++++++++ 14 files changed, 2334 insertions(+), 3 deletions(-) create mode 100644 debug_smtp_server.py create mode 100644 decode_email.py create mode 100644 services/email_service.py create mode 100644 services/student_report_service.py create mode 100644 templates/config/email.html create mode 100644 templates/email/base_email.html create mode 100644 templates/email/student_report.html diff --git a/app_config.py b/app_config.py index c613db1..d7423bc 100644 --- a/app_config.py +++ b/app_config.py @@ -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: """ diff --git a/debug_smtp_server.py b/debug_smtp_server.py new file mode 100644 index 0000000..de43077 --- /dev/null +++ b/debug_smtp_server.py @@ -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() \ No newline at end of file diff --git a/decode_email.py b/decode_email.py new file mode 100644 index 0000000..c41c5d7 --- /dev/null +++ b/decode_email.py @@ -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() \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 1b5eed4..745b09e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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", diff --git a/routes/assessments.py b/routes/assessments.py index 8a4514b..e1a43dc 100644 --- a/routes/assessments.py +++ b/routes/assessments.py @@ -438,4 +438,170 @@ def delete(id): db.session.commit() 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')) \ No newline at end of file + return redirect(url_for('assessments.list')) + +@bp.route('//preview-report/') +@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('//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('//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 \ No newline at end of file diff --git a/routes/config.py b/routes/config.py index 1fb829e..3f6ee05 100644 --- a/routes/config.py +++ b/routes/config.py @@ -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.""" diff --git a/services/email_service.py b/services/email_service.py new file mode 100644 index 0000000..cb140c2 --- /dev/null +++ b/services/email_service.py @@ -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 = """ + + +

Test de configuration email

+

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 +

+ + + """ + + 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) + } \ No newline at end of file diff --git a/services/student_report_service.py b/services/student_report_service.py new file mode 100644 index 0000000..0852d4b --- /dev/null +++ b/services/student_report_service.py @@ -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)) + } \ No newline at end of file diff --git a/templates/assessment_results.html b/templates/assessment_results.html index 64ff6f8..c021c45 100644 --- a/templates/assessment_results.html +++ b/templates/assessment_results.html @@ -52,6 +52,17 @@ + +
+ +
+
@@ -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 = ` + + + + Aucune adresse email configurée + `; + } else { + btn.innerHTML = ` + + + + 📧 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 = '

Aucun élève avec adresse email configurée

'; + return; + } + + container.innerHTML = studentsWithEmail.map(student => ` + + `).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 = ` + + + + + 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 = `
+

+ ${result.message} +

+
`; + + if (result.sent_count > 0) { + content += `
+

✅ ${result.sent_count} bilan(s) envoyé(s) avec succès

+
`; + } + + if (result.error_count > 0) { + content += `
+

❌ ${result.error_count} erreur(s):

+
    `; + + result.errors.forEach(error => { + content += `
  • • ${error}
  • `; + }); + + content += `
`; + } + + 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(); + } +}); + + + + {% endblock %} \ No newline at end of file diff --git a/templates/config/email.html b/templates/config/email.html new file mode 100644 index 0000000..9d62655 --- /dev/null +++ b/templates/config/email.html @@ -0,0 +1,258 @@ +{% extends "base.html" %} + +{% block title %}Configuration Email - Gestion Scolaire{% endblock %} + +{% block content %} +
+
+
+ + ← Retour à la configuration + +

Configuration Email

+

Configurez les paramètres SMTP pour l'envoi des bilans d'évaluation

+
+
+ + +
+
+

Paramètres SMTP

+

+ Configurez votre serveur SMTP pour envoyer les bilans d'évaluation par email +

+
+ +
+ +
+ +
+ + +

Ex: smtp.gmail.com, smtp.outlook.com

+
+ + +
+ + +

587 (TLS) ou 465 (SSL) généralement

+
+
+ +
+ +
+ + +

Votre adresse email de connexion SMTP

+
+ + +
+ + +

Mot de passe ou mot de passe d'application

+
+
+ + +
+ + +
+ +
+ +
+ + +

Nom qui apparaîtra comme expéditeur

+
+ + +
+ + +

Adresse qui apparaîtra comme expéditeur

+
+
+ +
+ + Annuler + + +
+
+
+ + +
+
+

Test de configuration

+

+ Testez votre configuration en envoyant un email de test +

+
+ +
+ +
+
+ + +

Un email de test sera envoyé à cette adresse

+
+ +
+ +
+
+
+
+ + +
+

💡 Aide à la configuration

+ +
+
+

Gmail

+
    +
  • Serveur: smtp.gmail.com
  • +
  • Port: 587 (TLS)
  • +
  • Sécurité: Utiliser un mot de passe d'application
  • +
+
+ +
+

Outlook/Hotmail

+
    +
  • Serveur: smtp-mail.outlook.com
  • +
  • Port: 587 (TLS)
  • +
  • Sécurité: Authentification moderne requise
  • +
+
+
+ +
+

+ ⚠️ Sécurité: Pour Gmail, créez un mot de passe d'application dans votre compte Google. + N'utilisez pas votre mot de passe principal. +

+
+
+ + +
+

🧪 Tests en local - Serveur SMTP factice

+ +

+ Pour tester l'envoi d'emails sans configuration réelle, utilisez le serveur SMTP de débogage Python : +

+ +
+

1. Lancer le serveur de débogage

+

Option A: Script inclus dans Notytex (recommandé)

+ + python debug_smtp_server.py + + +

Option B: Avec aiosmtpd

+ + pip install aiosmtpd + + + python -m aiosmtpd -n -l localhost:1025 + + +

💡 Le script inclus affiche les emails avec un formatage amélioré

+

⚠️ Laissez ce terminal ouvert pendant les tests

+
+ +
+

2. Configuration pour les tests

+
    +
  • Serveur SMTP: localhost
  • +
  • Port: 1025
  • +
  • Utilisateur/Mot de passe: Laisser vides
  • +
  • TLS: ❌ Décocher
  • +
  • Adresse expéditeur: test@notytex.local
  • +
+
+ +
+

3. Résultat

+

+ ✅ Tous les emails s'afficheront dans le terminal (contenu HTML complet)
+ ✅ Aucun email réellement envoyé
+ ✅ Parfait pour tester les bilans d'évaluation +

+
+
+
+{% endblock %} \ No newline at end of file diff --git a/templates/config/index.html b/templates/config/index.html index 7aeef13..198d084 100644 --- a/templates/config/index.html +++ b/templates/config/index.html @@ -103,6 +103,43 @@ Configurer l'échelle
+ + +
+
+
+ + + +
+

Configuration Email

+
+

Paramètres SMTP pour l'envoi des bilans d'évaluation

+
+
+ Serveur SMTP : + + {% set smtp_host = app_config.get('email.smtp_host', '') %} + {{ smtp_host if smtp_host else 'Non configuré' }} + +
+
+ Statut : + + {% set smtp_host = app_config.get('email.smtp_host', '') %} + {% set from_address = app_config.get('email.from_address', '') %} + {% if smtp_host and from_address %} + ✅ Configuré + {% else %} + ⚠️ À configurer + {% endif %} + +
+
+ + Configurer Email + +
diff --git a/templates/email/base_email.html b/templates/email/base_email.html new file mode 100644 index 0000000..1f74609 --- /dev/null +++ b/templates/email/base_email.html @@ -0,0 +1,269 @@ + + + + + + {% block title %}Notytex - Bilan d'évaluation{% endblock %} + + + + + + \ No newline at end of file diff --git a/templates/email/student_report.html b/templates/email/student_report.html new file mode 100644 index 0000000..d96077a --- /dev/null +++ b/templates/email/student_report.html @@ -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

+
+
+
{{ report.assessment.class_name }}
+
Classe
+
+
+
{{ report.assessment.date.strftime('%d/%m/%Y') }}
+
Date
+
+
+
T{{ report.assessment.trimester }}
+
Trimestre
+
+
+
×{{ report.assessment.coefficient }}
+
Coefficient
+
+
+ + {% if report.assessment.description %} +

+ Description : {{ report.assessment.description }} +

+ {% endif %} +
+ + +
+

🎯 Note globale

+
+
+ {{ "%.1f"|format(report.results.total_score) }}/{{ "%.1f"|format(report.results.total_max_points) }} +
+
+ Note obtenue sur cette évaluation +
+ +
+
+
+
+
+ + +{% if report.exercises %} +
+

📝 Résultats par exercice

+ + {% for exercise in report.exercises %} +
+

{{ exercise.title }}

+ {% if exercise.description %} +

{{ exercise.description }}

+ {% endif %} + +
+ Score : + + {{ "%.1f"|format(exercise.score) }}/{{ "%.1f"|format(exercise.max_points) }} + +
+ +
+
+
+ + {% if exercise.elements %} +
+

Détail des questions :

+ + + + + + + + + + + {% for element in exercise.elements %} + + + + + + + {% endfor %} + +
QuestionCompétenceDomaineRésultat
+ {{ element.label }} + {% if element.description %} +
{{ element.description }} + {% endif %} +
+ {% if element.skill %} + {{ element.skill }} + {% else %} + - + {% endif %} + + {% if element.domain %} + {{ element.domain }} + {% else %} + - + {% endif %} + + {% if element.raw_value == '.' %} + +
Pas de réponse + {% elif element.grading_type == 'score' and element.raw_value %} + {% set score_value = element.raw_value|int %} +
+ {% for i in range(3) %} + {% if i < score_value %}⭐{% else %}☆{% endif %} + {% endfor %} +
+ + {{ element.score_label if element.score_label else 'Score ' + score_value|string }} + + {% elif element.raw_value %} + {{ element.raw_value }}/{{ element.max_points }} + {% else %} + +
Non noté + {% endif %} +
+
+ {% endif %} +
+ {% endfor %} +
+{% endif %} + + +{% if report.competences %} +
+

⭐ Performances par compétence

+

+ Analyse de vos performances sur chaque compétence évaluée +

+ + {% for competence in report.competences %} +
+
+
{{ competence.name }}
+
+ {{ competence.elements_count }} élément{{ 's' if competence.elements_count > 1 else '' }} évalué{{ 's' if competence.elements_count > 1 else '' }} +
+
+
+ {% 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 %} +
+ {% for i in range(3) %} + {% if i < star_count %}⭐{% else %}☆{% endif %} + {% endfor %} +
+
+ {{ "%.1f"|format(competence.score) }}/{{ "%.1f"|format(competence.max_points) }} +
+
+
+ {% endfor %} +
+{% endif %} + + +{% if report.domains %} +
+

🏷️ Performances par domaine

+

+ Analyse de vos performances par thème/domaine +

+ + {% for domain in report.domains %} +
+
+
{{ domain.name }}
+
+ {{ domain.elements_count }} élément{{ 's' if domain.elements_count > 1 else '' }} évalué{{ 's' if domain.elements_count > 1 else '' }} +
+
+
+ {% 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 %} +
+ {% for i in range(3) %} + {% if i < star_count %}⭐{% else %}☆{% endif %} + {% endfor %} +
+
+ {{ "%.1f"|format(domain.score) }}/{{ "%.1f"|format(domain.max_points) }} +
+
+
+ {% endfor %} +
+{% endif %} + + + +{% if custom_message %} +
+

💬 Message du professeur

+

{{ custom_message }}

+
+{% endif %} + +{% endblock %} \ No newline at end of file diff --git a/uv.lock b/uv.lock index bcbdaf3..5003d9f 100644 --- a/uv.lock +++ b/uv.lock @@ -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"