328 lines
12 KiB
Python
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)}"
|
|
) |