feat: add mailing and bilan to send
This commit is contained in:
@@ -370,8 +370,31 @@ class ConfigManager:
|
|||||||
return self.default_config['grading_system']['special_values']
|
return self.default_config['grading_system']['special_values']
|
||||||
|
|
||||||
def get_score_meanings(self) -> Dict[int, Dict[str, str]]:
|
def get_score_meanings(self) -> Dict[int, Dict[str, str]]:
|
||||||
"""Récupère les significations des scores (0-3)."""
|
"""Récupère les significations des scores (0-3) depuis la base de données."""
|
||||||
return self.default_config['grading_system']['score_meanings']
|
# 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:
|
def validate_grade_value(self, value: str, grading_type: str, max_points: float = None) -> bool:
|
||||||
"""
|
"""
|
||||||
|
|||||||
57
debug_smtp_server.py
Normal file
57
debug_smtp_server.py
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Serveur SMTP de débogage simple pour tester l'envoi d'emails en local.
|
||||||
|
Usage: python debug_smtp_server.py
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncore
|
||||||
|
import smtpd
|
||||||
|
import sys
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
class DebuggingServer(smtpd.SMTPServer):
|
||||||
|
"""Serveur SMTP qui affiche tous les emails reçus dans le terminal."""
|
||||||
|
|
||||||
|
def process_message(self, peer, mailfrom, rcpttos, data, **kwargs):
|
||||||
|
"""Traite et affiche les messages reçus."""
|
||||||
|
print('=' * 80)
|
||||||
|
print(f'📧 EMAIL REÇU le {datetime.now().strftime("%Y-%m-%d %H:%M:%S")}')
|
||||||
|
print('=' * 80)
|
||||||
|
print(f'🔗 Connexion depuis: {peer}')
|
||||||
|
print(f'📨 De: {mailfrom}')
|
||||||
|
print(f'📬 Vers: {", ".join(rcpttos)}')
|
||||||
|
print('-' * 80)
|
||||||
|
print('📄 CONTENU:')
|
||||||
|
print(data.decode('utf-8', errors='replace'))
|
||||||
|
print('=' * 80)
|
||||||
|
print()
|
||||||
|
|
||||||
|
def main():
|
||||||
|
"""Lance le serveur SMTP de débogage."""
|
||||||
|
host = 'localhost'
|
||||||
|
port = 1025
|
||||||
|
|
||||||
|
print(f'🚀 Démarrage du serveur SMTP de débogage sur {host}:{port}')
|
||||||
|
print('📧 Tous les emails envoyés à ce serveur seront affichés ici')
|
||||||
|
print('❌ Aucun email ne sera réellement envoyé')
|
||||||
|
print('🛑 Appuyez sur Ctrl+C pour arrêter')
|
||||||
|
print('=' * 80)
|
||||||
|
|
||||||
|
try:
|
||||||
|
server = DebuggingServer((host, port), None)
|
||||||
|
print(f'✅ Serveur prêt ! Configurez Notytex avec:')
|
||||||
|
print(f' - Serveur SMTP: {host}')
|
||||||
|
print(f' - Port: {port}')
|
||||||
|
print(f' - TLS: Désactivé')
|
||||||
|
print(f' - Authentification: Aucune')
|
||||||
|
print('=' * 80)
|
||||||
|
asyncore.loop()
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
print('\n🛑 Serveur arrêté par l\'utilisateur')
|
||||||
|
sys.exit(0)
|
||||||
|
except Exception as e:
|
||||||
|
print(f'❌ Erreur: {e}')
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
44
decode_email.py
Normal file
44
decode_email.py
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Script pour décoder le contenu Base64 des emails du serveur de débogage.
|
||||||
|
Usage: python decode_email.py
|
||||||
|
Copiez le contenu Base64 et appuyez sur Ctrl+D pour décoder.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import sys
|
||||||
|
|
||||||
|
def decode_email_content():
|
||||||
|
"""Lit le contenu Base64 depuis stdin et l'affiche décodé."""
|
||||||
|
print("📧 Décodeur d'email Base64")
|
||||||
|
print("Collez le contenu Base64 de l'email et appuyez sur Ctrl+D :")
|
||||||
|
print("-" * 60)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Lire tout le contenu depuis stdin
|
||||||
|
base64_content = sys.stdin.read().strip()
|
||||||
|
|
||||||
|
if not base64_content:
|
||||||
|
print("❌ Aucun contenu fourni")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Décoder le Base64
|
||||||
|
decoded_bytes = base64.b64decode(base64_content)
|
||||||
|
decoded_html = decoded_bytes.decode('utf-8')
|
||||||
|
|
||||||
|
print("=" * 80)
|
||||||
|
print("📄 CONTENU HTML DÉCODÉ :")
|
||||||
|
print("=" * 80)
|
||||||
|
print(decoded_html)
|
||||||
|
print("=" * 80)
|
||||||
|
|
||||||
|
# Optionnel : sauvegarder dans un fichier
|
||||||
|
with open('email_decoded.html', 'w', encoding='utf-8') as f:
|
||||||
|
f.write(decoded_html)
|
||||||
|
print("✅ Email sauvegardé dans 'email_decoded.html'")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ Erreur lors du décodage : {e}")
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
decode_email_content()
|
||||||
@@ -10,6 +10,8 @@ dependencies = [
|
|||||||
"WTForms>=3.0.1",
|
"WTForms>=3.0.1",
|
||||||
"python-dotenv>=1.0.0",
|
"python-dotenv>=1.0.0",
|
||||||
"pydantic>=2.0.0",
|
"pydantic>=2.0.0",
|
||||||
|
"Flask-Mail>=0.9.1",
|
||||||
|
"premailer>=3.10.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[build-system]
|
[build-system]
|
||||||
@@ -26,6 +28,9 @@ dev-dependencies = [
|
|||||||
"pytest-cov>=4.1.0",
|
"pytest-cov>=4.1.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[tool.poetry.group.dev.dependencies]
|
||||||
|
aiosmtpd = "^1.4.6"
|
||||||
|
|
||||||
[dependency-groups]
|
[dependency-groups]
|
||||||
dev = [
|
dev = [
|
||||||
"psutil>=7.0.0",
|
"psutil>=7.0.0",
|
||||||
|
|||||||
@@ -439,3 +439,169 @@ def delete(id):
|
|||||||
current_app.logger.info(f'Évaluation supprimée: {title} (ID: {id})')
|
current_app.logger.info(f'Évaluation supprimée: {title} (ID: {id})')
|
||||||
flash('Évaluation supprimée avec succès !', 'success')
|
flash('Évaluation supprimée avec succès !', 'success')
|
||||||
return redirect(url_for('assessments.list'))
|
return redirect(url_for('assessments.list'))
|
||||||
|
|
||||||
|
@bp.route('/<int:id>/preview-report/<int:student_id>')
|
||||||
|
@handle_db_errors
|
||||||
|
def preview_report(id, student_id):
|
||||||
|
"""Prévisualise le bilan d'un élève dans le navigateur."""
|
||||||
|
from services.student_report_service import StudentReportService
|
||||||
|
from models import Student
|
||||||
|
|
||||||
|
# Récupérer l'évaluation
|
||||||
|
assessment_repo = AssessmentRepository()
|
||||||
|
assessment = assessment_repo.get_with_full_details_or_404(id)
|
||||||
|
|
||||||
|
# Récupérer l'élève
|
||||||
|
student = Student.query.get_or_404(student_id)
|
||||||
|
|
||||||
|
# Générer le rapport
|
||||||
|
report_service = StudentReportService()
|
||||||
|
report_data = report_service.generate_student_report(assessment, student)
|
||||||
|
|
||||||
|
# Afficher le template email directement
|
||||||
|
return render_template('email/student_report.html', report=report_data)
|
||||||
|
|
||||||
|
@bp.route('/<int:id>/send-reports', methods=['POST'])
|
||||||
|
@handle_db_errors
|
||||||
|
def send_reports(id):
|
||||||
|
"""Envoie les bilans d'évaluation par email."""
|
||||||
|
try:
|
||||||
|
# Récupération des données du formulaire
|
||||||
|
data = request.get_json()
|
||||||
|
if not data:
|
||||||
|
return jsonify({'success': False, 'error': 'Aucune donnée fournie'}), 400
|
||||||
|
|
||||||
|
student_ids = data.get('student_ids', [])
|
||||||
|
custom_message = data.get('custom_message', '').strip()
|
||||||
|
|
||||||
|
if not student_ids:
|
||||||
|
return jsonify({'success': False, 'error': 'Aucun élève sélectionné'}), 400
|
||||||
|
|
||||||
|
# Récupération de l'évaluation
|
||||||
|
assessment_repo = AssessmentRepository()
|
||||||
|
assessment = assessment_repo.get_with_full_details_or_404(id)
|
||||||
|
|
||||||
|
# Vérification de la configuration email
|
||||||
|
from services.email_service import EmailService
|
||||||
|
from services.student_report_service import StudentReportService
|
||||||
|
from flask import render_template
|
||||||
|
|
||||||
|
email_service = EmailService()
|
||||||
|
if not email_service.is_configured():
|
||||||
|
return jsonify({
|
||||||
|
'success': False,
|
||||||
|
'error': 'Configuration email incomplète. Rendez-vous dans Configuration > Email.'
|
||||||
|
}), 400
|
||||||
|
|
||||||
|
report_service = StudentReportService()
|
||||||
|
|
||||||
|
# Génération des rapports
|
||||||
|
reports_data = report_service.generate_multiple_reports(assessment, student_ids)
|
||||||
|
|
||||||
|
if reports_data['error_count'] > 0:
|
||||||
|
current_app.logger.warning(f"Erreurs lors de la génération de rapports: {reports_data['errors']}")
|
||||||
|
|
||||||
|
# Envoi des emails
|
||||||
|
sent_count = 0
|
||||||
|
errors = []
|
||||||
|
|
||||||
|
for student_id, report_data in reports_data['reports'].items():
|
||||||
|
try:
|
||||||
|
student = report_data['student']
|
||||||
|
|
||||||
|
# Vérification de l'email de l'élève
|
||||||
|
if not student['email']:
|
||||||
|
errors.append(f"{student['full_name']}: Aucune adresse email")
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Validation de l'email
|
||||||
|
validation = email_service.validate_email_addresses([student['email']])
|
||||||
|
if validation['invalid_count'] > 0:
|
||||||
|
errors.append(f"{student['full_name']}: Adresse email invalide ({student['email']})")
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Génération du HTML de l'email
|
||||||
|
html_content = render_template('email/student_report.html',
|
||||||
|
report=report_data,
|
||||||
|
custom_message=custom_message)
|
||||||
|
|
||||||
|
# Sujet de l'email
|
||||||
|
subject = f"Bilan d'évaluation - {assessment.title} - {student['full_name']}"
|
||||||
|
|
||||||
|
# Envoi de l'email
|
||||||
|
result = email_service.send_email([student['email']], subject, html_content)
|
||||||
|
|
||||||
|
if result['success']:
|
||||||
|
sent_count += 1
|
||||||
|
current_app.logger.info(f"Bilan envoyé à {student['full_name']} ({student['email']})")
|
||||||
|
else:
|
||||||
|
errors.append(f"{student['full_name']}: {result['error']}")
|
||||||
|
current_app.logger.error(f"Erreur envoi bilan à {student['full_name']}: {result['error']}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
error_msg = f"{report_data.get('student', {}).get('full_name', 'Élève inconnu')}: Erreur inattendue - {str(e)}"
|
||||||
|
errors.append(error_msg)
|
||||||
|
current_app.logger.error(f"Erreur envoi bilan: {e}")
|
||||||
|
|
||||||
|
# Préparer la réponse
|
||||||
|
response_data = {
|
||||||
|
'success': sent_count > 0,
|
||||||
|
'sent_count': sent_count,
|
||||||
|
'total_requested': len(student_ids),
|
||||||
|
'error_count': len(errors),
|
||||||
|
'errors': errors
|
||||||
|
}
|
||||||
|
|
||||||
|
if sent_count > 0:
|
||||||
|
if len(errors) == 0:
|
||||||
|
response_data['message'] = f"✅ {sent_count} bilan(s) envoyé(s) avec succès !"
|
||||||
|
else:
|
||||||
|
response_data['message'] = f"✅ {sent_count} bilan(s) envoyé(s), {len(errors)} erreur(s)"
|
||||||
|
else:
|
||||||
|
response_data['message'] = f"❌ Aucun bilan envoyé - {len(errors)} erreur(s)"
|
||||||
|
|
||||||
|
return jsonify(response_data)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
current_app.logger.error(f"Erreur lors de l'envoi de bilans: {e}")
|
||||||
|
return jsonify({
|
||||||
|
'success': False,
|
||||||
|
'error': f'Erreur inattendue: {str(e)}'
|
||||||
|
}), 500
|
||||||
|
|
||||||
|
@bp.route('/<int:id>/eligible-students')
|
||||||
|
@handle_db_errors
|
||||||
|
def get_eligible_students(id):
|
||||||
|
"""Récupère la liste des élèves éligibles avec leurs emails pour l'envoi de bilans."""
|
||||||
|
try:
|
||||||
|
assessment_repo = AssessmentRepository()
|
||||||
|
assessment = assessment_repo.get_with_full_details_or_404(id)
|
||||||
|
|
||||||
|
# Récupérer les élèves éligibles (ceux qui étaient dans la classe à la date de l'évaluation)
|
||||||
|
eligible_students = []
|
||||||
|
for student in assessment.class_group.get_students_at_date(assessment.date):
|
||||||
|
eligible_students.append({
|
||||||
|
'id': student.id,
|
||||||
|
'first_name': student.first_name,
|
||||||
|
'last_name': student.last_name,
|
||||||
|
'full_name': student.full_name,
|
||||||
|
'email': student.email or '',
|
||||||
|
'has_email': bool(student.email)
|
||||||
|
})
|
||||||
|
|
||||||
|
# Trier par nom de famille puis prénom
|
||||||
|
eligible_students.sort(key=lambda x: (x['last_name'].lower(), x['first_name'].lower()))
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'success': True,
|
||||||
|
'students': eligible_students,
|
||||||
|
'total_count': len(eligible_students),
|
||||||
|
'with_email_count': len([s for s in eligible_students if s['has_email']])
|
||||||
|
})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
current_app.logger.error(f"Erreur récupération élèves éligibles: {e}")
|
||||||
|
return jsonify({
|
||||||
|
'success': False,
|
||||||
|
'error': f'Erreur: {str(e)}'
|
||||||
|
}), 500
|
||||||
@@ -446,6 +446,105 @@ def update_general():
|
|||||||
|
|
||||||
return redirect(url_for('config.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'])
|
@bp.route('/reset', methods=['POST'])
|
||||||
def reset_config():
|
def reset_config():
|
||||||
"""Réinitialise la configuration aux valeurs par défaut."""
|
"""Réinitialise la configuration aux valeurs par défaut."""
|
||||||
|
|||||||
214
services/email_service.py
Normal file
214
services/email_service.py
Normal file
@@ -0,0 +1,214 @@
|
|||||||
|
"""
|
||||||
|
Service d'envoi d'emails pour Notytex.
|
||||||
|
Gère la configuration SMTP et l'envoi de bilans d'évaluation.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import smtplib
|
||||||
|
import logging
|
||||||
|
from email.mime.text import MIMEText
|
||||||
|
from email.mime.multipart import MIMEMultipart
|
||||||
|
from typing import List, Optional, Dict, Any
|
||||||
|
from flask import current_app
|
||||||
|
from premailer import transform
|
||||||
|
|
||||||
|
|
||||||
|
class EmailService:
|
||||||
|
"""Service d'envoi d'emails avec configuration dynamique."""
|
||||||
|
|
||||||
|
def __init__(self, config_manager=None):
|
||||||
|
"""Initialise le service avec le gestionnaire de configuration."""
|
||||||
|
if config_manager is None:
|
||||||
|
from app_config import config_manager as default_manager
|
||||||
|
self.config_manager = default_manager
|
||||||
|
else:
|
||||||
|
self.config_manager = config_manager
|
||||||
|
|
||||||
|
self.logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
def get_smtp_config(self) -> Dict[str, Any]:
|
||||||
|
"""Récupère la configuration SMTP depuis la base de données."""
|
||||||
|
try:
|
||||||
|
return {
|
||||||
|
'host': self.config_manager.get('email.smtp_host', ''),
|
||||||
|
'port': int(self.config_manager.get('email.smtp_port', 587)),
|
||||||
|
'username': self.config_manager.get('email.username', ''),
|
||||||
|
'password': self.config_manager.get('email.password', ''),
|
||||||
|
'use_tls': self.config_manager.get('email.use_tls', 'true').lower() == 'true',
|
||||||
|
'from_name': self.config_manager.get('email.from_name', 'Notytex'),
|
||||||
|
'from_address': self.config_manager.get('email.from_address', ''),
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.error(f"Erreur lors de la récupération de la configuration email: {e}")
|
||||||
|
return {}
|
||||||
|
|
||||||
|
def is_configured(self) -> bool:
|
||||||
|
"""Vérifie si la configuration email est complète."""
|
||||||
|
config = self.get_smtp_config()
|
||||||
|
|
||||||
|
# Vérifier les champs obligatoires de base
|
||||||
|
if not config.get('host'):
|
||||||
|
self.logger.warning("Configuration email incomplète: champ 'host' manquant")
|
||||||
|
return False
|
||||||
|
|
||||||
|
if not config.get('from_address'):
|
||||||
|
self.logger.warning("Configuration email incomplète: champ 'from_address' manquant")
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Pour les serveurs locaux de test (localhost), l'authentification n'est pas requise
|
||||||
|
is_localhost = config.get('host', '').lower() in ['localhost', '127.0.0.1']
|
||||||
|
is_test_port = str(config.get('port', '')).strip() in ['1025', '2525', '8025']
|
||||||
|
|
||||||
|
if not is_localhost or not is_test_port:
|
||||||
|
# Pour les vrais serveurs SMTP, username et password sont requis
|
||||||
|
if not config.get('username'):
|
||||||
|
self.logger.warning("Configuration email incomplète: champ 'username' manquant")
|
||||||
|
return False
|
||||||
|
if not config.get('password'):
|
||||||
|
self.logger.warning("Configuration email incomplète: champ 'password' manquant")
|
||||||
|
return False
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
def send_email(self, to_emails: List[str], subject: str, html_body: str,
|
||||||
|
text_body: Optional[str] = None) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Envoie un email à une liste de destinataires.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
to_emails: Liste des adresses email destinataires
|
||||||
|
subject: Sujet de l'email
|
||||||
|
html_body: Corps de l'email en HTML
|
||||||
|
text_body: Corps de l'email en texte brut (optionnel)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict avec le statut de l'envoi et les détails
|
||||||
|
"""
|
||||||
|
if not self.is_configured():
|
||||||
|
return {
|
||||||
|
'success': False,
|
||||||
|
'error': 'Configuration email incomplète. Vérifiez les paramètres dans Configuration > Email.'
|
||||||
|
}
|
||||||
|
|
||||||
|
config = self.get_smtp_config()
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Préparer l'email
|
||||||
|
msg = MIMEMultipart('alternative')
|
||||||
|
msg['Subject'] = subject
|
||||||
|
msg['From'] = f"{config['from_name']} <{config['from_address']}>"
|
||||||
|
msg['To'] = ', '.join(to_emails)
|
||||||
|
|
||||||
|
# Ajouter le corps en texte brut si fourni
|
||||||
|
if text_body:
|
||||||
|
part1 = MIMEText(text_body, 'plain', 'utf-8')
|
||||||
|
msg.attach(part1)
|
||||||
|
|
||||||
|
# Transformer le HTML pour optimiser l'affichage email
|
||||||
|
optimized_html = transform(html_body)
|
||||||
|
part2 = MIMEText(optimized_html, 'html', 'utf-8')
|
||||||
|
msg.attach(part2)
|
||||||
|
|
||||||
|
# Connexion SMTP et envoi
|
||||||
|
with smtplib.SMTP(config['host'], config['port']) as server:
|
||||||
|
if config['use_tls']:
|
||||||
|
server.starttls()
|
||||||
|
|
||||||
|
# Ne pas s'authentifier sur les serveurs de test locaux
|
||||||
|
is_localhost = config.get('host', '').lower() in ['localhost', '127.0.0.1']
|
||||||
|
is_test_port = str(config.get('port', '')).strip() in ['1025', '2525', '8025']
|
||||||
|
|
||||||
|
if not (is_localhost and is_test_port) and config.get('username') and config.get('password'):
|
||||||
|
server.login(config['username'], config['password'])
|
||||||
|
|
||||||
|
server.send_message(msg)
|
||||||
|
|
||||||
|
self.logger.info(f"Email envoyé avec succès à {len(to_emails)} destinataires: {subject}")
|
||||||
|
|
||||||
|
return {
|
||||||
|
'success': True,
|
||||||
|
'message': f'Email envoyé avec succès à {len(to_emails)} destinataire(s)',
|
||||||
|
'recipients_count': len(to_emails)
|
||||||
|
}
|
||||||
|
|
||||||
|
except smtplib.SMTPAuthenticationError as e:
|
||||||
|
error_msg = "Erreur d'authentification SMTP. Vérifiez les identifiants."
|
||||||
|
self.logger.error(f"Erreur SMTP Auth: {e}")
|
||||||
|
return {'success': False, 'error': error_msg}
|
||||||
|
|
||||||
|
except smtplib.SMTPException as e:
|
||||||
|
error_msg = f"Erreur SMTP lors de l'envoi: {str(e)}"
|
||||||
|
self.logger.error(f"Erreur SMTP: {e}")
|
||||||
|
return {'success': False, 'error': error_msg}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
error_msg = f"Erreur inattendue lors de l'envoi: {str(e)}"
|
||||||
|
self.logger.error(f"Erreur envoi email: {e}")
|
||||||
|
return {'success': False, 'error': error_msg}
|
||||||
|
|
||||||
|
def send_test_email(self, to_email: str) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Envoie un email de test pour vérifier la configuration.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
to_email: Adresse email de test
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict avec le statut du test
|
||||||
|
"""
|
||||||
|
subject = "Test de configuration email - Notytex"
|
||||||
|
html_body = """
|
||||||
|
<html>
|
||||||
|
<body style="font-family: Arial, sans-serif; padding: 20px;">
|
||||||
|
<h2 style="color: #3b82f6;">Test de configuration email</h2>
|
||||||
|
<p>Félicitations ! Votre configuration email fonctionne correctement.</p>
|
||||||
|
<p>Vous pouvez maintenant envoyer des bilans d'évaluation par email.</p>
|
||||||
|
<hr style="margin: 20px 0;">
|
||||||
|
<p style="color: #6b7280; font-size: 12px;">
|
||||||
|
Email envoyé depuis Notytex - Système de gestion scolaire
|
||||||
|
</p>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
"""
|
||||||
|
|
||||||
|
text_body = """
|
||||||
|
Test de configuration email - Notytex
|
||||||
|
|
||||||
|
Félicitations ! Votre configuration email fonctionne correctement.
|
||||||
|
Vous pouvez maintenant envoyer des bilans d'évaluation par email.
|
||||||
|
|
||||||
|
---
|
||||||
|
Email envoyé depuis Notytex - Système de gestion scolaire
|
||||||
|
"""
|
||||||
|
|
||||||
|
return self.send_email([to_email], subject, html_body, text_body)
|
||||||
|
|
||||||
|
def validate_email_addresses(self, emails: List[str]) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Valide une liste d'adresses email.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
emails: Liste des adresses à valider
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict avec les emails valides et invalides
|
||||||
|
"""
|
||||||
|
import re
|
||||||
|
|
||||||
|
email_regex = re.compile(r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$')
|
||||||
|
|
||||||
|
valid_emails = []
|
||||||
|
invalid_emails = []
|
||||||
|
|
||||||
|
for email in emails:
|
||||||
|
email = email.strip()
|
||||||
|
if email and email_regex.match(email):
|
||||||
|
valid_emails.append(email)
|
||||||
|
elif email: # Email non vide mais invalide
|
||||||
|
invalid_emails.append(email)
|
||||||
|
|
||||||
|
return {
|
||||||
|
'valid': valid_emails,
|
||||||
|
'invalid': invalid_emails,
|
||||||
|
'valid_count': len(valid_emails),
|
||||||
|
'invalid_count': len(invalid_emails)
|
||||||
|
}
|
||||||
325
services/student_report_service.py
Normal file
325
services/student_report_service.py
Normal file
@@ -0,0 +1,325 @@
|
|||||||
|
"""
|
||||||
|
Service de génération de bilans d'évaluation pour les élèves.
|
||||||
|
Génère les rapports HTML individualisés à envoyer par email.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Dict, List, Any, Optional
|
||||||
|
from models import Assessment, Student, GradingCalculator
|
||||||
|
|
||||||
|
|
||||||
|
class StudentReportService:
|
||||||
|
"""Service de génération des bilans d'évaluation individuels."""
|
||||||
|
|
||||||
|
def __init__(self, config_manager=None):
|
||||||
|
"""Initialise le service avec le gestionnaire de configuration."""
|
||||||
|
if config_manager is None:
|
||||||
|
from app_config import config_manager as default_manager
|
||||||
|
self.config_manager = default_manager
|
||||||
|
else:
|
||||||
|
self.config_manager = config_manager
|
||||||
|
|
||||||
|
def generate_student_report(self, assessment: Assessment, student: Student) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Génère le rapport individuel d'un élève pour une évaluation.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
assessment: L'évaluation concernée
|
||||||
|
student: L'élève pour qui générer le rapport
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict contenant toutes les données du rapport
|
||||||
|
"""
|
||||||
|
# Récupérer les labels des compétences depuis la configuration
|
||||||
|
score_meanings = self.config_manager.get_score_meanings()
|
||||||
|
# Calculer les scores de tous les élèves pour avoir les statistiques
|
||||||
|
students_scores, exercise_scores = assessment.calculate_student_scores()
|
||||||
|
|
||||||
|
# Vérifier que l'élève est dans les résultats
|
||||||
|
if student.id not in students_scores:
|
||||||
|
raise ValueError(f"L'élève {student.full_name} n'est pas éligible pour cette évaluation")
|
||||||
|
|
||||||
|
student_data = students_scores[student.id]
|
||||||
|
|
||||||
|
# Calculer les statistiques de classe
|
||||||
|
statistics = assessment.get_assessment_statistics()
|
||||||
|
total_max_points = assessment.get_total_max_points()
|
||||||
|
|
||||||
|
# Calculer la position de l'élève dans la classe
|
||||||
|
all_scores = [data['total_score'] for data in students_scores.values()]
|
||||||
|
all_scores_sorted = sorted(all_scores, reverse=True)
|
||||||
|
student_position = all_scores_sorted.index(student_data['total_score']) + 1
|
||||||
|
total_students = len(all_scores)
|
||||||
|
|
||||||
|
# Préparer les détails par exercice
|
||||||
|
exercises_details = []
|
||||||
|
for exercise in sorted(assessment.exercises, key=lambda x: x.order):
|
||||||
|
exercise_score = student_data['exercises'].get(exercise.id, {'score': 0, 'max_points': 0})
|
||||||
|
|
||||||
|
# Détails des éléments de notation de l'exercice
|
||||||
|
elements_details = []
|
||||||
|
for element in exercise.grading_elements:
|
||||||
|
# Trouver la note de l'élève pour cet élément
|
||||||
|
grade = None
|
||||||
|
for g in element.grades:
|
||||||
|
if g.student_id == student.id:
|
||||||
|
grade = g
|
||||||
|
break
|
||||||
|
|
||||||
|
if grade and grade.value:
|
||||||
|
calculated_score = GradingCalculator.calculate_score(
|
||||||
|
grade.value, element.grading_type, element.max_points
|
||||||
|
)
|
||||||
|
# Récupérer le label de compétence si c'est un score
|
||||||
|
score_label = ''
|
||||||
|
if element.grading_type == 'score' and grade.value.isdigit():
|
||||||
|
score_val = int(grade.value)
|
||||||
|
if score_val in score_meanings:
|
||||||
|
score_label = score_meanings[score_val]['label']
|
||||||
|
|
||||||
|
elements_details.append({
|
||||||
|
'label': element.label,
|
||||||
|
'description': element.description or '',
|
||||||
|
'skill': element.skill or '',
|
||||||
|
'domain': element.domain.name if element.domain else '',
|
||||||
|
'raw_value': grade.value,
|
||||||
|
'calculated_score': calculated_score,
|
||||||
|
'max_points': element.max_points,
|
||||||
|
'grading_type': element.grading_type,
|
||||||
|
'score_label': score_label,
|
||||||
|
'comment': grade.comment or ''
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
elements_details.append({
|
||||||
|
'label': element.label,
|
||||||
|
'description': element.description or '',
|
||||||
|
'skill': element.skill or '',
|
||||||
|
'domain': element.domain.name if element.domain else '',
|
||||||
|
'raw_value': None,
|
||||||
|
'calculated_score': None,
|
||||||
|
'max_points': element.max_points,
|
||||||
|
'grading_type': element.grading_type,
|
||||||
|
'score_label': '',
|
||||||
|
'comment': ''
|
||||||
|
})
|
||||||
|
|
||||||
|
exercises_details.append({
|
||||||
|
'title': exercise.title,
|
||||||
|
'description': exercise.description or '',
|
||||||
|
'score': exercise_score['score'],
|
||||||
|
'max_points': exercise_score['max_points'],
|
||||||
|
'percentage': round((exercise_score['score'] / exercise_score['max_points']) * 100, 1) if exercise_score['max_points'] > 0 else 0,
|
||||||
|
'elements': elements_details
|
||||||
|
})
|
||||||
|
|
||||||
|
# Calculer les performances par compétence
|
||||||
|
competences_performance = self._calculate_competences_performance(assessment, student)
|
||||||
|
|
||||||
|
# Calculer les performances par domaine
|
||||||
|
domains_performance = self._calculate_domains_performance(assessment, student)
|
||||||
|
|
||||||
|
return {
|
||||||
|
'assessment': {
|
||||||
|
'title': assessment.title,
|
||||||
|
'description': assessment.description or '',
|
||||||
|
'date': assessment.date,
|
||||||
|
'trimester': assessment.trimester,
|
||||||
|
'class_name': assessment.class_group.name,
|
||||||
|
'coefficient': assessment.coefficient
|
||||||
|
},
|
||||||
|
'student': {
|
||||||
|
'full_name': student.full_name,
|
||||||
|
'first_name': student.first_name,
|
||||||
|
'last_name': student.last_name,
|
||||||
|
'email': student.email
|
||||||
|
},
|
||||||
|
'results': {
|
||||||
|
'total_score': student_data['total_score'],
|
||||||
|
'total_max_points': student_data['total_max_points'],
|
||||||
|
'percentage': round((student_data['total_score'] / student_data['total_max_points']) * 100, 1) if student_data['total_max_points'] > 0 else 0,
|
||||||
|
'position': student_position,
|
||||||
|
'total_students': total_students
|
||||||
|
},
|
||||||
|
'exercises': exercises_details,
|
||||||
|
'competences': competences_performance,
|
||||||
|
'domains': domains_performance,
|
||||||
|
'class_statistics': {
|
||||||
|
'count': statistics['count'],
|
||||||
|
'mean': statistics['mean'],
|
||||||
|
'median': statistics['median'],
|
||||||
|
'min': statistics['min'],
|
||||||
|
'max': statistics['max'],
|
||||||
|
'std_dev': statistics['std_dev']
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
def _calculate_competences_performance(self, assessment: Assessment, student: Student) -> List[Dict[str, Any]]:
|
||||||
|
"""Calcule les performances de l'élève par compétence."""
|
||||||
|
competences_data = {}
|
||||||
|
|
||||||
|
for exercise in assessment.exercises:
|
||||||
|
for element in exercise.grading_elements:
|
||||||
|
if element.skill:
|
||||||
|
# Trouver la note de l'élève
|
||||||
|
grade = None
|
||||||
|
for g in element.grades:
|
||||||
|
if g.student_id == student.id:
|
||||||
|
grade = g
|
||||||
|
break
|
||||||
|
|
||||||
|
if grade and grade.value:
|
||||||
|
score = GradingCalculator.calculate_score(
|
||||||
|
grade.value, element.grading_type, element.max_points
|
||||||
|
)
|
||||||
|
if score is not None: # Exclure les dispensés
|
||||||
|
if element.skill not in competences_data:
|
||||||
|
competences_data[element.skill] = {
|
||||||
|
'total_score': 0,
|
||||||
|
'total_max_points': 0,
|
||||||
|
'elements_count': 0
|
||||||
|
}
|
||||||
|
|
||||||
|
competences_data[element.skill]['total_score'] += score
|
||||||
|
competences_data[element.skill]['total_max_points'] += element.max_points
|
||||||
|
competences_data[element.skill]['elements_count'] += 1
|
||||||
|
|
||||||
|
# Convertir en liste avec pourcentages
|
||||||
|
competences_performance = []
|
||||||
|
for competence, data in competences_data.items():
|
||||||
|
percentage = round((data['total_score'] / data['total_max_points']) * 100, 1) if data['total_max_points'] > 0 else 0
|
||||||
|
competences_performance.append({
|
||||||
|
'name': competence,
|
||||||
|
'score': data['total_score'],
|
||||||
|
'max_points': data['total_max_points'],
|
||||||
|
'percentage': percentage,
|
||||||
|
'elements_count': data['elements_count']
|
||||||
|
})
|
||||||
|
|
||||||
|
return sorted(competences_performance, key=lambda x: x['name'])
|
||||||
|
|
||||||
|
def _calculate_domains_performance(self, assessment: Assessment, student: Student) -> List[Dict[str, Any]]:
|
||||||
|
"""Calcule les performances de l'élève par domaine."""
|
||||||
|
domains_data = {}
|
||||||
|
|
||||||
|
for exercise in assessment.exercises:
|
||||||
|
for element in exercise.grading_elements:
|
||||||
|
if element.domain:
|
||||||
|
# Trouver la note de l'élève
|
||||||
|
grade = None
|
||||||
|
for g in element.grades:
|
||||||
|
if g.student_id == student.id:
|
||||||
|
grade = g
|
||||||
|
break
|
||||||
|
|
||||||
|
if grade and grade.value:
|
||||||
|
score = GradingCalculator.calculate_score(
|
||||||
|
grade.value, element.grading_type, element.max_points
|
||||||
|
)
|
||||||
|
if score is not None: # Exclure les dispensés
|
||||||
|
domain_name = element.domain.name
|
||||||
|
if domain_name not in domains_data:
|
||||||
|
domains_data[domain_name] = {
|
||||||
|
'total_score': 0,
|
||||||
|
'total_max_points': 0,
|
||||||
|
'elements_count': 0,
|
||||||
|
'color': element.domain.color
|
||||||
|
}
|
||||||
|
|
||||||
|
domains_data[domain_name]['total_score'] += score
|
||||||
|
domains_data[domain_name]['total_max_points'] += element.max_points
|
||||||
|
domains_data[domain_name]['elements_count'] += 1
|
||||||
|
|
||||||
|
# Convertir en liste avec pourcentages
|
||||||
|
domains_performance = []
|
||||||
|
for domain, data in domains_data.items():
|
||||||
|
percentage = round((data['total_score'] / data['total_max_points']) * 100, 1) if data['total_max_points'] > 0 else 0
|
||||||
|
domains_performance.append({
|
||||||
|
'name': domain,
|
||||||
|
'score': data['total_score'],
|
||||||
|
'max_points': data['total_max_points'],
|
||||||
|
'percentage': percentage,
|
||||||
|
'elements_count': data['elements_count'],
|
||||||
|
'color': data['color']
|
||||||
|
})
|
||||||
|
|
||||||
|
return sorted(domains_performance, key=lambda x: x['name'])
|
||||||
|
|
||||||
|
def generate_multiple_reports(self, assessment: Assessment, student_ids: List[int]) -> Dict[int, Dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
Génère les rapports pour plusieurs élèves.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
assessment: L'évaluation concernée
|
||||||
|
student_ids: Liste des IDs d'élèves
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict avec les rapports par ID d'élève
|
||||||
|
"""
|
||||||
|
from models import Student
|
||||||
|
|
||||||
|
reports = {}
|
||||||
|
errors = {}
|
||||||
|
|
||||||
|
for student_id in student_ids:
|
||||||
|
try:
|
||||||
|
student = Student.query.get(student_id)
|
||||||
|
if not student:
|
||||||
|
errors[student_id] = "Élève introuvable"
|
||||||
|
continue
|
||||||
|
|
||||||
|
report = self.generate_student_report(assessment, student)
|
||||||
|
reports[student_id] = report
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
errors[student_id] = str(e)
|
||||||
|
|
||||||
|
return {
|
||||||
|
'reports': reports,
|
||||||
|
'errors': errors,
|
||||||
|
'success_count': len(reports),
|
||||||
|
'error_count': len(errors)
|
||||||
|
}
|
||||||
|
|
||||||
|
def get_assessment_summary(self, assessment: Assessment) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Génère un résumé de l'évaluation pour les emails.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
assessment: L'évaluation concernée
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict avec le résumé de l'évaluation
|
||||||
|
"""
|
||||||
|
statistics = assessment.get_assessment_statistics()
|
||||||
|
total_max_points = assessment.get_total_max_points()
|
||||||
|
|
||||||
|
# Compter les exercices et éléments
|
||||||
|
exercises_count = len(assessment.exercises)
|
||||||
|
elements_count = sum(len(ex.grading_elements) for ex in assessment.exercises)
|
||||||
|
|
||||||
|
# Récupérer les compétences et domaines évalués
|
||||||
|
competences = set()
|
||||||
|
domains = set()
|
||||||
|
|
||||||
|
for exercise in assessment.exercises:
|
||||||
|
for element in exercise.grading_elements:
|
||||||
|
if element.skill:
|
||||||
|
competences.add(element.skill)
|
||||||
|
if element.domain:
|
||||||
|
domains.add(element.domain.name)
|
||||||
|
|
||||||
|
return {
|
||||||
|
'assessment': {
|
||||||
|
'title': assessment.title,
|
||||||
|
'description': assessment.description or '',
|
||||||
|
'date': assessment.date,
|
||||||
|
'trimester': assessment.trimester,
|
||||||
|
'class_name': assessment.class_group.name,
|
||||||
|
'coefficient': assessment.coefficient,
|
||||||
|
'total_max_points': total_max_points,
|
||||||
|
'exercises_count': exercises_count,
|
||||||
|
'elements_count': elements_count
|
||||||
|
},
|
||||||
|
'statistics': statistics,
|
||||||
|
'competences_evaluated': sorted(list(competences)),
|
||||||
|
'domains_evaluated': sorted(list(domains))
|
||||||
|
}
|
||||||
@@ -52,6 +52,17 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Bouton d'envoi des bilans -->
|
||||||
|
<div class="flex justify-end mb-6">
|
||||||
|
<button id="sendReportsBtn" onclick="openSendReportsModal()"
|
||||||
|
class="inline-flex items-center px-6 py-3 bg-blue-600 text-white font-semibold rounded-lg hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 transition-colors">
|
||||||
|
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 8l7.89 4.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"/>
|
||||||
|
</svg>
|
||||||
|
📧 Envoyer les bilans par email
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Histogramme des notes -->
|
<!-- Histogramme des notes -->
|
||||||
<div class="bg-white shadow rounded-lg">
|
<div class="bg-white shadow rounded-lg">
|
||||||
<div class="px-6 py-4 border-b border-gray-200">
|
<div class="px-6 py-4 border-b border-gray-200">
|
||||||
@@ -764,5 +775,284 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
createGradingElementsHeatmap('gradingElementsHeatmap', gradingElementsData);
|
createGradingElementsHeatmap('gradingElementsHeatmap', gradingElementsData);
|
||||||
{% endif %}
|
{% endif %}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// === FONCTIONNALITÉ D'ENVOI DE BILANS ===
|
||||||
|
|
||||||
|
let eligibleStudents = [];
|
||||||
|
let selectedStudents = [];
|
||||||
|
|
||||||
|
// Charger les élèves éligibles au chargement de la page
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
loadEligibleStudents();
|
||||||
|
});
|
||||||
|
|
||||||
|
function loadEligibleStudents() {
|
||||||
|
fetch(`/assessments/{{ assessment.id }}/eligible-students`)
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(data => {
|
||||||
|
if (data.success) {
|
||||||
|
eligibleStudents = data.students;
|
||||||
|
updateSendReportsButton(data.with_email_count, data.total_count);
|
||||||
|
} else {
|
||||||
|
console.error('Erreur lors du chargement des élèves:', data.error);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
console.error('Erreur réseau:', error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateSendReportsButton(withEmailCount, totalCount) {
|
||||||
|
const btn = document.getElementById('sendReportsBtn');
|
||||||
|
if (withEmailCount === 0) {
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.classList.add('opacity-50', 'cursor-not-allowed');
|
||||||
|
btn.classList.remove('hover:bg-blue-700');
|
||||||
|
btn.innerHTML = `
|
||||||
|
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||||
|
</svg>
|
||||||
|
Aucune adresse email configurée
|
||||||
|
`;
|
||||||
|
} else {
|
||||||
|
btn.innerHTML = `
|
||||||
|
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 8l7.89 4.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"/>
|
||||||
|
</svg>
|
||||||
|
📧 Envoyer les bilans (${withEmailCount}/${totalCount} avec email)
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openSendReportsModal() {
|
||||||
|
if (eligibleStudents.length === 0) {
|
||||||
|
alert('Aucun élève éligible trouvé');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('sendReportsModal').classList.remove('hidden');
|
||||||
|
populateStudentsList();
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeSendReportsModal() {
|
||||||
|
document.getElementById('sendReportsModal').classList.add('hidden');
|
||||||
|
selectedStudents = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function populateStudentsList() {
|
||||||
|
const container = document.getElementById('studentsList');
|
||||||
|
const studentsWithEmail = eligibleStudents.filter(s => s.has_email);
|
||||||
|
|
||||||
|
if (studentsWithEmail.length === 0) {
|
||||||
|
container.innerHTML = '<p class="text-gray-500 text-center py-4">Aucun élève avec adresse email configurée</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
container.innerHTML = studentsWithEmail.map(student => `
|
||||||
|
<label class="flex items-center p-3 hover:bg-gray-50 rounded-lg cursor-pointer">
|
||||||
|
<input type="checkbox"
|
||||||
|
value="${student.id}"
|
||||||
|
onchange="updateSelectedStudents()"
|
||||||
|
class="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded mr-3">
|
||||||
|
<div class="flex-1">
|
||||||
|
<div class="font-medium text-gray-900">${student.full_name}</div>
|
||||||
|
<div class="text-sm text-gray-500">${student.email}</div>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
`).join('');
|
||||||
|
|
||||||
|
// Sélectionner tous par défaut
|
||||||
|
selectAllStudents();
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectAllStudents() {
|
||||||
|
const checkboxes = document.querySelectorAll('#studentsList input[type="checkbox"]');
|
||||||
|
checkboxes.forEach(cb => cb.checked = true);
|
||||||
|
updateSelectedStudents();
|
||||||
|
}
|
||||||
|
|
||||||
|
function unselectAllStudents() {
|
||||||
|
const checkboxes = document.querySelectorAll('#studentsList input[type="checkbox"]');
|
||||||
|
checkboxes.forEach(cb => cb.checked = false);
|
||||||
|
updateSelectedStudents();
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateSelectedStudents() {
|
||||||
|
const checkboxes = document.querySelectorAll('#studentsList input[type="checkbox"]:checked');
|
||||||
|
selectedStudents = Array.from(checkboxes).map(cb => parseInt(cb.value));
|
||||||
|
|
||||||
|
const count = selectedStudents.length;
|
||||||
|
document.getElementById('selectedCount').textContent = count;
|
||||||
|
document.getElementById('sendReportsSubmitBtn').disabled = count === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendReports() {
|
||||||
|
if (selectedStudents.length === 0) {
|
||||||
|
alert('Veuillez sélectionner au moins un élève');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const customMessage = document.getElementById('customMessage').value.trim();
|
||||||
|
const submitBtn = document.getElementById('sendReportsSubmitBtn');
|
||||||
|
const originalText = submitBtn.innerHTML;
|
||||||
|
|
||||||
|
// Désactiver le bouton et afficher le loading
|
||||||
|
submitBtn.disabled = true;
|
||||||
|
submitBtn.innerHTML = `
|
||||||
|
<svg class="animate-spin -ml-1 mr-3 h-5 w-5 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||||
|
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||||
|
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||||
|
</svg>
|
||||||
|
Envoi en cours...
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Envoyer la requête
|
||||||
|
fetch(`/assessments/{{ assessment.id }}/send-reports`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
student_ids: selectedStudents,
|
||||||
|
custom_message: customMessage
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(data => {
|
||||||
|
// Restaurer le bouton
|
||||||
|
submitBtn.disabled = false;
|
||||||
|
submitBtn.innerHTML = originalText;
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
// Afficher le résultat
|
||||||
|
showSendResult(data);
|
||||||
|
// Fermer la modal après un délai
|
||||||
|
setTimeout(() => {
|
||||||
|
closeSendReportsModal();
|
||||||
|
}, 3000);
|
||||||
|
} else {
|
||||||
|
alert(`Erreur lors de l'envoi: ${data.error}`);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
// Restaurer le bouton en cas d'erreur
|
||||||
|
submitBtn.disabled = false;
|
||||||
|
submitBtn.innerHTML = originalText;
|
||||||
|
console.error('Erreur:', error);
|
||||||
|
alert('Erreur réseau lors de l\'envoi des bilans');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function showSendResult(result) {
|
||||||
|
const resultDiv = document.getElementById('sendResult');
|
||||||
|
|
||||||
|
let content = `<div class="mb-4">
|
||||||
|
<h3 class="text-lg font-medium ${result.success ? 'text-green-600' : 'text-red-600'} mb-2">
|
||||||
|
${result.message}
|
||||||
|
</h3>
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
if (result.sent_count > 0) {
|
||||||
|
content += `<div class="mb-3">
|
||||||
|
<p class="text-green-600">✅ ${result.sent_count} bilan(s) envoyé(s) avec succès</p>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.error_count > 0) {
|
||||||
|
content += `<div class="mb-3">
|
||||||
|
<p class="text-red-600 font-medium">❌ ${result.error_count} erreur(s):</p>
|
||||||
|
<ul class="text-sm text-red-600 mt-2 max-h-32 overflow-y-auto">`;
|
||||||
|
|
||||||
|
result.errors.forEach(error => {
|
||||||
|
content += `<li class="py-1">• ${error}</li>`;
|
||||||
|
});
|
||||||
|
|
||||||
|
content += `</ul></div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
resultDiv.innerHTML = content;
|
||||||
|
resultDiv.classList.remove('hidden');
|
||||||
|
|
||||||
|
// Cacher les autres sections
|
||||||
|
document.getElementById('studentsSelection').classList.add('hidden');
|
||||||
|
document.getElementById('modalActions').classList.add('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fermer la modal en cliquant à côté
|
||||||
|
document.addEventListener('click', function(e) {
|
||||||
|
const modal = document.getElementById('sendReportsModal');
|
||||||
|
if (e.target === modal) {
|
||||||
|
closeSendReportsModal();
|
||||||
|
}
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<!-- Modal d'envoi des bilans -->
|
||||||
|
<div id="sendReportsModal" class="hidden fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full z-50">
|
||||||
|
<div class="relative top-20 mx-auto p-5 border w-11/12 max-w-2xl shadow-lg rounded-md bg-white">
|
||||||
|
<div class="mt-3">
|
||||||
|
<!-- En-tête -->
|
||||||
|
<div class="flex justify-between items-center pb-4 border-b">
|
||||||
|
<h3 class="text-lg font-medium text-gray-900">📧 Envoyer les bilans par email</h3>
|
||||||
|
<button onclick="closeSendReportsModal()" class="text-gray-400 hover:text-gray-600">
|
||||||
|
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Sélection des élèves -->
|
||||||
|
<div id="studentsSelection" class="py-4">
|
||||||
|
<div class="flex justify-between items-center mb-4">
|
||||||
|
<h4 class="font-medium text-gray-900">Sélectionner les élèves</h4>
|
||||||
|
<div class="flex space-x-2">
|
||||||
|
<button onclick="selectAllStudents()"
|
||||||
|
class="text-sm text-blue-600 hover:text-blue-800">Tout sélectionner</button>
|
||||||
|
<span class="text-gray-300">|</span>
|
||||||
|
<button onclick="unselectAllStudents()"
|
||||||
|
class="text-sm text-blue-600 hover:text-blue-800">Tout désélectionner</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="studentsList" class="max-h-64 overflow-y-auto border border-gray-200 rounded-lg p-2 space-y-1">
|
||||||
|
<!-- Les élèves seront ajoutés ici par JavaScript -->
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="text-sm text-gray-600 mt-2">
|
||||||
|
<span id="selectedCount">0</span> élève(s) sélectionné(s)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Message personnalisé -->
|
||||||
|
<div class="py-4 border-t">
|
||||||
|
<label for="customMessage" class="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
Message personnalisé (optionnel)
|
||||||
|
</label>
|
||||||
|
<textarea id="customMessage"
|
||||||
|
rows="3"
|
||||||
|
placeholder="Message du professeur à ajouter dans les bilans..."
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Résultat de l'envoi -->
|
||||||
|
<div id="sendResult" class="hidden py-4 border-t">
|
||||||
|
<!-- Le résultat sera affiché ici -->
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Actions -->
|
||||||
|
<div id="modalActions" class="flex justify-end space-x-3 pt-4 border-t">
|
||||||
|
<button onclick="closeSendReportsModal()"
|
||||||
|
class="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50">
|
||||||
|
Annuler
|
||||||
|
</button>
|
||||||
|
<button id="sendReportsSubmitBtn" onclick="sendReports()"
|
||||||
|
class="px-4 py-2 text-sm font-medium text-white bg-blue-600 border border-transparent rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
|
||||||
|
disabled>
|
||||||
|
📧 Envoyer les bilans
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
258
templates/config/email.html
Normal file
258
templates/config/email.html
Normal file
@@ -0,0 +1,258 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Configuration Email - Gestion Scolaire{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="space-y-6">
|
||||||
|
<div class="flex justify-between items-center">
|
||||||
|
<div>
|
||||||
|
<a href="{{ url_for('config.index') }}" class="text-blue-600 hover:text-blue-800 text-sm font-medium mb-2 inline-block">
|
||||||
|
← Retour à la configuration
|
||||||
|
</a>
|
||||||
|
<h1 class="text-2xl font-bold text-gray-900">Configuration Email</h1>
|
||||||
|
<p class="text-gray-600">Configurez les paramètres SMTP pour l'envoi des bilans d'évaluation</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Formulaire de configuration email -->
|
||||||
|
<div class="bg-white shadow rounded-lg">
|
||||||
|
<div class="px-6 py-4 border-b border-gray-200">
|
||||||
|
<h2 class="text-lg font-medium text-gray-900">Paramètres SMTP</h2>
|
||||||
|
<p class="text-sm text-gray-600 mt-1">
|
||||||
|
Configurez votre serveur SMTP pour envoyer les bilans d'évaluation par email
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form method="POST" action="{{ url_for('config.update_email') }}" class="px-6 py-4 space-y-4">
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<!-- Serveur SMTP -->
|
||||||
|
<div>
|
||||||
|
<label for="smtp_host" class="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Serveur SMTP
|
||||||
|
</label>
|
||||||
|
<input type="text"
|
||||||
|
id="smtp_host"
|
||||||
|
name="smtp_host"
|
||||||
|
value="{{ email_config.smtp_host }}"
|
||||||
|
placeholder="smtp.gmail.com"
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||||
|
<p class="text-xs text-gray-500 mt-1">Ex: smtp.gmail.com, smtp.outlook.com</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Port SMTP -->
|
||||||
|
<div>
|
||||||
|
<label for="smtp_port" class="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Port SMTP
|
||||||
|
</label>
|
||||||
|
<input type="text"
|
||||||
|
id="smtp_port"
|
||||||
|
name="smtp_port"
|
||||||
|
value="{{ email_config.smtp_port }}"
|
||||||
|
placeholder="587"
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||||
|
<p class="text-xs text-gray-500 mt-1">587 (TLS) ou 465 (SSL) généralement</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<!-- Nom d'utilisateur -->
|
||||||
|
<div>
|
||||||
|
<label for="username" class="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Nom d'utilisateur
|
||||||
|
</label>
|
||||||
|
<input type="text"
|
||||||
|
id="username"
|
||||||
|
name="username"
|
||||||
|
value="{{ email_config.username }}"
|
||||||
|
placeholder="votre.email@domain.com"
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||||
|
<p class="text-xs text-gray-500 mt-1">Votre adresse email de connexion SMTP</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Mot de passe -->
|
||||||
|
<div>
|
||||||
|
<label for="password" class="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Mot de passe
|
||||||
|
</label>
|
||||||
|
<input type="password"
|
||||||
|
id="password"
|
||||||
|
name="password"
|
||||||
|
value="{{ email_config.password }}"
|
||||||
|
placeholder="••••••••"
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||||
|
<p class="text-xs text-gray-500 mt-1">Mot de passe ou mot de passe d'application</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Options de sécurité -->
|
||||||
|
<div class="flex items-center">
|
||||||
|
<input type="checkbox"
|
||||||
|
id="use_tls"
|
||||||
|
name="use_tls"
|
||||||
|
{% if email_config.use_tls %}checked{% endif %}
|
||||||
|
class="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded">
|
||||||
|
<label for="use_tls" class="ml-2 text-sm text-gray-700">
|
||||||
|
Utiliser TLS (recommandé)
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<!-- Nom d'expéditeur -->
|
||||||
|
<div>
|
||||||
|
<label for="from_name" class="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Nom d'expéditeur
|
||||||
|
</label>
|
||||||
|
<input type="text"
|
||||||
|
id="from_name"
|
||||||
|
name="from_name"
|
||||||
|
value="{{ email_config.from_name }}"
|
||||||
|
placeholder="Notytex"
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||||
|
<p class="text-xs text-gray-500 mt-1">Nom qui apparaîtra comme expéditeur</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Adresse d'expéditeur -->
|
||||||
|
<div>
|
||||||
|
<label for="from_address" class="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Adresse d'expéditeur
|
||||||
|
</label>
|
||||||
|
<input type="email"
|
||||||
|
id="from_address"
|
||||||
|
name="from_address"
|
||||||
|
value="{{ email_config.from_address }}"
|
||||||
|
placeholder="professeur@etablissement.fr"
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||||
|
<p class="text-xs text-gray-500 mt-1">Adresse qui apparaîtra comme expéditeur</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex justify-end space-x-3 pt-4 border-t border-gray-200">
|
||||||
|
<a href="{{ url_for('config.index') }}"
|
||||||
|
class="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500">
|
||||||
|
Annuler
|
||||||
|
</a>
|
||||||
|
<button type="submit"
|
||||||
|
class="px-4 py-2 text-sm font-medium text-white bg-blue-600 border border-transparent rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500">
|
||||||
|
Sauvegarder
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Test de configuration -->
|
||||||
|
<div class="bg-white shadow rounded-lg">
|
||||||
|
<div class="px-6 py-4 border-b border-gray-200">
|
||||||
|
<h2 class="text-lg font-medium text-gray-900">Test de configuration</h2>
|
||||||
|
<p class="text-sm text-gray-600 mt-1">
|
||||||
|
Testez votre configuration en envoyant un email de test
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form method="POST" action="{{ url_for('config.test_email') }}" class="px-6 py-4">
|
||||||
|
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label for="test_email" class="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Adresse email de test
|
||||||
|
</label>
|
||||||
|
<input type="email"
|
||||||
|
id="test_email"
|
||||||
|
name="test_email"
|
||||||
|
placeholder="test@example.com"
|
||||||
|
required
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||||
|
<p class="text-xs text-gray-500 mt-1">Un email de test sera envoyé à cette adresse</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex justify-end">
|
||||||
|
<button type="submit"
|
||||||
|
class="px-4 py-2 text-sm font-medium text-white bg-green-600 border border-transparent rounded-md hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500">
|
||||||
|
📧 Envoyer un test
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Aide configuration -->
|
||||||
|
<div class="bg-blue-50 border border-blue-200 rounded-lg p-6">
|
||||||
|
<h3 class="text-lg font-medium text-blue-900 mb-3">💡 Aide à la configuration</h3>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
<div>
|
||||||
|
<h4 class="font-medium text-blue-800 mb-2">Gmail</h4>
|
||||||
|
<ul class="text-sm text-blue-700 space-y-1">
|
||||||
|
<li><strong>Serveur:</strong> smtp.gmail.com</li>
|
||||||
|
<li><strong>Port:</strong> 587 (TLS)</li>
|
||||||
|
<li><strong>Sécurité:</strong> Utiliser un mot de passe d'application</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h4 class="font-medium text-blue-800 mb-2">Outlook/Hotmail</h4>
|
||||||
|
<ul class="text-sm text-blue-700 space-y-1">
|
||||||
|
<li><strong>Serveur:</strong> smtp-mail.outlook.com</li>
|
||||||
|
<li><strong>Port:</strong> 587 (TLS)</li>
|
||||||
|
<li><strong>Sécurité:</strong> Authentification moderne requise</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-4 p-4 bg-yellow-50 border border-yellow-200 rounded-md">
|
||||||
|
<p class="text-sm text-yellow-800">
|
||||||
|
<strong>⚠️ Sécurité:</strong> Pour Gmail, créez un mot de passe d'application dans votre compte Google.
|
||||||
|
N'utilisez pas votre mot de passe principal.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Serveur SMTP de test local -->
|
||||||
|
<div class="bg-green-50 border border-green-200 rounded-lg p-6">
|
||||||
|
<h3 class="text-lg font-medium text-green-900 mb-3">🧪 Tests en local - Serveur SMTP factice</h3>
|
||||||
|
|
||||||
|
<p class="text-sm text-green-700 mb-3">
|
||||||
|
Pour tester l'envoi d'emails sans configuration réelle, utilisez le serveur SMTP de débogage Python :
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="bg-green-100 p-4 rounded-md mb-4">
|
||||||
|
<h4 class="font-medium text-green-800 mb-2">1. Lancer le serveur de débogage</h4>
|
||||||
|
<p class="text-sm text-green-700 mb-2"><strong>Option A:</strong> Script inclus dans Notytex (recommandé)</p>
|
||||||
|
<code class="block text-sm bg-gray-900 text-green-400 p-2 rounded mb-3">
|
||||||
|
python debug_smtp_server.py
|
||||||
|
</code>
|
||||||
|
|
||||||
|
<p class="text-sm text-green-700 mb-2"><strong>Option B:</strong> Avec aiosmtpd</p>
|
||||||
|
<code class="block text-sm bg-gray-900 text-blue-400 p-2 rounded mb-1">
|
||||||
|
pip install aiosmtpd
|
||||||
|
</code>
|
||||||
|
<code class="block text-sm bg-gray-900 text-blue-400 p-2 rounded mb-3">
|
||||||
|
python -m aiosmtpd -n -l localhost:1025
|
||||||
|
</code>
|
||||||
|
|
||||||
|
<p class="text-xs text-green-600">💡 Le script inclus affiche les emails avec un formatage amélioré</p>
|
||||||
|
<p class="text-xs text-green-600">⚠️ Laissez ce terminal ouvert pendant les tests</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bg-green-100 p-4 rounded-md mb-4">
|
||||||
|
<h4 class="font-medium text-green-800 mb-2">2. Configuration pour les tests</h4>
|
||||||
|
<ul class="text-sm text-green-700 space-y-1">
|
||||||
|
<li><strong>Serveur SMTP:</strong> localhost</li>
|
||||||
|
<li><strong>Port:</strong> 1025</li>
|
||||||
|
<li><strong>Utilisateur/Mot de passe:</strong> Laisser vides</li>
|
||||||
|
<li><strong>TLS:</strong> ❌ Décocher</li>
|
||||||
|
<li><strong>Adresse expéditeur:</strong> test@notytex.local</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bg-green-100 p-4 rounded-md">
|
||||||
|
<h4 class="font-medium text-green-800 mb-2">3. Résultat</h4>
|
||||||
|
<p class="text-sm text-green-700">
|
||||||
|
✅ Tous les emails s'afficheront dans le terminal (contenu HTML complet)<br>
|
||||||
|
✅ Aucun email réellement envoyé<br>
|
||||||
|
✅ Parfait pour tester les bilans d'évaluation
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -103,6 +103,43 @@
|
|||||||
Configurer l'échelle
|
Configurer l'échelle
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Configuration email -->
|
||||||
|
<div class="bg-white rounded-lg shadow-md p-6 hover:shadow-lg transition-shadow">
|
||||||
|
<div class="flex items-center mb-4">
|
||||||
|
<div class="w-12 h-12 bg-orange-100 rounded-lg flex items-center justify-center mr-4">
|
||||||
|
<svg class="w-6 h-6 text-orange-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 8l7.89 4.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h3 class="text-lg font-semibold text-gray-900">Configuration Email</h3>
|
||||||
|
</div>
|
||||||
|
<p class="text-gray-600 mb-4">Paramètres SMTP pour l'envoi des bilans d'évaluation</p>
|
||||||
|
<div class="space-y-3">
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<span class="text-sm text-gray-500">Serveur SMTP :</span>
|
||||||
|
<span class="text-sm font-medium text-gray-900">
|
||||||
|
{% set smtp_host = app_config.get('email.smtp_host', '') %}
|
||||||
|
{{ smtp_host if smtp_host else 'Non configuré' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<span class="text-sm text-gray-500">Statut :</span>
|
||||||
|
<span class="text-sm font-medium">
|
||||||
|
{% set smtp_host = app_config.get('email.smtp_host', '') %}
|
||||||
|
{% set from_address = app_config.get('email.from_address', '') %}
|
||||||
|
{% if smtp_host and from_address %}
|
||||||
|
<span class="text-green-600">✅ Configuré</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="text-red-600">⚠️ À configurer</span>
|
||||||
|
{% endif %}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<a href="{{ url_for('config.email') }}" class="mt-4 block w-full bg-orange-600 text-white py-2 px-4 rounded-md hover:bg-orange-700 transition-colors text-center">
|
||||||
|
Configurer Email
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Actions globales -->
|
<!-- Actions globales -->
|
||||||
|
|||||||
269
templates/email/base_email.html
Normal file
269
templates/email/base_email.html
Normal file
@@ -0,0 +1,269 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="fr">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>{% block title %}Notytex - Bilan d'évaluation{% endblock %}</title>
|
||||||
|
<style>
|
||||||
|
/* Reset styles pour compatibilité email */
|
||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: #374151;
|
||||||
|
background-color: #f9fafb;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.email-container {
|
||||||
|
max-width: 800px;
|
||||||
|
margin: 0 auto;
|
||||||
|
background-color: #ffffff;
|
||||||
|
border-radius: 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
background: linear-gradient(135deg, #3b82f6, #8b5cf6);
|
||||||
|
color: white;
|
||||||
|
padding: 30px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header h1 {
|
||||||
|
font-size: 28px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header p {
|
||||||
|
font-size: 16px;
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
padding: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section {
|
||||||
|
margin-bottom: 30px;
|
||||||
|
padding: 20px;
|
||||||
|
background-color: #f8fafc;
|
||||||
|
border-radius: 8px;
|
||||||
|
border-left: 4px solid #3b82f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section h2 {
|
||||||
|
color: #1f2937;
|
||||||
|
font-size: 20px;
|
||||||
|
margin-bottom: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section h3 {
|
||||||
|
color: #374151;
|
||||||
|
font-size: 16px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
|
||||||
|
gap: 15px;
|
||||||
|
margin: 20px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-item {
|
||||||
|
text-align: center;
|
||||||
|
padding: 15px;
|
||||||
|
background-color: white;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 2px solid #e5e7eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-value {
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #3b82f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-label {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #6b7280;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
margin-top: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-bar {
|
||||||
|
width: 100%;
|
||||||
|
height: 8px;
|
||||||
|
background-color: #e5e7eb;
|
||||||
|
border-radius: 4px;
|
||||||
|
overflow: hidden;
|
||||||
|
margin: 8px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-fill {
|
||||||
|
height: 100%;
|
||||||
|
border-radius: 4px;
|
||||||
|
transition: width 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
margin: 15px 0;
|
||||||
|
background-color: white;
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table th,
|
||||||
|
.table td {
|
||||||
|
padding: 12px 15px;
|
||||||
|
text-align: left;
|
||||||
|
border-bottom: 1px solid #e5e7eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table th {
|
||||||
|
background-color: #f3f4f6;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #374151;
|
||||||
|
text-transform: uppercase;
|
||||||
|
font-size: 12px;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table tr:hover {
|
||||||
|
background-color: #f9fafb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 4px 12px;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-success {
|
||||||
|
background-color: #dcfce7;
|
||||||
|
color: #166534;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-warning {
|
||||||
|
background-color: #fef3c7;
|
||||||
|
color: #92400e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-info {
|
||||||
|
background-color: #dbeafe;
|
||||||
|
color: #1e40af;
|
||||||
|
}
|
||||||
|
|
||||||
|
.competence-item {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 10px 15px;
|
||||||
|
margin: 8px 0;
|
||||||
|
background-color: white;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.competence-name {
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.competence-score {
|
||||||
|
font-weight: 600;
|
||||||
|
color: #3b82f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer {
|
||||||
|
background-color: #f3f4f6;
|
||||||
|
padding: 20px 30px;
|
||||||
|
text-align: center;
|
||||||
|
border-top: 1px solid #e5e7eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer p {
|
||||||
|
color: #6b7280;
|
||||||
|
font-size: 12px;
|
||||||
|
margin: 5px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.highlight {
|
||||||
|
background-color: #fef3c7;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive pour mobile */
|
||||||
|
@media only screen and (max-width: 600px) {
|
||||||
|
body {
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header h1 {
|
||||||
|
font-size: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section {
|
||||||
|
padding: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-grid {
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table th,
|
||||||
|
.table td {
|
||||||
|
padding: 8px 10px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="email-container">
|
||||||
|
<div class="header">
|
||||||
|
<h1>{% block header_title %}🎓 Notytex{% endblock %}</h1>
|
||||||
|
<p>{% block header_subtitle %}Système de gestion scolaire{% endblock %}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="content">
|
||||||
|
{% block content %}
|
||||||
|
<!-- Contenu principal de l'email -->
|
||||||
|
{% endblock %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="footer">
|
||||||
|
<p><strong>{% block school_name %}Établissement scolaire{% endblock %}</strong></p>
|
||||||
|
<p>Email généré automatiquement par Notytex</p>
|
||||||
|
<p>Pour toute question, contactez votre professeur.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
239
templates/email/student_report.html
Normal file
239
templates/email/student_report.html
Normal file
@@ -0,0 +1,239 @@
|
|||||||
|
{% extends "email/base_email.html" %}
|
||||||
|
|
||||||
|
{% block title %}{{ report.assessment.title }} - {{ report.student.full_name }}{% endblock %}
|
||||||
|
|
||||||
|
{% block header_title %}📊 Bilan d'Évaluation{% endblock %}
|
||||||
|
{% block header_subtitle %}{{ report.assessment.title }} - {{ report.student.full_name }}{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<!-- Informations sur l'évaluation -->
|
||||||
|
<div class="section">
|
||||||
|
<h2>📋 Informations sur l'évaluation</h2>
|
||||||
|
<div class="stats-grid">
|
||||||
|
<div class="stat-item">
|
||||||
|
<div class="stat-value">{{ report.assessment.class_name }}</div>
|
||||||
|
<div class="stat-label">Classe</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-item">
|
||||||
|
<div class="stat-value">{{ report.assessment.date.strftime('%d/%m/%Y') }}</div>
|
||||||
|
<div class="stat-label">Date</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-item">
|
||||||
|
<div class="stat-value">T{{ report.assessment.trimester }}</div>
|
||||||
|
<div class="stat-label">Trimestre</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-item">
|
||||||
|
<div class="stat-value">×{{ report.assessment.coefficient }}</div>
|
||||||
|
<div class="stat-label">Coefficient</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if report.assessment.description %}
|
||||||
|
<p style="margin-top: 15px; padding: 15px; background-color: #e0f2fe; border-radius: 6px; border-left: 3px solid #0288d1;">
|
||||||
|
<strong>Description :</strong> {{ report.assessment.description }}
|
||||||
|
</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Note globale -->
|
||||||
|
<div class="section" style="border-left-color: #10b981;">
|
||||||
|
<h2>🎯 Note globale</h2>
|
||||||
|
<div style="text-align: center; padding: 30px;">
|
||||||
|
<div style="font-size: 48px; font-weight: bold; color: #10b981; margin-bottom: 10px;">
|
||||||
|
{{ "%.1f"|format(report.results.total_score) }}/{{ "%.1f"|format(report.results.total_max_points) }}
|
||||||
|
</div>
|
||||||
|
<div style="font-size: 16px; color: #6b7280; margin-bottom: 20px;">
|
||||||
|
Note obtenue sur cette évaluation
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="progress-bar">
|
||||||
|
<div class="progress-fill" style="width: {{ report.results.percentage }}%;
|
||||||
|
background: linear-gradient(90deg,
|
||||||
|
{% if report.results.percentage < 50 %}#ef4444{% elif report.results.percentage < 75 %}#f59e0b{% else %}#10b981{% endif %},
|
||||||
|
{% if report.results.percentage < 50 %}#dc2626{% elif report.results.percentage < 75 %}#d97706{% else %}#059669{% endif %});"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Résultats par exercice -->
|
||||||
|
{% if report.exercises %}
|
||||||
|
<div class="section" style="border-left-color: #8b5cf6;">
|
||||||
|
<h2>📝 Résultats par exercice</h2>
|
||||||
|
|
||||||
|
{% for exercise in report.exercises %}
|
||||||
|
<div style="margin: 20px 0; padding: 15px; background-color: white; border-radius: 8px; border: 1px solid #e5e7eb;">
|
||||||
|
<h3 style="color: #8b5cf6;">{{ exercise.title }}</h3>
|
||||||
|
{% if exercise.description %}
|
||||||
|
<p style="font-size: 14px; color: #6b7280; margin-bottom: 10px;">{{ exercise.description }}</p>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div style="display: flex; justify-content: space-between; align-items: center; margin: 10px 0;">
|
||||||
|
<span style="font-weight: 600;">Score :</span>
|
||||||
|
<span style="font-size: 18px; font-weight: 700; color: #8b5cf6;">
|
||||||
|
{{ "%.1f"|format(exercise.score) }}/{{ "%.1f"|format(exercise.max_points) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="progress-bar">
|
||||||
|
<div class="progress-fill" style="width: {{ exercise.percentage }}%; background-color: #8b5cf6;"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if exercise.elements %}
|
||||||
|
<div style="margin-top: 15px;">
|
||||||
|
<h4 style="font-size: 14px; color: #374151; margin-bottom: 8px;">Détail des questions :</h4>
|
||||||
|
<table class="table" style="font-size: 14px;">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Question</th>
|
||||||
|
<th>Compétence</th>
|
||||||
|
<th>Domaine</th>
|
||||||
|
<th>Résultat</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for element in exercise.elements %}
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<strong>{{ element.label }}</strong>
|
||||||
|
{% if element.description %}
|
||||||
|
<br><small style="color: #6b7280;">{{ element.description }}</small>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{% if element.skill %}
|
||||||
|
<span class="badge badge-info">{{ element.skill }}</span>
|
||||||
|
{% else %}
|
||||||
|
<span style="color: #9ca3af;">-</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{% if element.domain %}
|
||||||
|
<small style="color: #6b7280;">{{ element.domain }}</small>
|
||||||
|
{% else %}
|
||||||
|
<span style="color: #9ca3af;">-</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td style="text-align: center;">
|
||||||
|
{% if element.raw_value == '.' %}
|
||||||
|
<span style="color: #9ca3af; font-size: 20px;">❓</span>
|
||||||
|
<br><small style="color: #9ca3af;">Pas de réponse</small>
|
||||||
|
{% elif element.grading_type == 'score' and element.raw_value %}
|
||||||
|
{% set score_value = element.raw_value|int %}
|
||||||
|
<div style="font-size: 18px;">
|
||||||
|
{% for i in range(3) %}
|
||||||
|
{% if i < score_value %}⭐{% else %}☆{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
<small style="color: #6b7280;">
|
||||||
|
{{ element.score_label if element.score_label else 'Score ' + score_value|string }}
|
||||||
|
</small>
|
||||||
|
{% elif element.raw_value %}
|
||||||
|
<strong style="font-size: 16px;">{{ element.raw_value }}/{{ element.max_points }}</strong>
|
||||||
|
{% else %}
|
||||||
|
<span style="color: #9ca3af; font-size: 20px;">❓</span>
|
||||||
|
<br><small style="color: #9ca3af;">Non noté</small>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Performances par compétence -->
|
||||||
|
{% if report.competences %}
|
||||||
|
<div class="section" style="border-left-color: #f59e0b;">
|
||||||
|
<h2>⭐ Performances par compétence</h2>
|
||||||
|
<p style="font-size: 14px; color: #6b7280; margin-bottom: 15px;">
|
||||||
|
Analyse de vos performances sur chaque compétence évaluée
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{% for competence in report.competences %}
|
||||||
|
<div class="competence-item">
|
||||||
|
<div>
|
||||||
|
<div class="competence-name">{{ competence.name }}</div>
|
||||||
|
<div style="font-size: 12px; color: #6b7280;">
|
||||||
|
{{ competence.elements_count }} élément{{ 's' if competence.elements_count > 1 else '' }} évalué{{ 's' if competence.elements_count > 1 else '' }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="text-align: right;">
|
||||||
|
{% set competence_percentage = (competence.score / competence.max_points * 100) if competence.max_points > 0 else 0 %}
|
||||||
|
{% if competence_percentage < 20 %}
|
||||||
|
{% set star_count = 0 %}
|
||||||
|
{% elif competence_percentage < 50 %}
|
||||||
|
{% set star_count = 1 %}
|
||||||
|
{% elif competence_percentage < 80 %}
|
||||||
|
{% set star_count = 2 %}
|
||||||
|
{% else %}
|
||||||
|
{% set star_count = 3 %}
|
||||||
|
{% endif %}
|
||||||
|
<div style="font-size: 18px; margin-bottom: 4px;">
|
||||||
|
{% for i in range(3) %}
|
||||||
|
{% if i < star_count %}⭐{% else %}☆{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
<div style="font-size: 12px; color: #6b7280;">
|
||||||
|
{{ "%.1f"|format(competence.score) }}/{{ "%.1f"|format(competence.max_points) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Performances par domaine -->
|
||||||
|
{% if report.domains %}
|
||||||
|
<div class="section" style="border-left-color: #06b6d4;">
|
||||||
|
<h2>🏷️ Performances par domaine</h2>
|
||||||
|
<p style="font-size: 14px; color: #6b7280; margin-bottom: 15px;">
|
||||||
|
Analyse de vos performances par thème/domaine
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{% for domain in report.domains %}
|
||||||
|
<div class="competence-item">
|
||||||
|
<div>
|
||||||
|
<div class="competence-name">{{ domain.name }}</div>
|
||||||
|
<div style="font-size: 12px; color: #6b7280;">
|
||||||
|
{{ domain.elements_count }} élément{{ 's' if domain.elements_count > 1 else '' }} évalué{{ 's' if domain.elements_count > 1 else '' }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="text-align: right;">
|
||||||
|
{% set domain_percentage = (domain.score / domain.max_points * 100) if domain.max_points > 0 else 0 %}
|
||||||
|
{% if domain_percentage < 20 %}
|
||||||
|
{% set star_count = 0 %}
|
||||||
|
{% elif domain_percentage < 50 %}
|
||||||
|
{% set star_count = 1 %}
|
||||||
|
{% elif domain_percentage < 80 %}
|
||||||
|
{% set star_count = 2 %}
|
||||||
|
{% else %}
|
||||||
|
{% set star_count = 3 %}
|
||||||
|
{% endif %}
|
||||||
|
<div style="font-size: 18px; margin-bottom: 4px;">
|
||||||
|
{% for i in range(3) %}
|
||||||
|
{% if i < star_count %}⭐{% else %}☆{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
<div style="font-size: 12px; color: #6b7280;">
|
||||||
|
{{ "%.1f"|format(domain.score) }}/{{ "%.1f"|format(domain.max_points) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
|
||||||
|
<!-- Message personnalisé si fourni -->
|
||||||
|
{% if custom_message %}
|
||||||
|
<div class="section" style="border-left-color: #10b981; background-color: #f0fdf4;">
|
||||||
|
<h2>💬 Message du professeur</h2>
|
||||||
|
<p style="font-style: italic; color: #166534;">{{ custom_message }}</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
305
uv.lock
generated
305
uv.lock
generated
@@ -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" },
|
{ 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]]
|
[[package]]
|
||||||
name = "click"
|
name = "click"
|
||||||
version = "8.1.8"
|
version = "8.1.8"
|
||||||
@@ -163,6 +256,27 @@ toml = [
|
|||||||
{ name = "tomli", marker = "python_full_version <= '3.11'" },
|
{ 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]]
|
[[package]]
|
||||||
name = "exceptiongroup"
|
name = "exceptiongroup"
|
||||||
version = "1.3.0"
|
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" },
|
{ 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]]
|
[[package]]
|
||||||
name = "flask-sqlalchemy"
|
name = "flask-sqlalchemy"
|
||||||
version = "3.1.1"
|
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" },
|
{ 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]]
|
[[package]]
|
||||||
name = "importlib-metadata"
|
name = "importlib-metadata"
|
||||||
version = "8.7.0"
|
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" },
|
{ 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]]
|
[[package]]
|
||||||
name = "markupsafe"
|
name = "markupsafe"
|
||||||
version = "3.0.2"
|
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" },
|
{ 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]]
|
[[package]]
|
||||||
name = "packaging"
|
name = "packaging"
|
||||||
version = "25.0"
|
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" },
|
{ 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]]
|
[[package]]
|
||||||
name = "psutil"
|
name = "psutil"
|
||||||
version = "7.0.0"
|
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" },
|
{ 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]]
|
[[package]]
|
||||||
name = "school-management"
|
name = "school-management"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
source = { editable = "." }
|
source = { editable = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "flask" },
|
{ name = "flask" },
|
||||||
|
{ name = "flask-mail" },
|
||||||
{ name = "flask-sqlalchemy" },
|
{ name = "flask-sqlalchemy" },
|
||||||
{ name = "flask-wtf" },
|
{ name = "flask-wtf" },
|
||||||
|
{ name = "premailer" },
|
||||||
{ name = "pydantic" },
|
{ name = "pydantic" },
|
||||||
{ name = "python-dotenv" },
|
{ name = "python-dotenv" },
|
||||||
{ name = "wtforms" },
|
{ name = "wtforms" },
|
||||||
@@ -637,8 +931,10 @@ dev = [
|
|||||||
[package.metadata]
|
[package.metadata]
|
||||||
requires-dist = [
|
requires-dist = [
|
||||||
{ name = "flask", specifier = ">=2.3.3" },
|
{ name = "flask", specifier = ">=2.3.3" },
|
||||||
|
{ name = "flask-mail", specifier = ">=0.9.1" },
|
||||||
{ name = "flask-sqlalchemy", specifier = ">=3.0.5" },
|
{ name = "flask-sqlalchemy", specifier = ">=3.0.5" },
|
||||||
{ name = "flask-wtf", specifier = ">=1.1.1" },
|
{ name = "flask-wtf", specifier = ">=1.1.1" },
|
||||||
|
{ name = "premailer", specifier = ">=3.10.0" },
|
||||||
{ name = "pydantic", specifier = ">=2.0.0" },
|
{ name = "pydantic", specifier = ">=2.0.0" },
|
||||||
{ name = "python-dotenv", specifier = ">=1.0.0" },
|
{ name = "python-dotenv", specifier = ">=1.0.0" },
|
||||||
{ name = "wtforms", specifier = ">=3.0.1" },
|
{ 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" },
|
{ 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]]
|
[[package]]
|
||||||
name = "werkzeug"
|
name = "werkzeug"
|
||||||
version = "3.1.3"
|
version = "3.1.3"
|
||||||
|
|||||||
Reference in New Issue
Block a user