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:
2025-11-25 21:09:47 +01:00
parent 60c60c1605
commit 2b08eb534a
4125 changed files with 303 additions and 453271 deletions

View File

View 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}>"

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

View File

@@ -0,0 +1,13 @@
"""
Infrastructure externe pour les services tiers.
"""
from infrastructure.external.email_service import (
EmailService,
SMTPConfig,
)
__all__ = [
"EmailService",
"SMTPConfig",
]

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