Files
notytex/services/csv_import_service.py

328 lines
12 KiB
Python

"""
Service d'import CSV pour les élèves.
Permet d'importer plusieurs élèves en une fois depuis un fichier CSV.
"""
import csv
import re
from typing import List, Tuple, Dict, Any, Optional
from dataclasses import dataclass
from io import StringIO
from flask import current_app
from datetime import date
from models import db, Student, StudentEnrollment, ClassGroup
@dataclass
class ImportedStudent:
"""Représente un élève extrait du CSV."""
first_name: str
last_name: str
email: Optional[str] = None
line_number: int = 0
raw_name: str = ""
@dataclass
class ImportResult:
"""Résultat de l'import avec statistiques détaillées."""
success: bool
total_lines: int
imported_count: int
skipped_count: int
error_count: int
imported_students: List[ImportedStudent]
skipped_students: List[Dict[str, Any]] # Doublons
errors: List[Dict[str, str]] # Erreurs avec détails
message: str
class CSVImportService:
"""Service pour l'import d'élèves depuis un fichier CSV."""
def __init__(self):
self.delimiter = ';' # Séparateur CSV détecté
def extract_name_parts(self, full_name: str) -> Tuple[str, str]:
"""
Extrait le nom et prénom depuis un nom complet.
Stratégie pour "AABIDA LAHDILI Fatima Zahra":
- Les mots en MAJUSCULES → nom de famille
- Les autres mots → prénoms
Args:
full_name: Nom complet de l'élève
Returns:
Tuple (nom_de_famille, prenoms)
"""
if not full_name or not full_name.strip():
return "", ""
# Nettoyer et séparer les mots
parts = full_name.strip().split()
if not parts:
return "", ""
# Séparer les mots en majuscules des autres
uppercase_words = []
other_words = []
for part in parts:
# Un mot est considéré en majuscules s'il contient au moins 2 caractères
# et que tous ses caractères alphabétiques sont en majuscules
if len(part) >= 2 and part.isupper():
uppercase_words.append(part)
else:
other_words.append(part)
# Le nom de famille = mots en majuscules
last_name = " ".join(uppercase_words) if uppercase_words else parts[0]
# Les prénoms = autres mots, ou le reste si pas de majuscules détectées
if uppercase_words and other_words:
first_name = " ".join(other_words)
elif len(parts) > 1:
# Fallback: premier mot = nom, reste = prénoms
last_name = parts[0]
first_name = " ".join(parts[1:])
else:
# Un seul mot : considérer comme prénom
first_name = parts[0]
last_name = ""
return last_name.strip(), first_name.strip()
def parse_csv_content(self, csv_content: str) -> List[ImportedStudent]:
"""
Parse le contenu CSV et extrait les élèves.
Args:
csv_content: Contenu du fichier CSV
Returns:
Liste des élèves extraits
"""
students = []
try:
# Lire le CSV avec le délimiteur détecté
csv_reader = csv.reader(StringIO(csv_content), delimiter=self.delimiter)
# Ignorer la première ligne (headers)
headers = next(csv_reader, None)
if not headers:
return students
for line_number, row in enumerate(csv_reader, start=2): # Start=2 car ligne 1 = headers
if not row or len(row) < 1:
continue
# Prendre la première colonne (nom complet)
full_name = row[0].strip().strip('"')
if not full_name or full_name.lower() in ['undefined', 'null', '']:
continue
# Extraire nom et prénom
last_name, first_name = self.extract_name_parts(full_name)
if not first_name and not last_name:
continue
student = ImportedStudent(
first_name=first_name or "Prénom", # Fallback si pas de prénom
last_name=last_name or "Nom", # Fallback si pas de nom
line_number=line_number,
raw_name=full_name
)
students.append(student)
except Exception as e:
current_app.logger.error(f"Erreur parsing CSV: {e}")
raise ValueError(f"Erreur lors de l'analyse du fichier CSV: {str(e)}")
return students
def find_existing_students(self, students: List[ImportedStudent]) -> List[Student]:
"""
Trouve les élèves existants par nom et prénom.
Args:
students: Liste des élèves à vérifier
Returns:
Liste des élèves existants en base
"""
existing = []
for student in students:
existing_student = Student.query.filter(
db.func.lower(Student.first_name) == student.first_name.lower(),
db.func.lower(Student.last_name) == student.last_name.lower()
).first()
if existing_student:
existing.append(existing_student)
return existing
def import_students_to_class(
self,
csv_content: str,
class_group_id: int,
enrollment_date: date = None,
skip_duplicates: bool = True
) -> ImportResult:
"""
Importe les élèves du CSV dans une classe.
Args:
csv_content: Contenu du fichier CSV
class_group_id: ID de la classe de destination
enrollment_date: Date d'inscription (par défaut: aujourd'hui)
skip_duplicates: Ignorer les doublons (True) ou échouer (False)
Returns:
Résultat de l'import avec statistiques
"""
if enrollment_date is None:
enrollment_date = date.today()
# Vérifier que la classe existe
class_group = ClassGroup.query.get(class_group_id)
if not class_group:
return ImportResult(
success=False,
total_lines=0,
imported_count=0,
skipped_count=0,
error_count=1,
imported_students=[],
skipped_students=[],
errors=[{"line": 0, "error": f"Classe ID {class_group_id} non trouvée"}],
message="Classe non trouvée"
)
try:
# Parser le CSV
parsed_students = self.parse_csv_content(csv_content)
if not parsed_students:
return ImportResult(
success=True,
total_lines=0,
imported_count=0,
skipped_count=0,
error_count=0,
imported_students=[],
skipped_students=[],
errors=[],
message="Aucun élève trouvé dans le fichier CSV"
)
# Vérifier les doublons
imported_students = []
skipped_students = []
errors = []
for student_data in parsed_students:
try:
# Vérifier si l'élève existe déjà
existing_student = Student.query.filter(
db.func.lower(Student.first_name) == student_data.first_name.lower(),
db.func.lower(Student.last_name) == student_data.last_name.lower()
).first()
if existing_student:
if skip_duplicates:
skipped_students.append({
"line": student_data.line_number,
"name": f"{student_data.first_name} {student_data.last_name}",
"reason": "Élève déjà existant"
})
continue
else:
errors.append({
"line": student_data.line_number,
"error": f"Élève déjà existant: {student_data.first_name} {student_data.last_name}"
})
continue
# Créer le nouvel élève
new_student = Student(
first_name=student_data.first_name,
last_name=student_data.last_name,
email=student_data.email
)
db.session.add(new_student)
db.session.flush() # Pour obtenir l'ID
# Créer l'inscription dans la classe
enrollment = StudentEnrollment(
student_id=new_student.id,
class_group_id=class_group_id,
enrollment_date=enrollment_date,
enrollment_reason="Import CSV"
)
db.session.add(enrollment)
imported_students.append(student_data)
current_app.logger.info(f"Élève importé: {new_student.full_name} en {class_group.name}")
except Exception as e:
errors.append({
"line": student_data.line_number,
"error": f"Erreur création élève {student_data.first_name} {student_data.last_name}: {str(e)}"
})
current_app.logger.error(f"Erreur import élève ligne {student_data.line_number}: {e}")
# Valider la transaction
if errors and not skip_duplicates:
db.session.rollback()
return ImportResult(
success=False,
total_lines=len(parsed_students),
imported_count=0,
skipped_count=len(skipped_students),
error_count=len(errors),
imported_students=[],
skipped_students=skipped_students,
errors=errors,
message=f"Import échoué: {len(errors)} erreur(s)"
)
db.session.commit()
return ImportResult(
success=True,
total_lines=len(parsed_students),
imported_count=len(imported_students),
skipped_count=len(skipped_students),
error_count=len(errors),
imported_students=imported_students,
skipped_students=skipped_students,
errors=errors,
message=f"Import réussi: {len(imported_students)} élève(s) importé(s), {len(skipped_students)} ignoré(s)"
)
except Exception as e:
db.session.rollback()
current_app.logger.error(f"Erreur import CSV classe {class_group_id}: {e}")
return ImportResult(
success=False,
total_lines=0,
imported_count=0,
skipped_count=0,
error_count=1,
imported_students=[],
skipped_students=[],
errors=[{"line": 0, "error": str(e)}],
message=f"Erreur lors de l'import: {str(e)}"
)