Migration v1 (Flask) -> v2 (FastAPI + Vue.js) complétée
✨ Changements majeurs: - Suppression complète du code Flask legacy - Migration backend FastAPI vers racine /backend - Migration frontend Vue.js vers racine /frontend - Suppression de notytex-v2/ (code monté à la racine) ✅ Validations: - Backend démarre correctement (port 8000) - API /api/v2/health répond healthy - 99/99 tests unitaires passent - Frontend configuré avec proxy Vite 📝 Documentation: - README.md réécrit pour v2 - Instructions de démarrage mises à jour - .gitignore adapté pour backend/frontend/ 🎯 Architecture finale: notytex/ ├── backend/ # FastAPI + SQLAlchemy + Pydantic ├── frontend/ # Vue 3 + Vite + TailwindCSS ├── docs/ # Documentation └── school_management.db # Base de données (inchangée) Jalon 6 complété: Application v2 prête pour utilisation!
This commit is contained in:
0
backend/infrastructure/__init__.py
Normal file
0
backend/infrastructure/__init__.py
Normal file
0
backend/infrastructure/database/__init__.py
Normal file
0
backend/infrastructure/database/__init__.py
Normal file
376
backend/infrastructure/database/models.py
Normal file
376
backend/infrastructure/database/models.py
Normal file
@@ -0,0 +1,376 @@
|
||||
"""
|
||||
Modèles SQLAlchemy pour Notytex v2.
|
||||
IMPORTANT: Ce fichier contient UNIQUEMENT les définitions de tables.
|
||||
La logique métier est dans domain/services/.
|
||||
|
||||
Ces modèles sont identiques au schéma de la v1 pour assurer la compatibilité
|
||||
de la base de données partagée.
|
||||
"""
|
||||
|
||||
from datetime import datetime, date
|
||||
from typing import Optional, List
|
||||
|
||||
from sqlalchemy import (
|
||||
Column,
|
||||
Integer,
|
||||
String,
|
||||
Float,
|
||||
Date,
|
||||
DateTime,
|
||||
Text,
|
||||
Boolean,
|
||||
ForeignKey,
|
||||
CheckConstraint,
|
||||
Enum,
|
||||
Index,
|
||||
UniqueConstraint,
|
||||
)
|
||||
from sqlalchemy.orm import relationship, DeclarativeBase, Mapped, mapped_column
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
"""Base class for all models."""
|
||||
pass
|
||||
|
||||
|
||||
class ClassGroup(Base):
|
||||
"""Groupe de classe (6ème A, 5ème B, etc.)"""
|
||||
__tablename__ = "class_group"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
name: Mapped[str] = mapped_column(String(100), nullable=False, unique=True)
|
||||
description: Mapped[Optional[str]] = mapped_column(Text)
|
||||
year: Mapped[str] = mapped_column(String(20), nullable=False)
|
||||
|
||||
# Relations
|
||||
assessments: Mapped[List["Assessment"]] = relationship(
|
||||
"Assessment", back_populates="class_group", lazy="selectin"
|
||||
)
|
||||
enrollments: Mapped[List["StudentEnrollment"]] = relationship(
|
||||
"StudentEnrollment", back_populates="class_group", lazy="selectin"
|
||||
)
|
||||
council_appreciations: Mapped[List["CouncilAppreciation"]] = relationship(
|
||||
"CouncilAppreciation", back_populates="class_group", lazy="selectin"
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<ClassGroup {self.name}>"
|
||||
|
||||
|
||||
class StudentEnrollment(Base):
|
||||
"""
|
||||
Historique temporel des inscriptions élève-classe.
|
||||
Pattern: Association temporelle avec validité temporelle.
|
||||
"""
|
||||
__tablename__ = "student_enrollments"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
student_id: Mapped[int] = mapped_column(Integer, ForeignKey("student.id"), nullable=False)
|
||||
class_group_id: Mapped[int] = mapped_column(
|
||||
Integer, ForeignKey("class_group.id"), nullable=False
|
||||
)
|
||||
|
||||
# Période de validité
|
||||
enrollment_date: Mapped[date] = mapped_column(Date, nullable=False)
|
||||
departure_date: Mapped[Optional[date]] = mapped_column(Date, nullable=True)
|
||||
|
||||
# Métadonnées
|
||||
enrollment_reason: Mapped[Optional[str]] = mapped_column(String(200))
|
||||
departure_reason: Mapped[Optional[str]] = mapped_column(String(200))
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime, default=datetime.utcnow, onupdate=datetime.utcnow
|
||||
)
|
||||
|
||||
# Relations
|
||||
student: Mapped["Student"] = relationship("Student", back_populates="enrollments")
|
||||
class_group: Mapped["ClassGroup"] = relationship("ClassGroup", back_populates="enrollments")
|
||||
|
||||
__table_args__ = (
|
||||
CheckConstraint(
|
||||
"departure_date IS NULL OR departure_date >= enrollment_date",
|
||||
name="check_valid_enrollment_period",
|
||||
),
|
||||
Index(
|
||||
"idx_student_temporal", "student_id", "enrollment_date", "departure_date"
|
||||
),
|
||||
Index(
|
||||
"idx_class_temporal", "class_group_id", "enrollment_date", "departure_date"
|
||||
),
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<StudentEnrollment {self.student_id} in {self.class_group_id} from {self.enrollment_date}>"
|
||||
|
||||
|
||||
class Student(Base):
|
||||
"""Élève"""
|
||||
__tablename__ = "student"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
last_name: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||
first_name: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||
email: Mapped[Optional[str]] = mapped_column(String(120), unique=True)
|
||||
|
||||
# Relations
|
||||
grades: Mapped[List["Grade"]] = relationship(
|
||||
"Grade", back_populates="student", lazy="selectin"
|
||||
)
|
||||
enrollments: Mapped[List["StudentEnrollment"]] = relationship(
|
||||
"StudentEnrollment", back_populates="student", lazy="selectin"
|
||||
)
|
||||
council_appreciations: Mapped[List["CouncilAppreciation"]] = relationship(
|
||||
"CouncilAppreciation", back_populates="student", lazy="selectin"
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Student {self.first_name} {self.last_name}>"
|
||||
|
||||
|
||||
class Assessment(Base):
|
||||
"""Évaluation"""
|
||||
__tablename__ = "assessment"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
title: Mapped[str] = mapped_column(String(200), nullable=False)
|
||||
description: Mapped[Optional[str]] = mapped_column(Text)
|
||||
date: Mapped[date] = mapped_column(Date, nullable=False, default=datetime.utcnow)
|
||||
trimester: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
class_group_id: Mapped[int] = mapped_column(
|
||||
Integer, ForeignKey("class_group.id"), nullable=False
|
||||
)
|
||||
coefficient: Mapped[float] = mapped_column(Float, default=1.0)
|
||||
|
||||
# Relations
|
||||
class_group: Mapped["ClassGroup"] = relationship(
|
||||
"ClassGroup", back_populates="assessments"
|
||||
)
|
||||
exercises: Mapped[List["Exercise"]] = relationship(
|
||||
"Exercise",
|
||||
back_populates="assessment",
|
||||
lazy="selectin",
|
||||
cascade="all, delete-orphan",
|
||||
)
|
||||
|
||||
__table_args__ = (
|
||||
CheckConstraint("trimester IN (1, 2, 3)", name="check_trimester_valid"),
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Assessment {self.title}>"
|
||||
|
||||
|
||||
class Exercise(Base):
|
||||
"""Exercice d'une évaluation"""
|
||||
__tablename__ = "exercise"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
assessment_id: Mapped[int] = mapped_column(
|
||||
Integer, ForeignKey("assessment.id"), nullable=False
|
||||
)
|
||||
title: Mapped[str] = mapped_column(String(200), nullable=False)
|
||||
description: Mapped[Optional[str]] = mapped_column(Text)
|
||||
order: Mapped[int] = mapped_column(Integer, default=1)
|
||||
|
||||
# Relations
|
||||
assessment: Mapped["Assessment"] = relationship(
|
||||
"Assessment", back_populates="exercises"
|
||||
)
|
||||
grading_elements: Mapped[List["GradingElement"]] = relationship(
|
||||
"GradingElement",
|
||||
back_populates="exercise",
|
||||
lazy="selectin",
|
||||
cascade="all, delete-orphan",
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Exercise {self.title}>"
|
||||
|
||||
|
||||
class GradingElement(Base):
|
||||
"""Élément de notation (question, critère, etc.)"""
|
||||
__tablename__ = "grading_element"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
exercise_id: Mapped[int] = mapped_column(
|
||||
Integer, ForeignKey("exercise.id"), nullable=False
|
||||
)
|
||||
label: Mapped[str] = mapped_column(String(200), nullable=False)
|
||||
description: Mapped[Optional[str]] = mapped_column(Text)
|
||||
skill: Mapped[Optional[str]] = mapped_column(String(200))
|
||||
max_points: Mapped[float] = mapped_column(Float, nullable=False)
|
||||
grading_type: Mapped[str] = mapped_column(
|
||||
Enum("notes", "score", name="grading_types"), nullable=False, default="notes"
|
||||
)
|
||||
domain_id: Mapped[Optional[int]] = mapped_column(
|
||||
Integer, ForeignKey("domains.id"), nullable=True
|
||||
)
|
||||
|
||||
# Relations
|
||||
exercise: Mapped["Exercise"] = relationship(
|
||||
"Exercise", back_populates="grading_elements"
|
||||
)
|
||||
domain: Mapped[Optional["Domain"]] = relationship(
|
||||
"Domain", back_populates="grading_elements"
|
||||
)
|
||||
grades: Mapped[List["Grade"]] = relationship(
|
||||
"Grade",
|
||||
back_populates="grading_element",
|
||||
lazy="selectin",
|
||||
cascade="all, delete-orphan",
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<GradingElement {self.label}>"
|
||||
|
||||
|
||||
class Grade(Base):
|
||||
"""Note attribuée à un élève pour un élément de notation"""
|
||||
__tablename__ = "grade"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
student_id: Mapped[int] = mapped_column(
|
||||
Integer, ForeignKey("student.id"), nullable=False
|
||||
)
|
||||
grading_element_id: Mapped[int] = mapped_column(
|
||||
Integer, ForeignKey("grading_element.id"), nullable=False
|
||||
)
|
||||
value: Mapped[Optional[str]] = mapped_column(String(10))
|
||||
comment: Mapped[Optional[str]] = mapped_column(Text)
|
||||
|
||||
# Relations
|
||||
student: Mapped["Student"] = relationship("Student", back_populates="grades")
|
||||
grading_element: Mapped["GradingElement"] = relationship(
|
||||
"GradingElement", back_populates="grades"
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Grade {self.value}>"
|
||||
|
||||
|
||||
# Configuration tables
|
||||
|
||||
class AppConfig(Base):
|
||||
"""Configuration simple de l'application (clé-valeur)."""
|
||||
__tablename__ = "app_config"
|
||||
|
||||
key: Mapped[str] = mapped_column(String(100), primary_key=True)
|
||||
value: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
description: Mapped[Optional[str]] = mapped_column(Text)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime, default=datetime.utcnow, onupdate=datetime.utcnow
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<AppConfig {self.key}={self.value}>"
|
||||
|
||||
|
||||
class CompetenceScaleValue(Base):
|
||||
"""Valeurs de l'échelle des compétences (0, 1, 2, 3, ., d, etc.)."""
|
||||
__tablename__ = "competence_scale_values"
|
||||
|
||||
value: Mapped[str] = mapped_column(String(10), primary_key=True)
|
||||
label: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||
color: Mapped[str] = mapped_column(String(7), nullable=False)
|
||||
included_in_total: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime, default=datetime.utcnow, onupdate=datetime.utcnow
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<CompetenceScaleValue {self.value}: {self.label}>"
|
||||
|
||||
|
||||
class Competence(Base):
|
||||
"""Liste des compétences (Calculer, Raisonner, etc.)."""
|
||||
__tablename__ = "competences"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
name: Mapped[str] = mapped_column(String(100), unique=True, nullable=False)
|
||||
color: Mapped[str] = mapped_column(String(7), nullable=False)
|
||||
icon: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||
order_index: Mapped[int] = mapped_column(Integer, default=0)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime, default=datetime.utcnow, onupdate=datetime.utcnow
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Competence {self.name}>"
|
||||
|
||||
|
||||
class Domain(Base):
|
||||
"""Domaines/tags pour les éléments de notation."""
|
||||
__tablename__ = "domains"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
name: Mapped[str] = mapped_column(String(100), unique=True, nullable=False)
|
||||
color: Mapped[str] = mapped_column(String(7), nullable=False, default="#6B7280")
|
||||
description: Mapped[Optional[str]] = mapped_column(Text)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime, default=datetime.utcnow, onupdate=datetime.utcnow
|
||||
)
|
||||
|
||||
# Relation inverse
|
||||
grading_elements: Mapped[List["GradingElement"]] = relationship(
|
||||
"GradingElement", back_populates="domain", lazy="selectin"
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Domain {self.name}>"
|
||||
|
||||
|
||||
class CouncilAppreciation(Base):
|
||||
"""Appréciations saisies lors de la préparation du conseil de classe."""
|
||||
__tablename__ = "council_appreciations"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
student_id: Mapped[int] = mapped_column(
|
||||
Integer, ForeignKey("student.id"), nullable=False
|
||||
)
|
||||
class_group_id: Mapped[int] = mapped_column(
|
||||
Integer, ForeignKey("class_group.id"), nullable=False
|
||||
)
|
||||
trimester: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
|
||||
# Appréciations structurées
|
||||
general_appreciation: Mapped[Optional[str]] = mapped_column(Text)
|
||||
strengths: Mapped[Optional[str]] = mapped_column(Text)
|
||||
areas_for_improvement: Mapped[Optional[str]] = mapped_column(Text)
|
||||
|
||||
# Statut et métadonnées
|
||||
status: Mapped[str] = mapped_column(
|
||||
Enum("draft", "finalized", name="appreciation_status"),
|
||||
nullable=False,
|
||||
default="draft",
|
||||
)
|
||||
last_modified: Mapped[datetime] = mapped_column(
|
||||
DateTime, default=datetime.utcnow, onupdate=datetime.utcnow
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||
|
||||
# Relations
|
||||
student: Mapped["Student"] = relationship(
|
||||
"Student", back_populates="council_appreciations"
|
||||
)
|
||||
class_group: Mapped["ClassGroup"] = relationship(
|
||||
"ClassGroup", back_populates="council_appreciations"
|
||||
)
|
||||
|
||||
__table_args__ = (
|
||||
CheckConstraint(
|
||||
"trimester IN (1, 2, 3)", name="check_appreciation_trimester_valid"
|
||||
),
|
||||
UniqueConstraint(
|
||||
"student_id",
|
||||
"class_group_id",
|
||||
"trimester",
|
||||
name="uq_student_class_trimester_appreciation",
|
||||
),
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<CouncilAppreciation Student:{self.student_id} Class:{self.class_group_id} T{self.trimester}>"
|
||||
81
backend/infrastructure/database/session.py
Normal file
81
backend/infrastructure/database/session.py
Normal file
@@ -0,0 +1,81 @@
|
||||
"""
|
||||
Gestion de la session SQLAlchemy async.
|
||||
"""
|
||||
|
||||
from typing import AsyncGenerator
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from core.config import settings
|
||||
from infrastructure.database.models import Base
|
||||
|
||||
|
||||
# Engine async pour FastAPI
|
||||
async_engine = create_async_engine(
|
||||
settings.get_database_url(),
|
||||
echo=settings.debug,
|
||||
future=True,
|
||||
)
|
||||
|
||||
# Session factory async
|
||||
AsyncSessionLocal = async_sessionmaker(
|
||||
bind=async_engine,
|
||||
class_=AsyncSession,
|
||||
expire_on_commit=False,
|
||||
autocommit=False,
|
||||
autoflush=False,
|
||||
)
|
||||
|
||||
# Engine sync pour certaines opérations (comme les tests de health)
|
||||
sync_engine = create_engine(
|
||||
settings.sync_database_url,
|
||||
echo=settings.debug,
|
||||
)
|
||||
|
||||
SyncSessionLocal = sessionmaker(
|
||||
bind=sync_engine,
|
||||
autocommit=False,
|
||||
autoflush=False,
|
||||
)
|
||||
|
||||
|
||||
async def get_async_session() -> AsyncGenerator[AsyncSession, None]:
|
||||
"""
|
||||
Dependency pour obtenir une session async.
|
||||
Usage:
|
||||
@router.get("/items")
|
||||
async def get_items(session: AsyncSession = Depends(get_async_session)):
|
||||
...
|
||||
"""
|
||||
async with AsyncSessionLocal() as session:
|
||||
try:
|
||||
yield session
|
||||
await session.commit()
|
||||
except Exception:
|
||||
await session.rollback()
|
||||
raise
|
||||
finally:
|
||||
await session.close()
|
||||
|
||||
|
||||
def get_sync_session():
|
||||
"""
|
||||
Récupère une session synchrone (pour les opérations qui ne supportent pas async).
|
||||
"""
|
||||
session = SyncSessionLocal()
|
||||
try:
|
||||
yield session
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
|
||||
async def init_db():
|
||||
"""
|
||||
Initialise la base de données (crée les tables si elles n'existent pas).
|
||||
Note: En production, on utilise la DB existante de v1, donc cette fonction
|
||||
ne devrait pas être appelée.
|
||||
"""
|
||||
async with async_engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
13
backend/infrastructure/external/__init__.py
vendored
Normal file
13
backend/infrastructure/external/__init__.py
vendored
Normal file
@@ -0,0 +1,13 @@
|
||||
"""
|
||||
Infrastructure externe pour les services tiers.
|
||||
"""
|
||||
|
||||
from infrastructure.external.email_service import (
|
||||
EmailService,
|
||||
SMTPConfig,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"EmailService",
|
||||
"SMTPConfig",
|
||||
]
|
||||
246
backend/infrastructure/external/email_service.py
vendored
Normal file
246
backend/infrastructure/external/email_service.py
vendored
Normal file
@@ -0,0 +1,246 @@
|
||||
"""
|
||||
Service d'envoi d'emails pour Notytex v2.
|
||||
Gère la configuration SMTP et l'envoi de bilans d'évaluation.
|
||||
"""
|
||||
|
||||
import smtplib
|
||||
import logging
|
||||
import re
|
||||
from email.mime.text import MIMEText
|
||||
from email.mime.multipart import MIMEMultipart
|
||||
from typing import List, Optional, Dict, Any
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass
|
||||
class SMTPConfig:
|
||||
"""Configuration SMTP pour l'envoi d'emails."""
|
||||
host: str
|
||||
port: int
|
||||
username: str
|
||||
password: str
|
||||
use_tls: bool
|
||||
from_name: str
|
||||
from_address: str
|
||||
|
||||
def is_valid(self) -> bool:
|
||||
"""Vérifie si la configuration est valide pour l'envoi."""
|
||||
# Vérifier les champs obligatoires
|
||||
if not self.host or not self.from_address:
|
||||
return False
|
||||
|
||||
# Pour les serveurs locaux de test, l'authentification n'est pas requise
|
||||
is_localhost = self.host.lower() in ['localhost', '127.0.0.1']
|
||||
is_test_port = str(self.port) 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 self.username or not self.password:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
class EmailService:
|
||||
"""Service d'envoi d'emails avec configuration dynamique."""
|
||||
|
||||
def __init__(self, smtp_config: Optional[SMTPConfig] = None):
|
||||
"""
|
||||
Initialise le service avec la configuration SMTP.
|
||||
|
||||
Args:
|
||||
smtp_config: Configuration SMTP (optionnelle, peut être définie plus tard)
|
||||
"""
|
||||
self._config = smtp_config
|
||||
self.logger = logging.getLogger(__name__)
|
||||
|
||||
def set_config(self, config: SMTPConfig) -> None:
|
||||
"""
|
||||
Définit la configuration SMTP.
|
||||
|
||||
Args:
|
||||
config: Configuration SMTP
|
||||
"""
|
||||
self._config = config
|
||||
|
||||
@property
|
||||
def config(self) -> Optional[SMTPConfig]:
|
||||
"""Retourne la configuration actuelle."""
|
||||
return self._config
|
||||
|
||||
def is_configured(self) -> bool:
|
||||
"""Vérifie si la configuration email est complète."""
|
||||
if not self._config:
|
||||
return False
|
||||
return self._config.is_valid()
|
||||
|
||||
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 SMTP.'
|
||||
}
|
||||
|
||||
config = self._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)
|
||||
|
||||
# Ajouter le corps HTML
|
||||
# Note: premailer transform() peut être ajouté ici si nécessaire
|
||||
part2 = MIMEText(html_body, '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.host.lower() in ['localhost', '127.0.0.1']
|
||||
is_test_port = str(config.port) in ['1025', '2525', '8025']
|
||||
|
||||
if not (is_localhost and is_test_port) and config.username and config.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)
|
||||
|
||||
@staticmethod
|
||||
def validate_email_addresses(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
|
||||
"""
|
||||
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)
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_database_config(cls, db_config: Dict[str, Any]) -> "EmailService":
|
||||
"""
|
||||
Crée une instance à partir d'une configuration de base de données.
|
||||
|
||||
Args:
|
||||
db_config: Configuration extraite de la DB (format key-value)
|
||||
|
||||
Returns:
|
||||
Instance d'EmailService configurée
|
||||
"""
|
||||
smtp_config = SMTPConfig(
|
||||
host=db_config.get('email.smtp_host', ''),
|
||||
port=int(db_config.get('email.smtp_port', 587)),
|
||||
username=db_config.get('email.username', ''),
|
||||
password=db_config.get('email.password', ''),
|
||||
use_tls=str(db_config.get('email.use_tls', 'true')).lower() == 'true',
|
||||
from_name=db_config.get('email.from_name', 'Notytex'),
|
||||
from_address=db_config.get('email.from_address', ''),
|
||||
)
|
||||
|
||||
return cls(smtp_config)
|
||||
Reference in New Issue
Block a user