2 Commits

11 changed files with 673 additions and 142 deletions

View File

@ -225,4 +225,106 @@ def sendRegisterTeacher(recipients, establishment_id):
sendMail(subject=subject, message=html_message, recipients=recipients, connection=connection) sendMail(subject=subject, message=html_message, recipients=recipients, connection=connection)
except Exception as e: except Exception as e:
errorMessage = str(e) errorMessage = str(e)
return errorMessage
def sendRefusDossier(recipients, establishment_id, student_name, notes):
"""
Envoie un email au parent pour l'informer que son dossier d'inscription
nécessite des corrections.
Args:
recipients: Email du destinataire (parent)
establishment_id: ID de l'établissement
student_name: Nom complet de l'élève
notes: Motifs du refus (contenu du champ notes du RegistrationForm)
Returns:
str: Message d'erreur si échec, chaîne vide sinon
"""
errorMessage = ''
try:
EMAIL_REFUS_SUBJECT = '[N3WT-SCHOOL] Dossier d\'inscription - Corrections requises'
context = {
'BASE_URL': settings.BASE_URL,
'URL_DJANGO': settings.URL_DJANGO,
'student_name': student_name,
'notes': notes
}
connection = getConnection(establishment_id)
subject = EMAIL_REFUS_SUBJECT
html_message = render_to_string('emails/refus_dossier.html', context)
sendMail(subject=subject, message=html_message, recipients=recipients, connection=connection)
logger.info(f"Email de refus envoyé à {recipients} pour l'élève {student_name}")
except Exception as e:
errorMessage = str(e)
logger.error(f"Erreur lors de l'envoi de l'email de refus: {errorMessage}")
return errorMessage
def sendValidationDossier(recipients, establishment_id, student_name, class_name=None):
"""
Envoie un email au parent pour l'informer que le dossier d'inscription
a été validé.
Args:
recipients: Email du destinataire (parent)
establishment_id: ID de l'établissement
student_name: Nom complet de l'élève
class_name: Nom de la classe attribuée (optionnel)
Returns:
str: Message d'erreur si échec, chaîne vide sinon
"""
errorMessage = ''
try:
EMAIL_VALIDATION_SUBJECT = '[N3WT-SCHOOL] Dossier d\'inscription validé'
context = {
'BASE_URL': settings.BASE_URL,
'URL_DJANGO': settings.URL_DJANGO,
'student_name': student_name,
'class_name': class_name
}
connection = getConnection(establishment_id)
subject = EMAIL_VALIDATION_SUBJECT
html_message = render_to_string('emails/validation_dossier.html', context)
sendMail(subject=subject, message=html_message, recipients=recipients, connection=connection)
logger.info(f"Email de validation envoyé à {recipients} pour l'élève {student_name}")
except Exception as e:
errorMessage = str(e)
logger.error(f"Erreur lors de l'envoi de l'email de validation: {errorMessage}")
return errorMessage
def sendRefusDefinitif(recipients, establishment_id, student_name, notes):
"""
Envoie un email au parent pour l'informer que le dossier d'inscription
a été définitivement refusé.
Args:
recipients: Email du destinataire (parent)
establishment_id: ID de l'établissement
student_name: Nom complet de l'élève
notes: Motifs du refus
Returns:
str: Message d'erreur si échec, chaîne vide sinon
"""
errorMessage = ''
try:
EMAIL_REFUS_DEFINITIF_SUBJECT = '[N3WT-SCHOOL] Dossier d\'inscription refusé'
context = {
'BASE_URL': settings.BASE_URL,
'URL_DJANGO': settings.URL_DJANGO,
'student_name': student_name,
'notes': notes
}
connection = getConnection(establishment_id)
subject = EMAIL_REFUS_DEFINITIF_SUBJECT
html_message = render_to_string('emails/refus_definitif.html', context)
sendMail(subject=subject, message=html_message, recipients=recipients, connection=connection)
logger.info(f"Email de refus définitif envoyé à {recipients} pour l'élève {student_name}")
except Exception as e:
errorMessage = str(e)
logger.error(f"Erreur lors de l'envoi de l'email de refus définitif: {errorMessage}")
return errorMessage return errorMessage

View File

@ -214,9 +214,28 @@ class RegistrationFileGroup(models.Model):
def __str__(self): def __str__(self):
return f'{self.group.name} - {self.id}' return f'{self.group.name} - {self.id}'
def registration_file_path(instance, filename): def registration_form_file_upload_to(instance, filename):
# Génère le chemin : registration_files/dossier_rf_{student_id}/filename """
return f'registration_files/dossier_rf_{instance.student_id}/{filename}' Génère le chemin de stockage pour les fichiers du dossier d'inscription.
Structure : Etablissement/dossier_NomEleve_PrenomEleve/filename
"""
est_name = instance.establishment.name if instance.establishment else "unknown_establishment"
student_last = instance.student.last_name if instance.student else "unknown"
student_first = instance.student.first_name if instance.student else "unknown"
return f"{est_name}/dossier_{student_last}_{student_first}/{filename}"
def _delete_file_if_exists(file_field):
"""
Supprime le fichier physique s'il existe.
Utile pour éviter les suffixes automatiques Django lors du remplacement.
"""
if file_field and file_field.name:
try:
if hasattr(file_field, 'path') and os.path.exists(file_field.path):
os.remove(file_field.path)
logger.debug(f"Fichier supprimé: {file_field.path}")
except Exception as e:
logger.error(f"Erreur lors de la suppression du fichier {file_field.name}: {e}")
class RegistrationForm(models.Model): class RegistrationForm(models.Model):
class RegistrationFormStatus(models.IntegerChoices): class RegistrationFormStatus(models.IntegerChoices):
@ -238,17 +257,17 @@ class RegistrationForm(models.Model):
notes = models.CharField(max_length=200, blank=True) notes = models.CharField(max_length=200, blank=True)
registration_link_code = models.CharField(max_length=200, default="", blank=True) registration_link_code = models.CharField(max_length=200, default="", blank=True)
registration_file = models.FileField( registration_file = models.FileField(
upload_to=registration_file_path, upload_to=registration_form_file_upload_to,
null=True, null=True,
blank=True blank=True
) )
sepa_file = models.FileField( sepa_file = models.FileField(
upload_to=registration_file_path, upload_to=registration_form_file_upload_to,
null=True, null=True,
blank=True blank=True
) )
fusion_file = models.FileField( fusion_file = models.FileField(
upload_to=registration_file_path, upload_to=registration_form_file_upload_to,
null=True, null=True,
blank=True blank=True
) )
@ -285,13 +304,23 @@ class RegistrationForm(models.Model):
except RegistrationForm.DoesNotExist: except RegistrationForm.DoesNotExist:
old_fileGroup = None old_fileGroup = None
# Vérifier si un fichier existant doit être remplacé # Supprimer les anciens fichiers si remplacés (évite les suffixes Django)
if self.pk: # Si l'objet existe déjà dans la base de données if self.pk: # Si l'objet existe déjà dans la base de données
try: try:
old_instance = RegistrationForm.objects.get(pk=self.pk) old_instance = RegistrationForm.objects.get(pk=self.pk)
# Gestion du sepa_file
if old_instance.sepa_file and old_instance.sepa_file != self.sepa_file: if old_instance.sepa_file and old_instance.sepa_file != self.sepa_file:
# Supprimer l'ancien fichier _delete_file_if_exists(old_instance.sepa_file)
old_instance.sepa_file.delete(save=False)
# Gestion du registration_file
if old_instance.registration_file and old_instance.registration_file != self.registration_file:
_delete_file_if_exists(old_instance.registration_file)
# Gestion du fusion_file
if old_instance.fusion_file and old_instance.fusion_file != self.fusion_file:
_delete_file_if_exists(old_instance.fusion_file)
except RegistrationForm.DoesNotExist: except RegistrationForm.DoesNotExist:
pass # L'objet n'existe pas encore, rien à supprimer pass # L'objet n'existe pas encore, rien à supprimer
@ -485,10 +514,18 @@ class RegistrationParentFileMaster(models.Model):
############################################################ ############################################################
def registration_school_file_upload_to(instance, filename): def registration_school_file_upload_to(instance, filename):
"""
Génère le chemin pour les fichiers templates école.
Structure : Etablissement/dossier_NomEleve_PrenomEleve/filename
"""
return f"{instance.registration_form.establishment.name}/dossier_{instance.registration_form.student.last_name}_{instance.registration_form.student.first_name}/{filename}" return f"{instance.registration_form.establishment.name}/dossier_{instance.registration_form.student.last_name}_{instance.registration_form.student.first_name}/{filename}"
def registration_parent_file_upload_to(instance, filename): def registration_parent_file_upload_to(instance, filename):
return f"registration_files/{instance.registration_form.establishment.name}/dossier_rf_{instance.registration_form.pk}/parent/{filename}" """
Génère le chemin pour les fichiers à fournir par les parents.
Structure : Etablissement/dossier_NomEleve_PrenomEleve/parent/filename
"""
return f"{instance.registration_form.establishment.name}/dossier_{instance.registration_form.student.last_name}_{instance.registration_form.student.first_name}/parent/{filename}"
####### Formulaires templates (par dossier d'inscription) ####### ####### Formulaires templates (par dossier d'inscription) #######
class RegistrationSchoolFileTemplate(models.Model): class RegistrationSchoolFileTemplate(models.Model):
@ -503,15 +540,31 @@ class RegistrationSchoolFileTemplate(models.Model):
def __str__(self): def __str__(self):
return self.name return self.name
def save(self, *args, **kwargs):
# Supprimer l'ancien fichier si remplacé (évite les suffixes Django)
if self.pk:
try:
old_instance = RegistrationSchoolFileTemplate.objects.get(pk=self.pk)
if old_instance.file and old_instance.file != self.file:
_delete_file_if_exists(old_instance.file)
except RegistrationSchoolFileTemplate.DoesNotExist:
pass
super().save(*args, **kwargs)
@staticmethod @staticmethod
def get_files_from_rf(register_form_id): def get_files_from_rf(register_form_id):
""" """
Récupère tous les fichiers liés à un dossier dinscription donné. Récupère tous les fichiers liés à un dossier dinscription donné.
Ignore les fichiers qui n'existent pas physiquement.
""" """
registration_files = RegistrationSchoolFileTemplate.objects.filter(registration_form=register_form_id) registration_files = RegistrationSchoolFileTemplate.objects.filter(registration_form=register_form_id)
filenames = [] filenames = []
for reg_file in registration_files: for reg_file in registration_files:
filenames.append(reg_file.file.path) if reg_file.file and hasattr(reg_file.file, 'path'):
if os.path.exists(reg_file.file.path):
filenames.append(reg_file.file.path)
else:
logger.warning(f"Fichier introuvable ignoré: {reg_file.file.path}")
return filenames return filenames
class StudentCompetency(models.Model): class StudentCompetency(models.Model):
@ -544,20 +597,21 @@ class RegistrationParentFileTemplate(models.Model):
isValidated = models.BooleanField(default=False) isValidated = models.BooleanField(default=False)
def __str__(self): def __str__(self):
return self.name return self.master.name if self.master else f"ParentFile_{self.pk}"
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
if self.pk: # Si l'objet existe déjà dans la base de données # Supprimer l'ancien fichier si remplacé (évite les suffixes Django)
if self.pk:
try: try:
old_instance = RegistrationParentFileTemplate.objects.get(pk=self.pk) old_instance = RegistrationParentFileTemplate.objects.get(pk=self.pk)
if old_instance.file and (not self.file or self.file.name == ''): # Si le fichier change ou est supprimé
if os.path.exists(old_instance.file.path): if old_instance.file:
old_instance.file.delete(save=False) if old_instance.file != self.file or not self.file or self.file.name == '':
self.file = None _delete_file_if_exists(old_instance.file)
else: if not self.file or self.file.name == '':
print(f"Le fichier {old_instance.file.path} n'existe pas.") self.file = None
except RegistrationParentFileTemplate.DoesNotExist: except RegistrationParentFileTemplate.DoesNotExist:
print("Ancienne instance introuvable.") pass
super().save(*args, **kwargs) super().save(*args, **kwargs)
@staticmethod @staticmethod

View File

@ -0,0 +1,66 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Dossier d'inscription refusé</title>
<style>
body {
font-family: Arial, sans-serif;
line-height: 1.6;
color: #333;
}
.container {
max-width: 600px;
margin: 0 auto;
padding: 20px;
}
.header {
background-color: #f4f4f4;
padding: 10px;
text-align: center;
}
.content {
padding: 20px;
}
.notes {
background-color: #f8d7da;
border: 1px solid #f5c6cb;
border-radius: 5px;
padding: 15px;
margin: 20px 0;
white-space: pre-line;
}
.footer {
font-size: 12px;
text-align: center;
margin-top: 30px;
color: #777;
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<img src="{{URL_DJANGO}}static/img/logo_min.svg" alt="Logo N3wt School" class="logo" />
<h1>Dossier d'inscription refusé</h1>
</div>
<div class="content">
<p>Bonjour,</p>
<p>Nous avons le regret de vous informer que le dossier d'inscription de <strong>{{ student_name }}</strong> n'a pas été retenu.</p>
<div class="notes">
<strong>Motif(s) :</strong><br>
{{ notes }}
</div>
<p>Nous vous remercions de l'intérêt que vous avez porté à notre établissement et restons à votre disposition pour tout renseignement complémentaire.</p>
<p>Cordialement,</p>
<p>L'équipe N3wt School</p>
</div>
<div class="footer">
<p>Ce message est généré automatiquement, merci de ne pas y répondre.</p>
</div>
</div>
</body>
</html>

View File

@ -0,0 +1,67 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Dossier d'inscription - Corrections requises</title>
<style>
body {
font-family: Arial, sans-serif;
line-height: 1.6;
color: #333;
}
.container {
max-width: 600px;
margin: 0 auto;
padding: 20px;
}
.header {
background-color: #f4f4f4;
padding: 10px;
text-align: center;
}
.content {
padding: 20px;
}
.notes {
background-color: #fff3cd;
border: 1px solid #ffc107;
border-radius: 5px;
padding: 15px;
margin: 20px 0;
white-space: pre-line;
}
.footer {
font-size: 12px;
text-align: center;
margin-top: 30px;
color: #777;
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<img src="{{URL_DJANGO}}static/img/logo_min.svg" alt="Logo N3wt School" class="logo" />
<h1>Dossier d'inscription - Corrections requises</h1>
</div>
<div class="content">
<p>Bonjour,</p>
<p>Nous avons examiné le dossier d'inscription de <strong>{{ student_name }}</strong> et certaines corrections sont nécessaires avant de pouvoir le valider.</p>
<div class="notes">
<strong>Motif(s) :</strong><br>
{{ notes }}
</div>
<p>Veuillez vous connecter à votre espace pour effectuer les corrections demandées :</p>
<p><a href="{{BASE_URL}}/users/login">{{BASE_URL}}/users/login</a></p>
<p>Cordialement,</p>
<p>L'équipe N3wt School</p>
</div>
<div class="footer">
<p>Ce message est généré automatiquement, merci de ne pas y répondre.</p>
</div>
</div>
</body>
</html>

View File

@ -0,0 +1,85 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Dossier d'inscription validé</title>
<style>
body {
font-family: Arial, sans-serif;
line-height: 1.6;
color: #333;
}
.container {
max-width: 600px;
margin: 0 auto;
padding: 20px;
}
.header {
background-color: #f4f4f4;
padding: 10px;
text-align: center;
}
.content {
padding: 20px;
}
.success-box {
background-color: #d4edda;
border: 1px solid #28a745;
border-radius: 5px;
padding: 15px;
margin: 20px 0;
text-align: center;
}
.success-box h2 {
color: #155724;
margin: 0;
}
.class-info {
background-color: #e7f3ff;
border: 1px solid #0066cc;
border-radius: 5px;
padding: 15px;
margin: 20px 0;
}
.footer {
font-size: 12px;
text-align: center;
margin-top: 30px;
color: #777;
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<img src="{{URL_DJANGO}}static/img/logo_min.svg" alt="Logo N3wt School" class="logo" />
<h1>Dossier d'inscription validé</h1>
</div>
<div class="content">
<p>Bonjour,</p>
<div class="success-box">
<h2>Félicitations !</h2>
<p>Le dossier d'inscription de <strong>{{ student_name }}</strong> a été validé.</p>
</div>
{% if class_name %}
<div class="class-info">
<strong>Classe attribuée :</strong> {{ class_name }}
</div>
{% endif %}
<p>Vous pouvez accéder à votre espace pour consulter les détails :</p>
<p><a href="{{BASE_URL}}/users/login">{{BASE_URL}}/users/login</a></p>
<p>Nous vous remercions de votre confiance et vous souhaitons une excellente année scolaire.</p>
<p>Cordialement,</p>
<p>L'équipe N3wt School</p>
</div>
<div class="footer">
<p>Ce message est généré automatiquement, merci de ne pas y répondre.</p>
</div>
</div>
</body>
</html>

View File

@ -19,7 +19,8 @@ from enum import Enum
import random import random
import string import string
from rest_framework.parsers import JSONParser from rest_framework.parsers import JSONParser
from PyPDF2 import PdfMerger from PyPDF2 import PdfMerger, PdfReader
from PyPDF2.errors import PdfReadError
import shutil import shutil
import logging import logging
@ -31,6 +32,29 @@ from rest_framework import status
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def save_file_replacing_existing(file_field, filename, content, save=True):
"""
Sauvegarde un fichier en écrasant l'existant s'il porte le même nom.
Évite les suffixes automatiques Django (ex: fichier_N5QdZpk.pdf).
Args:
file_field: Le FileField Django (ex: registerForm.registration_file)
filename: Le nom du fichier à sauvegarder
content: Le contenu du fichier (File, BytesIO, ContentFile, etc.)
save: Si True, sauvegarde l'instance parente
"""
# Supprimer l'ancien fichier s'il existe
if file_field and file_field.name:
try:
if hasattr(file_field, 'path') and os.path.exists(file_field.path):
os.remove(file_field.path)
logger.debug(f"[save_file] Ancien fichier supprimé: {file_field.path}")
except Exception as e:
logger.error(f"[save_file] Erreur suppression ancien fichier: {e}")
# Sauvegarder le nouveau fichier
file_field.save(filename, content, save=save)
def build_payload_from_request(request): def build_payload_from_request(request):
""" """
Normalise la request en payload prêt à être donné au serializer. Normalise la request en payload prêt à être donné au serializer.
@ -344,12 +368,70 @@ def getArgFromRequest(_argument, _request):
def merge_files_pdf(file_paths): def merge_files_pdf(file_paths):
""" """
Fusionne plusieurs fichiers PDF et retourne le contenu fusionné en mémoire. Fusionne plusieurs fichiers PDF et retourne le contenu fusionné en mémoire.
Les fichiers non-PDF (images) sont convertis en PDF avant fusion.
Les fichiers invalides sont ignorés avec un log d'erreur.
""" """
merger = PdfMerger() merger = PdfMerger()
files_added = 0
# Extensions d'images supportées
image_extensions = {'.jpg', '.jpeg', '.png', '.gif', '.bmp', '.tiff', '.tif'}
# Ajouter les fichiers valides au merger
for file_path in file_paths: for file_path in file_paths:
merger.append(file_path) # Vérifier que le fichier existe
if not os.path.exists(file_path):
logger.warning(f"[merge_files_pdf] Fichier introuvable, ignoré: {file_path}")
continue
file_ext = os.path.splitext(file_path)[1].lower()
# Si c'est une image, la convertir en PDF
if file_ext in image_extensions:
try:
from PIL import Image
from reportlab.lib.utils import ImageReader
img = Image.open(file_path)
img_width, img_height = img.size
# Créer un PDF en mémoire avec l'image
img_pdf = BytesIO()
c = canvas.Canvas(img_pdf, pagesize=(img_width, img_height))
c.drawImage(file_path, 0, 0, width=img_width, height=img_height)
c.save()
img_pdf.seek(0)
merger.append(img_pdf)
files_added += 1
logger.debug(f"[merge_files_pdf] Image convertie et ajoutée: {file_path}")
except Exception as e:
logger.error(f"[merge_files_pdf] Erreur lors de la conversion de l'image {file_path}: {e}")
continue
# Sinon, essayer de l'ajouter comme PDF
try:
# Valider que c'est un PDF lisible
with open(file_path, 'rb') as f:
PdfReader(f, strict=False)
# Si la validation passe, ajouter au merger
merger.append(file_path)
files_added += 1
logger.debug(f"[merge_files_pdf] PDF ajouté: {file_path}")
except PdfReadError as e:
logger.error(f"[merge_files_pdf] Fichier PDF invalide, ignoré: {file_path} - {e}")
except Exception as e:
logger.error(f"[merge_files_pdf] Erreur lors de la lecture du fichier {file_path}: {e}")
if files_added == 0:
logger.warning("[merge_files_pdf] Aucun fichier valide à fusionner")
# Retourner un PDF vide
empty_pdf = BytesIO()
c = canvas.Canvas(empty_pdf, pagesize=A4)
c.drawString(100, 750, "Aucun document à afficher")
c.save()
empty_pdf.seek(0)
return empty_pdf
# Sauvegarder le fichier fusionné en mémoire # Sauvegarder le fichier fusionné en mémoire
merged_pdf = BytesIO() merged_pdf = BytesIO()
@ -378,25 +460,11 @@ def rfToPDF(registerForm, filename):
if not pdf: if not pdf:
raise ValueError("Erreur lors de la génération du PDF.") raise ValueError("Erreur lors de la génération du PDF.")
# Vérifier si un fichier avec le même nom existe déjà et le supprimer # Enregistrer directement le fichier dans le champ registration_file (écrase l'existant)
if registerForm.registration_file and registerForm.registration_file.name:
# Vérifiez si le chemin est déjà absolu ou relatif
if os.path.isabs(registerForm.registration_file.name):
existing_file_path = registerForm.registration_file.name
else:
existing_file_path = os.path.join(settings.MEDIA_ROOT, registerForm.registration_file.name.lstrip('/'))
# Vérifier si le fichier existe et le supprimer
if os.path.exists(existing_file_path):
os.remove(existing_file_path)
registerForm.registration_file.delete(save=False)
else:
print(f'File does not exist: {existing_file_path}')
# Enregistrer directement le fichier dans le champ registration_file
try: try:
registerForm.registration_file.save( save_file_replacing_existing(
os.path.basename(filename), # Utiliser uniquement le nom de fichier registerForm.registration_file,
os.path.basename(filename),
File(BytesIO(pdf.content)), File(BytesIO(pdf.content)),
save=True save=True
) )

View File

@ -9,6 +9,7 @@ from drf_yasg import openapi
import json import json
import os import os
import threading
from django.core.files import File from django.core.files import File
import N3wtSchool.mailManager as mailer import N3wtSchool.mailManager as mailer
@ -359,6 +360,26 @@ class RegisterFormWithIdView(APIView):
return JsonResponse({'error': str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) return JsonResponse({'error': str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
elif _status == RegistrationForm.RegistrationFormStatus.RF_SENT: elif _status == RegistrationForm.RegistrationFormStatus.RF_SENT:
if registerForm.status == RegistrationForm.RegistrationFormStatus.RF_UNDER_REVIEW: if registerForm.status == RegistrationForm.RegistrationFormStatus.RF_UNDER_REVIEW:
# Envoi de l'email de refus aux responsables légaux
try:
student = registerForm.student
student_name = f"{student.first_name} {student.last_name}"
notes = registerForm.notes or "Aucun motif spécifié"
guardians = student.guardians.all()
for guardian in guardians:
email = None
if hasattr(guardian, "profile_role") and guardian.profile_role and hasattr(guardian.profile_role, "profile") and guardian.profile_role.profile:
email = guardian.profile_role.profile.email
if not email:
email = getattr(guardian, "email", None)
if email:
logger.info(f"[RF_SENT] Envoi email de refus à {email} pour l'élève {student_name}")
mailer.sendRefusDossier(email, registerForm.establishment.pk, student_name, notes)
except Exception as e:
logger.error(f"[RF_SENT] Erreur lors de l'envoi de l'email de refus: {e}")
updateStateMachine(registerForm, 'EVENT_REFUSE') updateStateMachine(registerForm, 'EVENT_REFUSE')
util.delete_registration_files(registerForm) util.delete_registration_files(registerForm)
elif _status == RegistrationForm.RegistrationFormStatus.RF_SEPA_SENT: elif _status == RegistrationForm.RegistrationFormStatus.RF_SEPA_SENT:
@ -401,14 +422,23 @@ class RegisterFormWithIdView(APIView):
fileNames.extend(parent_file_templates) fileNames.extend(parent_file_templates)
# Création du fichier PDF fusionné # Création du fichier PDF fusionné
merged_pdf_content = util.merge_files_pdf(fileNames) merged_pdf_content = None
try:
merged_pdf_content = util.merge_files_pdf(fileNames)
# Mise à jour du champ registration_file avec le fichier fusionné # Mise à jour du champ fusion_file avec le fichier fusionné
registerForm.fusion_file.save( util.save_file_replacing_existing(
f"dossier_complet.pdf", registerForm.fusion_file,
File(merged_pdf_content), "dossier_complet.pdf",
save=True File(merged_pdf_content),
) save=True
)
except Exception as e:
logger.error(f"[RF_VALIDATED] Erreur lors de la fusion des fichiers: {e}")
finally:
# Libérer explicitement la mémoire du BytesIO
if merged_pdf_content is not None:
merged_pdf_content.close()
# Valorisation des StudentCompetency pour l'élève # Valorisation des StudentCompetency pour l'élève
try: try:
student = registerForm.student student = registerForm.student
@ -450,8 +480,65 @@ class RegisterFormWithIdView(APIView):
except Exception as e: except Exception as e:
logger.error(f"Erreur lors de la valorisation des StudentCompetency: {e}") logger.error(f"Erreur lors de la valorisation des StudentCompetency: {e}")
# Envoi de l'email de validation aux responsables légaux (en arrière-plan)
def send_validation_emails():
try:
student = registerForm.student
student_name = f"{student.first_name} {student.last_name}"
class_name = None
if student.associated_class:
class_name = student.associated_class.atmosphere_name
guardians = student.guardians.all()
for guardian in guardians:
email = None
if hasattr(guardian, "profile_role") and guardian.profile_role and hasattr(guardian.profile_role, "profile") and guardian.profile_role.profile:
email = guardian.profile_role.profile.email
if not email:
email = getattr(guardian, "email", None)
if email:
logger.info(f"[RF_VALIDATED] Envoi email de validation à {email} pour l'élève {student_name}")
mailer.sendValidationDossier(email, registerForm.establishment.pk, student_name, class_name)
except Exception as e:
logger.error(f"[RF_VALIDATED] Erreur lors de l'envoi de l'email de validation: {e}")
# Lancer l'envoi d'email dans un thread séparé pour ne pas bloquer la réponse
email_thread = threading.Thread(target=send_validation_emails)
email_thread.start()
updateStateMachine(registerForm, 'EVENT_VALIDATE') updateStateMachine(registerForm, 'EVENT_VALIDATE')
elif _status == RegistrationForm.RegistrationFormStatus.RF_ARCHIVED:
# Vérifier si on vient de l'état "À valider" (RF_UNDER_REVIEW) pour un refus définitif
if registerForm.status == RegistrationForm.RegistrationFormStatus.RF_UNDER_REVIEW:
# Envoi de l'email de refus définitif aux responsables légaux (en arrière-plan)
def send_refus_definitif_emails():
try:
student = registerForm.student
student_name = f"{student.first_name} {student.last_name}"
notes = data.get('notes', '') or "Aucun motif spécifié"
guardians = student.guardians.all()
for guardian in guardians:
email = None
if hasattr(guardian, "profile_role") and guardian.profile_role and hasattr(guardian.profile_role, "profile") and guardian.profile_role.profile:
email = guardian.profile_role.profile.email
if not email:
email = getattr(guardian, "email", None)
if email:
logger.info(f"[RF_ARCHIVED] Envoi email de refus définitif à {email} pour l'élève {student_name}")
mailer.sendRefusDefinitif(email, registerForm.establishment.pk, student_name, notes)
except Exception as e:
logger.error(f"[RF_ARCHIVED] Erreur lors de l'envoi de l'email de refus définitif: {e}")
# Lancer l'envoi d'email dans un thread séparé pour ne pas bloquer la réponse
email_thread = threading.Thread(target=send_refus_definitif_emails)
email_thread.start()
updateStateMachine(registerForm, 'EVENT_ARCHIVE')
# Retourner les données mises à jour # Retourner les données mises à jour
return JsonResponse(studentForm_serializer.data, safe=False) return JsonResponse(studentForm_serializer.data, safe=False)

View File

@ -18,6 +18,7 @@ import {
Plus, Plus,
Upload, Upload,
Eye, Eye,
XCircle,
} from 'lucide-react'; } from 'lucide-react';
import Modal from '@/components/Modal'; import Modal from '@/components/Modal';
import { useEstablishment } from '@/context/EstablishmentContext'; import { useEstablishment } from '@/context/EstablishmentContext';
@ -114,11 +115,10 @@ export default function Page({ params: { locale } }) {
showNotification('Merci de préciser la raison du refus.', 'error', 'Erreur'); showNotification('Merci de préciser la raison du refus.', 'error', 'Erreur');
return; return;
} }
editRegisterForm( const formData = new FormData();
rowToRefuse.student.id, formData.append('data', JSON.stringify({ status: RegistrationFormStatus.STATUS_ARCHIVED, notes: refuseReason }));
{ status: RegistrationFormStatus.STATUS_ARCHIVED, notes: refuseReason },
csrfToken editRegisterForm(rowToRefuse.student.id, formData, csrfToken)
)
.then(() => { .then(() => {
showNotification('Le dossier a été refusé et archivé.', 'success', 'Succès'); showNotification('Le dossier a été refusé et archivé.', 'success', 'Succès');
setReloadFetch(true); setReloadFetch(true);
@ -527,7 +527,7 @@ export default function Page({ params: { locale } }) {
{ {
icon: ( icon: (
<span title="Refuser le dossier"> <span title="Refuser le dossier">
<Archive className="w-5 h-5 text-red-500 hover:text-red-700" /> <XCircle className="w-5 h-5 text-red-500 hover:text-red-700" />
</span> </span>
), ),
onClick: () => openRefusePopup(row), onClick: () => openRefusePopup(row),

View File

@ -120,19 +120,9 @@ export default function Page() {
editFn(templateId, updateData, csrfToken) editFn(templateId, updateData, csrfToken)
.then((response) => { .then((response) => {
logger.debug(`Document ${validated ? 'validé' : 'refusé'} (type: ${type}, id: ${templateId})`, response); logger.debug(`Document ${validated ? 'validé' : 'refusé'} (type: ${type}, id: ${templateId})`, response);
showNotification(
`Le document a bien été ${validated ? 'validé' : 'refusé'}.`,
'success',
'Succès'
);
}) })
.catch((error) => { .catch((error) => {
logger.error('Erreur lors de la validation/refus du document:', error); logger.error('Erreur lors de la validation/refus du document:', error);
showNotification(
`Erreur lors de la ${validated ? 'validation' : 'refus'} du document.`,
'error',
'Erreur'
);
}); });
}; };

View File

@ -1,5 +1,6 @@
import logger from '@/utils/logger'; import logger from '@/utils/logger';
import { useForm, Controller } from 'react-hook-form'; import { useForm, Controller } from 'react-hook-form';
import { useEffect } from 'react';
import SelectChoice from './SelectChoice'; import SelectChoice from './SelectChoice';
import InputTextIcon from './InputTextIcon'; import InputTextIcon from './InputTextIcon';
import * as LucideIcons from 'lucide-react'; import * as LucideIcons from 'lucide-react';
@ -28,6 +29,7 @@ export function getIcon(name) {
export default function FormRenderer({ export default function FormRenderer({
formConfig, formConfig,
csrfToken, csrfToken,
initialValues = {},
onFormSubmit = (data) => { onFormSubmit = (data) => {
alert(JSON.stringify(data, null, 2)); alert(JSON.stringify(data, null, 2));
}, // Callback de soumission personnalisé (optionnel) }, // Callback de soumission personnalisé (optionnel)
@ -37,7 +39,14 @@ export default function FormRenderer({
control, control,
formState: { errors }, formState: { errors },
reset, reset,
} = useForm(); } = useForm({ defaultValues: initialValues });
// Réinitialiser le formulaire quand les valeurs initiales changent
useEffect(() => {
if (initialValues && Object.keys(initialValues).length > 0) {
reset(initialValues);
}
}, [initialValues, reset]);
// Fonction utilitaire pour envoyer les données au backend // Fonction utilitaire pour envoyer les données au backend
const sendFormDataToBackend = async (formData) => { const sendFormDataToBackend = async (formData) => {

View File

@ -23,26 +23,6 @@ export default function DynamicFormsList({
onFileUpload, // nouvelle prop pour gérer l'upload (à passer depuis le parent) onFileUpload, // nouvelle prop pour gérer l'upload (à passer depuis le parent)
}) { }) {
const [currentTemplateIndex, setCurrentTemplateIndex] = useState(0); const [currentTemplateIndex, setCurrentTemplateIndex] = useState(0);
// Remet formsValidation à false et formsData à undefined lors de la sélection d'un document refusé
useEffect(() => {
const currentTemplate = schoolFileTemplates[currentTemplateIndex];
if (
currentTemplate &&
currentTemplate.isValidated === false &&
formsValidation[currentTemplate.id] !== true
) {
setFormsValidation((prev) => {
const newValidation = { ...prev };
newValidation[currentTemplate.id] = false;
return newValidation;
});
setFormsData((prev) => {
const newData = { ...prev };
delete newData[currentTemplate.id];
return newData;
});
}
}, [currentTemplateIndex, schoolFileTemplates, formsValidation]);
const [formsData, setFormsData] = useState({}); const [formsData, setFormsData] = useState({});
const [formsValidation, setFormsValidation] = useState({}); const [formsValidation, setFormsValidation] = useState({});
const fileInputRefs = React.useRef({}); const fileInputRefs = React.useRef({});
@ -51,37 +31,46 @@ export default function DynamicFormsList({
useEffect(() => { useEffect(() => {
// Initialisation complète de formsValidation et formsData pour chaque template // Initialisation complète de formsValidation et formsData pour chaque template
if (schoolFileTemplates && schoolFileTemplates.length > 0) { if (schoolFileTemplates && schoolFileTemplates.length > 0) {
// Initialiser formsData pour chaque template (avec données existantes ou objet vide) // Fusionner avec l'état existant pour préserver les données locales
const dataState = {}; setFormsData((prevData) => {
schoolFileTemplates.forEach((tpl) => { const dataState = { ...prevData };
if ( schoolFileTemplates.forEach((tpl) => {
existingResponses && // Ne mettre à jour que si on n'a pas encore de données locales ou si les données du serveur ont changé
existingResponses[tpl.id] && const hasLocalData = prevData[tpl.id] && Object.keys(prevData[tpl.id]).length > 0;
Object.keys(existingResponses[tpl.id]).length > 0 const hasServerData = existingResponses && existingResponses[tpl.id] && Object.keys(existingResponses[tpl.id]).length > 0;
) {
dataState[tpl.id] = existingResponses[tpl.id]; if (!hasLocalData && hasServerData) {
} else { // Pas de données locales mais données serveur : utiliser les données serveur
dataState[tpl.id] = {}; dataState[tpl.id] = existingResponses[tpl.id];
} } else if (!hasLocalData && !hasServerData) {
// Pas de données du tout : initialiser à vide
dataState[tpl.id] = {};
}
// Si hasLocalData : on garde les données locales existantes
});
return dataState;
}); });
setFormsData(dataState);
// Initialiser formsValidation pour chaque template // Fusionner avec l'état de validation existant
const validationState = {}; setFormsValidation((prevValidation) => {
schoolFileTemplates.forEach((tpl) => { const validationState = { ...prevValidation };
if ( schoolFileTemplates.forEach((tpl) => {
existingResponses && const hasLocalValidation = prevValidation[tpl.id] === true;
existingResponses[tpl.id] && const hasServerData = existingResponses && existingResponses[tpl.id] && Object.keys(existingResponses[tpl.id]).length > 0;
Object.keys(existingResponses[tpl.id]).length > 0
) { if (!hasLocalValidation && hasServerData) {
validationState[tpl.id] = true; // Pas validé localement mais données serveur : marquer comme validé
} else { validationState[tpl.id] = true;
validationState[tpl.id] = false; } else if (validationState[tpl.id] === undefined) {
} // Pas encore initialisé : initialiser à false
validationState[tpl.id] = false;
}
// Si hasLocalValidation : on garde l'état local existant
});
return validationState;
}); });
setFormsValidation(validationState);
} }
}, [existingResponses]); }, [existingResponses, schoolFileTemplates]);
// Mettre à jour la validation globale quand la validation des formulaires change // Mettre à jour la validation globale quand la validation des formulaires change
useEffect(() => { useEffect(() => {
@ -163,6 +152,8 @@ export default function DynamicFormsList({
return schoolFileTemplates[currentTemplateIndex]; return schoolFileTemplates[currentTemplateIndex];
}; };
const currentTemplate = getCurrentTemplate();
// Handler d'upload pour formulaire existant // Handler d'upload pour formulaire existant
const handleUpload = async (file, selectedFile) => { const handleUpload = async (file, selectedFile) => {
if (!file || !selectedFile) return; if (!file || !selectedFile) return;
@ -399,6 +390,11 @@ export default function DynamicFormsList({
submitLabel: submitLabel:
currentTemplate.formTemplateData?.submitLabel || 'Valider', currentTemplate.formTemplateData?.submitLabel || 'Valider',
}} }}
initialValues={
formsData[currentTemplate.id] ||
existingResponses[currentTemplate.id] ||
{}
}
onFormSubmit={(formData) => onFormSubmit={(formData) =>
handleFormSubmit(formData, currentTemplate.id) handleFormSubmit(formData, currentTemplate.id)
} }
@ -408,38 +404,45 @@ export default function DynamicFormsList({
) : ( ) : (
// Formulaire existant (PDF, image, etc.) // Formulaire existant (PDF, image, etc.)
<div className="flex flex-col items-center gap-6"> <div className="flex flex-col items-center gap-6">
<div className="flex flex-col items-center gap-2"> {/* Cas validé : affichage en iframe */}
{currentTemplate.file && currentTemplate.isValidated === true ? ( {currentTemplate.isValidated === true && currentTemplate.file && (
<iframe <iframe
src={`${BASE_URL}${currentTemplate.file}`} src={`${BASE_URL}${currentTemplate.file}`}
title={currentTemplate.name} title={currentTemplate.name}
className="w-full" className="w-full"
style={{ height: '600px', border: 'none' }} style={{ height: '600px', border: 'none' }}
/>
) : currentTemplate.file && (
<a
href={`${BASE_URL}${currentTemplate.file}`}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2 px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600 transition"
download
>
<Download className="w-5 h-5" />
Télécharger le document
</a>
)}
</div>
{/* Upload désactivé si validé par l'école */}
{enable && currentTemplate.isValidated !== true && (
<FileUpload
key={currentTemplate.id}
selectionMessage={'Sélectionnez le fichier du document'}
onFileSelect={(file) => handleUpload(file, currentTemplate)}
required
enable={true}
/> />
)} )}
{/* Le label d'état est maintenant dans l'en-tête */}
{/* Cas non validé : bouton télécharger + upload */}
{currentTemplate.isValidated !== true && (
<div className="flex flex-col items-center gap-4 w-full">
{/* Bouton télécharger le document source */}
{currentTemplate.file && (
<a
href={`${BASE_URL}${currentTemplate.file}`}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2 px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600 transition"
download
>
<Download className="w-5 h-5" />
Télécharger le document
</a>
)}
{/* Composant d'upload */}
{enable && (
<FileUpload
key={currentTemplate.id}
selectionMessage={'Sélectionnez le fichier du document'}
onFileSelect={(file) => handleUpload(file, currentTemplate)}
required
enable={true}
/>
)}
</div>
)}
</div> </div>
)} )}
</div> </div>