mirror of
https://git.v0id.ovh/n3wt-innov/n3wt-school.git
synced 2026-04-03 16:51:26 +00:00
Compare commits
7 Commits
N3WTS-2-Re
...
e9a30b7bde
| Author | SHA1 | Date | |
|---|---|---|---|
| e9a30b7bde | |||
| ff1d113698 | |||
| 12a6ad1d61 | |||
| 856443d4ed | |||
| ace4dcbf07 | |||
| 61f63f9dc9 | |||
| d9e998d2ff |
@ -25,7 +25,7 @@ class ProfileRole(models.Model):
|
||||
profile = models.ForeignKey('Profile', on_delete=models.CASCADE, related_name='roles')
|
||||
role_type = models.IntegerField(choices=RoleType.choices, default=RoleType.PROFIL_UNDEFINED)
|
||||
establishment = models.ForeignKey('Establishment.Establishment', on_delete=models.CASCADE, related_name='profile_roles')
|
||||
is_active = models.BooleanField(default=False, blank=True)
|
||||
is_active = models.BooleanField(default=False)
|
||||
updated_date = models.DateTimeField(auto_now=True)
|
||||
|
||||
def __str__(self):
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
from django.core.mail import get_connection, EmailMultiAlternatives, EmailMessage
|
||||
from django.core.mail import send_mail, get_connection, EmailMultiAlternatives, EmailMessage
|
||||
from django.template.loader import render_to_string
|
||||
from django.utils.html import strip_tags
|
||||
from django.conf import settings
|
||||
@ -208,123 +208,3 @@ def isValid(message, fiche_inscription):
|
||||
mailReponsableAVerifier = responsable.mail
|
||||
|
||||
return responsableMail == mailReponsableAVerifier and str(idMail) == str(fiche_inscription.eleve.id)
|
||||
|
||||
def sendRegisterTeacher(recipients, establishment_id):
|
||||
errorMessage = ''
|
||||
try:
|
||||
EMAIL_INSCRIPTION_SUBJECT = '[N3WT-SCHOOL] Bienvenue sur N3wt School (Enseignant)'
|
||||
context = {
|
||||
'BASE_URL': settings.BASE_URL,
|
||||
'URL_DJANGO': settings.URL_DJANGO,
|
||||
'email': recipients,
|
||||
'establishment': establishment_id
|
||||
}
|
||||
connection = getConnection(establishment_id)
|
||||
subject = EMAIL_INSCRIPTION_SUBJECT
|
||||
html_message = render_to_string('emails/inscription_teacher.html', context)
|
||||
sendMail(subject=subject, message=html_message, recipients=recipients, connection=connection)
|
||||
except Exception as 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
|
||||
@ -60,7 +60,6 @@ class TeacherSerializer(serializers.ModelSerializer):
|
||||
profile_role = serializers.PrimaryKeyRelatedField(queryset=ProfileRole.objects.all(), required=False)
|
||||
profile_role_data = ProfileRoleSerializer(write_only=True, required=False)
|
||||
associated_profile_email = serializers.SerializerMethodField()
|
||||
profile = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = Teacher
|
||||
@ -156,12 +155,6 @@ class TeacherSerializer(serializers.ModelSerializer):
|
||||
return obj.profile_role.role_type
|
||||
return None
|
||||
|
||||
def get_profile(self, obj):
|
||||
# Retourne l'id du profile associé via profile_role
|
||||
if obj.profile_role and obj.profile_role.profile:
|
||||
return obj.profile_role.profile.id
|
||||
return None
|
||||
|
||||
class PlanningSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = Planning
|
||||
|
||||
@ -35,7 +35,6 @@ from collections import defaultdict
|
||||
from Subscriptions.models import Student, StudentCompetency
|
||||
from Subscriptions.util import getCurrentSchoolYear
|
||||
import logging
|
||||
from N3wtSchool.mailManager import sendRegisterForm, sendRegisterTeacher
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@ -103,17 +102,8 @@ class TeacherListCreateView(APIView):
|
||||
teacher_serializer = TeacherSerializer(data=teacher_data)
|
||||
|
||||
if teacher_serializer.is_valid():
|
||||
teacher_instance = teacher_serializer.save()
|
||||
# Envoi du mail d'inscription enseignant uniquement à la création
|
||||
email = None
|
||||
establishment_id = None
|
||||
if hasattr(teacher_instance, "profile_role") and teacher_instance.profile_role:
|
||||
if hasattr(teacher_instance.profile_role, "profile") and teacher_instance.profile_role.profile:
|
||||
email = teacher_instance.profile_role.profile.email
|
||||
if hasattr(teacher_instance.profile_role, "establishment") and teacher_instance.profile_role.establishment:
|
||||
establishment_id = teacher_instance.profile_role.establishment.id
|
||||
if email and establishment_id:
|
||||
sendRegisterTeacher(email, establishment_id)
|
||||
teacher_serializer.save()
|
||||
|
||||
return JsonResponse(teacher_serializer.data, safe=False)
|
||||
|
||||
return JsonResponse(teacher_serializer.errors, safe=False)
|
||||
@ -128,43 +118,17 @@ class TeacherDetailView(APIView):
|
||||
return JsonResponse(teacher_serializer.data, safe=False)
|
||||
|
||||
def put(self, request, id):
|
||||
teacher_data = JSONParser().parse(request)
|
||||
teacher_data=JSONParser().parse(request)
|
||||
teacher = getObject(_objectName=Teacher, _columnName='id', _value=id)
|
||||
|
||||
# Récupérer l'ancien profile avant modification
|
||||
old_profile_role = getattr(teacher, 'profile_role', None)
|
||||
old_profile = getattr(old_profile_role, 'profile', None) if old_profile_role else None
|
||||
|
||||
teacher_serializer = TeacherSerializer(teacher, data=teacher_data)
|
||||
if teacher_serializer.is_valid():
|
||||
teacher_serializer.save()
|
||||
|
||||
# Après modification, vérifier si l'ancien profile n'a plus de ProfileRole
|
||||
if old_profile:
|
||||
from Auth.models import ProfileRole # import local pour éviter les imports circulaires
|
||||
if not ProfileRole.objects.filter(profile=old_profile).exists():
|
||||
old_profile.delete()
|
||||
|
||||
return JsonResponse(teacher_serializer.data, safe=False)
|
||||
|
||||
return JsonResponse(teacher_serializer.errors, safe=False)
|
||||
|
||||
def delete(self, request, id):
|
||||
# Suppression du Teacher et du ProfileRole associé
|
||||
teacher = getObject(_objectName=Teacher, _columnName='id', _value=id)
|
||||
profile_role = getattr(teacher, 'profile_role', None)
|
||||
profile = getattr(profile_role, 'profile', None) if profile_role else None
|
||||
|
||||
# Supprime le Teacher (ce qui supprime le ProfileRole via on_delete=models.CASCADE)
|
||||
response = delete_object(Teacher, id, related_field='profile_role')
|
||||
|
||||
# Si un profile était associé, vérifier s'il reste des ProfileRole
|
||||
if profile:
|
||||
from Auth.models import ProfileRole # import local pour éviter les imports circulaires
|
||||
if not ProfileRole.objects.filter(profile=profile).exists():
|
||||
profile.delete()
|
||||
|
||||
return response
|
||||
return delete_object(Teacher, id, related_field='profile_role')
|
||||
|
||||
@method_decorator(csrf_protect, name='dispatch')
|
||||
@method_decorator(ensure_csrf_cookie, name='dispatch')
|
||||
|
||||
@ -214,28 +214,9 @@ class RegistrationFileGroup(models.Model):
|
||||
def __str__(self):
|
||||
return f'{self.group.name} - {self.id}'
|
||||
|
||||
def registration_form_file_upload_to(instance, 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}")
|
||||
def registration_file_path(instance, filename):
|
||||
# Génère le chemin : registration_files/dossier_rf_{student_id}/filename
|
||||
return f'registration_files/dossier_rf_{instance.student_id}/{filename}'
|
||||
|
||||
class RegistrationForm(models.Model):
|
||||
class RegistrationFormStatus(models.IntegerChoices):
|
||||
@ -257,17 +238,17 @@ class RegistrationForm(models.Model):
|
||||
notes = models.CharField(max_length=200, blank=True)
|
||||
registration_link_code = models.CharField(max_length=200, default="", blank=True)
|
||||
registration_file = models.FileField(
|
||||
upload_to=registration_form_file_upload_to,
|
||||
upload_to=registration_file_path,
|
||||
null=True,
|
||||
blank=True
|
||||
)
|
||||
sepa_file = models.FileField(
|
||||
upload_to=registration_form_file_upload_to,
|
||||
upload_to=registration_file_path,
|
||||
null=True,
|
||||
blank=True
|
||||
)
|
||||
fusion_file = models.FileField(
|
||||
upload_to=registration_form_file_upload_to,
|
||||
upload_to=registration_file_path,
|
||||
null=True,
|
||||
blank=True
|
||||
)
|
||||
@ -304,23 +285,13 @@ class RegistrationForm(models.Model):
|
||||
except RegistrationForm.DoesNotExist:
|
||||
old_fileGroup = None
|
||||
|
||||
# Supprimer les anciens fichiers si remplacés (évite les suffixes Django)
|
||||
# Vérifier si un fichier existant doit être remplacé
|
||||
if self.pk: # Si l'objet existe déjà dans la base de données
|
||||
try:
|
||||
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:
|
||||
_delete_file_if_exists(old_instance.sepa_file)
|
||||
|
||||
# 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)
|
||||
|
||||
# Supprimer l'ancien fichier
|
||||
old_instance.sepa_file.delete(save=False)
|
||||
except RegistrationForm.DoesNotExist:
|
||||
pass # L'objet n'existe pas encore, rien à supprimer
|
||||
|
||||
@ -514,18 +485,10 @@ class RegistrationParentFileMaster(models.Model):
|
||||
############################################################
|
||||
|
||||
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}"
|
||||
|
||||
def registration_parent_file_upload_to(instance, 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}"
|
||||
return f"registration_files/{instance.registration_form.establishment.name}/dossier_rf_{instance.registration_form.pk}/parent/{filename}"
|
||||
|
||||
####### Formulaires templates (par dossier d'inscription) #######
|
||||
class RegistrationSchoolFileTemplate(models.Model):
|
||||
@ -535,36 +498,19 @@ class RegistrationSchoolFileTemplate(models.Model):
|
||||
registration_form = models.ForeignKey(RegistrationForm, on_delete=models.CASCADE, related_name='school_file_templates', blank=True)
|
||||
file = models.FileField(null=True,blank=True, upload_to=registration_school_file_upload_to)
|
||||
formTemplateData = models.JSONField(default=list, blank=True, null=True)
|
||||
isValidated = models.BooleanField(default=False)
|
||||
|
||||
def __str__(self):
|
||||
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
|
||||
def get_files_from_rf(register_form_id):
|
||||
"""
|
||||
Récupère tous les fichiers liés à un dossier d’inscription donné.
|
||||
Ignore les fichiers qui n'existent pas physiquement.
|
||||
"""
|
||||
registration_files = RegistrationSchoolFileTemplate.objects.filter(registration_form=register_form_id)
|
||||
filenames = []
|
||||
for reg_file in registration_files:
|
||||
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}")
|
||||
filenames.append(reg_file.file.path)
|
||||
return filenames
|
||||
|
||||
class StudentCompetency(models.Model):
|
||||
@ -594,24 +540,22 @@ class RegistrationParentFileTemplate(models.Model):
|
||||
master = models.ForeignKey(RegistrationParentFileMaster, on_delete=models.CASCADE, related_name='parent_file_templates', blank=True)
|
||||
registration_form = models.ForeignKey(RegistrationForm, on_delete=models.CASCADE, related_name='parent_file_templates', blank=True)
|
||||
file = models.FileField(null=True,blank=True, upload_to=registration_parent_file_upload_to)
|
||||
isValidated = models.BooleanField(default=False)
|
||||
|
||||
def __str__(self):
|
||||
return self.master.name if self.master else f"ParentFile_{self.pk}"
|
||||
return self.name
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
# Supprimer l'ancien fichier si remplacé (évite les suffixes Django)
|
||||
if self.pk:
|
||||
if self.pk: # Si l'objet existe déjà dans la base de données
|
||||
try:
|
||||
old_instance = RegistrationParentFileTemplate.objects.get(pk=self.pk)
|
||||
# Si le fichier change ou est supprimé
|
||||
if old_instance.file:
|
||||
if old_instance.file != self.file or not self.file or self.file.name == '':
|
||||
_delete_file_if_exists(old_instance.file)
|
||||
if not self.file or self.file.name == '':
|
||||
self.file = None
|
||||
if old_instance.file and (not self.file or self.file.name == ''):
|
||||
if os.path.exists(old_instance.file.path):
|
||||
old_instance.file.delete(save=False)
|
||||
self.file = None
|
||||
else:
|
||||
print(f"Le fichier {old_instance.file.path} n'existe pas.")
|
||||
except RegistrationParentFileTemplate.DoesNotExist:
|
||||
pass
|
||||
print("Ancienne instance introuvable.")
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
@staticmethod
|
||||
|
||||
@ -21,7 +21,6 @@ from N3wtSchool import settings
|
||||
from django.utils import timezone
|
||||
import pytz
|
||||
import Subscriptions.util as util
|
||||
from N3wtSchool.mailManager import sendRegisterForm
|
||||
|
||||
class AbsenceManagementSerializer(serializers.ModelSerializer):
|
||||
student_name = serializers.SerializerMethodField()
|
||||
@ -216,14 +215,6 @@ class StudentSerializer(serializers.ModelSerializer):
|
||||
profile_role_serializer = ProfileRoleSerializer(data=profile_role_data)
|
||||
profile_role_serializer.is_valid(raise_exception=True)
|
||||
profile_role = profile_role_serializer.save()
|
||||
# Envoi du mail d'inscription si un nouveau profil vient d'être créé
|
||||
email = None
|
||||
if profile_data and 'email' in profile_data:
|
||||
email = profile_data['email']
|
||||
elif profile_role and profile_role.profile:
|
||||
email = profile_role.profile.email
|
||||
if email:
|
||||
sendRegisterForm(email, establishment_id)
|
||||
elif profile_role:
|
||||
# Récupérer un ProfileRole existant par son ID
|
||||
profile_role = ProfileRole.objects.get(id=profile_role.id)
|
||||
|
||||
@ -1,63 +0,0 @@
|
||||
<!-- Nouveau template pour l'inscription d'un enseignant -->
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Bienvenue sur N3wt School</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;
|
||||
}
|
||||
.footer {
|
||||
font-size: 12px;
|
||||
text-align: center;
|
||||
margin-top: 30px;
|
||||
color: #777;
|
||||
}
|
||||
.logo {
|
||||
width: 120px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<!-- Utilisation d'un lien absolu pour le logo -->
|
||||
<img src="{{URL_DJANGO}}/static/img/logo_min.svg" alt="Logo N3wt School" class="logo" style="display:block;margin:auto;" />
|
||||
<h1>Bienvenue sur N3wt School</h1>
|
||||
</div>
|
||||
<div class="content">
|
||||
<p>Bonjour,</p>
|
||||
<p>Votre compte enseignant a été créé sur la plateforme N3wt School.</p>
|
||||
<p>Pour accéder à votre espace personnel, veuillez vous connecter à l'adresse suivante :<br>
|
||||
<a href="{{BASE_URL}}/users/login">{{BASE_URL}}/users/login</a>
|
||||
</p>
|
||||
<p>Votre identifiant est : <b>{{ email }}</b></p>
|
||||
<p>Si c'est votre première connexion, veuillez activer votre compte ici :<br>
|
||||
<a href="{{BASE_URL}}/users/subscribe?establishment_id={{establishment}}">{{BASE_URL}}/users/subscribe</a>
|
||||
</p>
|
||||
<p>Nous vous souhaitons une excellente prise en main de l'outil.<br>
|
||||
L'équipe N3wt School reste à votre disposition pour toute question.</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>
|
||||
@ -1,66 +0,0 @@
|
||||
<!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>
|
||||
@ -1,67 +0,0 @@
|
||||
<!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>
|
||||
@ -1,85 +0,0 @@
|
||||
<!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>
|
||||
@ -19,8 +19,7 @@ from enum import Enum
|
||||
import random
|
||||
import string
|
||||
from rest_framework.parsers import JSONParser
|
||||
from PyPDF2 import PdfMerger, PdfReader
|
||||
from PyPDF2.errors import PdfReadError
|
||||
from PyPDF2 import PdfMerger
|
||||
|
||||
import shutil
|
||||
import logging
|
||||
@ -32,29 +31,6 @@ from rest_framework import status
|
||||
|
||||
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):
|
||||
"""
|
||||
Normalise la request en payload prêt à être donné au serializer.
|
||||
@ -368,70 +344,12 @@ def getArgFromRequest(_argument, _request):
|
||||
def merge_files_pdf(file_paths):
|
||||
"""
|
||||
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()
|
||||
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:
|
||||
# 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
|
||||
merger.append(file_path)
|
||||
|
||||
# Sauvegarder le fichier fusionné en mémoire
|
||||
merged_pdf = BytesIO()
|
||||
@ -460,11 +378,25 @@ def rfToPDF(registerForm, filename):
|
||||
if not pdf:
|
||||
raise ValueError("Erreur lors de la génération du PDF.")
|
||||
|
||||
# Enregistrer directement le fichier dans le champ registration_file (écrase l'existant)
|
||||
# Vérifier si un fichier avec le même nom existe déjà et le supprimer
|
||||
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:
|
||||
save_file_replacing_existing(
|
||||
registerForm.registration_file,
|
||||
os.path.basename(filename),
|
||||
registerForm.registration_file.save(
|
||||
os.path.basename(filename), # Utiliser uniquement le nom de fichier
|
||||
File(BytesIO(pdf.content)),
|
||||
save=True
|
||||
)
|
||||
@ -555,12 +487,7 @@ def generate_form_json_pdf(register_form, form_json):
|
||||
for field in fields:
|
||||
label = field.get("label", field.get("id", ""))
|
||||
ftype = field.get("type", "")
|
||||
value = field.get("value", "")
|
||||
# Afficher la valeur si elle existe
|
||||
if value not in (None, ""):
|
||||
c.drawString(100, y, f"{label} [{ftype}] : {value}")
|
||||
else:
|
||||
c.drawString(100, y, f"{label} [{ftype}]")
|
||||
c.drawString(100, y, f"{label} [{ftype}]")
|
||||
y -= 25
|
||||
if y < 100:
|
||||
c.showPage()
|
||||
|
||||
@ -9,7 +9,6 @@ from drf_yasg import openapi
|
||||
|
||||
import json
|
||||
import os
|
||||
import threading
|
||||
from django.core.files import File
|
||||
|
||||
import N3wtSchool.mailManager as mailer
|
||||
@ -324,27 +323,6 @@ class RegisterFormWithIdView(APIView):
|
||||
registerForm.registration_file = util.rfToPDF(registerForm, initial_pdf)
|
||||
registerForm.save()
|
||||
|
||||
# Envoi du mail d'inscription au second guardian si besoin
|
||||
guardians = registerForm.student.guardians.all()
|
||||
from Auth.models import Profile
|
||||
from N3wtSchool.mailManager import sendRegisterForm
|
||||
|
||||
for guardian in guardians:
|
||||
# Recherche de l'email dans le profil lié au guardian (si existant)
|
||||
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
|
||||
# Fallback sur le champ email direct (si jamais il existe)
|
||||
if not email:
|
||||
email = getattr(guardian, "email", None)
|
||||
logger.debug(f"[RF_UNDER_REVIEW] Guardian id={guardian.id}, email={email}")
|
||||
if email:
|
||||
profile_exists = Profile.objects.filter(email=email).exists()
|
||||
logger.debug(f"[RF_UNDER_REVIEW] Profile existe pour {email} ? {profile_exists}")
|
||||
if not profile_exists:
|
||||
logger.debug(f"[RF_UNDER_REVIEW] Envoi du mail d'inscription à {email} pour l'établissement {registerForm.establishment.pk}")
|
||||
sendRegisterForm(email, registerForm.establishment.pk)
|
||||
|
||||
# Mise à jour de l'automate
|
||||
# Vérification de la présence du fichier SEPA
|
||||
if registerForm.sepa_file:
|
||||
@ -354,32 +332,9 @@ class RegisterFormWithIdView(APIView):
|
||||
# Mise à jour de l'automate pour une signature classique
|
||||
updateStateMachine(registerForm, 'EVENT_SIGNATURE')
|
||||
except Exception as e:
|
||||
logger.error(f"[RF_UNDER_REVIEW] Exception: {e}")
|
||||
import traceback
|
||||
logger.error(traceback.format_exc())
|
||||
return JsonResponse({'error': str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
||||
elif _status == RegistrationForm.RegistrationFormStatus.RF_SENT:
|
||||
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')
|
||||
util.delete_registration_files(registerForm)
|
||||
elif _status == RegistrationForm.RegistrationFormStatus.RF_SEPA_SENT:
|
||||
@ -422,23 +377,14 @@ class RegisterFormWithIdView(APIView):
|
||||
fileNames.extend(parent_file_templates)
|
||||
|
||||
# Création du fichier PDF fusionné
|
||||
merged_pdf_content = None
|
||||
try:
|
||||
merged_pdf_content = util.merge_files_pdf(fileNames)
|
||||
merged_pdf_content = util.merge_files_pdf(fileNames)
|
||||
|
||||
# Mise à jour du champ fusion_file avec le fichier fusionné
|
||||
util.save_file_replacing_existing(
|
||||
registerForm.fusion_file,
|
||||
"dossier_complet.pdf",
|
||||
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()
|
||||
# Mise à jour du champ registration_file avec le fichier fusionné
|
||||
registerForm.fusion_file.save(
|
||||
f"dossier_complet.pdf",
|
||||
File(merged_pdf_content),
|
||||
save=True
|
||||
)
|
||||
# Valorisation des StudentCompetency pour l'élève
|
||||
try:
|
||||
student = registerForm.student
|
||||
@ -480,65 +426,8 @@ class RegisterFormWithIdView(APIView):
|
||||
except Exception as 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')
|
||||
|
||||
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
|
||||
return JsonResponse(studentForm_serializer.data, safe=False)
|
||||
|
||||
|
||||
@ -6,10 +6,11 @@ from rest_framework.response import Response
|
||||
from rest_framework.views import APIView
|
||||
from rest_framework import status
|
||||
|
||||
from Subscriptions.serializers import RegistrationParentFileMasterSerializer
|
||||
from Subscriptions.serializers import RegistrationParentFileMasterSerializer, RegistrationParentFileTemplateSerializer
|
||||
from Subscriptions.models import (
|
||||
RegistrationForm,
|
||||
RegistrationParentFileMaster
|
||||
RegistrationParentFileMaster,
|
||||
RegistrationParentFileTemplate
|
||||
)
|
||||
from N3wtSchool import bdd
|
||||
import logging
|
||||
@ -175,3 +176,97 @@ class RegistrationParentFileMasterSimpleView(APIView):
|
||||
return JsonResponse({'message': 'La suppression du fichier parent a été effectuée avec succès'}, safe=False, status=status.HTTP_204_NO_CONTENT)
|
||||
else:
|
||||
return JsonResponse({'erreur': 'Le fichier parent n\'a pas été trouvé'}, safe=False, status=status.HTTP_404_NOT_FOUND)
|
||||
|
||||
class RegistrationParentFileTemplateView(APIView):
|
||||
@swagger_auto_schema(
|
||||
operation_description="Récupère tous les templates parents pour un établissement donné",
|
||||
manual_parameters=[
|
||||
openapi.Parameter(
|
||||
'establishment_id',
|
||||
openapi.IN_QUERY,
|
||||
description="ID de l'établissement",
|
||||
type=openapi.TYPE_INTEGER,
|
||||
required=True
|
||||
)
|
||||
],
|
||||
responses={200: RegistrationParentFileTemplateSerializer(many=True)}
|
||||
)
|
||||
def get(self, request):
|
||||
establishment_id = request.GET.get('establishment_id')
|
||||
if not establishment_id:
|
||||
return Response({'error': "Paramètre 'establishment_id' requis"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
# Filtrer les templates parents liés à l'établissement via master.groups.establishment
|
||||
templates = RegistrationParentFileTemplate.objects.filter(
|
||||
master__groups__establishment__id=establishment_id
|
||||
).distinct()
|
||||
serializer = RegistrationParentFileTemplateSerializer(templates, many=True)
|
||||
return Response(serializer.data)
|
||||
|
||||
@swagger_auto_schema(
|
||||
operation_description="Crée un nouveau template d'inscription",
|
||||
request_body=RegistrationParentFileTemplateSerializer,
|
||||
responses={
|
||||
201: RegistrationParentFileTemplateSerializer,
|
||||
400: "Données invalides"
|
||||
}
|
||||
)
|
||||
def post(self, request):
|
||||
serializer = RegistrationParentFileTemplateSerializer(data=request.data)
|
||||
if serializer.is_valid():
|
||||
serializer.save()
|
||||
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
class RegistrationParentFileTemplateSimpleView(APIView):
|
||||
@swagger_auto_schema(
|
||||
operation_description="Récupère un template d'inscription spécifique",
|
||||
responses={
|
||||
200: RegistrationParentFileTemplateSerializer,
|
||||
404: "Template non trouvé"
|
||||
}
|
||||
)
|
||||
def get(self, request, id):
|
||||
template = bdd.getObject(_objectName=RegistrationParentFileTemplate, _columnName='id', _value=id)
|
||||
if template is None:
|
||||
return JsonResponse({"errorMessage":'Le template d\'inscription n\'a pas été trouvé'}, safe=False, status=status.HTTP_404_NOT_FOUND)
|
||||
serializer = RegistrationParentFileTemplateSerializer(template)
|
||||
return JsonResponse(serializer.data, safe=False)
|
||||
|
||||
@swagger_auto_schema(
|
||||
operation_description="Met à jour un template d'inscription existant",
|
||||
request_body=RegistrationParentFileTemplateSerializer,
|
||||
responses={
|
||||
200: RegistrationParentFileTemplateSerializer,
|
||||
400: "Données invalides",
|
||||
404: "Template non trouvé"
|
||||
}
|
||||
)
|
||||
def put(self, request, id):
|
||||
template = bdd.getObject(_objectName=RegistrationParentFileTemplate, _columnName='id', _value=id)
|
||||
if template is None:
|
||||
return JsonResponse({'erreur': 'Le template d\'inscription n\'a pas été trouvé'}, safe=False, status=status.HTTP_404_NOT_FOUND)
|
||||
|
||||
serializer = RegistrationParentFileTemplateSerializer(template, data=request.data, partial=True)
|
||||
if serializer.is_valid():
|
||||
serializer.save()
|
||||
return Response({'message': 'Template mis à jour avec succès', 'data': serializer.data}, status=status.HTTP_200_OK)
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
@swagger_auto_schema(
|
||||
operation_description="Supprime un template d'inscription",
|
||||
responses={
|
||||
204: "Suppression réussie",
|
||||
404: "Template non trouvé"
|
||||
}
|
||||
)
|
||||
def delete(self, request, id):
|
||||
template = bdd.getObject(_objectName=RegistrationParentFileTemplate, _columnName='id', _value=id)
|
||||
if template is not None:
|
||||
# Suppression du fichier PDF associé avant suppression de l'objet
|
||||
if template.file and template.file.name:
|
||||
template.file.delete(save=False)
|
||||
template.delete()
|
||||
return JsonResponse({'message': 'La suppression du template a été effectuée avec succès'}, safe=False, status=status.HTTP_204_NO_CONTENT)
|
||||
else:
|
||||
return JsonResponse({'erreur': 'Le template n\'a pas été trouvé'}, safe=False, status=status.HTTP_404_NOT_FOUND)
|
||||
|
||||
@ -1,21 +1,128 @@
|
||||
from django.http.response import JsonResponse
|
||||
from drf_yasg.utils import swagger_auto_schema
|
||||
from drf_yasg import openapi
|
||||
from rest_framework.parsers import MultiPartParser, FormParser
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.views import APIView
|
||||
from rest_framework import status
|
||||
import os
|
||||
import glob
|
||||
|
||||
from Subscriptions.serializers import RegistrationSchoolFileTemplateSerializer
|
||||
from Subscriptions.models import RegistrationSchoolFileTemplate
|
||||
from Subscriptions.serializers import RegistrationSchoolFileMasterSerializer, RegistrationSchoolFileTemplateSerializer, RegistrationParentFileMasterSerializer, RegistrationParentFileTemplateSerializer
|
||||
from Subscriptions.models import RegistrationSchoolFileMaster, RegistrationSchoolFileTemplate, RegistrationParentFileMaster, RegistrationParentFileTemplate
|
||||
from N3wtSchool import bdd
|
||||
import logging
|
||||
from rest_framework.parsers import MultiPartParser, FormParser, JSONParser
|
||||
import Subscriptions.util as util
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class RegistrationSchoolFileMasterView(APIView):
|
||||
parser_classes = [MultiPartParser, FormParser]
|
||||
@swagger_auto_schema(
|
||||
operation_description="Récupère tous les masters de templates d'inscription pour un établissement donné",
|
||||
manual_parameters=[
|
||||
openapi.Parameter(
|
||||
'establishment_id',
|
||||
openapi.IN_QUERY,
|
||||
description="ID de l'établissement",
|
||||
type=openapi.TYPE_INTEGER,
|
||||
required=True
|
||||
)
|
||||
],
|
||||
responses={200: RegistrationSchoolFileMasterSerializer(many=True)}
|
||||
)
|
||||
def get(self, request):
|
||||
establishment_id = request.GET.get('establishment_id')
|
||||
if not establishment_id:
|
||||
return Response({'error': "Paramètre 'establishment_id' requis"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
# Filtrer les masters liés à l'établissement via groups.establishment
|
||||
masters = RegistrationSchoolFileMaster.objects.filter(
|
||||
groups__establishment__id=establishment_id
|
||||
).distinct()
|
||||
serializer = RegistrationSchoolFileMasterSerializer(masters, many=True)
|
||||
return Response(serializer.data)
|
||||
|
||||
@swagger_auto_schema(
|
||||
operation_description="Crée un nouveau master de template d'inscription",
|
||||
request_body=RegistrationSchoolFileMasterSerializer,
|
||||
responses={
|
||||
201: RegistrationSchoolFileMasterSerializer,
|
||||
400: "Données invalides"
|
||||
}
|
||||
)
|
||||
def post(self, request):
|
||||
logger.info(f"raw request.data: {request.data}")
|
||||
|
||||
payload, resp = util.build_payload_from_request(request)
|
||||
if resp:
|
||||
return resp
|
||||
|
||||
logger.info(f"payload for serializer: {payload}")
|
||||
serializer = RegistrationSchoolFileMasterSerializer(data=payload, partial=True)
|
||||
if serializer.is_valid():
|
||||
obj = serializer.save()
|
||||
return Response(RegistrationSchoolFileMasterSerializer(obj).data, status=status.HTTP_201_CREATED)
|
||||
|
||||
logger.error(f"serializer errors: {serializer.errors}")
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
class RegistrationSchoolFileMasterSimpleView(APIView):
|
||||
@swagger_auto_schema(
|
||||
operation_description="Récupère un master de template d'inscription spécifique",
|
||||
responses={
|
||||
200: RegistrationSchoolFileMasterSerializer,
|
||||
404: "Master non trouvé"
|
||||
}
|
||||
)
|
||||
def get(self, request, id):
|
||||
master = bdd.getObject(_objectName=RegistrationSchoolFileMaster, _columnName='id', _value=id)
|
||||
if master is None:
|
||||
return JsonResponse({"errorMessage":'Le master de template n\'a pas été trouvé'}, safe=False, status=status.HTTP_404_NOT_FOUND)
|
||||
serializer = RegistrationSchoolFileMasterSerializer(master)
|
||||
return JsonResponse(serializer.data, safe=False)
|
||||
|
||||
@swagger_auto_schema(
|
||||
operation_description="Met à jour un master de template d'inscription existant",
|
||||
request_body=RegistrationSchoolFileMasterSerializer,
|
||||
responses={
|
||||
200: RegistrationSchoolFileMasterSerializer,
|
||||
400: "Données invalides",
|
||||
404: "Master non trouvé"
|
||||
}
|
||||
)
|
||||
def put(self, request, id):
|
||||
master = bdd.getObject(_objectName=RegistrationSchoolFileMaster, _columnName='id', _value=id)
|
||||
if master is None:
|
||||
return JsonResponse({'erreur': "Le master de template n'a pas été trouvé"}, safe=False, status=status.HTTP_404_NOT_FOUND)
|
||||
|
||||
# Normaliser payload (supporte form-data avec champ 'data' JSON ou fichier JSON)
|
||||
payload, resp = util.build_payload_from_request(request)
|
||||
if resp:
|
||||
return resp
|
||||
|
||||
logger.info(f"payload for update serializer: {payload}")
|
||||
serializer = RegistrationSchoolFileMasterSerializer(master, data=payload, partial=True)
|
||||
if serializer.is_valid():
|
||||
serializer.save()
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
logger.error(f"serializer errors on put: {serializer.errors}")
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
@swagger_auto_schema(
|
||||
operation_description="Supprime un master de template d'inscription",
|
||||
responses={
|
||||
204: "Suppression réussie",
|
||||
404: "Master non trouvé"
|
||||
}
|
||||
)
|
||||
def delete(self, request, id):
|
||||
master = bdd.getObject(_objectName=RegistrationSchoolFileMaster, _columnName='id', _value=id)
|
||||
if master is not None:
|
||||
master.delete()
|
||||
return JsonResponse({'message': 'La suppression du master de template a été effectuée avec succès'}, safe=False, status=status.HTTP_200_OK)
|
||||
else:
|
||||
return JsonResponse({'erreur': 'Le master de template n\'a pas été trouvé'}, safe=False, status=status.HTTP_404_NOT_FOUND)
|
||||
|
||||
class RegistrationSchoolFileTemplateView(APIView):
|
||||
@swagger_auto_schema(
|
||||
operation_description="Récupère tous les templates d'inscription pour un établissement donné",
|
||||
@ -58,8 +165,6 @@ class RegistrationSchoolFileTemplateView(APIView):
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
class RegistrationSchoolFileTemplateSimpleView(APIView):
|
||||
parser_classes = [MultiPartParser, FormParser, JSONParser]
|
||||
|
||||
@swagger_auto_schema(
|
||||
operation_description="Récupère un template d'inscription spécifique",
|
||||
responses={
|
||||
@ -84,83 +189,12 @@ class RegistrationSchoolFileTemplateSimpleView(APIView):
|
||||
}
|
||||
)
|
||||
def put(self, request, id):
|
||||
# Normaliser la payload (support form-data avec champ 'data' JSON ou fichier JSON)
|
||||
payload, resp = util.build_payload_from_request(request)
|
||||
if resp is not None:
|
||||
return resp
|
||||
|
||||
# Synchroniser fields[].value dans le payload AVANT le serializer (pour les formulaires dynamiques)
|
||||
formTemplateData = payload.get('formTemplateData')
|
||||
if formTemplateData and isinstance(formTemplateData, dict):
|
||||
responses = None
|
||||
if "responses" in formTemplateData:
|
||||
resp = formTemplateData["responses"]
|
||||
if isinstance(resp, dict) and "responses" in resp:
|
||||
responses = resp["responses"]
|
||||
elif isinstance(resp, dict):
|
||||
responses = resp
|
||||
if responses and "fields" in formTemplateData:
|
||||
for field in formTemplateData["fields"]:
|
||||
field_id = field.get("id")
|
||||
if field_id and field_id in responses:
|
||||
field["value"] = responses[field_id]
|
||||
payload['formTemplateData'] = formTemplateData
|
||||
|
||||
template = bdd.getObject(_objectName=RegistrationSchoolFileTemplate, _columnName='id', _value=id)
|
||||
if template is None:
|
||||
return JsonResponse({'erreur': 'Le template d\'inscription n\'a pas été trouvé'}, safe=False, status=status.HTTP_404_NOT_FOUND)
|
||||
|
||||
# Cas 1 : Upload d'un fichier existant (PDF/image)
|
||||
if 'file' in request.FILES:
|
||||
upload = request.FILES['file']
|
||||
file_field = template.file
|
||||
upload_name = upload.name
|
||||
upload_dir = os.path.dirname(file_field.path) if file_field and file_field.name else None
|
||||
if upload_dir:
|
||||
base_name, _ = os.path.splitext(upload_name)
|
||||
pattern = os.path.join(upload_dir, f"{base_name}.*")
|
||||
for f in glob.glob(pattern):
|
||||
try:
|
||||
if os.path.exists(f):
|
||||
os.remove(f)
|
||||
logger.info(f"Suppression du fichier existant (pattern): {f}")
|
||||
except Exception as e:
|
||||
logger.error(f"Erreur suppression fichier existant (pattern): {e}")
|
||||
target_path = os.path.join(upload_dir, upload_name)
|
||||
if os.path.exists(target_path):
|
||||
try:
|
||||
os.remove(target_path)
|
||||
except Exception as e:
|
||||
logger.error(f"Erreur suppression fichier cible: {e}")
|
||||
# On écrase le fichier existant sans passer par le serializer
|
||||
template.file.save(upload_name, upload, save=True)
|
||||
return Response({'message': 'Template mis à jour avec succès', 'data': RegistrationSchoolFileTemplateSerializer(template).data}, status=status.HTTP_200_OK)
|
||||
|
||||
# Cas 2 : Formulaire dynamique (JSON)
|
||||
serializer = RegistrationSchoolFileTemplateSerializer(template, data=payload)
|
||||
serializer = RegistrationSchoolFileTemplateSerializer(template, data=request.data)
|
||||
if serializer.is_valid():
|
||||
serializer.save()
|
||||
# Régénérer le PDF si besoin
|
||||
formTemplateData = serializer.validated_data.get('formTemplateData')
|
||||
if (
|
||||
formTemplateData
|
||||
and isinstance(formTemplateData, dict)
|
||||
and formTemplateData.get("fields")
|
||||
and hasattr(template, "file")
|
||||
):
|
||||
old_pdf_name = None
|
||||
if template.file and template.file.name:
|
||||
old_pdf_name = os.path.basename(template.file.name)
|
||||
try:
|
||||
template.file.delete(save=False)
|
||||
if os.path.exists(template.file.path):
|
||||
os.remove(template.file.path)
|
||||
except Exception as e:
|
||||
logger.error(f"Erreur lors de la suppression du fichier existant: {e}")
|
||||
from Subscriptions.util import generate_form_json_pdf
|
||||
pdf_file = generate_form_json_pdf(template.registration_form, formTemplateData)
|
||||
pdf_filename = old_pdf_name or f"{template.name}_{template.id}.pdf"
|
||||
template.file.save(pdf_filename, pdf_file, save=True)
|
||||
return Response({'message': 'Template mis à jour avec succès', 'data': serializer.data}, status=status.HTTP_200_OK)
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
@ -189,3 +223,195 @@ class RegistrationSchoolFileTemplateSimpleView(APIView):
|
||||
return JsonResponse({'message': 'La suppression du template a été effectuée avec succès'}, safe=False, status=status.HTTP_204_NO_CONTENT)
|
||||
else:
|
||||
return JsonResponse({'erreur': 'Le template n\'a pas été trouvé'}, safe=False, status=status.HTTP_404_NOT_FOUND)
|
||||
|
||||
class RegistrationParentFileMasterView(APIView):
|
||||
@swagger_auto_schema(
|
||||
operation_description="Récupère tous les fichiers parents pour un établissement donné",
|
||||
manual_parameters=[
|
||||
openapi.Parameter(
|
||||
'establishment_id',
|
||||
openapi.IN_QUERY,
|
||||
description="ID de l'établissement",
|
||||
type=openapi.TYPE_INTEGER,
|
||||
required=True
|
||||
)
|
||||
],
|
||||
responses={200: RegistrationParentFileMasterSerializer(many=True)}
|
||||
)
|
||||
def get(self, request):
|
||||
establishment_id = request.GET.get('establishment_id')
|
||||
if not establishment_id:
|
||||
return Response({'error': "Paramètre 'establishment_id' requis"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
# Filtrer les fichiers parents liés à l'établissement
|
||||
templates = RegistrationParentFileMaster.objects.filter(
|
||||
groups__establishment__id=establishment_id
|
||||
).distinct()
|
||||
serializer = RegistrationParentFileMasterSerializer(templates, many=True)
|
||||
return Response(serializer.data)
|
||||
|
||||
@swagger_auto_schema(
|
||||
operation_description="Crée un nouveau fichier parent",
|
||||
request_body=RegistrationParentFileMasterSerializer,
|
||||
responses={
|
||||
201: RegistrationParentFileMasterSerializer,
|
||||
400: "Données invalides"
|
||||
}
|
||||
)
|
||||
def post(self, request):
|
||||
serializer = RegistrationParentFileMasterSerializer(data=request.data)
|
||||
if serializer.is_valid():
|
||||
serializer.save()
|
||||
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
class RegistrationParentFileMasterSimpleView(APIView):
|
||||
@swagger_auto_schema(
|
||||
operation_description="Récupère un fichier parent spécifique",
|
||||
responses={
|
||||
200: RegistrationParentFileMasterSerializer,
|
||||
404: "Fichier parent non trouvé"
|
||||
}
|
||||
)
|
||||
def get(self, request, id):
|
||||
template = bdd.getObject(_objectName=RegistrationParentFileMaster, _columnName='id', _value=id)
|
||||
if template is None:
|
||||
return JsonResponse({"errorMessage":'Le fichier parent n\'a pas été trouvé'}, safe=False, status=status.HTTP_404_NOT_FOUND)
|
||||
serializer = RegistrationParentFileMasterSerializer(template)
|
||||
return JsonResponse(serializer.data, safe=False)
|
||||
|
||||
@swagger_auto_schema(
|
||||
operation_description="Met à jour un fichier parent existant",
|
||||
request_body=RegistrationParentFileMasterSerializer,
|
||||
responses={
|
||||
200: RegistrationParentFileMasterSerializer,
|
||||
400: "Données invalides",
|
||||
404: "Fichier parent non trouvé"
|
||||
}
|
||||
)
|
||||
def put(self, request, id):
|
||||
template = bdd.getObject(_objectName=RegistrationParentFileMaster, _columnName='id', _value=id)
|
||||
if template is None:
|
||||
return JsonResponse({'erreur': 'Le fichier parent n\'a pas été trouvé'}, safe=False, status=status.HTTP_404_NOT_FOUND)
|
||||
serializer = RegistrationParentFileMasterSerializer(template, data=request.data)
|
||||
if serializer.is_valid():
|
||||
serializer.save()
|
||||
return Response({'message': 'Fichier parent mis à jour avec succès', 'data': serializer.data}, status=status.HTTP_200_OK)
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
@swagger_auto_schema(
|
||||
operation_description="Supprime un fichier parent",
|
||||
responses={
|
||||
204: "Suppression réussie",
|
||||
404: "Fichier parent non trouvé"
|
||||
}
|
||||
)
|
||||
def delete(self, request, id):
|
||||
template = bdd.getObject(_objectName=RegistrationParentFileMaster, _columnName='id', _value=id)
|
||||
if template is not None:
|
||||
template.delete()
|
||||
return JsonResponse({'message': 'La suppression du fichier parent a été effectuée avec succès'}, safe=False, status=status.HTTP_204_NO_CONTENT)
|
||||
else:
|
||||
return JsonResponse({'erreur': 'Le fichier parent n\'a pas été trouvé'}, safe=False, status=status.HTTP_404_NOT_FOUND)
|
||||
|
||||
class RegistrationParentFileTemplateView(APIView):
|
||||
@swagger_auto_schema(
|
||||
operation_description="Récupère tous les templates parents pour un établissement donné",
|
||||
manual_parameters=[
|
||||
openapi.Parameter(
|
||||
'establishment_id',
|
||||
openapi.IN_QUERY,
|
||||
description="ID de l'établissement",
|
||||
type=openapi.TYPE_INTEGER,
|
||||
required=True
|
||||
)
|
||||
],
|
||||
responses={200: RegistrationParentFileTemplateSerializer(many=True)}
|
||||
)
|
||||
def get(self, request):
|
||||
establishment_id = request.GET.get('establishment_id')
|
||||
if not establishment_id:
|
||||
return Response({'error': "Paramètre 'establishment_id' requis"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
# Filtrer les templates parents liés à l'établissement via master.groups.establishment
|
||||
templates = RegistrationParentFileTemplate.objects.filter(
|
||||
master__groups__establishment__id=establishment_id
|
||||
).distinct()
|
||||
serializer = RegistrationParentFileTemplateSerializer(templates, many=True)
|
||||
return Response(serializer.data)
|
||||
|
||||
@swagger_auto_schema(
|
||||
operation_description="Crée un nouveau template d'inscription",
|
||||
request_body=RegistrationParentFileTemplateSerializer,
|
||||
responses={
|
||||
201: RegistrationParentFileTemplateSerializer,
|
||||
400: "Données invalides"
|
||||
}
|
||||
)
|
||||
def post(self, request):
|
||||
serializer = RegistrationParentFileTemplateSerializer(data=request.data)
|
||||
if serializer.is_valid():
|
||||
serializer.save()
|
||||
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
class RegistrationParentFileTemplateSimpleView(APIView):
|
||||
@swagger_auto_schema(
|
||||
operation_description="Récupère un template d'inscription spécifique",
|
||||
responses={
|
||||
200: RegistrationParentFileTemplateSerializer,
|
||||
404: "Template non trouvé"
|
||||
}
|
||||
)
|
||||
def get(self, request, id):
|
||||
template = bdd.getObject(_objectName=RegistrationParentFileTemplate, _columnName='id', _value=id)
|
||||
if template is None:
|
||||
return JsonResponse({"errorMessage":'Le template d\'inscription n\'a pas été trouvé'}, safe=False, status=status.HTTP_404_NOT_FOUND)
|
||||
serializer = RegistrationParentFileTemplateSerializer(template)
|
||||
return JsonResponse(serializer.data, safe=False)
|
||||
|
||||
@swagger_auto_schema(
|
||||
operation_description="Met à jour un template d'inscription existant",
|
||||
request_body=RegistrationParentFileTemplateSerializer,
|
||||
responses={
|
||||
200: RegistrationParentFileTemplateSerializer,
|
||||
400: "Données invalides",
|
||||
404: "Template non trouvé"
|
||||
}
|
||||
)
|
||||
def put(self, request, id):
|
||||
template = bdd.getObject(_objectName=RegistrationParentFileTemplate, _columnName='id', _value=id)
|
||||
if template is None:
|
||||
return JsonResponse({'erreur': 'Le template d\'inscription n\'a pas été trouvé'}, safe=False, status=status.HTTP_404_NOT_FOUND)
|
||||
|
||||
serializer = RegistrationParentFileTemplateSerializer(template, data=request.data, partial=True)
|
||||
if serializer.is_valid():
|
||||
serializer.save()
|
||||
return Response({'message': 'Template mis à jour avec succès', 'data': serializer.data}, status=status.HTTP_200_OK)
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
@swagger_auto_schema(
|
||||
operation_description="Supprime un template d'inscription",
|
||||
responses={
|
||||
204: "Suppression réussie",
|
||||
404: "Template non trouvé"
|
||||
}
|
||||
)
|
||||
def delete(self, request, id):
|
||||
template = bdd.getObject(_objectName=RegistrationParentFileTemplate, _columnName='id', _value=id)
|
||||
if template is not None:
|
||||
# Suppression du fichier PDF associé
|
||||
if template.file and template.file.name:
|
||||
file_path = template.file.path
|
||||
template.file.delete(save=False)
|
||||
# Vérification post-suppression
|
||||
if os.path.exists(file_path):
|
||||
try:
|
||||
os.remove(file_path)
|
||||
logger.info(f"Fichier supprimé manuellement: {file_path}")
|
||||
except Exception as e:
|
||||
logger.error(f"Erreur lors de la suppression manuelle du fichier: {e}")
|
||||
template.delete()
|
||||
return JsonResponse({'message': 'La suppression du template a été effectuée avec succès'}, safe=False, status=status.HTTP_204_NO_CONTENT)
|
||||
else:
|
||||
return JsonResponse({'erreur': 'Le template n\'a pas été trouvé'}, safe=False, status=status.HTTP_404_NOT_FOUND)
|
||||
|
||||
@ -1 +1 @@
|
||||
__version__ = "0.0.3"
|
||||
__version__ = "0.0.4"
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "n3wt-school-front-end",
|
||||
"version": "0.0.3",
|
||||
"version": "0.0.4",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
|
||||
@ -11,6 +11,7 @@ import {
|
||||
Award,
|
||||
Calendar,
|
||||
Settings,
|
||||
LogOut,
|
||||
MessageSquare,
|
||||
} from 'lucide-react';
|
||||
|
||||
@ -28,14 +29,16 @@ import {
|
||||
|
||||
import { disconnect } from '@/app/actions/authAction';
|
||||
import ProtectedRoute from '@/components/ProtectedRoute';
|
||||
import { getGravatarUrl } from '@/utils/gravatar';
|
||||
import Footer from '@/components/Footer';
|
||||
import { RIGHTS } from '@/utils/rights';
|
||||
import { getRightStr, RIGHTS } from '@/utils/rights';
|
||||
import { useEstablishment } from '@/context/EstablishmentContext';
|
||||
import ProfileSelector from '@/components/ProfileSelector';
|
||||
|
||||
export default function Layout({ children }) {
|
||||
const t = useTranslations('sidebar');
|
||||
const [isSidebarOpen, setIsSidebarOpen] = useState(false);
|
||||
const { profileRole, establishments, clearContext } =
|
||||
const { profileRole, establishments, user, clearContext } =
|
||||
useEstablishment();
|
||||
|
||||
const sidebarItems = {
|
||||
@ -94,15 +97,45 @@ export default function Layout({ children }) {
|
||||
const pathname = usePathname();
|
||||
const currentPage = pathname.split('/').pop();
|
||||
|
||||
const headerTitle = sidebarItems[currentPage]?.name || t('dashboard');
|
||||
|
||||
const softwareName = 'N3WT School';
|
||||
const softwareVersion = `${process.env.NEXT_PUBLIC_APP_VERSION}`;
|
||||
|
||||
const handleDisconnect = () => {
|
||||
setIsPopupVisible(true);
|
||||
};
|
||||
|
||||
const confirmDisconnect = () => {
|
||||
setIsPopupVisible(false);
|
||||
disconnect();
|
||||
clearContext();
|
||||
};
|
||||
|
||||
const dropdownItems = [
|
||||
{
|
||||
type: 'info',
|
||||
content: (
|
||||
<div className="px-4 py-2">
|
||||
<div className="font-medium">{user?.email || 'Utilisateur'}</div>
|
||||
<div className="text-xs text-gray-400">
|
||||
{getRightStr(profileRole) || ''}
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
type: 'separator',
|
||||
content: <hr className="my-2 border-gray-200" />,
|
||||
},
|
||||
{
|
||||
type: 'item',
|
||||
label: 'Déconnexion',
|
||||
onClick: handleDisconnect,
|
||||
icon: LogOut,
|
||||
},
|
||||
];
|
||||
|
||||
const toggleSidebar = () => {
|
||||
setIsSidebarOpen(!isSidebarOpen);
|
||||
};
|
||||
@ -112,15 +145,6 @@ export default function Layout({ children }) {
|
||||
setIsSidebarOpen(false);
|
||||
}, [pathname]);
|
||||
|
||||
// Filtrage dynamique des items de la sidebar selon le rôle
|
||||
let sidebarItemsToDisplay = Object.values(sidebarItems);
|
||||
if (profileRole === 0) {
|
||||
// Si pas admin, on retire "directory" et "settings"
|
||||
sidebarItemsToDisplay = sidebarItemsToDisplay.filter(
|
||||
(item) => item.id !== 'directory' && item.id !== 'settings'
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ProtectedRoute requiredRight={[RIGHTS.ADMIN, RIGHTS.TEACHER]}>
|
||||
{/* Sidebar */}
|
||||
@ -132,7 +156,7 @@ export default function Layout({ children }) {
|
||||
<Sidebar
|
||||
establishments={establishments}
|
||||
currentPage={currentPage}
|
||||
items={sidebarItemsToDisplay}
|
||||
items={Object.values(sidebarItems)}
|
||||
onCloseMobile={toggleSidebar}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@ -52,7 +52,7 @@ export default function Page() {
|
||||
);
|
||||
|
||||
const csrfToken = useCsrfToken();
|
||||
const { selectedEstablishmentId, profileRole } = useEstablishment();
|
||||
const { selectedEstablishmentId } = useEstablishment();
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedEstablishmentId) {
|
||||
@ -316,39 +316,35 @@ export default function Page() {
|
||||
</div>
|
||||
),
|
||||
},
|
||||
...(profileRole !== 0
|
||||
? [
|
||||
{
|
||||
id: 'Fees',
|
||||
label: 'Tarifs',
|
||||
content: (
|
||||
<div className="h-full overflow-y-auto p-4">
|
||||
<FeesManagement
|
||||
registrationDiscounts={registrationDiscounts}
|
||||
setRegistrationDiscounts={setRegistrationDiscounts}
|
||||
tuitionDiscounts={tuitionDiscounts}
|
||||
setTuitionDiscounts={setTuitionDiscounts}
|
||||
registrationFees={registrationFees}
|
||||
setRegistrationFees={setRegistrationFees}
|
||||
tuitionFees={tuitionFees}
|
||||
setTuitionFees={setTuitionFees}
|
||||
registrationPaymentPlans={registrationPaymentPlans}
|
||||
setRegistrationPaymentPlans={setRegistrationPaymentPlans}
|
||||
tuitionPaymentPlans={tuitionPaymentPlans}
|
||||
setTuitionPaymentPlans={setTuitionPaymentPlans}
|
||||
registrationPaymentModes={registrationPaymentModes}
|
||||
setRegistrationPaymentModes={setRegistrationPaymentModes}
|
||||
tuitionPaymentModes={tuitionPaymentModes}
|
||||
setTuitionPaymentModes={setTuitionPaymentModes}
|
||||
handleCreate={handleCreate}
|
||||
handleEdit={handleEdit}
|
||||
handleDelete={handleDelete}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
id: 'Fees',
|
||||
label: 'Tarifs',
|
||||
content: (
|
||||
<div className="h-full overflow-y-auto p-4">
|
||||
<FeesManagement
|
||||
registrationDiscounts={registrationDiscounts}
|
||||
setRegistrationDiscounts={setRegistrationDiscounts}
|
||||
tuitionDiscounts={tuitionDiscounts}
|
||||
setTuitionDiscounts={setTuitionDiscounts}
|
||||
registrationFees={registrationFees}
|
||||
setRegistrationFees={setRegistrationFees}
|
||||
tuitionFees={tuitionFees}
|
||||
setTuitionFees={setTuitionFees}
|
||||
registrationPaymentPlans={registrationPaymentPlans}
|
||||
setRegistrationPaymentPlans={setRegistrationPaymentPlans}
|
||||
tuitionPaymentPlans={tuitionPaymentPlans}
|
||||
setTuitionPaymentPlans={setTuitionPaymentPlans}
|
||||
registrationPaymentModes={registrationPaymentModes}
|
||||
setRegistrationPaymentModes={setRegistrationPaymentModes}
|
||||
tuitionPaymentModes={tuitionPaymentModes}
|
||||
setTuitionPaymentModes={setTuitionPaymentModes}
|
||||
handleCreate={handleCreate}
|
||||
handleEdit={handleEdit}
|
||||
handleDelete={handleDelete}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'Files',
|
||||
label: 'Documents',
|
||||
@ -357,7 +353,6 @@ export default function Page() {
|
||||
<FilesGroupsManagement
|
||||
csrfToken={csrfToken}
|
||||
selectedEstablishmentId={selectedEstablishmentId}
|
||||
profileRole={profileRole}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
|
||||
@ -2,7 +2,6 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import Table from '@/components/Table';
|
||||
import Tab from '@/components/Tab';
|
||||
import Textarea from '@/components/Textarea';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import StatusLabel from '@/components/StatusLabel';
|
||||
import Popup from '@/components/Popup';
|
||||
@ -18,7 +17,6 @@ import {
|
||||
Plus,
|
||||
Upload,
|
||||
Eye,
|
||||
XCircle,
|
||||
} from 'lucide-react';
|
||||
import Modal from '@/components/Modal';
|
||||
import { useEstablishment } from '@/context/EstablishmentContext';
|
||||
@ -85,9 +83,12 @@ export default function Page({ params: { locale } }) {
|
||||
const [totalHistorical, setTotalHistorical] = useState(0);
|
||||
const [itemsPerPage, setItemsPerPage] = useState(10); // Définir le nombre d'éléments par page
|
||||
|
||||
const [student, setStudent] = useState('');
|
||||
const [classes, setClasses] = useState([]);
|
||||
const [reloadFetch, setReloadFetch] = useState(false);
|
||||
|
||||
const [isOpenAddGuardian, setIsOpenAddGuardian] = useState(false);
|
||||
|
||||
const [isFilesModalOpen, setIsFilesModalOpen] = useState(false);
|
||||
const [selectedRegisterForm, setSelectedRegisterForm] = useState([]);
|
||||
|
||||
@ -98,40 +99,9 @@ export default function Page({ params: { locale } }) {
|
||||
const [isSepaUploadModalOpen, setIsSepaUploadModalOpen] = useState(false);
|
||||
const [selectedRowForUpload, setSelectedRowForUpload] = useState(null);
|
||||
|
||||
// Refus popup state
|
||||
const [isRefusePopupOpen, setIsRefusePopupOpen] = useState(false);
|
||||
const [refuseReason, setRefuseReason] = useState('');
|
||||
const [rowToRefuse, setRowToRefuse] = useState(null);
|
||||
// Ouvre la popup de refus
|
||||
const openRefusePopup = (row) => {
|
||||
setRowToRefuse(row);
|
||||
setRefuseReason('');
|
||||
setIsRefusePopupOpen(true);
|
||||
};
|
||||
|
||||
// Valide le refus
|
||||
const handleRefuse = () => {
|
||||
if (!refuseReason.trim()) {
|
||||
showNotification('Merci de préciser la raison du refus.', 'error', 'Erreur');
|
||||
return;
|
||||
}
|
||||
const formData = new FormData();
|
||||
formData.append('data', JSON.stringify({ status: RegistrationFormStatus.STATUS_ARCHIVED, notes: refuseReason }));
|
||||
|
||||
editRegisterForm(rowToRefuse.student.id, formData, csrfToken)
|
||||
.then(() => {
|
||||
showNotification('Le dossier a été refusé et archivé.', 'success', 'Succès');
|
||||
setReloadFetch(true);
|
||||
setIsRefusePopupOpen(false);
|
||||
})
|
||||
.catch(() => {
|
||||
showNotification('Erreur lors du refus du dossier.', 'error', 'Erreur');
|
||||
});
|
||||
};
|
||||
|
||||
const csrfToken = useCsrfToken();
|
||||
const router = useRouter();
|
||||
const { selectedEstablishmentId, profileRole } = useEstablishment();
|
||||
const { selectedEstablishmentId } = useEstablishment();
|
||||
const { showNotification } = useNotification();
|
||||
|
||||
const openSepaUploadModal = (row) => {
|
||||
@ -520,18 +490,10 @@ export default function Page({ params: { locale } }) {
|
||||
</span>
|
||||
),
|
||||
onClick: () => {
|
||||
const url = `${FE_ADMIN_SUBSCRIPTIONS_VALIDATE_URL}?studentId=${row.student.id}&firstName=${row.student.first_name}&lastName=${row.student.last_name}&email=${row.student.guardians[0].associated_profile_email}&level=${row.student.level}&sepa_file=${row.sepa_file}&student_file=${row.registration_file}`;
|
||||
const url = `${FE_ADMIN_SUBSCRIPTIONS_VALIDATE_URL}?studentId=${row.student.id}&firstName=${row.student.first_name}&lastName=${row.student.last_name}&level=${row.student.level}&sepa_file=${row.sepa_file}&student_file=${row.registration_file}`;
|
||||
router.push(`${url}`);
|
||||
},
|
||||
},
|
||||
{
|
||||
icon: (
|
||||
<span title="Refuser le dossier">
|
||||
<XCircle className="w-5 h-5 text-red-500 hover:text-red-700" />
|
||||
</span>
|
||||
),
|
||||
onClick: () => openRefusePopup(row),
|
||||
},
|
||||
],
|
||||
// Etat "A relancer" - NON TESTE
|
||||
[RegistrationFormStatus.STATUS_TO_FOLLOW_UP]: [
|
||||
@ -839,17 +801,15 @@ export default function Page({ params: { locale } }) {
|
||||
onChange={handleSearchChange}
|
||||
/>
|
||||
</div>
|
||||
{profileRole !== 0 && (
|
||||
<button
|
||||
onClick={() => {
|
||||
const url = `${FE_ADMIN_SUBSCRIPTIONS_CREATE_URL}`;
|
||||
router.push(url);
|
||||
}}
|
||||
className="flex items-center bg-emerald-600 text-white p-2 rounded-full shadow hover:bg-emerald-900 transition duration-200 ml-4"
|
||||
>
|
||||
<Plus className="w-5 h-5" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => {
|
||||
const url = `${FE_ADMIN_SUBSCRIPTIONS_CREATE_URL}`;
|
||||
router.push(url);
|
||||
}}
|
||||
className="flex items-center bg-emerald-600 text-white p-2 rounded-full shadow hover:bg-emerald-900 transition duration-200 ml-4"
|
||||
>
|
||||
<Plus className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="w-full">
|
||||
@ -893,25 +853,6 @@ export default function Page({ params: { locale } }) {
|
||||
onCancel={() => setConfirmPopupVisible(false)}
|
||||
/>
|
||||
|
||||
{/* Popup de refus de dossier */}
|
||||
<Popup
|
||||
isOpen={isRefusePopupOpen}
|
||||
message={
|
||||
<div>
|
||||
<div className="mb-2 font-semibold">Veuillez indiquer la raison du refus :</div>
|
||||
<Textarea
|
||||
value={refuseReason}
|
||||
onChange={(e) => setRefuseReason(e.target.value)}
|
||||
placeholder="Ex : Réception de dossier trop tardive"
|
||||
rows={3}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
onConfirm={handleRefuse}
|
||||
onCancel={() => setIsRefusePopupOpen(false)}
|
||||
/>
|
||||
|
||||
{isSepaUploadModalOpen && (
|
||||
<Modal
|
||||
isOpen={isSepaUploadModalOpen}
|
||||
|
||||
@ -10,10 +10,8 @@ import Loader from '@/components/Loader';
|
||||
import { useEstablishment } from '@/context/EstablishmentContext';
|
||||
import { FE_ADMIN_SUBSCRIPTIONS_URL } from '@/utils/Url';
|
||||
import { useNotification } from '@/context/NotificationContext';
|
||||
import { editRegistrationSchoolFileTemplates, editRegistrationParentFileTemplates } from '@/app/actions/registerFileGroupAction';
|
||||
|
||||
export default function Page() {
|
||||
const [isLoadingRefuse, setIsLoadingRefuse] = useState(false);
|
||||
const searchParams = useSearchParams();
|
||||
const router = useRouter();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
@ -22,7 +20,6 @@ export default function Page() {
|
||||
const studentId = searchParams.get('studentId');
|
||||
const firstName = searchParams.get('firstName');
|
||||
const lastName = searchParams.get('lastName');
|
||||
const email = searchParams.get('email');
|
||||
const level = searchParams.get('level');
|
||||
const sepa_file =
|
||||
searchParams.get('sepa_file') === 'null'
|
||||
@ -87,45 +84,6 @@ export default function Page() {
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
const handleRefuseRF = (data) => {
|
||||
const formData = new FormData();
|
||||
formData.append('data', JSON.stringify(data));
|
||||
editRegisterForm(studentId, formData, csrfToken)
|
||||
.then((response) => {
|
||||
logger.debug('RF refusé et archivé:', response);
|
||||
showNotification('Le dossier a été refusé et archivé.', 'success', 'Succès');
|
||||
router.push(FE_ADMIN_SUBSCRIPTIONS_URL);
|
||||
setIsLoadingRefuse(false);
|
||||
})
|
||||
.catch((error) => {
|
||||
showNotification('Erreur lors du refus du dossier.', 'error', 'Erreur');
|
||||
setIsLoadingRefuse(false);
|
||||
logger.error('Erreur lors du refus du RF:', error);
|
||||
});
|
||||
};
|
||||
|
||||
// Validation/refus d'un document individuel (hors fiche élève)
|
||||
const handleValidateOrRefuseDoc = ({ templateId, type, validated, csrfToken }) => {
|
||||
if (!templateId) return;
|
||||
let editFn = null;
|
||||
if (type === 'school') {
|
||||
editFn = editRegistrationSchoolFileTemplates;
|
||||
} else if (type === 'parent') {
|
||||
editFn = editRegistrationParentFileTemplates;
|
||||
}
|
||||
if (!editFn) return;
|
||||
const updateData = new FormData();
|
||||
updateData.append('data', JSON.stringify({ isValidated: validated }));
|
||||
editFn(templateId, updateData, csrfToken)
|
||||
.then((response) => {
|
||||
logger.debug(`Document ${validated ? 'validé' : 'refusé'} (type: ${type}, id: ${templateId})`, response);
|
||||
})
|
||||
.catch((error) => {
|
||||
logger.error('Erreur lors de la validation/refus du document:', error);
|
||||
});
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return <Loader />;
|
||||
}
|
||||
@ -135,15 +93,10 @@ export default function Page() {
|
||||
studentId={studentId}
|
||||
firstName={firstName}
|
||||
lastName={lastName}
|
||||
email={email}
|
||||
sepa_file={sepa_file}
|
||||
student_file={student_file}
|
||||
onAccept={handleAcceptRF}
|
||||
classes={classes}
|
||||
onRefuse={handleRefuseRF}
|
||||
isLoadingRefuse={isLoadingRefuse}
|
||||
handleValidateOrRefuseDoc={handleValidateOrRefuseDoc}
|
||||
csrfToken={csrfToken}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@ -10,16 +10,10 @@ export default function FileUpload({
|
||||
required,
|
||||
errorMsg,
|
||||
enable = true, // Nouvelle prop pour activer/désactiver le champ
|
||||
key,
|
||||
}) {
|
||||
const [localFileName, setLocalFileName] = useState(uploadedFileName || '');
|
||||
const fileInputRef = useRef(null);
|
||||
|
||||
// Réinitialise localFileName à chaque changement de key (id template)
|
||||
React.useEffect(() => {
|
||||
setLocalFileName(uploadedFileName || '');
|
||||
}, [key, uploadedFileName]);
|
||||
|
||||
const handleFileChange = (e) => {
|
||||
const file = e.target.files[0];
|
||||
if (file) {
|
||||
|
||||
@ -1,6 +1,5 @@
|
||||
import logger from '@/utils/logger';
|
||||
import { useForm, Controller } from 'react-hook-form';
|
||||
import { useEffect } from 'react';
|
||||
import SelectChoice from './SelectChoice';
|
||||
import InputTextIcon from './InputTextIcon';
|
||||
import * as LucideIcons from 'lucide-react';
|
||||
@ -29,7 +28,6 @@ export function getIcon(name) {
|
||||
export default function FormRenderer({
|
||||
formConfig,
|
||||
csrfToken,
|
||||
initialValues = {},
|
||||
onFormSubmit = (data) => {
|
||||
alert(JSON.stringify(data, null, 2));
|
||||
}, // Callback de soumission personnalisé (optionnel)
|
||||
@ -39,14 +37,7 @@ export default function FormRenderer({
|
||||
control,
|
||||
formState: { errors },
|
||||
reset,
|
||||
} = useForm({ defaultValues: initialValues });
|
||||
|
||||
// Réinitialiser le formulaire quand les valeurs initiales changent
|
||||
useEffect(() => {
|
||||
if (initialValues && Object.keys(initialValues).length > 0) {
|
||||
reset(initialValues);
|
||||
}
|
||||
}, [initialValues, reset]);
|
||||
} = useForm();
|
||||
|
||||
// Fonction utilitaire pour envoyer les données au backend
|
||||
const sendFormDataToBackend = async (formData) => {
|
||||
|
||||
@ -1,88 +1,64 @@
|
||||
'use client';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import FormRenderer from '@/components/Form/FormRenderer';
|
||||
import FileUpload from '@/components/Form/FileUpload';
|
||||
import { CheckCircle, Hourglass, FileText, Download, Upload, XCircle } from 'lucide-react';
|
||||
import { CheckCircle, Hourglass, FileText } from 'lucide-react';
|
||||
import logger from '@/utils/logger';
|
||||
import { BASE_URL } from '@/utils/Url';
|
||||
|
||||
/**
|
||||
* Composant pour afficher et gérer les formulaires dynamiques d'inscription
|
||||
* @param {Array} schoolFileTemplates - Liste des templates de formulaires
|
||||
* @param {Array} schoolFileMasters - Liste des formulaires maîtres
|
||||
* @param {Object} existingResponses - Réponses déjà sauvegardées
|
||||
* @param {Function} onFormSubmit - Callback appelé quand un formulaire est soumis
|
||||
* @param {Boolean} enable - Si les formulaires sont modifiables
|
||||
* @param {Function} onFileUpload - Callback appelé quand un fichier est sélectionné
|
||||
*/
|
||||
export default function DynamicFormsList({
|
||||
schoolFileTemplates,
|
||||
schoolFileMasters,
|
||||
existingResponses = {},
|
||||
onFormSubmit,
|
||||
enable = true,
|
||||
onValidationChange,
|
||||
onFileUpload, // nouvelle prop pour gérer l'upload (à passer depuis le parent)
|
||||
}) {
|
||||
const [currentTemplateIndex, setCurrentTemplateIndex] = useState(0);
|
||||
const [formsData, setFormsData] = useState({});
|
||||
const [formsValidation, setFormsValidation] = useState({});
|
||||
const fileInputRefs = React.useRef({});
|
||||
|
||||
// Initialiser les données avec les réponses existantes
|
||||
useEffect(() => {
|
||||
// Initialisation complète de formsValidation et formsData pour chaque template
|
||||
if (schoolFileTemplates && schoolFileTemplates.length > 0) {
|
||||
// Fusionner avec l'état existant pour préserver les données locales
|
||||
setFormsData((prevData) => {
|
||||
const dataState = { ...prevData };
|
||||
schoolFileTemplates.forEach((tpl) => {
|
||||
// Ne mettre à jour que si on n'a pas encore de données locales ou si les données du serveur ont changé
|
||||
const hasLocalData = prevData[tpl.id] && Object.keys(prevData[tpl.id]).length > 0;
|
||||
const hasServerData = existingResponses && existingResponses[tpl.id] && Object.keys(existingResponses[tpl.id]).length > 0;
|
||||
if (existingResponses && Object.keys(existingResponses).length > 0) {
|
||||
setFormsData(existingResponses);
|
||||
|
||||
if (!hasLocalData && hasServerData) {
|
||||
// Pas de données locales mais données serveur : utiliser les données serveur
|
||||
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;
|
||||
});
|
||||
|
||||
// Fusionner avec l'état de validation existant
|
||||
setFormsValidation((prevValidation) => {
|
||||
const validationState = { ...prevValidation };
|
||||
schoolFileTemplates.forEach((tpl) => {
|
||||
const hasLocalValidation = prevValidation[tpl.id] === true;
|
||||
const hasServerData = existingResponses && existingResponses[tpl.id] && Object.keys(existingResponses[tpl.id]).length > 0;
|
||||
|
||||
if (!hasLocalValidation && hasServerData) {
|
||||
// Pas validé localement mais données serveur : marquer comme validé
|
||||
validationState[tpl.id] = true;
|
||||
} 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;
|
||||
// Marquer les formulaires avec réponses comme valides
|
||||
const validationState = {};
|
||||
Object.keys(existingResponses).forEach((formId) => {
|
||||
if (
|
||||
existingResponses[formId] &&
|
||||
Object.keys(existingResponses[formId]).length > 0
|
||||
) {
|
||||
validationState[formId] = true;
|
||||
}
|
||||
});
|
||||
setFormsValidation(validationState);
|
||||
}
|
||||
}, [existingResponses, schoolFileTemplates]);
|
||||
}, [existingResponses]);
|
||||
|
||||
// Debug: Log des formulaires maîtres reçus
|
||||
useEffect(() => {
|
||||
logger.debug(
|
||||
'DynamicFormsList - Formulaires maîtres reçus:',
|
||||
schoolFileMasters
|
||||
);
|
||||
}, [schoolFileMasters]);
|
||||
|
||||
// Mettre à jour la validation globale quand la validation des formulaires change
|
||||
useEffect(() => {
|
||||
// Un document est considéré comme "validé" s'il est validé par l'école OU complété localement OU déjà existant
|
||||
const allFormsValid = schoolFileTemplates.every(
|
||||
tpl => tpl.isValidated === true ||
|
||||
(formsData[tpl.id] && Object.keys(formsData[tpl.id]).length > 0) ||
|
||||
(existingResponses[tpl.id] && Object.keys(existingResponses[tpl.id]).length > 0)
|
||||
const allFormsValid = schoolFileMasters.every(
|
||||
(master, index) => formsValidation[master.id] === true
|
||||
);
|
||||
|
||||
onValidationChange(allFormsValid);
|
||||
}, [formsData, formsValidation, existingResponses, schoolFileTemplates, onValidationChange]);
|
||||
if (onValidationChange) {
|
||||
onValidationChange(allFormsValid);
|
||||
}
|
||||
}, [formsValidation, schoolFileMasters, onValidationChange]);
|
||||
|
||||
/**
|
||||
* Gère la soumission d'un formulaire individuel
|
||||
@ -109,7 +85,7 @@ export default function DynamicFormsList({
|
||||
}
|
||||
|
||||
// Passer au formulaire suivant si disponible
|
||||
if (currentTemplateIndex < schoolFileTemplates.length - 1) {
|
||||
if (currentTemplateIndex < schoolFileMasters.length - 1) {
|
||||
setCurrentTemplateIndex(currentTemplateIndex + 1);
|
||||
}
|
||||
|
||||
@ -119,6 +95,16 @@ export default function DynamicFormsList({
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Gère les changements de validation d'un formulaire
|
||||
*/
|
||||
const handleFormValidationChange = (isValid, templateId) => {
|
||||
setFormsValidation((prev) => ({
|
||||
...prev,
|
||||
[templateId]: isValid,
|
||||
}));
|
||||
};
|
||||
|
||||
/**
|
||||
* Vérifie si un formulaire est complété
|
||||
*/
|
||||
@ -149,44 +135,10 @@ export default function DynamicFormsList({
|
||||
* Obtient le formulaire actuel à afficher
|
||||
*/
|
||||
const getCurrentTemplate = () => {
|
||||
return schoolFileTemplates[currentTemplateIndex];
|
||||
return schoolFileMasters[currentTemplateIndex];
|
||||
};
|
||||
|
||||
const currentTemplate = getCurrentTemplate();
|
||||
|
||||
// Handler d'upload pour formulaire existant
|
||||
const handleUpload = async (file, selectedFile) => {
|
||||
if (!file || !selectedFile) return;
|
||||
try {
|
||||
const templateId = currentTemplate.id;
|
||||
if (onFileUpload) {
|
||||
await onFileUpload(file, selectedFile);
|
||||
setFormsData((prev) => {
|
||||
const newData = {
|
||||
...prev,
|
||||
[templateId]: { uploaded: true, fileName: file.name },
|
||||
};
|
||||
return newData;
|
||||
});
|
||||
setFormsValidation((prev) => {
|
||||
const newValidation = {
|
||||
...prev,
|
||||
[templateId]: true,
|
||||
};
|
||||
return newValidation;
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Erreur lors de l\'upload du fichier :', error);
|
||||
}
|
||||
};
|
||||
|
||||
const isDynamicForm = (template) =>
|
||||
template.formTemplateData &&
|
||||
Array.isArray(template.formTemplateData.fields) &&
|
||||
template.formTemplateData.fields.length > 0;
|
||||
|
||||
if (!schoolFileTemplates || schoolFileTemplates.length === 0) {
|
||||
if (!schoolFileMasters || schoolFileMasters.length === 0) {
|
||||
return (
|
||||
<div className="text-center py-8">
|
||||
<FileText className="w-16 h-16 text-gray-300 mx-auto mb-4" />
|
||||
@ -195,6 +147,8 @@ export default function DynamicFormsList({
|
||||
);
|
||||
}
|
||||
|
||||
const currentTemplate = getCurrentTemplate();
|
||||
|
||||
return (
|
||||
<div className="mt-8 mb-4 w-full mx-auto flex gap-8">
|
||||
{/* Liste des formulaires */}
|
||||
@ -203,253 +157,128 @@ export default function DynamicFormsList({
|
||||
Formulaires à compléter
|
||||
</h3>
|
||||
<div className="text-sm text-gray-600 mb-4">
|
||||
{/* Compteur x/y : inclut les documents validés */}
|
||||
{
|
||||
schoolFileTemplates.filter(tpl => {
|
||||
// Validé ou complété localement
|
||||
return tpl.isValidated === true ||
|
||||
(formsData[tpl.id] && Object.keys(formsData[tpl.id]).length > 0) ||
|
||||
(existingResponses[tpl.id] && Object.keys(existingResponses[tpl.id]).length > 0);
|
||||
}).length
|
||||
}
|
||||
{' / '}
|
||||
{schoolFileTemplates.length} complétés
|
||||
Object.keys(formsValidation).filter((id) => formsValidation[id])
|
||||
.length
|
||||
}{' '}
|
||||
/ {schoolFileMasters.length} complétés
|
||||
</div>
|
||||
|
||||
{/* Tri des templates par état */}
|
||||
{(() => {
|
||||
// Helper pour état
|
||||
const getState = tpl => {
|
||||
if (tpl.isValidated === true) return 0; // validé
|
||||
const isCompletedLocally = !!(
|
||||
(formsData[tpl.id] && Object.keys(formsData[tpl.id]).length > 0) ||
|
||||
(existingResponses[tpl.id] && Object.keys(existingResponses[tpl.id]).length > 0)
|
||||
);
|
||||
if (isCompletedLocally) return 1; // complété/en attente
|
||||
return 2; // à compléter/refusé
|
||||
};
|
||||
const sortedTemplates = [...schoolFileTemplates].sort((a, b) => {
|
||||
return getState(a) - getState(b);
|
||||
});
|
||||
return (
|
||||
<ul className="space-y-2">
|
||||
{sortedTemplates.map((tpl, index) => {
|
||||
const isActive = schoolFileTemplates[currentTemplateIndex]?.id === tpl.id;
|
||||
const isValidated = typeof tpl.isValidated === 'boolean' ? tpl.isValidated : undefined;
|
||||
const isCompletedLocally = !!(
|
||||
(formsData[tpl.id] && Object.keys(formsData[tpl.id]).length > 0) ||
|
||||
(existingResponses[tpl.id] && Object.keys(existingResponses[tpl.id]).length > 0)
|
||||
);
|
||||
<ul className="space-y-2">
|
||||
{schoolFileMasters.map((master, index) => {
|
||||
const isActive = index === currentTemplateIndex;
|
||||
const isCompleted = isFormCompleted(master.id);
|
||||
|
||||
// Statut d'affichage
|
||||
let statusLabel = '';
|
||||
let statusColor = '';
|
||||
let icon = null;
|
||||
let bgClass = '';
|
||||
let borderClass = '';
|
||||
let textClass = '';
|
||||
let canEdit = true;
|
||||
|
||||
if (isValidated === true) {
|
||||
statusLabel = 'Validé';
|
||||
statusColor = 'emerald';
|
||||
icon = <CheckCircle className="w-5 h-5 text-emerald-600" />;
|
||||
bgClass = 'bg-emerald-50';
|
||||
borderClass = 'border border-emerald-200';
|
||||
textClass = 'text-emerald-700';
|
||||
bgClass = isActive ? 'bg-emerald-200' : bgClass;
|
||||
borderClass = isActive ? 'border border-emerald-300' : borderClass;
|
||||
textClass = isActive ? 'text-emerald-900 font-semibold' : textClass;
|
||||
canEdit = false;
|
||||
} else if (isValidated === false) {
|
||||
if (isCompletedLocally) {
|
||||
statusLabel = 'Complété';
|
||||
statusColor = 'orange';
|
||||
icon = <Hourglass className="w-5 h-5 text-orange-400" />;
|
||||
bgClass = isActive ? 'bg-orange-200' : 'bg-orange-50';
|
||||
borderClass = isActive ? 'border border-orange-300' : 'border border-orange-200';
|
||||
textClass = isActive ? 'text-orange-900 font-semibold' : 'text-orange-700';
|
||||
canEdit = true;
|
||||
} else {
|
||||
statusLabel = 'Refusé';
|
||||
statusColor = 'red';
|
||||
icon = <XCircle className="w-5 h-5 text-red-500" />;
|
||||
bgClass = isActive ? 'bg-red-200' : 'bg-red-50';
|
||||
borderClass = isActive ? 'border border-red-300' : 'border border-red-200';
|
||||
textClass = isActive ? 'text-red-900 font-semibold' : 'text-red-700';
|
||||
canEdit = true;
|
||||
}
|
||||
} else {
|
||||
if (isCompletedLocally) {
|
||||
statusLabel = 'Complété';
|
||||
statusColor = 'orange';
|
||||
icon = <Hourglass className="w-5 h-5 text-orange-400" />;
|
||||
bgClass = isActive ? 'bg-orange-200' : 'bg-orange-50';
|
||||
borderClass = isActive ? 'border border-orange-300' : 'border border-orange-200';
|
||||
textClass = isActive ? 'text-orange-900 font-semibold' : 'text-orange-700';
|
||||
canEdit = true;
|
||||
} else {
|
||||
statusLabel = 'À compléter';
|
||||
statusColor = 'gray';
|
||||
icon = <Hourglass className="w-5 h-5 text-gray-400" />;
|
||||
bgClass = isActive ? 'bg-gray-200' : '';
|
||||
borderClass = isActive ? 'border border-gray-300' : '';
|
||||
textClass = isActive ? 'text-gray-900 font-semibold' : 'text-gray-600';
|
||||
canEdit = true;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<li
|
||||
key={tpl.id}
|
||||
className={`flex items-center cursor-pointer p-2 rounded-md transition-colors ${
|
||||
isActive
|
||||
? `${bgClass.replace('50', '200')} ${borderClass.replace('200', '300')} ${textClass.replace('700', '900')} font-semibold`
|
||||
: `${bgClass} ${borderClass} ${textClass}`
|
||||
}`}
|
||||
onClick={() => setCurrentTemplateIndex(schoolFileTemplates.findIndex(t => t.id === tpl.id))}
|
||||
>
|
||||
<span className="mr-3">{icon}</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm truncate flex items-center gap-2">
|
||||
{tpl.formMasterData?.title || tpl.title || tpl.name || 'Formulaire sans nom'}
|
||||
<span className={`ml-2 px-2 py-0.5 rounded bg-${statusColor}-100 text-${statusColor}-700 text-xs font-semibold`}>
|
||||
{statusLabel}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">
|
||||
{tpl.formMasterData?.fields || tpl.fields
|
||||
? `${(tpl.formMasterData?.fields || tpl.fields).length} champ(s)`
|
||||
: 'À compléter'}
|
||||
</div>
|
||||
return (
|
||||
<li
|
||||
key={master.id}
|
||||
className={`flex items-center cursor-pointer p-2 rounded-md transition-colors ${
|
||||
isActive
|
||||
? 'bg-blue-100 text-blue-700 font-semibold'
|
||||
: isCompleted
|
||||
? 'text-green-600 hover:bg-green-50'
|
||||
: 'text-gray-600 hover:bg-gray-100'
|
||||
}`}
|
||||
onClick={() => setCurrentTemplateIndex(index)}
|
||||
>
|
||||
<span className="mr-3">
|
||||
{getFormStatusIcon(master.id, isActive)}
|
||||
</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm truncate">
|
||||
{master.formMasterData?.title ||
|
||||
master.title ||
|
||||
master.name ||
|
||||
'Formulaire sans nom'}
|
||||
</div>
|
||||
{isCompleted ? (
|
||||
<div className="text-xs text-green-600">
|
||||
Complété -{' '}
|
||||
{
|
||||
Object.keys(
|
||||
formsData[master.id] ||
|
||||
existingResponses[master.id] ||
|
||||
{}
|
||||
).length
|
||||
}{' '}
|
||||
réponse(s)
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
);
|
||||
})()}
|
||||
) : (
|
||||
<div className="text-xs text-gray-500">
|
||||
{master.formMasterData?.fields || master.fields
|
||||
? `${(master.formMasterData?.fields || master.fields).length} champ(s)`
|
||||
: 'À compléter'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Affichage du formulaire actuel */}
|
||||
<div className="w-3/4">
|
||||
{currentTemplate && (
|
||||
<div className="bg-white p-6 rounded-lg shadow-sm border border-gray-200">
|
||||
<div className="mb-6">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<h3 className="text-xl font-semibold text-gray-800">
|
||||
{currentTemplate.name}
|
||||
</h3>
|
||||
{/* Label d'état */}
|
||||
{currentTemplate.isValidated === true ? (
|
||||
<span className="px-2 py-0.5 rounded bg-emerald-100 text-emerald-700 text-sm font-semibold">Validé</span>
|
||||
) : ((formsData[currentTemplate.id] && Object.keys(formsData[currentTemplate.id]).length > 0) ||
|
||||
(existingResponses[currentTemplate.id] && Object.keys(existingResponses[currentTemplate.id]).length > 0)) ? (
|
||||
<span className="px-2 py-0.5 rounded bg-orange-100 text-orange-700 text-sm font-semibold">Complété</span>
|
||||
) : (
|
||||
<span className="px-2 py-0.5 rounded bg-red-100 text-red-700 text-sm font-semibold">Refusé</span>
|
||||
)}
|
||||
</div>
|
||||
<h3 className="text-xl font-semibold text-gray-800 mb-2">
|
||||
{currentTemplate.formMasterData?.title ||
|
||||
currentTemplate.title ||
|
||||
currentTemplate.name ||
|
||||
'Formulaire sans nom'}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-600">
|
||||
{currentTemplate.formTemplateData?.description ||
|
||||
currentTemplate.description || ''}
|
||||
{currentTemplate.formMasterData?.description ||
|
||||
currentTemplate.description ||
|
||||
'Veuillez compléter ce formulaire pour continuer votre inscription.'}
|
||||
</p>
|
||||
<div className="text-xs text-gray-500 mt-1">
|
||||
Formulaire {(() => {
|
||||
// Trouver l'index du template courant dans la liste triée
|
||||
const getState = tpl => {
|
||||
if (tpl.isValidated === true) return 0;
|
||||
const isCompletedLocally = !!(
|
||||
(formsData[tpl.id] && Object.keys(formsData[tpl.id]).length > 0) ||
|
||||
(existingResponses[tpl.id] && Object.keys(existingResponses[tpl.id]).length > 0)
|
||||
);
|
||||
if (isCompletedLocally) return 1;
|
||||
return 2;
|
||||
};
|
||||
const sortedTemplates = [...schoolFileTemplates].sort((a, b) => getState(a) - getState(b));
|
||||
const idx = sortedTemplates.findIndex(tpl => tpl.id === currentTemplate.id);
|
||||
return idx + 1;
|
||||
})()} sur {schoolFileTemplates.length}
|
||||
Formulaire {currentTemplateIndex + 1} sur{' '}
|
||||
{schoolFileMasters.length}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Affichage dynamique ou existant */}
|
||||
{isDynamicForm(currentTemplate) ? (
|
||||
{/* Vérifier si le formulaire maître a des données de configuration */}
|
||||
{(currentTemplate.formMasterData?.fields &&
|
||||
currentTemplate.formMasterData.fields.length > 0) ||
|
||||
(currentTemplate.fields && currentTemplate.fields.length > 0) ? (
|
||||
<FormRenderer
|
||||
key={currentTemplate.id}
|
||||
formConfig={{
|
||||
id: currentTemplate.id,
|
||||
title:
|
||||
currentTemplate.formTemplateData?.title ||
|
||||
currentTemplate.formMasterData?.title ||
|
||||
currentTemplate.title ||
|
||||
currentTemplate.name ||
|
||||
'Formulaire',
|
||||
fields:
|
||||
currentTemplate.formTemplateData?.fields ||
|
||||
currentTemplate.formMasterData?.fields ||
|
||||
currentTemplate.fields ||
|
||||
[],
|
||||
submitLabel:
|
||||
currentTemplate.formTemplateData?.submitLabel || 'Valider',
|
||||
currentTemplate.formMasterData?.submitLabel || 'Valider',
|
||||
}}
|
||||
initialValues={
|
||||
formsData[currentTemplate.id] ||
|
||||
existingResponses[currentTemplate.id] ||
|
||||
{}
|
||||
}
|
||||
onFormSubmit={(formData) =>
|
||||
handleFormSubmit(formData, currentTemplate.id)
|
||||
}
|
||||
// Désactive le bouton suivant si le template est validé
|
||||
enable={currentTemplate.isValidated !== true}
|
||||
/>
|
||||
) : (
|
||||
// Formulaire existant (PDF, image, etc.)
|
||||
<div className="flex flex-col items-center gap-6">
|
||||
{/* Cas validé : affichage en iframe */}
|
||||
{currentTemplate.isValidated === true && currentTemplate.file && (
|
||||
<iframe
|
||||
src={`${BASE_URL}${currentTemplate.file}`}
|
||||
title={currentTemplate.name}
|
||||
className="w-full"
|
||||
style={{ height: '600px', border: 'none' }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 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 className="text-center py-8">
|
||||
<FileText className="w-16 h-16 text-gray-300 mx-auto mb-4" />
|
||||
<p className="text-gray-600">
|
||||
Ce formulaire n'est pas encore configuré.
|
||||
</p>
|
||||
<p className="text-sm text-gray-500 mt-2">
|
||||
Contactez l'administration pour plus d'informations.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Message de fin */}
|
||||
{currentTemplateIndex >= schoolFileTemplates.length && (
|
||||
{currentTemplateIndex >= schoolFileMasters.length && (
|
||||
<div className="text-center py-8">
|
||||
<CheckCircle className="w-16 h-16 text-green-600 mx-auto mb-4" />
|
||||
<h3 className="text-lg font-semibold text-green-600 mb-2">
|
||||
|
||||
@ -5,12 +5,15 @@ import DjangoCSRFToken from '@/components/DjangoCSRFToken';
|
||||
import {
|
||||
fetchSchoolFileTemplatesFromRegistrationFiles,
|
||||
fetchParentFileTemplatesFromRegistrationFiles,
|
||||
fetchRegistrationSchoolFileMasters,
|
||||
saveFormResponses,
|
||||
fetchFormResponses,
|
||||
autoSaveRegisterForm,
|
||||
} from '@/app/actions/subscriptionAction';
|
||||
import {
|
||||
downloadTemplate,
|
||||
editRegistrationSchoolFileTemplates,
|
||||
editRegistrationParentFileTemplates,
|
||||
} from '@/app/actions/registerFileGroupAction';
|
||||
import {
|
||||
fetchRegistrationPaymentModes,
|
||||
@ -19,7 +22,7 @@ import {
|
||||
fetchTuitionPaymentPlans,
|
||||
} from '@/app/actions/schoolAction';
|
||||
import { fetchProfiles } from '@/app/actions/authAction';
|
||||
import { FE_PARENTS_HOME_URL } from '@/utils/Url';
|
||||
import { BASE_URL, FE_PARENTS_HOME_URL } from '@/utils/Url';
|
||||
import logger from '@/utils/logger';
|
||||
import FilesToUpload from '@/components/Inscription/FilesToUpload';
|
||||
import DynamicFormsList from '@/components/Inscription/DynamicFormsList';
|
||||
@ -29,6 +32,7 @@ import ResponsableInputFields from '@/components/Inscription/ResponsableInputFie
|
||||
import SiblingInputFields from '@/components/Inscription/SiblingInputFields';
|
||||
import PaymentMethodSelector from '@/components/Inscription/PaymentMethodSelector';
|
||||
import ProgressStep from '@/components/ProgressStep';
|
||||
import { CheckCircle, Hourglass } from 'lucide-react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
/**
|
||||
@ -43,6 +47,7 @@ export default function InscriptionFormShared({
|
||||
studentId,
|
||||
csrfToken,
|
||||
selectedEstablishmentId,
|
||||
apiDocuseal,
|
||||
onSubmit,
|
||||
errors = {}, // Nouvelle prop pour les erreurs
|
||||
enable = true,
|
||||
@ -278,8 +283,8 @@ export default function InscriptionFormShared({
|
||||
});
|
||||
|
||||
// Trouver le template correspondant pour récupérer sa configuration
|
||||
const currentTemplate = schoolFileTemplates.find(
|
||||
(template) => template.id === templateId
|
||||
const currentTemplate = schoolFileMasters.find(
|
||||
(master) => master.id === templateId
|
||||
);
|
||||
if (!currentTemplate) {
|
||||
throw new Error(`Template avec l'ID ${templateId} non trouvé`);
|
||||
@ -289,16 +294,17 @@ export default function InscriptionFormShared({
|
||||
const formTemplateData = {
|
||||
id: currentTemplate.id,
|
||||
title:
|
||||
currentTemplate.formTemplateData?.title ||
|
||||
currentTemplate.formMasterData?.title ||
|
||||
currentTemplate.title ||
|
||||
currentTemplate.name ||
|
||||
'Formulaire',
|
||||
fields: (
|
||||
currentTemplate.formTemplateData?.fields ||
|
||||
currentTemplate.formMasterData?.fields ||
|
||||
currentTemplate.fields ||
|
||||
[]
|
||||
).map((field) => ({
|
||||
...field,
|
||||
// Ajouter la réponse de l'utilisateur selon le type de champ
|
||||
...(field.type === 'checkbox'
|
||||
? { checked: formData[field.id] || false }
|
||||
: {}),
|
||||
@ -309,8 +315,8 @@ export default function InscriptionFormShared({
|
||||
? { value: formData[field.id] || '' }
|
||||
: {}),
|
||||
})),
|
||||
submitLabel: currentTemplate.formTemplateData?.submitLabel || 'Valider',
|
||||
responses: formData,
|
||||
submitLabel: currentTemplate.formMasterData?.submitLabel || 'Valider',
|
||||
responses: formData, // Garder aussi les réponses brutes pour facilité d'accès
|
||||
};
|
||||
|
||||
// Sauvegarder les réponses du formulaire via l'API RegistrationSchoolFileTemplate
|
||||
@ -325,37 +331,18 @@ export default function InscriptionFormShared({
|
||||
);
|
||||
logger.debug("Réponse de l'API:", result);
|
||||
|
||||
// Prendre en compte la réponse du back pour mettre à jour les réponses locales
|
||||
let newResponses = formData;
|
||||
if (
|
||||
result &&
|
||||
result.data &&
|
||||
result.data.formTemplateData &&
|
||||
result.data.formTemplateData.responses &&
|
||||
result.data.formTemplateData.responses.responses
|
||||
) {
|
||||
// Si la structure responses.responses existe, on la prend
|
||||
newResponses = result.data.formTemplateData.responses.responses;
|
||||
} else if (
|
||||
result &&
|
||||
result.data &&
|
||||
result.data.formTemplateData &&
|
||||
result.data.formTemplateData.responses
|
||||
) {
|
||||
// Sinon, on prend responses directement
|
||||
newResponses = result.data.formTemplateData.responses;
|
||||
}
|
||||
|
||||
// Mettre à jour l'état local des réponses
|
||||
setFormResponses((prev) => ({
|
||||
...prev,
|
||||
[templateId]: newResponses,
|
||||
[templateId]: formData,
|
||||
}));
|
||||
|
||||
setSchoolFileTemplates((prevTemplates) => {
|
||||
return prevTemplates.map((template) =>
|
||||
template.id === templateId
|
||||
? { ...template, completed: true, responses: newResponses }
|
||||
: template
|
||||
// Mettre à jour l'état local pour indiquer que le formulaire est complété
|
||||
setSchoolFileMasters((prevMasters) => {
|
||||
return prevMasters.map((master) =>
|
||||
master.id === templateId
|
||||
? { ...master, completed: true, responses: formData }
|
||||
: master
|
||||
);
|
||||
});
|
||||
|
||||
@ -367,6 +354,7 @@ export default function InscriptionFormShared({
|
||||
error: error.message,
|
||||
stack: error.stack,
|
||||
});
|
||||
// Afficher l'erreur à l'utilisateur
|
||||
alert(`Erreur lors de la sauvegarde du formulaire: ${error.message}`);
|
||||
return Promise.reject(error);
|
||||
}
|
||||
@ -382,56 +370,6 @@ export default function InscriptionFormShared({
|
||||
useEffect(() => {
|
||||
fetchSchoolFileTemplatesFromRegistrationFiles(studentId).then((data) => {
|
||||
setSchoolFileTemplates(data);
|
||||
|
||||
// Récupérer les réponses existantes pour chaque template
|
||||
const fetchAllResponses = async () => {
|
||||
const responsesMap = {};
|
||||
for (const template of data) {
|
||||
if (template.id) {
|
||||
try {
|
||||
const templateData = await fetchFormResponses(template.id);
|
||||
if (templateData && templateData.formTemplateData) {
|
||||
if (templateData.formTemplateData.responses) {
|
||||
responsesMap[template.id] = templateData.formTemplateData.responses;
|
||||
} else {
|
||||
// Extraire les réponses depuis les champs
|
||||
const responses = {};
|
||||
if (templateData.formTemplateData.fields) {
|
||||
templateData.formTemplateData.fields.forEach((field) => {
|
||||
if (
|
||||
field.type === 'checkbox' &&
|
||||
field.checked !== undefined
|
||||
) {
|
||||
responses[field.id] = field.checked;
|
||||
} else if (
|
||||
field.type === 'radio' &&
|
||||
field.selected !== undefined
|
||||
) {
|
||||
responses[field.id] = field.selected;
|
||||
} else if (
|
||||
(field.type === 'text' ||
|
||||
field.type === 'textarea' ||
|
||||
field.type === 'email') &&
|
||||
field.value !== undefined
|
||||
) {
|
||||
responses[field.id] = field.value;
|
||||
}
|
||||
});
|
||||
}
|
||||
responsesMap[template.id] = responses;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.debug(
|
||||
`Pas de données existantes pour le template ${template.id}:`,
|
||||
error
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
setFormResponses(responsesMap);
|
||||
};
|
||||
fetchAllResponses();
|
||||
});
|
||||
|
||||
fetchParentFileTemplatesFromRegistrationFiles(studentId).then((data) => {
|
||||
@ -454,6 +392,66 @@ export default function InscriptionFormShared({
|
||||
.catch((error) => logger.error('Error fetching profiles : ', error));
|
||||
|
||||
if (selectedEstablishmentId) {
|
||||
// Fetch data for school file masters
|
||||
fetchRegistrationSchoolFileMasters(selectedEstablishmentId)
|
||||
.then(async (data) => {
|
||||
logger.debug('School file masters fetched:', data);
|
||||
setSchoolFileMasters(data);
|
||||
|
||||
// Récupérer les données existantes de chaque template
|
||||
const responsesMap = {};
|
||||
for (const master of data) {
|
||||
if (master.id) {
|
||||
try {
|
||||
const templateData = await fetchFormResponses(master.id);
|
||||
if (templateData && templateData.formTemplateData) {
|
||||
// Si on a les réponses brutes sauvegardées, les utiliser
|
||||
if (templateData.formTemplateData.responses) {
|
||||
responsesMap[master.id] =
|
||||
templateData.formTemplateData.responses;
|
||||
} else {
|
||||
// Sinon, extraire les réponses depuis les champs
|
||||
const responses = {};
|
||||
if (templateData.formTemplateData.fields) {
|
||||
templateData.formTemplateData.fields.forEach((field) => {
|
||||
if (
|
||||
field.type === 'checkbox' &&
|
||||
field.checked !== undefined
|
||||
) {
|
||||
responses[field.id] = field.checked;
|
||||
} else if (
|
||||
field.type === 'radio' &&
|
||||
field.selected !== undefined
|
||||
) {
|
||||
responses[field.id] = field.selected;
|
||||
} else if (
|
||||
(field.type === 'text' ||
|
||||
field.type === 'textarea' ||
|
||||
field.type === 'email') &&
|
||||
field.value !== undefined
|
||||
) {
|
||||
responses[field.id] = field.value;
|
||||
}
|
||||
});
|
||||
}
|
||||
responsesMap[master.id] = responses;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.debug(
|
||||
`Pas de données existantes pour le template ${master.id}:`,
|
||||
error
|
||||
);
|
||||
// Ce n'est pas critique si un template n'a pas de données
|
||||
}
|
||||
}
|
||||
}
|
||||
setFormResponses(responsesMap);
|
||||
})
|
||||
.catch((error) =>
|
||||
logger.error('Error fetching school file masters:', error)
|
||||
);
|
||||
|
||||
// Fetch data for registration payment modes
|
||||
handleRegistrationPaymentModes();
|
||||
|
||||
@ -466,7 +464,7 @@ export default function InscriptionFormShared({
|
||||
// Fetch data for tuition payment plans
|
||||
handleTuitionnPaymentPlans();
|
||||
}
|
||||
}, [studentId, selectedEstablishmentId]);
|
||||
}, [selectedEstablishmentId]);
|
||||
|
||||
const handleRegistrationPaymentModes = () => {
|
||||
fetchRegistrationPaymentModes(selectedEstablishmentId)
|
||||
@ -516,22 +514,10 @@ export default function InscriptionFormShared({
|
||||
);
|
||||
}
|
||||
|
||||
// Générer le nom du fichier : <nom_template>.<extension d'origine>
|
||||
let extension = '';
|
||||
if (file.name && file.name.lastIndexOf('.') !== -1) {
|
||||
extension = file.name.substring(file.name.lastIndexOf('.'));
|
||||
}
|
||||
// Nettoyer le nom du template pour éviter les caractères spéciaux
|
||||
const cleanName = (selectedFile.name || 'document')
|
||||
.replace(/[^a-zA-Z0-9_\-]/g, '_')
|
||||
.replace(/_+/g, '_')
|
||||
.replace(/^_+|_+$/g, '');
|
||||
const finalFileName = `${cleanName}${extension}`;
|
||||
|
||||
const updateData = new FormData();
|
||||
updateData.append('file', file, finalFileName);
|
||||
updateData.append('file', file);
|
||||
|
||||
return editRegistrationSchoolFileTemplates(
|
||||
return editRegistrationParentFileTemplates(
|
||||
selectedFile.id,
|
||||
updateData,
|
||||
csrfToken
|
||||
@ -542,10 +528,11 @@ export default function InscriptionFormShared({
|
||||
setUploadedFiles((prev) => {
|
||||
const updatedFiles = prev.map((uploadedFile) =>
|
||||
uploadedFile.id === selectedFile.id
|
||||
? { ...uploadedFile, fileName: response.data.file }
|
||||
? { ...uploadedFile, fileName: response.data.file } // Met à jour le fichier téléversé
|
||||
: uploadedFile
|
||||
);
|
||||
|
||||
// Si le fichier n'existe pas encore, l'ajouter
|
||||
if (!updatedFiles.find((file) => file.id === selectedFile.id)) {
|
||||
updatedFiles.push({
|
||||
id: selectedFile.id,
|
||||
@ -565,11 +552,11 @@ export default function InscriptionFormShared({
|
||||
)
|
||||
);
|
||||
|
||||
return response;
|
||||
return response; // Retourner la réponse pour signaler le succès
|
||||
})
|
||||
.catch((error) => {
|
||||
logger.error('Erreur lors de la mise à jour du fichier :', error);
|
||||
throw error;
|
||||
throw error; // Relancer l'erreur pour que l'appelant puisse la capturer
|
||||
});
|
||||
};
|
||||
|
||||
@ -600,7 +587,7 @@ export default function InscriptionFormShared({
|
||||
setUploadedFiles((prev) =>
|
||||
prev.map((uploadedFile) =>
|
||||
uploadedFile.id === templateId
|
||||
? { ...uploadedFile, fileName: null, fileUrl: null }
|
||||
? { ...uploadedFile, fileName: null, fileUrl: null } // Réinitialiser les champs liés au fichier
|
||||
: uploadedFile
|
||||
)
|
||||
);
|
||||
@ -799,12 +786,11 @@ export default function InscriptionFormShared({
|
||||
{/* Page 5 : Formulaires dynamiques d'inscription */}
|
||||
{currentPage === 5 && (
|
||||
<DynamicFormsList
|
||||
schoolFileTemplates={schoolFileTemplates}
|
||||
schoolFileMasters={schoolFileMasters}
|
||||
existingResponses={formResponses}
|
||||
onFormSubmit={handleDynamicFormSubmit}
|
||||
onValidationChange={handleDynamicFormsValidationChange}
|
||||
enable={enable}
|
||||
onFileUpload={handleFileUpload}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@ -100,7 +100,7 @@ export default function ResponsableInputFields({
|
||||
profile_role_data: {
|
||||
establishment: selectedEstablishmentId,
|
||||
role_type: 2,
|
||||
is_active: false,
|
||||
is_active: true,
|
||||
profile_data: {
|
||||
email: '',
|
||||
password: 'Provisoire01!',
|
||||
|
||||
@ -1,6 +1,5 @@
|
||||
'use client';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import Popup from '@/components/Popup';
|
||||
import ToggleSwitch from '@/components/Form/ToggleSwitch';
|
||||
import SelectChoice from '@/components/Form/SelectChoice';
|
||||
import { BASE_URL } from '@/utils/Url';
|
||||
@ -9,55 +8,24 @@ import {
|
||||
fetchParentFileTemplatesFromRegistrationFiles,
|
||||
} from '@/app/actions/subscriptionAction';
|
||||
import logger from '@/utils/logger';
|
||||
import { School, FileText } from 'lucide-react';
|
||||
import { School, CheckCircle, Hourglass, FileText } from 'lucide-react';
|
||||
import SectionHeader from '@/components/SectionHeader';
|
||||
import Button from '@/components/Form/Button';
|
||||
|
||||
export default function ValidateSubscription({
|
||||
studentId,
|
||||
firstName,
|
||||
email,
|
||||
lastName,
|
||||
sepa_file,
|
||||
student_file,
|
||||
onAccept,
|
||||
onRefuse,
|
||||
classes,
|
||||
handleValidateOrRefuseDoc,
|
||||
csrfToken,
|
||||
}) {
|
||||
const [schoolFileTemplates, setSchoolFileTemplates] = useState([]);
|
||||
const [parentFileTemplates, setParentFileTemplates] = useState([]);
|
||||
const [currentTemplateIndex, setCurrentTemplateIndex] = useState(0);
|
||||
const [mergeDocuments, setMergeDocuments] = useState(false);
|
||||
const [isPageValid, setIsPageValid] = useState(false);
|
||||
// Pour la validation/refus des documents
|
||||
const [docStatuses, setDocStatuses] = useState({}); // {index: 'accepted'|'refused'}
|
||||
|
||||
// Met à jour docStatuses selon isValidated des templates récupérés
|
||||
useEffect(() => {
|
||||
// On construit la map index -> status à partir des templates
|
||||
const newStatuses = {};
|
||||
// Fiche élève (pas de validation individuelle)
|
||||
newStatuses[0] = undefined;
|
||||
// School templates
|
||||
schoolFileTemplates.forEach((tpl, i) => {
|
||||
if (typeof tpl.isValidated === 'boolean') {
|
||||
newStatuses[1 + i] = tpl.isValidated ? 'accepted' : 'refused';
|
||||
}
|
||||
});
|
||||
// Parent templates
|
||||
parentFileTemplates.forEach((tpl, i) => {
|
||||
if (typeof tpl.isValidated === 'boolean') {
|
||||
newStatuses[1 + schoolFileTemplates.length + i] = tpl.isValidated ? 'accepted' : 'refused';
|
||||
}
|
||||
});
|
||||
setDocStatuses(s => ({ ...s, ...newStatuses }));
|
||||
}, [schoolFileTemplates, parentFileTemplates]);
|
||||
const [showRefusedPopup, setShowRefusedPopup] = useState(false);
|
||||
|
||||
// Affiche la popup de confirmation finale (tous docs validés et classe sélectionnée)
|
||||
const [showFinalValidationPopup, setShowFinalValidationPopup] = useState(false);
|
||||
|
||||
const [formData, setFormData] = useState({
|
||||
associated_class: null,
|
||||
@ -120,27 +88,14 @@ export default function ValidateSubscription({
|
||||
},
|
||||
status: 5,
|
||||
fusionParam: mergeDocuments,
|
||||
notes: 'Dossier validé',
|
||||
};
|
||||
|
||||
onAccept(data);
|
||||
} else {
|
||||
logger.warn('Aucune classe sélectionnée.');
|
||||
}
|
||||
};
|
||||
|
||||
const handleRefuseDossier = () => {
|
||||
// Message clair avec la liste des documents refusés
|
||||
let notes = 'Dossier non validé pour les raisons suivantes :\n';
|
||||
notes += refusedDocs.map(doc => `- ${doc.name}`).join('\n');
|
||||
const data = {
|
||||
status: 2,
|
||||
notes,
|
||||
};
|
||||
if (onRefuse) {
|
||||
onRefuse(data);
|
||||
}
|
||||
};
|
||||
|
||||
const onChange = (field, value) => {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
@ -170,17 +125,6 @@ export default function ValidateSubscription({
|
||||
]
|
||||
: []),
|
||||
];
|
||||
|
||||
// Récupère la liste des documents refusés (inclut la fiche élève si refusée)
|
||||
const refusedDocs = allTemplates
|
||||
.map((doc, idx) => ({ ...doc, idx }))
|
||||
.filter((doc, idx) => docStatuses[idx] === 'refused');
|
||||
|
||||
// Récupère la liste des documents à cocher (hors fiche élève)
|
||||
const docIndexes = allTemplates.map((_, idx) => idx).filter(idx => idx !== 0);
|
||||
const allChecked = docIndexes.length > 0 && docIndexes.every(idx => docStatuses[idx] === 'accepted' || docStatuses[idx] === 'refused');
|
||||
const allValidated = docIndexes.length > 0 && docIndexes.every(idx => docStatuses[idx] === 'accepted');
|
||||
const hasRefused = docIndexes.some(idx => docStatuses[idx] === 'refused');
|
||||
logger.debug(allTemplates);
|
||||
|
||||
return (
|
||||
@ -218,8 +162,8 @@ export default function ValidateSubscription({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Colonne droite : Liste des documents, Option de fusion, Affectation, Refus */}
|
||||
<div className="w-1/4 flex flex-col flex-1 gap-4 h-full">
|
||||
{/* Colonne droite : Liste des documents, Option de fusion et Affectation */}
|
||||
<div className="w-1/4 flex flex-col gap-4">
|
||||
{/* Liste des documents */}
|
||||
<div className="flex-1 bg-gray-50 p-4 rounded-lg shadow-sm border border-gray-200 overflow-y-auto">
|
||||
<h3 className="text-lg font-semibold text-gray-800 mb-4">
|
||||
@ -243,176 +187,60 @@ export default function ValidateSubscription({
|
||||
<FileText className="w-5 h-5 text-green-600" />
|
||||
)}
|
||||
</span>
|
||||
<span className="flex-1">{template.name}</span>
|
||||
{/* 2 boutons : Validé / Refusé (sauf fiche élève) */}
|
||||
{index !== 0 && (
|
||||
<span className="ml-2 flex gap-1">
|
||||
<button
|
||||
type="button"
|
||||
className={`px-2 py-1 rounded-full text-xs font-medium border transition-colors focus:outline-none focus:ring-2 focus:ring-blue-400 flex items-center gap-1
|
||||
${docStatuses[index] === 'accepted' ? 'bg-emerald-500 text-white border-emerald-500' : 'bg-white text-emerald-600 border-emerald-300'}`}
|
||||
aria-pressed={docStatuses[index] === 'accepted'}
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
setDocStatuses(s => ({ ...s, [index]: 'accepted' }));
|
||||
// Appel API pour valider le document
|
||||
if (handleValidateOrRefuseDoc) {
|
||||
let template = null;
|
||||
let type = null;
|
||||
if (index > 0 && index <= schoolFileTemplates.length) {
|
||||
template = schoolFileTemplates[index - 1];
|
||||
type = 'school';
|
||||
} else if (index > schoolFileTemplates.length && index <= schoolFileTemplates.length + parentFileTemplates.length) {
|
||||
template = parentFileTemplates[index - 1 - schoolFileTemplates.length];
|
||||
type = 'parent';
|
||||
}
|
||||
if (template && template.id) {
|
||||
handleValidateOrRefuseDoc({
|
||||
templateId: template.id,
|
||||
type,
|
||||
validated: true,
|
||||
csrfToken,
|
||||
});
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
<span className="text-lg">✓</span> Validé
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`px-2 py-1 rounded-full text-xs font-medium border transition-colors focus:outline-none focus:ring-2 focus:ring-blue-400 flex items-center gap-1
|
||||
${docStatuses[index] === 'refused' ? 'bg-red-500 text-white border-red-500' : 'bg-white text-red-600 border-red-300'}`}
|
||||
aria-pressed={docStatuses[index] === 'refused'}
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
setDocStatuses(s => ({ ...s, [index]: 'refused' }));
|
||||
// Appel API pour refuser le document
|
||||
if (handleValidateOrRefuseDoc) {
|
||||
let template = null;
|
||||
let type = null;
|
||||
if (index > 0 && index <= schoolFileTemplates.length) {
|
||||
template = schoolFileTemplates[index - 1];
|
||||
type = 'school';
|
||||
} else if (index > schoolFileTemplates.length && index <= schoolFileTemplates.length + parentFileTemplates.length) {
|
||||
template = parentFileTemplates[index - 1 - schoolFileTemplates.length];
|
||||
type = 'parent';
|
||||
}
|
||||
if (template && template.id) {
|
||||
handleValidateOrRefuseDoc({
|
||||
templateId: template.id,
|
||||
type,
|
||||
validated: false,
|
||||
csrfToken,
|
||||
});
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
<span className="text-lg">✗</span> Refusé
|
||||
</button>
|
||||
</span>
|
||||
)}
|
||||
{template.name}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Nouvelle section Options de validation : carte unique, sélecteur de classe (ligne 1), toggle fusion (ligne 2 aligné à droite) */}
|
||||
{allChecked && allValidated && (
|
||||
<div className="bg-white p-4 rounded-lg shadow-sm border border-gray-200 flex flex-col gap-4">
|
||||
<div>
|
||||
<SelectChoice
|
||||
name="associated_class"
|
||||
label="Liste des classes"
|
||||
placeHolder="Sélectionner une classe"
|
||||
selected={formData.associated_class || ''}
|
||||
callback={(e) => onChange('associated_class', e.target.value)}
|
||||
choices={classes.map((classe) => ({
|
||||
value: classe.id,
|
||||
label: classe.atmosphere_name,
|
||||
}))}
|
||||
required
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-end items-center mt-2">
|
||||
<ToggleSwitch
|
||||
label="Fusionner les documents"
|
||||
checked={mergeDocuments}
|
||||
onChange={handleToggleMergeDocuments}
|
||||
className="ml-0"
|
||||
/>
|
||||
</div>
|
||||
{/* Option de fusion */}
|
||||
<div className="bg-gray-50 p-4 rounded-lg shadow-sm border border-gray-200">
|
||||
<h3 className="text-lg font-semibold text-gray-800 mb-4">
|
||||
Option de fusion
|
||||
</h3>
|
||||
<div className="flex items-center justify-between">
|
||||
<ToggleSwitch
|
||||
label="Fusionner les documents"
|
||||
checked={mergeDocuments}
|
||||
onChange={handleToggleMergeDocuments}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Boutons Valider/Refuser en bas, centrés */}
|
||||
<div className="mt-auto py-4">
|
||||
<Button
|
||||
text="Soumettre"
|
||||
onClick={e => {
|
||||
e.preventDefault();
|
||||
// 1. Si tous les documents ne sont pas cochés, rien ne se passe (bouton désactivé)
|
||||
// 2. Si tous cochés et au moins un refusé : popup refus
|
||||
if (allChecked && hasRefused) {
|
||||
setShowRefusedPopup(true);
|
||||
return;
|
||||
}
|
||||
// 3. Si tous cochés et tous validés mais pas de classe sélectionnée : bouton désactivé
|
||||
// 4. Si tous cochés, tous validés et classe sélectionnée : popup de validation finale
|
||||
if (allChecked && allValidated && formData.associated_class) {
|
||||
setShowFinalValidationPopup(true);
|
||||
}
|
||||
}}
|
||||
primary
|
||||
className={`w-full h-12 rounded-md shadow-sm focus:outline-none ${
|
||||
!allChecked || (allChecked && allValidated && !formData.associated_class)
|
||||
? 'bg-gray-300 text-gray-700 cursor-not-allowed'
|
||||
: 'bg-emerald-500 text-white hover:bg-emerald-600'
|
||||
}`}
|
||||
disabled={
|
||||
!allChecked || (allChecked && allValidated && !formData.associated_class)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Popup de confirmation si refus */}
|
||||
<Popup
|
||||
isOpen={showRefusedPopup}
|
||||
onCancel={() => setShowRefusedPopup(false)}
|
||||
onConfirm={() => {
|
||||
setShowRefusedPopup(false);
|
||||
handleRefuseDossier();
|
||||
}}
|
||||
message={
|
||||
<span>
|
||||
{`Le dossier d'inscription de ${firstName} ${lastName} va être refusé. Un email sera envoyé au responsable à l'adresse : `}
|
||||
<span className="font-semibold text-blue-700">{email}</span>
|
||||
{' avec la liste des documents non validés :'}
|
||||
<ul className="list-disc ml-6 mt-2">
|
||||
{refusedDocs.map(doc => (
|
||||
<li key={doc.idx}>{doc.name}</li>
|
||||
))}
|
||||
</ul>
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Popup de confirmation finale si tous validés et classe sélectionnée */}
|
||||
<Popup
|
||||
isOpen={showFinalValidationPopup}
|
||||
onCancel={() => setShowFinalValidationPopup(false)}
|
||||
onConfirm={() => {
|
||||
setShowFinalValidationPopup(false);
|
||||
handleAssignClass();
|
||||
}}
|
||||
message={
|
||||
<span>
|
||||
{`Le dossier d'inscription de ${lastName} ${firstName} va être validé et l'élève affecté à la classe sélectionnée.`}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
{/* Section Affectation */}
|
||||
<div className="bg-white p-4 rounded-lg shadow-sm border border-gray-200">
|
||||
<h3 className="text-lg font-semibold text-gray-800 mb-4">
|
||||
Affectation à une classe
|
||||
</h3>
|
||||
<div className="flex flex-col gap-4">
|
||||
<SelectChoice
|
||||
name="associated_class"
|
||||
label="Classe"
|
||||
placeHolder="Sélectionner une classe"
|
||||
selected={formData.associated_class || ''} // La valeur actuelle de la classe associée
|
||||
callback={(e) => onChange('associated_class', e.target.value)} // Met à jour formData
|
||||
choices={classes.map((classe) => ({
|
||||
value: classe.id,
|
||||
label: classe.atmosphere_name,
|
||||
}))} // Liste des classes disponibles
|
||||
required
|
||||
/>
|
||||
<Button
|
||||
text="Valider le dossier d'inscription"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
handleAssignClass();
|
||||
}}
|
||||
primary
|
||||
className={`px-4 py-2 rounded-md shadow-sm focus:outline-none ${
|
||||
!isPageValid
|
||||
? 'bg-gray-300 text-gray-700 cursor-not-allowed'
|
||||
: 'bg-emerald-500 text-white hover:bg-emerald-600'
|
||||
}`}
|
||||
disabled={!isPageValid}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -7,6 +7,7 @@ import CheckBox from '@/components/Form/CheckBox';
|
||||
import Button from '@/components/Form/Button';
|
||||
import { useEstablishment } from '@/context/EstablishmentContext';
|
||||
import {
|
||||
fetchEstablishmentCompetencies,
|
||||
createEstablishmentCompetencies,
|
||||
deleteEstablishmentCompetencies,
|
||||
} from '@/app/actions/schoolAction';
|
||||
@ -43,7 +44,7 @@ export default function CompetenciesList({
|
||||
3: false,
|
||||
4: false,
|
||||
});
|
||||
const { selectedEstablishmentId, profileRole } = useEstablishment();
|
||||
const { selectedEstablishmentId } = useEstablishment();
|
||||
const csrfToken = useCsrfToken();
|
||||
const { showNotification } = useNotification();
|
||||
|
||||
@ -279,19 +280,17 @@ export default function CompetenciesList({
|
||||
</div>
|
||||
{/* Bouton submit centré en bas */}
|
||||
<div className="flex justify-center mb-2 mt-6">
|
||||
{profileRole !== 0 && (
|
||||
<Button
|
||||
text="Sauvegarder"
|
||||
className={`px-6 py-2 rounded-md shadow ${
|
||||
!hasSelection
|
||||
? 'bg-gray-300 text-gray-500 cursor-not-allowed'
|
||||
: 'bg-emerald-500 text-white hover:bg-emerald-600'
|
||||
}`}
|
||||
onClick={handleSubmit}
|
||||
primary
|
||||
disabled={!hasSelection}
|
||||
/>
|
||||
)}
|
||||
<Button
|
||||
text="Sauvegarder"
|
||||
className={`px-6 py-2 rounded-md shadow ${
|
||||
!hasSelection
|
||||
? 'bg-gray-300 text-gray-500 cursor-not-allowed'
|
||||
: 'bg-emerald-500 text-white hover:bg-emerald-600'
|
||||
}`}
|
||||
onClick={handleSubmit}
|
||||
primary
|
||||
disabled={!hasSelection}
|
||||
/>
|
||||
</div>
|
||||
{/* Légende en dessous du bouton, alignée à gauche */}
|
||||
<div className="flex flex-row items-center gap-4 mb-4">
|
||||
|
||||
@ -5,7 +5,6 @@ import React, {
|
||||
useImperativeHandle,
|
||||
} from 'react';
|
||||
import { CheckCircle, Circle } from 'lucide-react';
|
||||
import { useEstablishment } from '@/context/EstablishmentContext';
|
||||
|
||||
const TreeView = forwardRef(function TreeView(
|
||||
{ data, expandAll, onSelectionChange },
|
||||
@ -73,8 +72,6 @@ const TreeView = forwardRef(function TreeView(
|
||||
clearSelection: () => setSelectedCompetencies({}),
|
||||
}));
|
||||
|
||||
const { profileRole } = useEstablishment();
|
||||
|
||||
return (
|
||||
<div>
|
||||
{data.map((domaine) => (
|
||||
@ -115,18 +112,12 @@ const TreeView = forwardRef(function TreeView(
|
||||
? 'text-emerald-600 font-semibold cursor-pointer'
|
||||
: 'text-gray-500 cursor-pointer hover:text-emerald-600'
|
||||
}`}
|
||||
onClick={
|
||||
profileRole !== 0
|
||||
? () => handleCompetenceClick(competence)
|
||||
: undefined
|
||||
}
|
||||
onClick={() => handleCompetenceClick(competence)}
|
||||
style={{
|
||||
cursor:
|
||||
competence.state === 'required'
|
||||
? 'default'
|
||||
: profileRole !== 0
|
||||
? 'pointer'
|
||||
: 'default',
|
||||
: 'pointer',
|
||||
userSelect: 'none',
|
||||
}}
|
||||
>
|
||||
|
||||
@ -130,7 +130,9 @@ const ClassesSection = ({
|
||||
const [removePopupVisible, setRemovePopupVisible] = useState(false);
|
||||
const [removePopupMessage, setRemovePopupMessage] = useState('');
|
||||
const [removePopupOnConfirm, setRemovePopupOnConfirm] = useState(() => {});
|
||||
const { selectedEstablishmentId, profileRole } = useEstablishment();
|
||||
const [detailsModalVisible, setDetailsModalVisible] = useState(false);
|
||||
const [selectedClass, setSelectedClass] = useState(null);
|
||||
const { selectedEstablishmentId } = useEstablishment();
|
||||
const { addSchedule, reloadPlanning, reloadEvents } = usePlanning();
|
||||
const { getNiveauxLabels, allNiveaux } = useClasses();
|
||||
const router = useRouter();
|
||||
@ -447,25 +449,6 @@ const ClassesSection = ({
|
||||
case 'MISE A JOUR':
|
||||
return classe.updated_date_formatted;
|
||||
case 'ACTIONS':
|
||||
// Affichage des actions en mode affichage (hors édition/création)
|
||||
if (profileRole === 0) {
|
||||
// Si professeur, uniquement le bouton ZoomIn
|
||||
return (
|
||||
<div className="flex justify-center space-x-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const url = `${FE_ADMIN_STRUCTURE_SCHOOLCLASS_MANAGEMENT_URL}?schoolClassId=${classe.id}`;
|
||||
router.push(`${url}`);
|
||||
}}
|
||||
className="text-gray-500 hover:text-gray-700"
|
||||
>
|
||||
<ZoomIn className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
// Sinon, toutes les actions (admin)
|
||||
return (
|
||||
<div className="flex justify-center space-x-2">
|
||||
<button
|
||||
@ -551,7 +534,7 @@ const ClassesSection = ({
|
||||
icon={Users}
|
||||
title="Liste des classes"
|
||||
description="Gérez les classes de votre école"
|
||||
button={profileRole !== 0}
|
||||
button={true}
|
||||
onClick={handleAddClass}
|
||||
/>
|
||||
<Table
|
||||
|
||||
@ -29,7 +29,7 @@ const SpecialitiesSection = ({
|
||||
const [removePopupMessage, setRemovePopupMessage] = useState('');
|
||||
const [removePopupOnConfirm, setRemovePopupOnConfirm] = useState(() => {});
|
||||
|
||||
const { selectedEstablishmentId, profileRole } = useEstablishment();
|
||||
const { selectedEstablishmentId } = useEstablishment();
|
||||
|
||||
// Récupération des messages d'erreur
|
||||
const getError = (field) => {
|
||||
@ -239,7 +239,7 @@ const SpecialitiesSection = ({
|
||||
const columns = [
|
||||
{ name: 'LIBELLE', label: 'Libellé' },
|
||||
{ name: 'MISE A JOUR', label: 'Date mise à jour' },
|
||||
...(profileRole !== 0 ? [{ name: 'ACTIONS', label: 'Actions' }] : []),
|
||||
{ name: 'ACTIONS', label: 'Actions' },
|
||||
];
|
||||
|
||||
return (
|
||||
@ -249,7 +249,7 @@ const SpecialitiesSection = ({
|
||||
icon={BookOpen}
|
||||
title="Liste des spécialités"
|
||||
description="Gérez les spécialités de votre école"
|
||||
button={profileRole !== 0}
|
||||
button={true}
|
||||
onClick={handleAddSpeciality}
|
||||
/>
|
||||
<Table
|
||||
|
||||
@ -3,7 +3,8 @@ import { Edit3, Trash2, GraduationCap, Check, X, Hand } from 'lucide-react';
|
||||
import Table from '@/components/Table';
|
||||
import Popup from '@/components/Popup';
|
||||
import ToggleSwitch from '@/components/Form/ToggleSwitch';
|
||||
import { DndProvider, useDrop } from 'react-dnd';
|
||||
import { useCsrfToken } from '@/context/CsrfContext';
|
||||
import { DndProvider, useDrag, useDrop } from 'react-dnd';
|
||||
import { HTML5Backend } from 'react-dnd-html5-backend';
|
||||
import InputText from '@/components/Form/InputText';
|
||||
import SpecialityItem from '@/components/Structure/Configuration/SpecialityItem';
|
||||
@ -127,6 +128,7 @@ const TeachersSection = ({
|
||||
handleEdit,
|
||||
handleDelete,
|
||||
}) => {
|
||||
const csrfToken = useCsrfToken();
|
||||
const [editingTeacher, setEditingTeacher] = useState(null);
|
||||
const [newTeacher, setNewTeacher] = useState(null);
|
||||
const [formData, setFormData] = useState({});
|
||||
@ -138,46 +140,40 @@ const TeachersSection = ({
|
||||
const [removePopupMessage, setRemovePopupMessage] = useState('');
|
||||
const [removePopupOnConfirm, setRemovePopupOnConfirm] = useState(() => {});
|
||||
|
||||
const { selectedEstablishmentId, profileRole } = useEstablishment();
|
||||
const [confirmPopupVisible, setConfirmPopupVisible] = useState(false);
|
||||
const [confirmPopupMessage, setConfirmPopupMessage] = useState('');
|
||||
const [confirmPopupOnConfirm, setConfirmPopupOnConfirm] = useState(() => {});
|
||||
|
||||
// --- UTILS ---
|
||||
|
||||
// Retourne le profil existant pour un email
|
||||
const getUsedProfileForEmail = (email) => {
|
||||
// On cherche tous les profils dont l'email correspond
|
||||
const matchingProfiles = profiles.filter(p => p.email === email);
|
||||
|
||||
// On retourne le premier profil correspondant (ou undefined)
|
||||
const result = matchingProfiles.length > 0 ? matchingProfiles[0] : undefined;
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
// Met à jour le formData et newTeacher si besoin
|
||||
const updateFormData = (data) => {
|
||||
setFormData(prev => ({ ...prev, ...data }));
|
||||
if (newTeacher) setNewTeacher(prev => ({ ...prev, ...data }));
|
||||
};
|
||||
|
||||
// Récupération des messages d'erreur pour un champ donné
|
||||
const getError = (field) => {
|
||||
return localErrors?.[field]?.[0];
|
||||
};
|
||||
|
||||
// --- HANDLERS ---
|
||||
const { selectedEstablishmentId } = useEstablishment();
|
||||
|
||||
const handleEmailChange = (e) => {
|
||||
const email = e.target.value;
|
||||
const existingProfile = getUsedProfileForEmail(email);
|
||||
|
||||
if (existingProfile) {
|
||||
logger.info(`Adresse email déjà utilisée pour le profil ${existingProfile.id}`);
|
||||
}
|
||||
// Vérifier si l'email correspond à un profil existant
|
||||
const existingProfile = profiles.find((profile) => profile.email === email);
|
||||
|
||||
updateFormData({
|
||||
setFormData((prevData) => ({
|
||||
...prevData,
|
||||
associated_profile_email: email,
|
||||
existingProfileId: existingProfile ? existingProfile.id : null,
|
||||
});
|
||||
}));
|
||||
|
||||
if (newTeacher) {
|
||||
setNewTeacher((prevData) => ({
|
||||
...prevData,
|
||||
associated_profile_email: email,
|
||||
existingProfileId: existingProfile ? existingProfile.id : null,
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancelConfirmation = () => {
|
||||
setConfirmPopupVisible(false);
|
||||
};
|
||||
|
||||
// Récupération des messages d'erreur
|
||||
const getError = (field) => {
|
||||
return localErrors?.[field]?.[0];
|
||||
};
|
||||
|
||||
const handleAddTeacher = () => {
|
||||
@ -199,15 +195,15 @@ const TeachersSection = ({
|
||||
};
|
||||
|
||||
const handleRemoveTeacher = (id) => {
|
||||
logger.debug('[DELETE] Suppression teacher id:', id);
|
||||
return handleDelete(id)
|
||||
.then(() => {
|
||||
setTeachers(prevTeachers =>
|
||||
prevTeachers.filter(teacher => teacher.id !== id)
|
||||
setTeachers((prevTeachers) =>
|
||||
prevTeachers.filter((teacher) => teacher.id !== id)
|
||||
);
|
||||
logger.debug('[DELETE] Teacher supprimé:', id);
|
||||
})
|
||||
.catch(logger.error);
|
||||
.catch((error) => {
|
||||
logger.error(error);
|
||||
});
|
||||
};
|
||||
|
||||
const handleSaveNewTeacher = () => {
|
||||
@ -238,29 +234,16 @@ const TeachersSection = ({
|
||||
|
||||
handleCreate(data)
|
||||
.then((createdTeacher) => {
|
||||
// Recherche du profile associé dans profiles
|
||||
let newProfileId = undefined;
|
||||
let foundProfile = undefined;
|
||||
if (
|
||||
createdTeacher &&
|
||||
createdTeacher.profile_role &&
|
||||
createdTeacher.profile
|
||||
) {
|
||||
newProfileId = createdTeacher.profile;
|
||||
foundProfile = profiles.find(p => p.id === newProfileId);
|
||||
}
|
||||
|
||||
setTeachers([createdTeacher, ...teachers]);
|
||||
setNewTeacher(null);
|
||||
setLocalErrors({});
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
existingProfileId: newProfileId,
|
||||
}));
|
||||
})
|
||||
.catch((error) => {
|
||||
logger.error('Error:', error.message);
|
||||
if (error.details) setLocalErrors(error.details);
|
||||
if (error.details) {
|
||||
logger.error('Form errors:', error.details);
|
||||
setLocalErrors(error.details);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
setPopupMessage('Tous les champs doivent être remplis et valides');
|
||||
@ -269,24 +252,51 @@ const TeachersSection = ({
|
||||
};
|
||||
|
||||
const handleUpdateTeacher = (id, updatedData) => {
|
||||
// Récupérer l'enseignant actuel à partir de la liste des enseignants
|
||||
const currentTeacher = teachers.find((teacher) => teacher.id === id);
|
||||
|
||||
// Vérifier si l'email correspond à un profil existant
|
||||
const existingProfile = profiles.find(
|
||||
(profile) => profile.email === currentTeacher.associated_profile_email
|
||||
);
|
||||
|
||||
// Vérifier si l'email a été modifié
|
||||
const isEmailModified = currentTeacher
|
||||
? currentTeacher.associated_profile_email !==
|
||||
updatedData.associated_profile_email
|
||||
: true;
|
||||
|
||||
// Mettre à jour existingProfileId en fonction de l'email
|
||||
updatedData.existingProfileId = existingProfile ? existingProfile.id : null;
|
||||
|
||||
if (
|
||||
updatedData.last_name &&
|
||||
updatedData.first_name &&
|
||||
updatedData.associated_profile_email
|
||||
) {
|
||||
const profileRoleData = {
|
||||
id: updatedData.profile_role,
|
||||
establishment: selectedEstablishmentId,
|
||||
role_type: updatedData.role_type || 0,
|
||||
profile: updatedData.existingProfileId,
|
||||
};
|
||||
|
||||
handleEdit(id, {
|
||||
const data = {
|
||||
last_name: updatedData.last_name,
|
||||
first_name: updatedData.first_name,
|
||||
profile_role_data: profileRoleData,
|
||||
profile_role_data: {
|
||||
id: updatedData.profile_role,
|
||||
establishment: selectedEstablishmentId,
|
||||
role_type: updatedData.role_type || 0,
|
||||
is_active: true,
|
||||
...(isEmailModified
|
||||
? {
|
||||
profile_data: {
|
||||
id: updatedData.existingProfileId,
|
||||
email: updatedData.associated_profile_email,
|
||||
username: updatedData.associated_profile_email,
|
||||
password: 'Provisoire01!',
|
||||
},
|
||||
}
|
||||
: { profile: updatedData.existingProfileId }),
|
||||
},
|
||||
specialities: updatedData.specialities || [],
|
||||
})
|
||||
};
|
||||
|
||||
handleEdit(id, data)
|
||||
.then((updatedTeacher) => {
|
||||
setTeachers((prevTeachers) =>
|
||||
prevTeachers.map((teacher) =>
|
||||
@ -298,7 +308,10 @@ const TeachersSection = ({
|
||||
})
|
||||
.catch((error) => {
|
||||
logger.error('Error:', error.message);
|
||||
if (error.details) setLocalErrors(error.details);
|
||||
if (error.details) {
|
||||
logger.error('Form errors:', error.details);
|
||||
setLocalErrors(error.details);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
setPopupMessage('Tous les champs doivent être remplis et valides');
|
||||
@ -308,12 +321,45 @@ const TeachersSection = ({
|
||||
|
||||
const handleChange = (e) => {
|
||||
const { name, value, type, checked } = e.target;
|
||||
let parsedValue = type === 'checkbox' ? (checked ? 1 : 0) : value;
|
||||
updateFormData({ [name]: parsedValue });
|
||||
let parsedValue = value;
|
||||
|
||||
if (type === 'checkbox') {
|
||||
parsedValue = checked ? 1 : 0;
|
||||
}
|
||||
|
||||
if (editingTeacher) {
|
||||
setFormData((prevData) => ({
|
||||
...prevData,
|
||||
[name]: parsedValue,
|
||||
}));
|
||||
} else if (newTeacher) {
|
||||
setNewTeacher((prevData) => ({
|
||||
...prevData,
|
||||
[name]: parsedValue,
|
||||
}));
|
||||
setFormData((prevData) => ({
|
||||
...prevData,
|
||||
[name]: parsedValue,
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
const handleSpecialitiesChange = (selectedSpecialities) => {
|
||||
updateFormData({ specialities: selectedSpecialities });
|
||||
if (editingTeacher) {
|
||||
setFormData((prevData) => ({
|
||||
...prevData,
|
||||
specialities: selectedSpecialities,
|
||||
}));
|
||||
} else if (newTeacher) {
|
||||
setNewTeacher((prevData) => ({
|
||||
...prevData,
|
||||
specialities: selectedSpecialities,
|
||||
}));
|
||||
setFormData((prevData) => ({
|
||||
...prevData,
|
||||
specialities: selectedSpecialities,
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
const handleEditTeacher = (teacher) => {
|
||||
@ -360,7 +406,6 @@ const TeachersSection = ({
|
||||
onChange={handleEmailChange}
|
||||
placeholder="Adresse email de l'enseignant"
|
||||
errorMsg={getError('email')}
|
||||
enable={!isEditing}
|
||||
/>
|
||||
);
|
||||
case 'SPECIALITES':
|
||||
@ -518,7 +563,7 @@ const TeachersSection = ({
|
||||
{ name: 'SPECIALITES', label: 'Spécialités' },
|
||||
{ name: 'ADMINISTRATEUR', label: 'Profil' },
|
||||
{ name: 'MISE A JOUR', label: 'Mise à jour' },
|
||||
...(profileRole !== 0 ? [{ name: 'ACTIONS', label: 'Actions' }] : []),
|
||||
{ name: 'ACTIONS', label: 'Actions' },
|
||||
];
|
||||
|
||||
return (
|
||||
@ -528,7 +573,7 @@ const TeachersSection = ({
|
||||
icon={GraduationCap}
|
||||
title="Liste des enseignants.es"
|
||||
description="Gérez les enseignants.es de votre école"
|
||||
button={profileRole !== 0}
|
||||
button={true}
|
||||
onClick={handleAddTeacher}
|
||||
/>
|
||||
<Table
|
||||
|
||||
209
Front-End/src/components/Structure/Files/CreateDocumentModal.js
Normal file
209
Front-End/src/components/Structure/Files/CreateDocumentModal.js
Normal file
@ -0,0 +1,209 @@
|
||||
import React, { useState } from 'react';
|
||||
import Modal from '@/components/Modal';
|
||||
import { FolderPlus, FileText, FilePlus2, ArrowLeft, Settings2, Upload as UploadIcon } from 'lucide-react';
|
||||
import FormTemplateBuilder from '@/components/Form/FormTemplateBuilder';
|
||||
import FileUpload from '@/components/Form/FileUpload';
|
||||
|
||||
export default function CreateDocumentModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
onCreateGroup,
|
||||
onCreateParentFile,
|
||||
onCreateSchoolFileMaster,
|
||||
groups = [],
|
||||
}) {
|
||||
const [step, setStep] = useState('main'); // main | choose_form | form_builder | file_upload
|
||||
const [fileName, setFileName] = useState('');
|
||||
const [selectedGroupsFileUpload, setSelectedGroupsFileUpload] = useState([]);
|
||||
const [uploadedFile, setUploadedFile] = useState(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!isOpen) {
|
||||
setStep('main');
|
||||
setFileName('');
|
||||
setSelectedGroupsFileUpload([]);
|
||||
setUploadedFile(null);
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
// Handler pour chaque type
|
||||
const handleSelect = (type) => {
|
||||
if (type === 'groupe') {
|
||||
setStep('main');
|
||||
onCreateGroup();
|
||||
onClose();
|
||||
}
|
||||
if (type === 'formulaire') {
|
||||
setStep('choose_form');
|
||||
}
|
||||
if (type === 'parent') {
|
||||
setStep('main');
|
||||
onCreateParentFile();
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
// Retour au menu principal
|
||||
const handleBack = () => setStep('main');
|
||||
|
||||
// Submit pour formulaire existant
|
||||
const handleFileUploadSubmit = (e) => {
|
||||
e.preventDefault();
|
||||
if (!fileName || selectedGroupsFileUpload.length === 0 || !uploadedFile) return;
|
||||
onCreateSchoolFileMaster({
|
||||
name: fileName,
|
||||
group_ids: selectedGroupsFileUpload,
|
||||
file: uploadedFile,
|
||||
});
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
setIsOpen={onClose}
|
||||
title="Créer un document"
|
||||
modalClassName="w-full max-w-md"
|
||||
>
|
||||
{step === 'main' && (
|
||||
<div className="flex flex-col gap-6 py-4">
|
||||
<button
|
||||
className="flex items-center gap-3 px-4 py-3 rounded-lg bg-blue-50 hover:bg-blue-100 border border-blue-200 transition"
|
||||
onClick={() => handleSelect('groupe')}
|
||||
>
|
||||
<FolderPlus className="w-6 h-6 text-blue-600" />
|
||||
<span className="font-semibold text-blue-800">Dossier d&aposinscription</span>
|
||||
</button>
|
||||
<button
|
||||
className="flex items-center gap-3 px-4 py-3 rounded-lg bg-emerald-50 hover:bg-emerald-100 border border-emerald-200 transition"
|
||||
onClick={() => handleSelect('formulaire')}
|
||||
>
|
||||
<FileText className="w-6 h-6 text-emerald-600" />
|
||||
<span className="font-semibold text-emerald-800">Formulaire scolaire</span>
|
||||
</button>
|
||||
<button
|
||||
className="flex items-center gap-3 px-4 py-3 rounded-lg bg-orange-50 hover:bg-orange-100 border border-orange-200 transition"
|
||||
onClick={() => handleSelect('parent')}
|
||||
>
|
||||
<FilePlus2 className="w-6 h-6 text-orange-500" />
|
||||
<span className="font-semibold text-orange-700">Pièce à fournir</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{step === 'choose_form' && (
|
||||
<div className="flex flex-col gap-4 py-4">
|
||||
<button
|
||||
className="flex items-center gap-3 px-4 py-3 rounded-lg bg-emerald-100 hover:bg-emerald-200 border border-emerald-300 transition"
|
||||
onClick={() => setStep('form_builder')}
|
||||
>
|
||||
<Settings2 className="w-6 h-6 text-emerald-700" />
|
||||
<span className="font-semibold text-emerald-900">Formulaire personnalisé</span>
|
||||
</button>
|
||||
<button
|
||||
className="flex items-center gap-3 px-4 py-3 rounded-lg bg-gray-100 hover:bg-gray-200 border border-gray-300 transition"
|
||||
onClick={() => setStep('file_upload')}
|
||||
>
|
||||
<UploadIcon className="w-6 h-6 text-gray-700" />
|
||||
<span className="font-semibold text-gray-900">Importer un formulaire existant</span>
|
||||
</button>
|
||||
<button
|
||||
className="flex items-center gap-2 text-gray-600 hover:text-gray-900 mt-2"
|
||||
onClick={handleBack}
|
||||
>
|
||||
<ArrowLeft className="w-5 h-5" />
|
||||
<span>Retour</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{step === 'form_builder' && (
|
||||
<div>
|
||||
<button
|
||||
className="flex items-center gap-2 text-gray-600 hover:text-gray-900 mb-2"
|
||||
onClick={handleBack}
|
||||
>
|
||||
<ArrowLeft className="w-5 h-5" />
|
||||
<span>Retour</span>
|
||||
</button>
|
||||
<FormTemplateBuilder
|
||||
onSave={(data) => {
|
||||
onCreateSchoolFileMaster(data);
|
||||
onClose();
|
||||
}}
|
||||
groups={groups}
|
||||
isEditing={false}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{step === 'file_upload' && (
|
||||
<div>
|
||||
<button
|
||||
className="flex items-center gap-2 text-gray-600 hover:text-gray-900 mb-2"
|
||||
onClick={handleBack}
|
||||
>
|
||||
<ArrowLeft className="w-5 h-5" />
|
||||
<span>Retour</span>
|
||||
</button>
|
||||
<form className="flex flex-col gap-4" onSubmit={handleFileUploadSubmit}>
|
||||
<input
|
||||
type="text"
|
||||
className="border rounded px-3 py-2"
|
||||
placeholder="Nom du formulaire"
|
||||
value={fileName}
|
||||
onChange={e => setFileName(e.target.value)}
|
||||
required
|
||||
/>
|
||||
{/* Sélecteur de groupes à cocher */}
|
||||
<div className="space-y-2">
|
||||
<label className="block text-sm font-medium text-gray-700">
|
||||
Groupes d'inscription <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<div className="space-y-2 max-h-32 overflow-y-auto border rounded-md p-2">
|
||||
{groups && groups.length > 0 ? (
|
||||
groups.map((group) => (
|
||||
<label key={group.id} className="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedGroupsFileUpload.includes(group.id)}
|
||||
onChange={(e) => {
|
||||
if (e.target.checked) {
|
||||
setSelectedGroupsFileUpload([
|
||||
...selectedGroupsFileUpload,
|
||||
group.id,
|
||||
]);
|
||||
} else {
|
||||
setSelectedGroupsFileUpload(
|
||||
selectedGroupsFileUpload.filter((id) => id !== group.id)
|
||||
);
|
||||
}
|
||||
}}
|
||||
className="mr-2 text-blue-600"
|
||||
/>
|
||||
<span className="text-sm">{group.name}</span>
|
||||
</label>
|
||||
))
|
||||
) : (
|
||||
<p className="text-gray-500 text-sm">
|
||||
Aucun groupe disponible
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<FileUpload
|
||||
selectionMessage="Sélectionnez le fichier du formulaire"
|
||||
onFileSelect={setUploadedFile}
|
||||
required
|
||||
enable
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
className="bg-emerald-600 text-white px-4 py-2 rounded font-bold mt-2"
|
||||
disabled={!fileName || selectedGroupsFileUpload.length === 0 || !uploadedFile}
|
||||
>
|
||||
Créer le formulaire
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@ -1,4 +1,4 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import {
|
||||
Edit,
|
||||
Trash2,
|
||||
@ -6,6 +6,8 @@ import {
|
||||
Star,
|
||||
ChevronDown,
|
||||
Plus,
|
||||
Archive,
|
||||
Eye
|
||||
} from 'lucide-react';
|
||||
import Modal from '@/components/Modal';
|
||||
import FormTemplateBuilder from '@/components/Form/FormTemplateBuilder';
|
||||
@ -29,9 +31,11 @@ import {
|
||||
} from '@/app/actions/registerFileGroupAction';
|
||||
import RegistrationFileGroupForm from '@/components/Structure/Files/RegistrationFileGroupForm';
|
||||
import logger from '@/utils/logger';
|
||||
import ParentFiles from './ParentFiles';
|
||||
import Popup from '@/components/Popup';
|
||||
import Loader from '@/components/Loader';
|
||||
import { useNotification } from '@/context/NotificationContext';
|
||||
import CreateDocumentModal from '@/components/Structure/Files/CreateDocumentModal';
|
||||
import FileUpload from '@/components/Form/FileUpload';
|
||||
import SectionTitle from '@/components/SectionTitle';
|
||||
import DropdownMenu from '@/components/DropdownMenu';
|
||||
@ -192,8 +196,8 @@ function SimpleList({
|
||||
export default function FilesGroupsManagement({
|
||||
csrfToken,
|
||||
selectedEstablishmentId,
|
||||
profileRole
|
||||
}) {
|
||||
const [showFilePreview, setShowFilePreview] = useState(false);
|
||||
const [schoolFileMasters, setSchoolFileMasters] = useState([]);
|
||||
const [parentFiles, setParentFileMasters] = useState([]);
|
||||
const [groups, setGroups] = useState([]);
|
||||
@ -211,6 +215,7 @@ export default function FilesGroupsManagement({
|
||||
const [selectedGroupId, setSelectedGroupId] = useState(null);
|
||||
const [showHelp, setShowHelp] = useState(false);
|
||||
const { showNotification } = useNotification();
|
||||
const [isFileUploadOpen, setIsFileUploadOpen] = useState(false);
|
||||
const [isParentFileModalOpen, setIsParentFileModalOpen] = useState(false);
|
||||
const [editingParentFile, setEditingParentFile] = useState(null);
|
||||
|
||||
@ -818,15 +823,13 @@ export default function FilesGroupsManagement({
|
||||
<div className="flex items-center mb-4">
|
||||
<SectionTitle title="Liste des dossiers d'inscriptions" />
|
||||
<div className="flex-1" />
|
||||
{profileRole !== 0 && (
|
||||
<button
|
||||
className="flex items-center justify-center bg-emerald-500 hover:bg-emerald-600 text-white px-3 py-2 rounded-lg shadow transition text-base font-semibold"
|
||||
onClick={() => setIsGroupModalOpen(true)}
|
||||
title="Créer un nouveau dossier"
|
||||
>
|
||||
<Plus className="w-5 h-5" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className="flex items-center justify-center bg-emerald-500 hover:bg-emerald-600 text-white px-3 py-2 rounded-lg shadow transition text-base font-semibold"
|
||||
onClick={() => setIsGroupModalOpen(true)}
|
||||
title="Créer un nouveau dossier"
|
||||
>
|
||||
<Plus className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
<SimpleList
|
||||
items={groups}
|
||||
@ -866,54 +869,52 @@ export default function FilesGroupsManagement({
|
||||
<div className="flex flex-col w-2/3">
|
||||
<div className="flex items-center mb-4">
|
||||
<SectionTitle title="Liste des documents" />
|
||||
<div className="flex-1" />
|
||||
{profileRole !== 0 && (
|
||||
<DropdownMenu
|
||||
buttonContent={
|
||||
<div className="flex-1" />
|
||||
<DropdownMenu
|
||||
buttonContent={
|
||||
<span className="flex items-center">
|
||||
<Plus className="w-5 h-5" />
|
||||
<ChevronDown className="w-4 h-4 ml-1" />
|
||||
</span>
|
||||
}
|
||||
items={[
|
||||
{
|
||||
type: 'item',
|
||||
label: (
|
||||
<span className="flex items-center">
|
||||
<Plus className="w-5 h-5" />
|
||||
<ChevronDown className="w-4 h-4 ml-1" />
|
||||
<Star className="w-5 h-5 mr-2 text-yellow-600" />
|
||||
Formulaire personnalisé
|
||||
</span>
|
||||
}
|
||||
items={[
|
||||
{
|
||||
type: 'item',
|
||||
label: (
|
||||
<span className="flex items-center">
|
||||
<Star className="w-5 h-5 mr-2 text-yellow-600" />
|
||||
Formulaire personnalisé
|
||||
</span>
|
||||
),
|
||||
onClick: () => handleDocDropdownSelect('formulaire'),
|
||||
},
|
||||
{
|
||||
type: 'item',
|
||||
label: (
|
||||
<span className="flex items-center">
|
||||
<FileText className="w-5 h-5 mr-2 text-gray-600" />
|
||||
Formulaire existant
|
||||
</span>
|
||||
),
|
||||
onClick: () => handleDocDropdownSelect('formulaire_existant'),
|
||||
},
|
||||
{
|
||||
type: 'item',
|
||||
label: (
|
||||
<span className="flex items-center">
|
||||
<Plus className="w-5 h-5 mr-2 text-orange-500" />
|
||||
Pièce à fournir
|
||||
</span>
|
||||
),
|
||||
onClick: () => handleDocDropdownSelect('parent'),
|
||||
},
|
||||
]}
|
||||
buttonClassName="flex items-center justify-center bg-emerald-500 hover:bg-emerald-600 text-white px-3 py-2 rounded-lg shadow transition text-base font-semibold"
|
||||
menuClassName="absolute right-0 mt-2 w-56 bg-white border border-gray-200 rounded shadow-lg z-20"
|
||||
dropdownOpen={isDocDropdownOpen}
|
||||
setDropdownOpen={setIsDocDropdownOpen}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
onClick: () => handleDocDropdownSelect('formulaire'),
|
||||
},
|
||||
{
|
||||
type: 'item',
|
||||
label: (
|
||||
<span className="flex items-center">
|
||||
<FileText className="w-5 h-5 mr-2 text-gray-600" />
|
||||
Formulaire existant
|
||||
</span>
|
||||
),
|
||||
onClick: () => handleDocDropdownSelect('formulaire_existant'),
|
||||
},
|
||||
{
|
||||
type: 'item',
|
||||
label: (
|
||||
<span className="flex items-center">
|
||||
<Plus className="w-5 h-5 mr-2 text-orange-500" />
|
||||
Pièce à fournir
|
||||
</span>
|
||||
),
|
||||
onClick: () => handleDocDropdownSelect('parent'),
|
||||
},
|
||||
]}
|
||||
buttonClassName="flex items-center justify-center bg-emerald-500 hover:bg-emerald-600 text-white px-3 py-2 rounded-lg shadow transition text-base font-semibold"
|
||||
menuClassName="absolute right-0 mt-2 w-56 bg-white border border-gray-200 rounded shadow-lg z-20"
|
||||
dropdownOpen={isDocDropdownOpen}
|
||||
setDropdownOpen={setIsDocDropdownOpen}
|
||||
/>
|
||||
</div>
|
||||
{!selectedGroupId ? (
|
||||
<div className="flex items-center justify-center h-40 text-gray-400 text-lg italic border border-gray-200 rounded bg-white">
|
||||
Sélectionner un dossier d'inscription
|
||||
|
||||
@ -1,23 +0,0 @@
|
||||
import React from 'react';
|
||||
|
||||
/**
|
||||
* Textarea composant réutilisable
|
||||
* @param {string} value - Valeur du textarea
|
||||
* @param {function} onChange - Fonction appelée lors d'un changement
|
||||
* @param {string} placeholder - Texte d'exemple
|
||||
* @param {number} rows - Nombre de lignes
|
||||
* @param {string} className - Classes CSS additionnelles
|
||||
* @param {object} props - Props additionnels
|
||||
*/
|
||||
const Textarea = ({ value, onChange, placeholder = '', rows = 3, className = '', ...props }) => (
|
||||
<textarea
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
placeholder={placeholder}
|
||||
rows={rows}
|
||||
className={`border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-emerald-500 resize-y ${className}`}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export default Textarea;
|
||||
85
JenkinsFile
85
JenkinsFile
@ -1,85 +0,0 @@
|
||||
pipeline {
|
||||
agent any
|
||||
|
||||
environment {
|
||||
DOCKER_REGISTRY = 'git.v0id.ovh'
|
||||
ORGANIZATION = "n3wt-innov"
|
||||
APP_NAME = 'n3wt-school'
|
||||
}
|
||||
|
||||
// Déclencher uniquement sur les tags
|
||||
triggers {
|
||||
issueCommentTrigger('.*deploy.*')
|
||||
}
|
||||
|
||||
stages {
|
||||
stage('Vérification du Tag') {
|
||||
when {
|
||||
expression { env.TAG_NAME != null }
|
||||
}
|
||||
steps {
|
||||
script {
|
||||
// Extraire la version du tag
|
||||
env.VERSION = env.TAG_NAME
|
||||
echo "Version détectée: ${env.VERSION}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
stage('Build Docker Images') {
|
||||
when {
|
||||
expression { env.TAG_NAME != null }
|
||||
}
|
||||
steps {
|
||||
script {
|
||||
// Donner les permissions d'exécution au script
|
||||
sh 'chmod +x ./ci-scripts/makeDocker.sh'
|
||||
|
||||
// Exécuter le script avec la version
|
||||
sh """
|
||||
./ci-scripts/makeDocker.sh ${env.VERSION}
|
||||
"""
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
stage('Push sur Registry') {
|
||||
when {
|
||||
expression { env.TAG_NAME != null }
|
||||
}
|
||||
steps {
|
||||
script {
|
||||
withCredentials([usernamePassword(
|
||||
credentialsId: 'docker-registry-credentials',
|
||||
usernameVariable: 'REGISTRY_USER',
|
||||
passwordVariable: 'REGISTRY_PASS'
|
||||
)]) {
|
||||
// Login au registry
|
||||
sh "docker login ${DOCKER_REGISTRY} -u ${REGISTRY_USER} -p ${REGISTRY_PASS}"
|
||||
|
||||
// Push des images
|
||||
sh """
|
||||
docker push ${DOCKER_REGISTRY}/${ORGANIZATION}/${APP_NAME}/frontend:${env.VERSION}
|
||||
docker push ${DOCKER_REGISTRY}/${ORGANIZATION}/${APP_NAME}/backend:${env.VERSION}
|
||||
docker push ${DOCKER_REGISTRY}/${ORGANIZATION}/${APP_NAME}/frontend:latest
|
||||
docker push ${DOCKER_REGISTRY}/${ORGANIZATION}/${APP_NAME}/backend:latest
|
||||
"""
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
post {
|
||||
success {
|
||||
echo "Build et push des images Docker réussis pour la version ${env.VERSION}"
|
||||
}
|
||||
failure {
|
||||
echo "Échec du build ou du push des images Docker"
|
||||
}
|
||||
always {
|
||||
// Nettoyage
|
||||
sh 'docker system prune -f'
|
||||
}
|
||||
}
|
||||
}
|
||||
27
README.md
27
README.md
@ -24,7 +24,7 @@ Maquette figma : https://www.figma.com/design/1BtWHIQlJDTeue2oYblefV/Maquette-Lo
|
||||
|
||||
Lien de téléchargement : https://www.docker.com/get-started/
|
||||
|
||||
# Lancement de monteschool
|
||||
# Lancement du projet
|
||||
|
||||
```sh
|
||||
docker compose up -d
|
||||
@ -36,7 +36,7 @@ Lancement du front end
|
||||
npm run dev
|
||||
```
|
||||
|
||||
se connecter à localhost:8080
|
||||
- se connecter à localhost:8080 pour le backend localhost:3000 pour le front
|
||||
|
||||
# Installation et développement en local
|
||||
|
||||
@ -57,25 +57,6 @@ npm i
|
||||
npm run format
|
||||
```
|
||||
|
||||
# Faire une livraison Mise en Production
|
||||
# Mise en Production, Préparation de la release
|
||||
|
||||
```sh
|
||||
# Faire la première release (1.0.0)
|
||||
npm run release -- --first-release
|
||||
|
||||
# Faire une prerelease (RC,alpha,beta)
|
||||
npm run release -- --prerelease <name>
|
||||
|
||||
|
||||
# Faire une release
|
||||
npm run release
|
||||
|
||||
# Forcer la release sur un mode particulier (majeur, mineur ou patch)
|
||||
# npm run script
|
||||
npm run release -- --release-as minor
|
||||
# Or
|
||||
npm run release -- --release-as 1.1.0
|
||||
|
||||
# ignorer les hooks de commit lors de la release
|
||||
npm run release -- --no-verify
|
||||
```
|
||||
- [MO_PREPARATION_MISE_EN_PROD](./docs/MEP/MO_PRE_MEP.md)
|
||||
|
||||
90
ci/build.Jenkinsfile
Normal file
90
ci/build.Jenkinsfile
Normal file
@ -0,0 +1,90 @@
|
||||
pipeline {
|
||||
|
||||
agent {
|
||||
label "SLAVE-N3WT"
|
||||
}
|
||||
|
||||
options {
|
||||
disableConcurrentBuilds()
|
||||
timestamps()
|
||||
}
|
||||
|
||||
environment {
|
||||
DOCKER_REGISTRY = "git.v0id.ovh"
|
||||
ORG_NAME = "n3wt-innov"
|
||||
APP_NAME = "n3wt-school"
|
||||
|
||||
IMAGE_FRONT = "${DOCKER_REGISTRY}/${ORG_NAME}/${APP_NAME}/frontend"
|
||||
IMAGE_BACK = "${DOCKER_REGISTRY}/${ORG_NAME}/${APP_NAME}/backend"
|
||||
}
|
||||
|
||||
stages {
|
||||
|
||||
stage("Check Tag") {
|
||||
when {
|
||||
not {
|
||||
buildingTag()
|
||||
}
|
||||
}
|
||||
steps {
|
||||
script {
|
||||
currentBuild.result = 'NOT_BUILT'
|
||||
error("⚠️ Pipeline uniquement déclenchée sur les tags. Aucun tag détecté.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
stage("Build Docker Images") {
|
||||
when {
|
||||
buildingTag()
|
||||
}
|
||||
steps {
|
||||
sh """
|
||||
chmod +x ./ci/scripts/makeDocker.sh
|
||||
./ci/scripts/makeDocker.sh ${TAG_NAME}
|
||||
"""
|
||||
}
|
||||
}
|
||||
|
||||
stage("Push Images to Registry") {
|
||||
when {
|
||||
buildingTag()
|
||||
}
|
||||
steps {
|
||||
withCredentials([usernamePassword(
|
||||
credentialsId: "gitea-jenkins",
|
||||
usernameVariable: "REGISTRY_USER",
|
||||
passwordVariable: "REGISTRY_PASS"
|
||||
)]) {
|
||||
|
||||
sh """
|
||||
echo "Login registry..."
|
||||
docker login ${DOCKER_REGISTRY} \
|
||||
-u ${REGISTRY_USER} \
|
||||
-p ${REGISTRY_PASS}
|
||||
|
||||
echo "Push version images..."
|
||||
docker push ${IMAGE_FRONT}:${TAG_NAME}
|
||||
docker push ${IMAGE_BACK}:${TAG_NAME}
|
||||
|
||||
echo "Tag latest..."
|
||||
docker tag ${IMAGE_FRONT}:${TAG_NAME} ${IMAGE_FRONT}:latest
|
||||
docker tag ${IMAGE_BACK}:${TAG_NAME} ${IMAGE_BACK}:latest
|
||||
|
||||
docker push ${IMAGE_FRONT}:latest
|
||||
docker push ${IMAGE_BACK}:latest
|
||||
"""
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
post {
|
||||
always {
|
||||
sh """
|
||||
docker builder prune -f
|
||||
docker image prune -f
|
||||
"""
|
||||
}
|
||||
}
|
||||
}
|
||||
42
ci/deploy.Jenkinsfile
Normal file
42
ci/deploy.Jenkinsfile
Normal file
@ -0,0 +1,42 @@
|
||||
pipeline {
|
||||
agent { label "SLAVE-N3WT" }
|
||||
|
||||
parameters {
|
||||
choice(name: 'ENVIRONMENT', choices: ['demo', 'prod'], description: 'Choisir environnement')
|
||||
string(name: 'VERSION', defaultValue: 'v1.0.0', description: 'Version Docker à déployer')
|
||||
}
|
||||
|
||||
environment {
|
||||
PLATEFORME_DEMO = 'demo.n3wtschool.com'
|
||||
PLATEFORME_PROD = 'vps.n3wtschool.com'
|
||||
DEPLOY_DIR = '~/n3wtschool'
|
||||
}
|
||||
|
||||
stages {
|
||||
stage('Deploy') {
|
||||
steps {
|
||||
script {
|
||||
def targetHost = params.ENVIRONMENT == 'prod' ? env.PLATEFORME_PROD : env.PLATEFORME_DEMO
|
||||
def deployDir = env.DEPLOY_DIR
|
||||
|
||||
// Le credential id Jenkins qui contient la clé SSH
|
||||
def sshCredentialId = params.ENVIRONMENT == 'prod' ? 'vps_n3wt_prod' : 'demo_n3wt'
|
||||
|
||||
// Le user SSH que tu passes dans la commande ssh
|
||||
def sshUser = params.ENVIRONMENT == 'prod' ? 'root' : 'demo'
|
||||
|
||||
sshagent([sshCredentialId]) {
|
||||
sh """
|
||||
ssh -o StrictHostKeyChecking=no ${sshUser}@${targetHost} <<EOF
|
||||
cd ${deployDir}
|
||||
docker compose down
|
||||
docker compose pull
|
||||
docker compose up -d
|
||||
EOF
|
||||
"""
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,5 +1,8 @@
|
||||
#!/bin/bash
|
||||
|
||||
SCRIPT_PATH="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
|
||||
|
||||
|
||||
# Récupération de la version depuis les arguments
|
||||
VERSION=$1
|
||||
|
||||
@ -8,21 +11,22 @@ if [ -z "$VERSION" ]; then
|
||||
echo "Usage: ./makeDocker.sh <version>"
|
||||
exit 1
|
||||
fi
|
||||
SCRIPT_PATH="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
|
||||
cd $SCRIPT_PATH
|
||||
|
||||
# Configuration
|
||||
DOCKER_REGISTRY="git.v0id.ovh"
|
||||
ORG_NAME="n3wt-innov"
|
||||
APP_NAME="n3wt-school"
|
||||
|
||||
echo "Début de la construction des images Docker pour la version ${VERSION}"
|
||||
|
||||
# Construction de l'image Frontend
|
||||
echo "Construction de l'image Frontend..."
|
||||
cd ../Front-End
|
||||
cd $SCRIPT_PATH/../../Front-End
|
||||
|
||||
docker build \
|
||||
--build-arg BUILD_MODE=production \
|
||||
-t ${DOCKER_REGISTRY}/n3wt-innov/${APP_NAME}/frontend:${VERSION} \
|
||||
-t ${DOCKER_REGISTRY}/n3wt-innov/${APP_NAME}/frontend:latest \
|
||||
-t ${DOCKER_REGISTRY}/${ORG_NAME}/${APP_NAME}/frontend:${VERSION} \
|
||||
-t ${DOCKER_REGISTRY}/${ORG_NAME}/${APP_NAME}/frontend:latest \
|
||||
.
|
||||
|
||||
if [ $? -ne 0 ]; then
|
||||
@ -32,10 +36,10 @@ fi
|
||||
|
||||
# Construction de l'image Backend
|
||||
echo "Construction de l'image Backend..."
|
||||
cd ../Back-End
|
||||
cd $SCRIPT_PATH/../../Back-End
|
||||
docker build \
|
||||
-t ${DOCKER_REGISTRY}/n3wt-innov/${APP_NAME}/backend:${VERSION} \
|
||||
-t ${DOCKER_REGISTRY}/n3wt-innov/${APP_NAME}/backend:latest \
|
||||
-t ${DOCKER_REGISTRY}/${ORG_NAME}/${APP_NAME}/backend:${VERSION} \
|
||||
-t ${DOCKER_REGISTRY}/${ORG_NAME}/${APP_NAME}/backend:latest \
|
||||
.
|
||||
|
||||
if [ $? -ne 0 ]; then
|
||||
@ -45,9 +49,9 @@ fi
|
||||
|
||||
echo "Construction des images Docker terminée avec succès"
|
||||
echo "Images créées :"
|
||||
echo "- ${DOCKER_REGISTRY}/${APP_NAME}/frontend:${VERSION}"
|
||||
echo "- ${DOCKER_REGISTRY}/${APP_NAME}/frontend:latest"
|
||||
echo "- ${DOCKER_REGISTRY}/${APP_NAME}/backend:${VERSION}"
|
||||
echo "- ${DOCKER_REGISTRY}/${APP_NAME}/backend:latest"
|
||||
echo "- ${DOCKER_REGISTRY}/${ORG_NAME}/${APP_NAME}/frontend:${VERSION}"
|
||||
echo "- ${DOCKER_REGISTRY}/${ORG_NAME}/${APP_NAME}/frontend:latest"
|
||||
echo "- ${DOCKER_REGISTRY}/${ORG_NAME}/${APP_NAME}/backend:${VERSION}"
|
||||
echo "- ${DOCKER_REGISTRY}/${ORG_NAME}/${APP_NAME}/backend:latest"
|
||||
|
||||
exit 0
|
||||
38
docs/manuels/MEP/MO_PRE_MEP.md
Normal file
38
docs/manuels/MEP/MO_PRE_MEP.md
Normal file
@ -0,0 +1,38 @@
|
||||
# Préparation de la RELEASE et du CHANGELOG
|
||||
|
||||
- Vérifier que l'ensemble des tickets sont mergé dans develop
|
||||
- Fusionner develop dans main via une [nouvelle demande d'ajout](https://git.v0id.ovh/n3wt-innov/n3wt-school/compare/main...develop)
|
||||
- Faire une release avec la commande `npm run release` sur la branch main
|
||||
\*\* NB: si vous souhaité avoir une release particulier (cf. Utilisation de standart-version)
|
||||
- Pousser le commit de changement de version/Changelog et le tag sur main
|
||||
- Depuis jenkins lancer le build sur le nouveau tag créé : https://jenkins.v0id.ovh/job/N3WT/job/Newt-Innov/job/n3wt-school/view/tags/
|
||||
|
||||
# Faire une Mise en Production
|
||||
|
||||
- Depuis jenkins deployer la nouvelle version tagué.
|
||||
|
||||
# Utilisation de standart-version
|
||||
|
||||
L'utilisation de la norme conventionnal commit permet la génération automatique d'un CHANGELOG
|
||||
via l'outil [standard-version](https://github.com/conventional-changelog/standard-version)
|
||||
|
||||
```sh
|
||||
# Faire la première release (1.0.0)
|
||||
npm run release -- --first-release
|
||||
|
||||
# Faire une prerelease (RC,alpha,beta)
|
||||
npm run release -- --prerelease <name>
|
||||
|
||||
|
||||
# Faire une release
|
||||
npm run release
|
||||
|
||||
# Forcer la release sur un mode particulier (majeur, mineur ou patch)
|
||||
# npm run script
|
||||
npm run release -- --release-as minor
|
||||
# Or
|
||||
npm run release -- --release-as 1.1.0
|
||||
|
||||
# ignorer les hooks de commit lors de la release
|
||||
npm run release -- --no-verify
|
||||
```
|
||||
@ -1,84 +0,0 @@
|
||||
# 🧭 Premiers Pas avec N3WT-SCHOOL
|
||||
|
||||
Bienvenue dans **N3WT-SCHOOL** !
|
||||
Ce guide rapide vous accompagne dans les premières étapes de configuration de votre instance afin de la rendre pleinement opérationnelle pour votre établissement.
|
||||
|
||||
> **ℹ️ Version bêta**
|
||||
> N3WT-SCHOOL est actuellement en version bêta. Certaines fonctionnalités sont encore en cours de développement (par exemple : création d'une vue dédiée aux professeurs, génération automatique de factures, renforcement de la sécurité du site, etc).
|
||||
> Il est donc possible que vous rencontriez des bugs ou des comportements inattendus. Merci de votre compréhension et de vos retours !
|
||||
|
||||
## ✅ Étapes à suivre :
|
||||
|
||||
1. **Configurer la signature électronique des documents via Docuseal**
|
||||
2. **Activer l'envoi d'e-mails depuis la plateforme**
|
||||
|
||||
---
|
||||
|
||||
## ✍️ 1. Configuration de la signature électronique (Docuseal)
|
||||
|
||||
Pour permettre la signature électronique des documents administratifs (inscriptions, conventions, etc.), N3WT-SCHOOL s'appuie sur [**Docuseal**](https://docuseal.com), un service sécurisé de signature électronique.
|
||||
|
||||
### Étapes :
|
||||
|
||||
1. Créez un compte sur Docuseal :
|
||||
👉 [https://docuseal.com/sign_up](https://docuseal.com/sign_up)
|
||||
|
||||
2. Une fois connecté, accédez à la section API :
|
||||
👉 [https://console.docuseal.com/api](https://console.docuseal.com/api)
|
||||
|
||||
3. Copiez votre **X-Auth-Token** personnel.
|
||||
Ce jeton permettra à N3WT-SCHOOL de se connecter à votre compte Docuseal.
|
||||
|
||||
4. **Envoyez votre X-Auth-Token à l'équipe N3WT-SCHOOL** pour qu'un administrateur puisse finaliser la configuration :
|
||||
✉️ Contact : [contact@n3wtschool.com](mailto:contact@n3wtschool.com)
|
||||
|
||||
> ⚠️ Cette opération doit impérativement être réalisée par un administrateur N3WT-SCHOOL.
|
||||
> Ne partagez pas ce token en dehors de ce cadre.
|
||||
|
||||
---
|
||||
|
||||
## 📧 2. Configuration de l'envoi d’e-mails
|
||||
|
||||
N3WT-SCHOOL assure la gestion des inscriptions de façon entièrement dématérialisée, avec l’envoi automatique d’e-mails à chaque étape clé du parcours (notifications aux étudiants, accusés de réception, transmission de documents, etc.).
|
||||
Pour permettre le bon fonctionnement de ces envois automatiques, il est nécessaire que vous configuriez votre mot de passe dans les paramètres de messagerie (SMTP) de votre établissement dans le menu **Paramètres** de l’application.
|
||||
|
||||
### Informations requises :
|
||||
|
||||
- Hôte SMTP
|
||||
- Port SMTP
|
||||
- Type de sécurité (TLS / SSL)
|
||||
- Adresse e-mail (utilisateur SMTP)
|
||||
- Mot de passe ou **mot de passe applicatif**
|
||||
|
||||
La plupart des champs ont déjà été pré-remplis grâce aux informations fournies lors de votre inscription : un vrai gain de temps !
|
||||
Il ne vous reste plus qu’à saisir votre mot de passe pour finaliser la configuration et profiter pleinement de l’envoi automatique d’e-mails.
|
||||
|
||||
---
|
||||
|
||||
## 🔐 Mot de passe applicatif (Gmail, Outlook, etc.)
|
||||
|
||||
Certains fournisseurs (notamment **Gmail**, **Yahoo**, **iCloud**) ne permettent pas d’utiliser directement votre mot de passe personnel pour des applications tierces.
|
||||
Vous devez créer un **mot de passe applicatif**.
|
||||
|
||||
### Exemple : Créer un mot de passe applicatif avec Gmail
|
||||
|
||||
1. Connectez-vous à [votre compte Google](https://myaccount.google.com)
|
||||
2. Allez dans **Sécurité > Validation en 2 étapes**
|
||||
3. Activez la validation en 2 étapes si ce n’est pas déjà fait
|
||||
4. Ensuite, allez dans **Mots de passe des applications**
|
||||
5. Sélectionnez une application (ex. : "Autre (personnalisée)") et nommez-la "N3WT-SCHOOL"
|
||||
6. Copiez le mot de passe généré et utilisez-le comme **mot de passe SMTP**
|
||||
|
||||
> 📎 Consultez l’aide officielle de Google :
|
||||
> [Créer un mot de passe d’application – Google](https://support.google.com/accounts/answer/185833)
|
||||
|
||||
> ℹ️ Si vous rencontrez la moindre difficulté pour générer ou utiliser un mot de passe applicatif, n'hésitez pas à contacter l'équipe N3WT-SCHOOL : nous sommes à votre disposition pour vous accompagner dans cette démarche.
|
||||
|
||||
---
|
||||
|
||||
## 🎉 Vous êtes prêt·e !
|
||||
|
||||
Une fois ces deux configurations effectuées, votre instance N3WT-SCHOOL est prête à fonctionner pleinement.
|
||||
Vous pourrez ensuite ajouter vos formations, étudiants, documents et automatiser toute votre gestion scolaire.
|
||||
|
||||
Merci de votre confiance et n’hésitez pas à nous faire part de vos retours pour améliorer la plateforme !
|
||||
BIN
premier-pas.pdf
BIN
premier-pas.pdf
Binary file not shown.
Reference in New Issue
Block a user